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 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    // JS/TS dev servers & watchers
217    "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
244    "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    // Kubernetes
261    "kubectl logs",
262    "kubectl exec -it",
263    "kubectl exec -ti",
264    "kubectl attach",
265    "kubectl port-forward",
266    "kubectl proxy",
267    // System monitors & streaming
268    "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    // Editors & pagers
284    "less",
285    "more",
286    "vim",
287    "nvim",
288    "vi ",
289    "nano",
290    "micro ",
291    "helix ",
292    "hx ",
293    "emacs",
294    // Terminal multiplexers
295    "tmux",
296    "screen",
297    // Interactive shells & REPLs
298    "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    // Rust watchers
315    "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    // Apply lightweight cleanup to remove whitespace-only lines and collapse braces
424    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
476/// Windows only: argument that passes one command string to the shell binary.
477/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
478fn 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        // POSIX-style shells: Git Bash / MSYS (`bash`, `sh`, `zsh`, `fish`, …).
485        // `/C` is only valid for `cmd.exe`; using it with bash produced
486        // `/C: Is a directory` and exit 126 (see github.com/yvgude/lean-ctx/issues/7).
487        "-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: &regex::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}