Skip to main content

lean_ctx/
shell.rs

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    // JS/TS dev servers & watchers
203    "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
230    "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    // Kubernetes
247    "kubectl logs",
248    "kubectl exec -it",
249    "kubectl exec -ti",
250    "kubectl attach",
251    "kubectl port-forward",
252    "kubectl proxy",
253    // System monitors & streaming
254    "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    // Editors & pagers
270    "less",
271    "more",
272    "vim",
273    "nvim",
274    "vi ",
275    "nano",
276    "micro ",
277    "helix ",
278    "hx ",
279    "emacs",
280    // Terminal multiplexers
281    "tmux",
282    "screen",
283    // Interactive shells & REPLs
284    "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    // Rust watchers
301    "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    // Apply lightweight cleanup to remove whitespace-only lines and collapse braces
410    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
462/// Windows only: argument that passes one command string to the shell binary.
463/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
464fn 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        // POSIX-style shells: Git Bash / MSYS (`bash`, `sh`, `zsh`, `fish`, …).
471        // `/C` is only valid for `cmd.exe`; using it with bash produced
472        // `/C: Is a directory` and exit 126 (see github.com/yvgude/lean-ctx/issues/7).
473        "-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: &regex::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}