1use std::io::{self, BufRead, IsTerminal, Read, Write};
2use std::process::{Command, Stdio};
3
4use crate::core::config;
5use crate::core::patterns;
6use crate::core::slow_log;
7use crate::core::stats;
8use crate::core::tokens::count_tokens;
9
10pub fn exec(command: &str) -> i32 {
11 let (shell, shell_flag) = shell_and_flag();
12 let cfg = config::Config::load();
13 let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
14
15 if !force_compress && is_excluded_command(command, &cfg.excluded_commands) {
16 return exec_inherit(command, &shell, &shell_flag);
17 }
18
19 if !force_compress && io::stdout().is_terminal() {
20 return exec_streaming(command, &shell, &shell_flag, &cfg);
21 }
22
23 exec_buffered(command, &shell, &shell_flag, &cfg)
24}
25
26fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
27 let status = Command::new(shell)
28 .arg(shell_flag)
29 .arg(command)
30 .env("LEAN_CTX_ACTIVE", "1")
31 .stdin(Stdio::inherit())
32 .stdout(Stdio::inherit())
33 .stderr(Stdio::inherit())
34 .status();
35
36 match status {
37 Ok(s) => s.code().unwrap_or(1),
38 Err(e) => {
39 eprintln!("lean-ctx: failed to execute: {e}");
40 127
41 }
42 }
43}
44
45fn exec_streaming(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
46 let start = std::time::Instant::now();
47
48 let mut child = match Command::new(shell)
49 .arg(shell_flag)
50 .arg(command)
51 .env("LEAN_CTX_ACTIVE", "1")
52 .stdin(Stdio::inherit())
53 .stdout(Stdio::piped())
54 .stderr(Stdio::piped())
55 .spawn()
56 {
57 Ok(c) => c,
58 Err(e) => {
59 eprintln!("lean-ctx: failed to execute: {e}");
60 return 127;
61 }
62 };
63
64 let child_stdout = child.stdout.take().expect("stdout piped");
65 let child_stderr = child.stderr.take().expect("stderr piped");
66
67 let stdout_thread = spawn_stream_thread(child_stdout, io::stdout());
68 let stderr_thread = spawn_stream_thread(child_stderr, io::stderr());
69
70 let stdout_buf = stdout_thread.join().unwrap_or_default();
71 let stderr_buf = stderr_thread.join().unwrap_or_default();
72
73 let exit_code = child.wait().map(|s| s.code().unwrap_or(1)).unwrap_or(127);
74 let duration_ms = start.elapsed().as_millis();
75
76 let full_output = combine_output(&stdout_buf, &stderr_buf);
77 let input_tokens = count_tokens(&full_output);
78 let (_, output_tokens) = compress_and_measure(command, &stdout_buf, &stderr_buf);
79
80 stats::record(command, input_tokens, output_tokens);
81
82 if input_tokens > 50 && output_tokens < input_tokens {
83 let saved = input_tokens - output_tokens;
84 let pct = (saved as f64 / input_tokens as f64 * 100.0).round() as usize;
85 if pct >= 10 {
86 eprintln!(
87 "\x1b[2m[lean-ctx: {input_tokens}\u{2192}{output_tokens} tok, -{pct}%]\x1b[0m"
88 );
89 }
90 }
91
92 if cfg.tee_on_error && exit_code != 0 && !full_output.trim().is_empty() {
93 if let Some(path) = save_tee(command, &full_output) {
94 eprintln!(
95 "[lean-ctx: output saved to {path} (secrets redacted, auto-deleted after 24h)]"
96 );
97 }
98 }
99
100 let threshold = cfg.slow_command_threshold_ms;
101 if threshold > 0 && duration_ms >= threshold as u128 {
102 slow_log::record(command, duration_ms, exit_code);
103 }
104
105 exit_code
106}
107
108fn spawn_stream_thread(
109 mut pipe: impl Read + Send + 'static,
110 mut writer: impl Write + Send + 'static,
111) -> std::thread::JoinHandle<String> {
112 std::thread::spawn(move || {
113 let mut buf = Vec::new();
114 let mut chunk = [0u8; 4096];
115 loop {
116 match pipe.read(&mut chunk) {
117 Ok(0) => break,
118 Ok(n) => {
119 let _ = writer.write_all(&chunk[..n]);
120 let _ = writer.flush();
121 buf.extend_from_slice(&chunk[..n]);
122 }
123 Err(_) => break,
124 }
125 }
126 String::from_utf8_lossy(&buf).to_string()
127 })
128}
129
130fn combine_output(stdout: &str, stderr: &str) -> String {
131 if stderr.is_empty() {
132 stdout.to_string()
133 } else if stdout.is_empty() {
134 stderr.to_string()
135 } else {
136 format!("{stdout}\n{stderr}")
137 }
138}
139
140fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
141 let start = std::time::Instant::now();
142
143 let child = Command::new(shell)
144 .arg(shell_flag)
145 .arg(command)
146 .env("LEAN_CTX_ACTIVE", "1")
147 .stdout(Stdio::piped())
148 .stderr(Stdio::piped())
149 .spawn();
150
151 let child = match child {
152 Ok(c) => c,
153 Err(e) => {
154 eprintln!("lean-ctx: failed to execute: {e}");
155 return 127;
156 }
157 };
158
159 let output = match child.wait_with_output() {
160 Ok(o) => o,
161 Err(e) => {
162 eprintln!("lean-ctx: failed to wait: {e}");
163 return 127;
164 }
165 };
166
167 let duration_ms = start.elapsed().as_millis();
168 let exit_code = output.status.code().unwrap_or(1);
169 let stdout = String::from_utf8_lossy(&output.stdout);
170 let stderr = String::from_utf8_lossy(&output.stderr);
171
172 let full_output = combine_output(&stdout, &stderr);
173 let input_tokens = count_tokens(&full_output);
174
175 let (compressed, output_tokens) = compress_and_measure(command, &stdout, &stderr);
176
177 stats::record(command, input_tokens, output_tokens);
178
179 if !compressed.is_empty() {
180 let _ = io::stdout().write_all(compressed.as_bytes());
181 if !compressed.ends_with('\n') {
182 let _ = io::stdout().write_all(b"\n");
183 }
184 }
185 if cfg.tee_on_error && exit_code != 0 && !full_output.trim().is_empty() {
186 if let Some(path) = save_tee(command, &full_output) {
187 eprintln!(
188 "[lean-ctx: output saved to {path} (secrets redacted, auto-deleted after 24h)]"
189 );
190 }
191 }
192
193 let threshold = cfg.slow_command_threshold_ms;
194 if threshold > 0 && duration_ms >= threshold as u128 {
195 slow_log::record(command, duration_ms, exit_code);
196 }
197
198 exit_code
199}
200
201const BUILTIN_PASSTHROUGH: &[&str] = &[
202 "turbo",
204 "nx serve",
205 "nx dev",
206 "next dev",
207 "vite dev",
208 "vite preview",
209 "vitest",
210 "nuxt dev",
211 "astro dev",
212 "webpack serve",
213 "webpack-dev-server",
214 "nodemon",
215 "concurrently",
216 "pm2",
217 "pm2 logs",
218 "gatsby develop",
219 "expo start",
220 "react-scripts start",
221 "ng serve",
222 "remix dev",
223 "wrangler dev",
224 "hugo server",
225 "hugo serve",
226 "jekyll serve",
227 "bun dev",
228 "ember serve",
229 "docker compose up",
231 "docker-compose up",
232 "docker compose logs",
233 "docker-compose logs",
234 "docker compose exec",
235 "docker-compose exec",
236 "docker compose run",
237 "docker-compose run",
238 "docker logs",
239 "docker attach",
240 "docker exec -it",
241 "docker exec -ti",
242 "docker run -it",
243 "docker run -ti",
244 "docker stats",
245 "docker events",
246 "kubectl logs",
248 "kubectl exec -it",
249 "kubectl exec -ti",
250 "kubectl attach",
251 "kubectl port-forward",
252 "kubectl proxy",
253 "top",
255 "htop",
256 "btop",
257 "watch ",
258 "tail -f",
259 "tail -F",
260 "journalctl -f",
261 "journalctl --follow",
262 "dmesg -w",
263 "dmesg --follow",
264 "strace",
265 "tcpdump",
266 "ping ",
267 "ping6 ",
268 "traceroute",
269 "less",
271 "more",
272 "vim",
273 "nvim",
274 "vi ",
275 "nano",
276 "micro ",
277 "helix ",
278 "hx ",
279 "emacs",
280 "tmux",
282 "screen",
283 "ssh ",
285 "telnet ",
286 "nc ",
287 "ncat ",
288 "psql",
289 "mysql",
290 "sqlite3",
291 "redis-cli",
292 "mongosh",
293 "mongo ",
294 "python3 -i",
295 "python -i",
296 "irb",
297 "rails console",
298 "rails c ",
299 "iex",
300 "cargo watch",
302];
303
304fn is_excluded_command(command: &str, excluded: &[String]) -> bool {
305 let cmd = command.trim().to_lowercase();
306 for pattern in BUILTIN_PASSTHROUGH {
307 if cmd == *pattern || cmd.starts_with(&format!("{pattern} ")) || cmd.contains(pattern) {
308 return true;
309 }
310 }
311 if excluded.is_empty() {
312 return false;
313 }
314 excluded.iter().any(|excl| {
315 let excl_lower = excl.trim().to_lowercase();
316 cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} "))
317 })
318}
319
320pub fn interactive() {
321 let real_shell = detect_shell();
322
323 eprintln!("lean-ctx shell v2.14.1 (wrapping {real_shell})");
324 eprintln!("All command output is automatically compressed.");
325 eprintln!("Type 'exit' to quit.\n");
326
327 let stdin = io::stdin();
328 let mut stdout = io::stdout();
329
330 loop {
331 let _ = write!(stdout, "lean-ctx> ");
332 let _ = stdout.flush();
333
334 let mut line = String::new();
335 match stdin.lock().read_line(&mut line) {
336 Ok(0) => break,
337 Ok(_) => {}
338 Err(_) => break,
339 }
340
341 let cmd = line.trim();
342 if cmd.is_empty() {
343 continue;
344 }
345 if cmd == "exit" || cmd == "quit" {
346 break;
347 }
348 if cmd == "gain" {
349 println!("{}", stats::format_gain());
350 continue;
351 }
352
353 let exit_code = exec(cmd);
354
355 if exit_code != 0 {
356 let _ = writeln!(stdout, "[exit: {exit_code}]");
357 }
358 }
359}
360
361fn compress_and_measure(command: &str, stdout: &str, stderr: &str) -> (String, usize) {
362 let compressed_stdout = compress_if_beneficial(command, stdout);
363 let compressed_stderr = compress_if_beneficial(command, stderr);
364
365 let mut result = String::new();
366 if !compressed_stdout.is_empty() {
367 result.push_str(&compressed_stdout);
368 }
369 if !compressed_stderr.is_empty() {
370 if !result.is_empty() {
371 result.push('\n');
372 }
373 result.push_str(&compressed_stderr);
374 }
375
376 let output_tokens = count_tokens(&result);
377 (result, output_tokens)
378}
379
380fn compress_if_beneficial(command: &str, output: &str) -> String {
381 if output.trim().is_empty() {
382 return String::new();
383 }
384
385 let original_tokens = count_tokens(output);
386
387 if original_tokens < 50 {
388 return output.to_string();
389 }
390
391 let min_output_tokens = 5;
392
393 if let Some(compressed) = patterns::compress_output(command, output) {
394 if !compressed.trim().is_empty() {
395 let compressed_tokens = count_tokens(&compressed);
396 if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
397 let saved = original_tokens - compressed_tokens;
398 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
399 return format!(
400 "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
401 );
402 }
403 if compressed_tokens < min_output_tokens {
404 return output.to_string();
405 }
406 }
407 }
408
409 let cleaned = crate::core::compressor::lightweight_cleanup(output);
411 let cleaned_tokens = count_tokens(&cleaned);
412 if cleaned_tokens < original_tokens {
413 let lines: Vec<&str> = cleaned.lines().collect();
414 if lines.len() > 30 {
415 let first = &lines[..5];
416 let last = &lines[lines.len() - 5..];
417 let omitted = lines.len() - 10;
418 let compressed = format!(
419 "{}\n... ({omitted} lines omitted) ...\n{}",
420 first.join("\n"),
421 last.join("\n")
422 );
423 let ct = count_tokens(&compressed);
424 if ct < original_tokens {
425 let saved = original_tokens - ct;
426 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
427 return format!("{compressed}\n[lean-ctx: {original_tokens}→{ct} tok, -{pct}%]");
428 }
429 }
430 if cleaned_tokens < original_tokens {
431 let saved = original_tokens - cleaned_tokens;
432 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
433 return format!(
434 "{cleaned}\n[lean-ctx: {original_tokens}→{cleaned_tokens} tok, -{pct}%]"
435 );
436 }
437 }
438
439 let lines: Vec<&str> = output.lines().collect();
440 if lines.len() > 30 {
441 let first = &lines[..5];
442 let last = &lines[lines.len() - 5..];
443 let omitted = lines.len() - 10;
444 let compressed = format!(
445 "{}\n... ({omitted} lines omitted) ...\n{}",
446 first.join("\n"),
447 last.join("\n")
448 );
449 let compressed_tokens = count_tokens(&compressed);
450 if compressed_tokens < original_tokens {
451 let saved = original_tokens - compressed_tokens;
452 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
453 return format!(
454 "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
455 );
456 }
457 }
458
459 output.to_string()
460}
461
462fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
465 if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
466 "-Command"
467 } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
468 "/C"
469 } else {
470 "-c"
474 }
475}
476
477pub fn shell_and_flag() -> (String, String) {
478 let shell = detect_shell();
479 let flag = if cfg!(windows) {
480 let name = std::path::Path::new(&shell)
481 .file_name()
482 .and_then(|n| n.to_str())
483 .unwrap_or("")
484 .to_ascii_lowercase();
485 windows_shell_flag_for_exe_basename(&name).to_string()
486 } else {
487 "-c".to_string()
488 };
489 (shell, flag)
490}
491
492fn detect_shell() -> String {
493 if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
494 return shell;
495 }
496
497 if let Ok(shell) = std::env::var("SHELL") {
498 let bin = std::path::Path::new(&shell)
499 .file_name()
500 .and_then(|n| n.to_str())
501 .unwrap_or("sh");
502
503 if bin == "lean-ctx" {
504 return find_real_shell();
505 }
506 return shell;
507 }
508
509 find_real_shell()
510}
511
512#[cfg(unix)]
513fn find_real_shell() -> String {
514 for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
515 if std::path::Path::new(shell).exists() {
516 return shell.to_string();
517 }
518 }
519 "/bin/sh".to_string()
520}
521
522#[cfg(windows)]
523fn find_real_shell() -> String {
524 if is_running_in_powershell() {
525 if let Ok(pwsh) = which_powershell() {
526 return pwsh;
527 }
528 }
529 if let Ok(comspec) = std::env::var("COMSPEC") {
530 return comspec;
531 }
532 "cmd.exe".to_string()
533}
534
535#[cfg(windows)]
536fn is_running_in_powershell() -> bool {
537 std::env::var("PSModulePath").is_ok()
538}
539
540#[cfg(windows)]
541fn which_powershell() -> Result<String, ()> {
542 for candidate in &["pwsh.exe", "powershell.exe"] {
543 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
544 if output.status.success() {
545 if let Ok(path) = String::from_utf8(output.stdout) {
546 if let Some(first_line) = path.lines().next() {
547 let trimmed = first_line.trim();
548 if !trimmed.is_empty() {
549 return Ok(trimmed.to_string());
550 }
551 }
552 }
553 }
554 }
555 }
556 Err(())
557}
558
559fn save_tee(command: &str, output: &str) -> Option<String> {
560 let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
561 std::fs::create_dir_all(&tee_dir).ok()?;
562
563 cleanup_old_tee_logs(&tee_dir);
564
565 let cmd_slug: String = command
566 .chars()
567 .take(40)
568 .map(|c| {
569 if c.is_alphanumeric() || c == '-' {
570 c
571 } else {
572 '_'
573 }
574 })
575 .collect();
576 let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
577 let filename = format!("{ts}_{cmd_slug}.log");
578 let path = tee_dir.join(&filename);
579
580 let masked = mask_sensitive_data(output);
581 std::fs::write(&path, masked).ok()?;
582 Some(path.to_string_lossy().to_string())
583}
584
585fn mask_sensitive_data(input: &str) -> String {
586 use regex::Regex;
587
588 let patterns: Vec<(&str, Regex)> = vec![
589 ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
590 ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
591 ("API key param", Regex::new(r#"(?i)((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)[^\s\r\n,;&"']+"#).unwrap()),
592 ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
593 ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
594 ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
595 ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
596 ];
597
598 let mut result = input.to_string();
599 for (label, re) in &patterns {
600 result = re
601 .replace_all(&result, |caps: ®ex::Captures| {
602 if let Some(prefix) = caps.get(1) {
603 format!("{}[REDACTED:{}]", prefix.as_str(), label)
604 } else {
605 format!("[REDACTED:{}]", label)
606 }
607 })
608 .to_string();
609 }
610 result
611}
612
613fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
614 let cutoff =
615 std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
616 let cutoff = match cutoff {
617 Some(t) => t,
618 None => return,
619 };
620
621 if let Ok(entries) = std::fs::read_dir(tee_dir) {
622 for entry in entries.flatten() {
623 if let Ok(meta) = entry.metadata() {
624 if let Ok(modified) = meta.modified() {
625 if modified < cutoff {
626 let _ = std::fs::remove_file(entry.path());
627 }
628 }
629 }
630 }
631 }
632}
633
634#[cfg(test)]
635mod windows_shell_flag_tests {
636 use super::windows_shell_flag_for_exe_basename;
637
638 #[test]
639 fn cmd_uses_slash_c() {
640 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
641 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
642 }
643
644 #[test]
645 fn powershell_uses_command() {
646 assert_eq!(
647 windows_shell_flag_for_exe_basename("powershell.exe"),
648 "-Command"
649 );
650 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
651 }
652
653 #[test]
654 fn posix_shells_use_dash_c() {
655 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
656 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
657 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
658 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
659 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
660 }
661}
662
663#[cfg(test)]
664mod passthrough_tests {
665 use super::is_excluded_command;
666
667 #[test]
668 fn turbo_is_passthrough() {
669 assert!(is_excluded_command("turbo run dev", &[]));
670 assert!(is_excluded_command("turbo run build", &[]));
671 assert!(is_excluded_command("pnpm turbo run dev", &[]));
672 assert!(is_excluded_command("npx turbo run dev", &[]));
673 }
674
675 #[test]
676 fn dev_servers_are_passthrough() {
677 assert!(is_excluded_command("next dev", &[]));
678 assert!(is_excluded_command("vite dev", &[]));
679 assert!(is_excluded_command("nuxt dev", &[]));
680 assert!(is_excluded_command("astro dev", &[]));
681 assert!(is_excluded_command("nodemon server.js", &[]));
682 }
683
684 #[test]
685 fn interactive_tools_are_passthrough() {
686 assert!(is_excluded_command("vim file.rs", &[]));
687 assert!(is_excluded_command("nvim", &[]));
688 assert!(is_excluded_command("htop", &[]));
689 assert!(is_excluded_command("ssh user@host", &[]));
690 assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
691 }
692
693 #[test]
694 fn docker_streaming_is_passthrough() {
695 assert!(is_excluded_command("docker logs my-container", &[]));
696 assert!(is_excluded_command("docker logs -f webapp", &[]));
697 assert!(is_excluded_command("docker attach my-container", &[]));
698 assert!(is_excluded_command("docker exec -it web bash", &[]));
699 assert!(is_excluded_command("docker exec -ti web bash", &[]));
700 assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
701 assert!(is_excluded_command("docker compose exec web bash", &[]));
702 assert!(is_excluded_command("docker stats", &[]));
703 assert!(is_excluded_command("docker events", &[]));
704 }
705
706 #[test]
707 fn kubectl_is_passthrough() {
708 assert!(is_excluded_command("kubectl logs my-pod", &[]));
709 assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
710 assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
711 assert!(is_excluded_command(
712 "kubectl port-forward svc/web 8080:80",
713 &[]
714 ));
715 assert!(is_excluded_command("kubectl attach my-pod", &[]));
716 assert!(is_excluded_command("kubectl proxy", &[]));
717 }
718
719 #[test]
720 fn database_repls_are_passthrough() {
721 assert!(is_excluded_command("psql -U user mydb", &[]));
722 assert!(is_excluded_command("mysql -u root -p", &[]));
723 assert!(is_excluded_command("sqlite3 data.db", &[]));
724 assert!(is_excluded_command("redis-cli", &[]));
725 assert!(is_excluded_command("mongosh", &[]));
726 }
727
728 #[test]
729 fn streaming_tools_are_passthrough() {
730 assert!(is_excluded_command("journalctl -f", &[]));
731 assert!(is_excluded_command("ping 8.8.8.8", &[]));
732 assert!(is_excluded_command("strace -p 1234", &[]));
733 assert!(is_excluded_command("tcpdump -i eth0", &[]));
734 assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
735 assert!(is_excluded_command("tmux new -s work", &[]));
736 assert!(is_excluded_command("screen -S dev", &[]));
737 }
738
739 #[test]
740 fn additional_dev_servers_are_passthrough() {
741 assert!(is_excluded_command("gatsby develop", &[]));
742 assert!(is_excluded_command("ng serve --port 4200", &[]));
743 assert!(is_excluded_command("remix dev", &[]));
744 assert!(is_excluded_command("wrangler dev", &[]));
745 assert!(is_excluded_command("hugo server", &[]));
746 assert!(is_excluded_command("bun dev", &[]));
747 assert!(is_excluded_command("cargo watch -x test", &[]));
748 }
749
750 #[test]
751 fn normal_commands_not_excluded() {
752 assert!(!is_excluded_command("git status", &[]));
753 assert!(!is_excluded_command("cargo test", &[]));
754 assert!(!is_excluded_command("npm run build", &[]));
755 assert!(!is_excluded_command("ls -la", &[]));
756 }
757
758 #[test]
759 fn user_exclusions_work() {
760 let excl = vec!["myapp".to_string()];
761 assert!(is_excluded_command("myapp serve", &excl));
762 assert!(!is_excluded_command("git status", &excl));
763 }
764}