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 force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
176 let piped = !io::stdout().is_terminal();
177
178 if piped && !force_compress {
179 if !full_output.is_empty() {
180 let _ = io::stdout().write_all(full_output.as_bytes());
181 if !full_output.ends_with('\n') {
182 let _ = io::stdout().write_all(b"\n");
183 }
184 }
185 stats::record(command, input_tokens, input_tokens);
186 return exit_code;
187 }
188
189 let (compressed, output_tokens) = compress_and_measure(command, &stdout, &stderr);
190
191 stats::record(command, input_tokens, output_tokens);
192
193 if !compressed.is_empty() {
194 let _ = io::stdout().write_all(compressed.as_bytes());
195 if !compressed.ends_with('\n') {
196 let _ = io::stdout().write_all(b"\n");
197 }
198 }
199 if cfg.tee_on_error && exit_code != 0 && !full_output.trim().is_empty() {
200 if let Some(path) = save_tee(command, &full_output) {
201 eprintln!(
202 "[lean-ctx: output saved to {path} (secrets redacted, auto-deleted after 24h)]"
203 );
204 }
205 }
206
207 let threshold = cfg.slow_command_threshold_ms;
208 if threshold > 0 && duration_ms >= threshold as u128 {
209 slow_log::record(command, duration_ms, exit_code);
210 }
211
212 exit_code
213}
214
215const BUILTIN_PASSTHROUGH: &[&str] = &[
216 "turbo",
218 "nx serve",
219 "nx dev",
220 "next dev",
221 "vite dev",
222 "vite preview",
223 "vitest",
224 "nuxt dev",
225 "astro dev",
226 "webpack serve",
227 "webpack-dev-server",
228 "nodemon",
229 "concurrently",
230 "pm2",
231 "pm2 logs",
232 "gatsby develop",
233 "expo start",
234 "react-scripts start",
235 "ng serve",
236 "remix dev",
237 "wrangler dev",
238 "hugo server",
239 "hugo serve",
240 "jekyll serve",
241 "bun dev",
242 "ember serve",
243 "docker compose up",
245 "docker-compose up",
246 "docker compose logs",
247 "docker-compose logs",
248 "docker compose exec",
249 "docker-compose exec",
250 "docker compose run",
251 "docker-compose run",
252 "docker logs",
253 "docker attach",
254 "docker exec -it",
255 "docker exec -ti",
256 "docker run -it",
257 "docker run -ti",
258 "docker stats",
259 "docker events",
260 "kubectl logs",
262 "kubectl exec -it",
263 "kubectl exec -ti",
264 "kubectl attach",
265 "kubectl port-forward",
266 "kubectl proxy",
267 "top",
269 "htop",
270 "btop",
271 "watch ",
272 "tail -f",
273 "tail -F",
274 "journalctl -f",
275 "journalctl --follow",
276 "dmesg -w",
277 "dmesg --follow",
278 "strace",
279 "tcpdump",
280 "ping ",
281 "ping6 ",
282 "traceroute",
283 "less",
285 "more",
286 "vim",
287 "nvim",
288 "vi ",
289 "nano",
290 "micro ",
291 "helix ",
292 "hx ",
293 "emacs",
294 "tmux",
296 "screen",
297 "ssh ",
299 "telnet ",
300 "nc ",
301 "ncat ",
302 "psql",
303 "mysql",
304 "sqlite3",
305 "redis-cli",
306 "mongosh",
307 "mongo ",
308 "python3 -i",
309 "python -i",
310 "irb",
311 "rails console",
312 "rails c ",
313 "iex",
314 "cargo watch",
316];
317
318fn is_excluded_command(command: &str, excluded: &[String]) -> bool {
319 let cmd = command.trim().to_lowercase();
320 for pattern in BUILTIN_PASSTHROUGH {
321 if cmd == *pattern || cmd.starts_with(&format!("{pattern} ")) || cmd.contains(pattern) {
322 return true;
323 }
324 }
325 if excluded.is_empty() {
326 return false;
327 }
328 excluded.iter().any(|excl| {
329 let excl_lower = excl.trim().to_lowercase();
330 cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} "))
331 })
332}
333
334pub fn interactive() {
335 let real_shell = detect_shell();
336
337 eprintln!("lean-ctx shell v2.13.1 (wrapping {real_shell})");
338 eprintln!("All command output is automatically compressed.");
339 eprintln!("Type 'exit' to quit.\n");
340
341 let stdin = io::stdin();
342 let mut stdout = io::stdout();
343
344 loop {
345 let _ = write!(stdout, "lean-ctx> ");
346 let _ = stdout.flush();
347
348 let mut line = String::new();
349 match stdin.lock().read_line(&mut line) {
350 Ok(0) => break,
351 Ok(_) => {}
352 Err(_) => break,
353 }
354
355 let cmd = line.trim();
356 if cmd.is_empty() {
357 continue;
358 }
359 if cmd == "exit" || cmd == "quit" {
360 break;
361 }
362 if cmd == "gain" {
363 println!("{}", stats::format_gain());
364 continue;
365 }
366
367 let exit_code = exec(cmd);
368
369 if exit_code != 0 {
370 let _ = writeln!(stdout, "[exit: {exit_code}]");
371 }
372 }
373}
374
375fn compress_and_measure(command: &str, stdout: &str, stderr: &str) -> (String, usize) {
376 let compressed_stdout = compress_if_beneficial(command, stdout);
377 let compressed_stderr = compress_if_beneficial(command, stderr);
378
379 let mut result = String::new();
380 if !compressed_stdout.is_empty() {
381 result.push_str(&compressed_stdout);
382 }
383 if !compressed_stderr.is_empty() {
384 if !result.is_empty() {
385 result.push('\n');
386 }
387 result.push_str(&compressed_stderr);
388 }
389
390 let output_tokens = count_tokens(&result);
391 (result, output_tokens)
392}
393
394fn compress_if_beneficial(command: &str, output: &str) -> String {
395 if output.trim().is_empty() {
396 return String::new();
397 }
398
399 let original_tokens = count_tokens(output);
400
401 if original_tokens < 50 {
402 return output.to_string();
403 }
404
405 let min_output_tokens = 5;
406
407 if let Some(compressed) = patterns::compress_output(command, output) {
408 if !compressed.trim().is_empty() {
409 let compressed_tokens = count_tokens(&compressed);
410 if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
411 let saved = original_tokens - compressed_tokens;
412 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
413 return format!(
414 "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
415 );
416 }
417 if compressed_tokens < min_output_tokens {
418 return output.to_string();
419 }
420 }
421 }
422
423 let cleaned = crate::core::compressor::lightweight_cleanup(output);
425 let cleaned_tokens = count_tokens(&cleaned);
426 if cleaned_tokens < original_tokens {
427 let lines: Vec<&str> = cleaned.lines().collect();
428 if lines.len() > 30 {
429 let first = &lines[..5];
430 let last = &lines[lines.len() - 5..];
431 let omitted = lines.len() - 10;
432 let compressed = format!(
433 "{}\n... ({omitted} lines omitted) ...\n{}",
434 first.join("\n"),
435 last.join("\n")
436 );
437 let ct = count_tokens(&compressed);
438 if ct < original_tokens {
439 let saved = original_tokens - ct;
440 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
441 return format!("{compressed}\n[lean-ctx: {original_tokens}→{ct} tok, -{pct}%]");
442 }
443 }
444 if cleaned_tokens < original_tokens {
445 let saved = original_tokens - cleaned_tokens;
446 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
447 return format!(
448 "{cleaned}\n[lean-ctx: {original_tokens}→{cleaned_tokens} tok, -{pct}%]"
449 );
450 }
451 }
452
453 let lines: Vec<&str> = output.lines().collect();
454 if lines.len() > 30 {
455 let first = &lines[..5];
456 let last = &lines[lines.len() - 5..];
457 let omitted = lines.len() - 10;
458 let compressed = format!(
459 "{}\n... ({omitted} lines omitted) ...\n{}",
460 first.join("\n"),
461 last.join("\n")
462 );
463 let compressed_tokens = count_tokens(&compressed);
464 if compressed_tokens < original_tokens {
465 let saved = original_tokens - compressed_tokens;
466 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
467 return format!(
468 "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
469 );
470 }
471 }
472
473 output.to_string()
474}
475
476fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
479 if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
480 "-Command"
481 } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
482 "/C"
483 } else {
484 "-c"
488 }
489}
490
491pub fn shell_and_flag() -> (String, String) {
492 let shell = detect_shell();
493 let flag = if cfg!(windows) {
494 let name = std::path::Path::new(&shell)
495 .file_name()
496 .and_then(|n| n.to_str())
497 .unwrap_or("")
498 .to_ascii_lowercase();
499 windows_shell_flag_for_exe_basename(&name).to_string()
500 } else {
501 "-c".to_string()
502 };
503 (shell, flag)
504}
505
506fn detect_shell() -> String {
507 if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
508 return shell;
509 }
510
511 if let Ok(shell) = std::env::var("SHELL") {
512 let bin = std::path::Path::new(&shell)
513 .file_name()
514 .and_then(|n| n.to_str())
515 .unwrap_or("sh");
516
517 if bin == "lean-ctx" {
518 return find_real_shell();
519 }
520 return shell;
521 }
522
523 find_real_shell()
524}
525
526#[cfg(unix)]
527fn find_real_shell() -> String {
528 for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
529 if std::path::Path::new(shell).exists() {
530 return shell.to_string();
531 }
532 }
533 "/bin/sh".to_string()
534}
535
536#[cfg(windows)]
537fn find_real_shell() -> String {
538 if is_running_in_powershell() {
539 if let Ok(pwsh) = which_powershell() {
540 return pwsh;
541 }
542 }
543 if let Ok(comspec) = std::env::var("COMSPEC") {
544 return comspec;
545 }
546 "cmd.exe".to_string()
547}
548
549#[cfg(windows)]
550fn is_running_in_powershell() -> bool {
551 std::env::var("PSModulePath").is_ok()
552}
553
554#[cfg(windows)]
555fn which_powershell() -> Result<String, ()> {
556 for candidate in &["pwsh.exe", "powershell.exe"] {
557 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
558 if output.status.success() {
559 if let Ok(path) = String::from_utf8(output.stdout) {
560 if let Some(first_line) = path.lines().next() {
561 let trimmed = first_line.trim();
562 if !trimmed.is_empty() {
563 return Ok(trimmed.to_string());
564 }
565 }
566 }
567 }
568 }
569 }
570 Err(())
571}
572
573fn save_tee(command: &str, output: &str) -> Option<String> {
574 let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
575 std::fs::create_dir_all(&tee_dir).ok()?;
576
577 cleanup_old_tee_logs(&tee_dir);
578
579 let cmd_slug: String = command
580 .chars()
581 .take(40)
582 .map(|c| {
583 if c.is_alphanumeric() || c == '-' {
584 c
585 } else {
586 '_'
587 }
588 })
589 .collect();
590 let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
591 let filename = format!("{ts}_{cmd_slug}.log");
592 let path = tee_dir.join(&filename);
593
594 let masked = mask_sensitive_data(output);
595 std::fs::write(&path, masked).ok()?;
596 Some(path.to_string_lossy().to_string())
597}
598
599fn mask_sensitive_data(input: &str) -> String {
600 use regex::Regex;
601
602 let patterns: Vec<(&str, Regex)> = vec![
603 ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
604 ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
605 ("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()),
606 ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
607 ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
608 ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
609 ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
610 ];
611
612 let mut result = input.to_string();
613 for (label, re) in &patterns {
614 result = re
615 .replace_all(&result, |caps: ®ex::Captures| {
616 if let Some(prefix) = caps.get(1) {
617 format!("{}[REDACTED:{}]", prefix.as_str(), label)
618 } else {
619 format!("[REDACTED:{}]", label)
620 }
621 })
622 .to_string();
623 }
624 result
625}
626
627fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
628 let cutoff =
629 std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
630 let cutoff = match cutoff {
631 Some(t) => t,
632 None => return,
633 };
634
635 if let Ok(entries) = std::fs::read_dir(tee_dir) {
636 for entry in entries.flatten() {
637 if let Ok(meta) = entry.metadata() {
638 if let Ok(modified) = meta.modified() {
639 if modified < cutoff {
640 let _ = std::fs::remove_file(entry.path());
641 }
642 }
643 }
644 }
645 }
646}
647
648#[cfg(test)]
649mod windows_shell_flag_tests {
650 use super::windows_shell_flag_for_exe_basename;
651
652 #[test]
653 fn cmd_uses_slash_c() {
654 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
655 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
656 }
657
658 #[test]
659 fn powershell_uses_command() {
660 assert_eq!(
661 windows_shell_flag_for_exe_basename("powershell.exe"),
662 "-Command"
663 );
664 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
665 }
666
667 #[test]
668 fn posix_shells_use_dash_c() {
669 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
670 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
671 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
672 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
673 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
674 }
675}
676
677#[cfg(test)]
678mod passthrough_tests {
679 use super::is_excluded_command;
680
681 #[test]
682 fn turbo_is_passthrough() {
683 assert!(is_excluded_command("turbo run dev", &[]));
684 assert!(is_excluded_command("turbo run build", &[]));
685 assert!(is_excluded_command("pnpm turbo run dev", &[]));
686 assert!(is_excluded_command("npx turbo run dev", &[]));
687 }
688
689 #[test]
690 fn dev_servers_are_passthrough() {
691 assert!(is_excluded_command("next dev", &[]));
692 assert!(is_excluded_command("vite dev", &[]));
693 assert!(is_excluded_command("nuxt dev", &[]));
694 assert!(is_excluded_command("astro dev", &[]));
695 assert!(is_excluded_command("nodemon server.js", &[]));
696 }
697
698 #[test]
699 fn interactive_tools_are_passthrough() {
700 assert!(is_excluded_command("vim file.rs", &[]));
701 assert!(is_excluded_command("nvim", &[]));
702 assert!(is_excluded_command("htop", &[]));
703 assert!(is_excluded_command("ssh user@host", &[]));
704 assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
705 }
706
707 #[test]
708 fn docker_streaming_is_passthrough() {
709 assert!(is_excluded_command("docker logs my-container", &[]));
710 assert!(is_excluded_command("docker logs -f webapp", &[]));
711 assert!(is_excluded_command("docker attach my-container", &[]));
712 assert!(is_excluded_command("docker exec -it web bash", &[]));
713 assert!(is_excluded_command("docker exec -ti web bash", &[]));
714 assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
715 assert!(is_excluded_command("docker compose exec web bash", &[]));
716 assert!(is_excluded_command("docker stats", &[]));
717 assert!(is_excluded_command("docker events", &[]));
718 }
719
720 #[test]
721 fn kubectl_is_passthrough() {
722 assert!(is_excluded_command("kubectl logs my-pod", &[]));
723 assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
724 assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
725 assert!(is_excluded_command(
726 "kubectl port-forward svc/web 8080:80",
727 &[]
728 ));
729 assert!(is_excluded_command("kubectl attach my-pod", &[]));
730 assert!(is_excluded_command("kubectl proxy", &[]));
731 }
732
733 #[test]
734 fn database_repls_are_passthrough() {
735 assert!(is_excluded_command("psql -U user mydb", &[]));
736 assert!(is_excluded_command("mysql -u root -p", &[]));
737 assert!(is_excluded_command("sqlite3 data.db", &[]));
738 assert!(is_excluded_command("redis-cli", &[]));
739 assert!(is_excluded_command("mongosh", &[]));
740 }
741
742 #[test]
743 fn streaming_tools_are_passthrough() {
744 assert!(is_excluded_command("journalctl -f", &[]));
745 assert!(is_excluded_command("ping 8.8.8.8", &[]));
746 assert!(is_excluded_command("strace -p 1234", &[]));
747 assert!(is_excluded_command("tcpdump -i eth0", &[]));
748 assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
749 assert!(is_excluded_command("tmux new -s work", &[]));
750 assert!(is_excluded_command("screen -S dev", &[]));
751 }
752
753 #[test]
754 fn additional_dev_servers_are_passthrough() {
755 assert!(is_excluded_command("gatsby develop", &[]));
756 assert!(is_excluded_command("ng serve --port 4200", &[]));
757 assert!(is_excluded_command("remix dev", &[]));
758 assert!(is_excluded_command("wrangler dev", &[]));
759 assert!(is_excluded_command("hugo server", &[]));
760 assert!(is_excluded_command("bun dev", &[]));
761 assert!(is_excluded_command("cargo watch -x test", &[]));
762 }
763
764 #[test]
765 fn normal_commands_not_excluded() {
766 assert!(!is_excluded_command("git status", &[]));
767 assert!(!is_excluded_command("cargo test", &[]));
768 assert!(!is_excluded_command("npm run build", &[]));
769 assert!(!is_excluded_command("ls -la", &[]));
770 }
771
772 #[test]
773 fn user_exclusions_work() {
774 let excl = vec!["myapp".to_string()];
775 assert!(is_excluded_command("myapp serve", &excl));
776 assert!(!is_excluded_command("git status", &excl));
777 }
778}