gestura_core_tools/
shell.rs1use 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#[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#[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
34pub 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 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 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 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 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 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 if let Ok(mut last) = self.last_output.write() {
163 *last = Some(result.clone());
164 }
165
166 Ok(result)
167 }
168
169 pub fn test(&self, command: &str) -> Result<CommandTestResult> {
171 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 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 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#[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}