1use std::collections::HashSet;
8use std::time::Duration;
9
10pub const DEFAULT_COMMAND_TIMEOUT: u64 = 30;
12
13pub const MAX_OUTPUT_SIZE: usize = 1024 * 1024;
15
16static INTERACTIVE_COMMANDS: &[&str] = &[
18 "vim",
20 "vi",
21 "nano",
22 "emacs",
23 "code",
24 "subl",
25 "python",
27 "python3",
28 "node",
29 "nodejs",
30 "ruby",
31 "irb",
32 "scala",
33 "ghci",
34 "mysql",
36 "psql",
37 "sqlite3",
38 "redis-cli",
39 "mongo",
40 "less",
42 "more",
43 "view",
44 "top",
46 "htop",
47 "watch",
48 "tail -f",
49 "git rebase -i",
51 "git add -i",
52 "git commit", ];
54
55static LONG_RUNNING_COMMANDS: &[&str] = &[
57 "make",
59 "cargo build",
60 "npm install",
61 "pip install",
62 "yarn install",
63 "gcc",
65 "g++",
66 "clang",
67 "rustc",
68 "javac",
69 "apt-get",
71 "yum",
72 "brew install",
73 "pacman",
74 "wget",
76 "curl",
77 "rsync",
78 "scp",
79 "tar",
81 "zip",
82 "unzip",
83 "gzip",
84];
85
86static BACKGROUND_COMMANDS: &[&str] = &[
88 "python -m http.server",
90 "node server",
91 "rails server",
92 "cargo run",
93 "nohup",
95 "screen",
96 "tmux",
97 "systemctl start",
99 "service start",
100];
101
102#[derive(Debug, Clone)]
104pub struct CommandSafety {
105 interactive: HashSet<String>,
106 long_running: HashSet<String>,
107 background: HashSet<String>,
108}
109
110impl Default for CommandSafety {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116impl CommandSafety {
117 pub fn new() -> Self {
119 let interactive = INTERACTIVE_COMMANDS.iter().map(|s| (*s).to_string()).collect();
120
121 let long_running = LONG_RUNNING_COMMANDS.iter().map(|s| (*s).to_string()).collect();
122
123 let background = BACKGROUND_COMMANDS.iter().map(|s| (*s).to_string()).collect();
124
125 Self { interactive, long_running, background }
126 }
127
128 pub fn is_interactive(&self, command: &str) -> bool {
130 let normalized = Self::normalize_command(command);
131
132 if self.interactive.contains(&normalized) {
134 return true;
135 }
136
137 for interactive_cmd in &self.interactive {
139 if normalized.starts_with(interactive_cmd) {
140 let rest = &normalized[interactive_cmd.len()..];
142 if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
143 if interactive_cmd == "git commit"
145 && (normalized.contains("-m") || normalized.contains("--message"))
146 {
147 return false;
148 }
149 if interactive_cmd == "python" || interactive_cmd == "python3" {
151 let parts: Vec<&str> = normalized.split_whitespace().collect();
153 if parts.len() > 1 && !parts[1].starts_with('-') {
154 return false;
155 }
156 }
157 return true;
158 }
159 }
160 }
161
162 Self::check_special_interactive_cases(&normalized)
164 }
165
166 pub fn is_long_running(&self, command: &str) -> bool {
168 let normalized = Self::normalize_command(command);
169
170 for long_cmd in &self.long_running {
171 if normalized.starts_with(long_cmd) {
172 let rest = &normalized[long_cmd.len()..];
173 if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
174 return true;
175 }
176 }
177 }
178
179 false
180 }
181
182 pub fn is_background_command(&self, command: &str) -> bool {
184 let normalized = Self::normalize_command(command);
185
186 if normalized.contains(" &") || normalized.ends_with('&') {
188 return true;
189 }
190
191 for bg_cmd in &self.background {
192 if normalized.starts_with(bg_cmd) {
193 let rest = &normalized[bg_cmd.len()..];
194 if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
195 return true;
196 }
197 }
198 }
199
200 false
201 }
202
203 pub fn get_timeout(&self, command: &str) -> Duration {
205 if self.is_long_running(command) {
206 Duration::from_secs(300) } else if self.is_background_command(command) {
208 Duration::from_secs(60) } else {
210 Duration::from_secs(DEFAULT_COMMAND_TIMEOUT) }
212 }
213
214 pub fn get_warnings(&self, command: &str) -> Vec<String> {
216 let mut warnings = Vec::new();
217
218 if self.is_interactive(command) {
219 warnings.push(format!(
220 "Command '{command}' appears to be interactive and may hang waiting for input"
221 ));
222 warnings.push("Consider using non-interactive flags or alternatives".to_string());
223 }
224
225 if self.is_long_running(command) {
226 warnings.push(format!("Command '{command}' may take a long time to complete"));
227 warnings.push("Consider using status_check to monitor progress".to_string());
228 }
229
230 if self.is_background_command(command) {
231 warnings.push(format!("Command '{command}' may spawn background processes"));
232 warnings.push("Use explicit process management if needed".to_string());
233 }
234
235 warnings
236 }
237
238 fn normalize_command(command: &str) -> String {
240 command.trim().to_lowercase()
241 }
242
243 fn check_special_interactive_cases(command: &str) -> bool {
245 if command.starts_with("git commit")
247 && !command.contains("-m")
248 && !command.contains("--message")
249 {
250 return true;
251 }
252
253 if command.starts_with("docker run")
255 && !command.contains("-d")
256 && !command.contains("--detach")
257 {
258 return true;
259 }
260
261 if command == "ssh" || (command.starts_with("ssh ") && !command.contains(" -- ")) {
263 return true;
264 }
265
266 if command.starts_with("ftp ") || command.starts_with("sftp ") {
268 return true;
269 }
270
271 false
272 }
273}
274
275#[derive(Debug, Clone)]
277pub struct CommandContext {
278 pub command: String,
279 pub timeout: Duration,
280 pub max_output_size: usize,
281 pub is_interactive: bool,
282 pub is_long_running: bool,
283 pub is_background: bool,
284 pub warnings: Vec<String>,
285}
286
287impl CommandContext {
288 pub fn new(command: &str) -> Self {
290 let safety = CommandSafety::new();
291 let timeout = safety.get_timeout(command);
292 let is_interactive = safety.is_interactive(command);
293 let is_long_running = safety.is_long_running(command);
294 let is_background = safety.is_background_command(command);
295 let warnings = safety.get_warnings(command);
296
297 Self {
298 command: command.to_string(),
299 timeout,
300 max_output_size: MAX_OUTPUT_SIZE,
301 is_interactive,
302 is_long_running,
303 is_background,
304 warnings,
305 }
306 }
307
308 pub fn should_allow_execution(&self) -> Result<(), crate::errors::WinxError> {
310 if self.is_interactive {
311 return Err(crate::errors::WinxError::InteractiveCommandDetected {
312 command: self.command.clone(),
313 });
314 }
315
316 Ok(())
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn test_interactive_detection() {
326 let safety = CommandSafety::new();
327
328 assert!(safety.is_interactive("vim file.txt"));
330 assert!(safety.is_interactive("python"));
331 assert!(safety.is_interactive("git commit"));
332 assert!(safety.is_interactive("mysql -u root"));
333
334 assert!(!safety.is_interactive("ls -la"));
336 assert!(!safety.is_interactive("git commit -m 'message'"));
337 assert!(!safety.is_interactive("python script.py"));
338 assert!(!safety.is_interactive("cat file.txt"));
339 }
340
341 #[test]
342 fn test_long_running_detection() {
343 let safety = CommandSafety::new();
344
345 assert!(safety.is_long_running("cargo build"));
347 assert!(safety.is_long_running("npm install"));
348 assert!(safety.is_long_running("make all"));
349
350 assert!(!safety.is_long_running("ls"));
352 assert!(!safety.is_long_running("echo hello"));
353 }
354
355 #[test]
356 fn test_background_detection() {
357 let safety = CommandSafety::new();
358
359 assert!(safety.is_background_command("python -m http.server &"));
361 assert!(safety.is_background_command("nohup long_process"));
362 assert!(safety.is_background_command("screen -S session"));
363
364 assert!(!safety.is_background_command("ls"));
366 assert!(!safety.is_background_command("python script.py"));
367 }
368
369 #[test]
370 fn test_timeout_calculation() {
371 let safety = CommandSafety::new();
372
373 assert_eq!(safety.get_timeout("cargo build"), Duration::from_secs(300));
375
376 assert_eq!(safety.get_timeout("nohup process &"), Duration::from_secs(60));
378
379 assert_eq!(safety.get_timeout("ls"), Duration::from_secs(30));
381 }
382
383 #[test]
384 fn test_command_context() {
385 let ctx = CommandContext::new("vim file.txt");
386
387 assert!(ctx.is_interactive);
388 assert!(!ctx.warnings.is_empty());
389 assert!(ctx.should_allow_execution().is_err());
390
391 let ctx2 = CommandContext::new("ls -la");
392 assert!(!ctx2.is_interactive);
393 assert!(ctx2.should_allow_execution().is_ok());
394 }
395}