gestura_core_tools/
shell.rs

1//! Shell command execution tool
2//!
3//! Provides shell command execution with structured output.
4
5use crate::error::{AppError, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::io::Read;
9use std::path::Path;
10use std::process::{Command, Stdio};
11use std::sync::RwLock;
12use std::time::{Duration, Instant};
13
14/// Result of executing a shell command
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct CommandResult {
17    pub command: String,
18    pub stdout: String,
19    pub stderr: String,
20    pub exit_code: i32,
21    pub success: bool,
22    pub duration_ms: u64,
23}
24
25/// Command history entry
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct HistoryEntry {
28    pub command: String,
29    pub timestamp: chrono::DateTime<chrono::Utc>,
30    pub exit_code: i32,
31    pub duration_ms: u64,
32}
33
34/// Shell command service
35pub struct ShellTools {
36    history: RwLock<Vec<HistoryEntry>>,
37    last_output: RwLock<Option<CommandResult>>,
38}
39
40impl Default for ShellTools {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl ShellTools {
47    pub fn new() -> Self {
48        Self {
49            history: RwLock::new(Vec::new()),
50            last_output: RwLock::new(None),
51        }
52    }
53
54    /// Execute a shell command
55    pub fn run(&self, command: &str, timeout_secs: Option<u64>) -> Result<CommandResult> {
56        self.run_with_options(command, None, None, timeout_secs)
57    }
58
59    /// Execute a shell command with working directory and environment.
60    ///
61    /// This method enforces a timeout to avoid "hanging" tool calls.
62    pub fn run_with_options(
63        &self,
64        command: &str,
65        cwd: Option<&Path>,
66        env: Option<&HashMap<String, String>>,
67        timeout_secs: Option<u64>,
68    ) -> Result<CommandResult> {
69        let timeout = timeout_secs
70            .map(Duration::from_secs)
71            .unwrap_or(Duration::from_secs(300));
72        let start = Instant::now();
73
74        let mut cmd = Command::new("sh");
75        cmd.arg("-c")
76            .arg(command)
77            .stdin(Stdio::null())
78            .stdout(Stdio::piped())
79            .stderr(Stdio::piped());
80
81        if let Some(dir) = cwd {
82            cmd.current_dir(dir);
83        }
84        if let Some(env_map) = env {
85            cmd.envs(env_map);
86        }
87
88        let mut child = cmd.spawn().map_err(AppError::Io)?;
89        let mut stdout = child
90            .stdout
91            .take()
92            .ok_or_else(|| AppError::Io(std::io::Error::other("Missing child stdout")))?;
93        let mut stderr = child
94            .stderr
95            .take()
96            .ok_or_else(|| AppError::Io(std::io::Error::other("Missing child stderr")))?;
97
98        let out_handle = std::thread::spawn(move || {
99            let mut buf = Vec::new();
100            let _ = stdout.read_to_end(&mut buf);
101            buf
102        });
103        let err_handle = std::thread::spawn(move || {
104            let mut buf = Vec::new();
105            let _ = stderr.read_to_end(&mut buf);
106            buf
107        });
108
109        let mut timed_out = false;
110        let status = loop {
111            if let Some(status) = child.try_wait().map_err(AppError::Io)? {
112                break status;
113            }
114            if start.elapsed() >= timeout {
115                timed_out = true;
116                // Best-effort termination. Even if kill fails, we still try to wait.
117                let _ = child.kill();
118                break child.wait().map_err(AppError::Io)?;
119            }
120            std::thread::sleep(Duration::from_millis(10));
121        };
122
123        let stdout_bytes = out_handle.join().unwrap_or_default();
124        let stderr_bytes = err_handle.join().unwrap_or_default();
125
126        let duration = start.elapsed();
127        let exit_code = if timed_out {
128            // Conventional "timeout" code.
129            124
130        } else {
131            status.code().unwrap_or(-1)
132        };
133
134        let mut stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
135        if timed_out {
136            if !stderr_str.ends_with('\n') && !stderr_str.is_empty() {
137                stderr_str.push('\n');
138            }
139            stderr_str.push_str("Command timed out");
140        }
141
142        let result = CommandResult {
143            command: command.to_string(),
144            stdout: String::from_utf8_lossy(&stdout_bytes).to_string(),
145            stderr: stderr_str,
146            exit_code,
147            success: !timed_out && status.success(),
148            duration_ms: duration.as_millis() as u64,
149        };
150
151        // Store in history
152        if let Ok(mut history) = self.history.write() {
153            history.push(HistoryEntry {
154                command: command.to_string(),
155                timestamp: chrono::Utc::now(),
156                exit_code,
157                duration_ms: result.duration_ms,
158            });
159        }
160
161        // Store as last output
162        if let Ok(mut last) = self.last_output.write() {
163            *last = Some(result.clone());
164        }
165
166        Ok(result)
167    }
168
169    /// Test a command (parse without executing)
170    pub fn test(&self, command: &str) -> Result<CommandTestResult> {
171        // Use shell to check syntax without executing
172        let output = Command::new("sh")
173            .arg("-n")
174            .arg("-c")
175            .arg(command)
176            .output()
177            .map_err(AppError::Io)?;
178
179        Ok(CommandTestResult {
180            command: command.to_string(),
181            valid: output.status.success(),
182            error: if output.status.success() {
183                None
184            } else {
185                Some(String::from_utf8_lossy(&output.stderr).to_string())
186            },
187        })
188    }
189
190    /// Get command history
191    pub fn history(&self, limit: Option<usize>) -> Result<Vec<HistoryEntry>> {
192        let history = self
193            .history
194            .read()
195            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
196
197        let limit = limit.unwrap_or(50);
198        Ok(history.iter().rev().take(limit).cloned().collect())
199    }
200
201    /// Get last command output
202    pub fn last(&self) -> Result<Option<CommandResult>> {
203        let last = self
204            .last_output
205            .read()
206            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?;
207        Ok(last.clone())
208    }
209}
210
211/// Result of testing a command
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct CommandTestResult {
214    pub command: String,
215    pub valid: bool,
216    pub error: Option<String>,
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_run_echo() {
225        let tools = ShellTools::new();
226        let result = tools.run("echo hello", None).unwrap();
227        assert!(result.success);
228        assert_eq!(result.exit_code, 0);
229        assert!(result.stdout.contains("hello"));
230    }
231
232    #[test]
233    fn test_run_failing_command() {
234        let tools = ShellTools::new();
235        let result = tools.run("exit 1", None).unwrap();
236        assert!(!result.success);
237        assert_eq!(result.exit_code, 1);
238    }
239
240    #[test]
241    fn test_history() {
242        let tools = ShellTools::new();
243        tools.run("echo test1", None).unwrap();
244        tools.run("echo test2", None).unwrap();
245
246        let history = tools.history(None).unwrap();
247        assert_eq!(history.len(), 2);
248        assert!(history[0].command.contains("echo test2"));
249    }
250
251    #[test]
252    fn test_last() {
253        let tools = ShellTools::new();
254        assert!(tools.last().unwrap().is_none());
255
256        tools.run("echo last", None).unwrap();
257        let last = tools.last().unwrap().unwrap();
258        assert!(last.stdout.contains("last"));
259    }
260
261    #[test]
262    fn test_test_command() {
263        let tools = ShellTools::new();
264        let result = tools.test("echo").unwrap();
265        assert!(result.valid);
266        assert!(result.error.is_none());
267    }
268}