Skip to main content

lean_ctx/
shell.rs

1use std::io::{self, BufRead, IsTerminal, 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
10/// Detects if the current process runs inside a Docker/container environment.
11pub fn is_container() -> bool {
12    #[cfg(unix)]
13    {
14        if std::path::Path::new("/.dockerenv").exists() {
15            return true;
16        }
17        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
18            if cgroup.contains("/docker/") || cgroup.contains("/lxc/") {
19                return true;
20            }
21        }
22        if let Ok(mounts) = std::fs::read_to_string("/proc/self/mountinfo") {
23            if mounts.contains("/docker/containers/") {
24                return true;
25            }
26        }
27        false
28    }
29    #[cfg(not(unix))]
30    {
31        false
32    }
33}
34
35/// Returns true if stdin is NOT a terminal (pipe, /dev/null, etc.)
36pub fn is_non_interactive() -> bool {
37    !io::stdin().is_terminal()
38}
39
40pub fn exec(command: &str) -> i32 {
41    let (shell, shell_flag) = shell_and_flag();
42    let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
43    let command = command.as_str();
44
45    if std::env::var("LEAN_CTX_DISABLED").is_ok() {
46        return exec_inherit(command, &shell, &shell_flag);
47    }
48
49    let cfg = config::Config::load();
50    let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
51    let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
52
53    if raw_mode || (!force_compress && is_excluded_command(command, &cfg.excluded_commands)) {
54        return exec_inherit(command, &shell, &shell_flag);
55    }
56
57    if !force_compress {
58        if io::stdout().is_terminal() {
59            return exec_inherit_tracked(command, &shell, &shell_flag);
60        }
61        return exec_inherit(command, &shell, &shell_flag);
62    }
63
64    exec_buffered(command, &shell, &shell_flag, &cfg)
65}
66
67fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
68    let status = Command::new(shell)
69        .arg(shell_flag)
70        .arg(command)
71        .env("LEAN_CTX_ACTIVE", "1")
72        .stdin(Stdio::inherit())
73        .stdout(Stdio::inherit())
74        .stderr(Stdio::inherit())
75        .status();
76
77    match status {
78        Ok(s) => s.code().unwrap_or(1),
79        Err(e) => {
80            eprintln!("lean-ctx: failed to execute: {e}");
81            127
82        }
83    }
84}
85
86fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
87    let code = exec_inherit(command, shell, shell_flag);
88    stats::record(command, 0, 0);
89    code
90}
91
92fn combine_output(stdout: &str, stderr: &str) -> String {
93    if stderr.is_empty() {
94        stdout.to_string()
95    } else if stdout.is_empty() {
96        stderr.to_string()
97    } else {
98        format!("{stdout}\n{stderr}")
99    }
100}
101
102fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
103    let start = std::time::Instant::now();
104
105    let child = Command::new(shell)
106        .arg(shell_flag)
107        .arg(command)
108        .env("LEAN_CTX_ACTIVE", "1")
109        .stdout(Stdio::piped())
110        .stderr(Stdio::piped())
111        .spawn();
112
113    let child = match child {
114        Ok(c) => c,
115        Err(e) => {
116            eprintln!("lean-ctx: failed to execute: {e}");
117            return 127;
118        }
119    };
120
121    let output = match child.wait_with_output() {
122        Ok(o) => o,
123        Err(e) => {
124            eprintln!("lean-ctx: failed to wait: {e}");
125            return 127;
126        }
127    };
128
129    let duration_ms = start.elapsed().as_millis();
130    let exit_code = output.status.code().unwrap_or(1);
131    let stdout = String::from_utf8_lossy(&output.stdout);
132    let stderr = String::from_utf8_lossy(&output.stderr);
133
134    let full_output = combine_output(&stdout, &stderr);
135    let input_tokens = count_tokens(&full_output);
136
137    let (compressed, output_tokens) = compress_and_measure(command, &stdout, &stderr);
138
139    stats::record(command, input_tokens, output_tokens);
140
141    if !compressed.is_empty() {
142        let _ = io::stdout().write_all(compressed.as_bytes());
143        if !compressed.ends_with('\n') {
144            let _ = io::stdout().write_all(b"\n");
145        }
146    }
147    let should_tee = match cfg.tee_mode {
148        config::TeeMode::Always => !full_output.trim().is_empty(),
149        config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
150        config::TeeMode::Never => false,
151    };
152    if should_tee {
153        if let Some(path) = save_tee(command, &full_output) {
154            eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
155        }
156    }
157
158    let threshold = cfg.slow_command_threshold_ms;
159    if threshold > 0 && duration_ms >= threshold as u128 {
160        slow_log::record(command, duration_ms, exit_code);
161    }
162
163    exit_code
164}
165
166const BUILTIN_PASSTHROUGH: &[&str] = &[
167    // JS/TS dev servers & watchers
168    "turbo",
169    "nx serve",
170    "nx dev",
171    "next dev",
172    "vite dev",
173    "vite preview",
174    "vitest",
175    "nuxt dev",
176    "astro dev",
177    "webpack serve",
178    "webpack-dev-server",
179    "nodemon",
180    "concurrently",
181    "pm2",
182    "pm2 logs",
183    "gatsby develop",
184    "expo start",
185    "react-scripts start",
186    "ng serve",
187    "remix dev",
188    "wrangler dev",
189    "hugo server",
190    "hugo serve",
191    "jekyll serve",
192    "bun dev",
193    "ember serve",
194    // Docker
195    "docker compose up",
196    "docker-compose up",
197    "docker compose logs",
198    "docker-compose logs",
199    "docker compose exec",
200    "docker-compose exec",
201    "docker compose run",
202    "docker-compose run",
203    "docker logs",
204    "docker attach",
205    "docker exec -it",
206    "docker exec -ti",
207    "docker run -it",
208    "docker run -ti",
209    "docker stats",
210    "docker events",
211    // Kubernetes
212    "kubectl logs",
213    "kubectl exec -it",
214    "kubectl exec -ti",
215    "kubectl attach",
216    "kubectl port-forward",
217    "kubectl proxy",
218    // System monitors & streaming
219    "top",
220    "htop",
221    "btop",
222    "watch ",
223    "tail -f",
224    "tail -F",
225    "journalctl -f",
226    "journalctl --follow",
227    "dmesg -w",
228    "dmesg --follow",
229    "strace",
230    "tcpdump",
231    "ping ",
232    "ping6 ",
233    "traceroute",
234    // Editors & pagers
235    "less",
236    "more",
237    "vim",
238    "nvim",
239    "vi ",
240    "nano",
241    "micro ",
242    "helix ",
243    "hx ",
244    "emacs",
245    // Terminal multiplexers
246    "tmux",
247    "screen",
248    // Interactive shells & REPLs
249    "ssh ",
250    "telnet ",
251    "nc ",
252    "ncat ",
253    "psql",
254    "mysql",
255    "sqlite3",
256    "redis-cli",
257    "mongosh",
258    "mongo ",
259    "python3 -i",
260    "python -i",
261    "irb",
262    "rails console",
263    "rails c ",
264    "iex",
265    // Rust watchers
266    "cargo watch",
267    // Authentication flows (device code, OAuth, SSO — output contains codes users must see)
268    "az login",
269    "az account",
270    "gh auth",
271    "gcloud auth",
272    "gcloud init",
273    "aws sso",
274    "aws configure sso",
275    "firebase login",
276    "netlify login",
277    "vercel login",
278    "heroku login",
279    "flyctl auth",
280    "fly auth",
281    "railway login",
282    "supabase login",
283    "wrangler login",
284    "doppler login",
285    "vault login",
286    "oc login",
287    "kubelogin",
288    "--use-device-code",
289];
290
291fn is_excluded_command(command: &str, excluded: &[String]) -> bool {
292    let cmd = command.trim().to_lowercase();
293    for pattern in BUILTIN_PASSTHROUGH {
294        if cmd == *pattern || cmd.starts_with(&format!("{pattern} ")) || cmd.contains(pattern) {
295            return true;
296        }
297    }
298    if excluded.is_empty() {
299        return false;
300    }
301    excluded.iter().any(|excl| {
302        let excl_lower = excl.trim().to_lowercase();
303        cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} "))
304    })
305}
306
307pub fn interactive() {
308    let real_shell = detect_shell();
309
310    eprintln!(
311        "lean-ctx shell v{} (wrapping {real_shell})",
312        env!("CARGO_PKG_VERSION")
313    );
314    eprintln!("All command output is automatically compressed.");
315    eprintln!("Type 'exit' to quit.\n");
316
317    let stdin = io::stdin();
318    let mut stdout = io::stdout();
319
320    loop {
321        let _ = write!(stdout, "lean-ctx> ");
322        let _ = stdout.flush();
323
324        let mut line = String::new();
325        match stdin.lock().read_line(&mut line) {
326            Ok(0) => break,
327            Ok(_) => {}
328            Err(_) => break,
329        }
330
331        let cmd = line.trim();
332        if cmd.is_empty() {
333            continue;
334        }
335        if cmd == "exit" || cmd == "quit" {
336            break;
337        }
338        if cmd == "gain" {
339            println!("{}", stats::format_gain());
340            continue;
341        }
342
343        let exit_code = exec(cmd);
344
345        if exit_code != 0 {
346            let _ = writeln!(stdout, "[exit: {exit_code}]");
347        }
348    }
349}
350
351fn compress_and_measure(command: &str, stdout: &str, stderr: &str) -> (String, usize) {
352    let compressed_stdout = compress_if_beneficial(command, stdout);
353    let compressed_stderr = compress_if_beneficial(command, stderr);
354
355    let mut result = String::new();
356    if !compressed_stdout.is_empty() {
357        result.push_str(&compressed_stdout);
358    }
359    if !compressed_stderr.is_empty() {
360        if !result.is_empty() {
361            result.push('\n');
362        }
363        result.push_str(&compressed_stderr);
364    }
365
366    let output_tokens = count_tokens(&result);
367    (result, output_tokens)
368}
369
370fn show_shell_stats() -> bool {
371    std::env::var("LEAN_CTX_SHELL_STATS")
372        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
373        .unwrap_or(false)
374}
375
376fn maybe_stats_suffix(original: usize, compressed: usize) -> String {
377    if !show_shell_stats() {
378        return String::new();
379    }
380    let saved = original.saturating_sub(compressed);
381    let pct = if original > 0 {
382        (saved as f64 / original as f64 * 100.0).round() as usize
383    } else {
384        0
385    };
386    format!("\n[lean-ctx: {original}→{compressed} tok, -{pct}%]")
387}
388
389fn compress_if_beneficial(command: &str, output: &str) -> String {
390    if output.trim().is_empty() {
391        return String::new();
392    }
393
394    if crate::tools::ctx_shell::contains_auth_flow(output) {
395        return output.to_string();
396    }
397
398    let original_tokens = count_tokens(output);
399
400    if original_tokens < 50 {
401        return output.to_string();
402    }
403
404    let min_output_tokens = 5;
405
406    if let Some(compressed) = patterns::compress_output(command, output) {
407        if !compressed.trim().is_empty() {
408            let compressed_tokens = count_tokens(&compressed);
409            if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
410                let suffix = maybe_stats_suffix(original_tokens, compressed_tokens);
411                return format!("{compressed}{suffix}");
412            }
413            if compressed_tokens < min_output_tokens {
414                return output.to_string();
415            }
416        }
417    }
418
419    let cleaned = crate::core::compressor::lightweight_cleanup(output);
420    let cleaned_tokens = count_tokens(&cleaned);
421    if cleaned_tokens < original_tokens {
422        let lines: Vec<&str> = cleaned.lines().collect();
423        if lines.len() > 30 {
424            let first = &lines[..5];
425            let last = &lines[lines.len() - 5..];
426            let omitted = lines.len() - 10;
427            let total = lines.len();
428            let compressed = format!(
429                "{}\n[truncated: showing 10/{total} lines, {omitted} omitted]\n{}",
430                first.join("\n"),
431                last.join("\n")
432            );
433            let ct = count_tokens(&compressed);
434            if ct < original_tokens {
435                let suffix = maybe_stats_suffix(original_tokens, ct);
436                return format!("{compressed}{suffix}");
437            }
438        }
439        if cleaned_tokens < original_tokens {
440            let suffix = maybe_stats_suffix(original_tokens, cleaned_tokens);
441            return format!("{cleaned}{suffix}");
442        }
443    }
444
445    let lines: Vec<&str> = output.lines().collect();
446    if lines.len() > 30 {
447        let first = &lines[..5];
448        let last = &lines[lines.len() - 5..];
449        let omitted = lines.len() - 10;
450        let compressed = format!(
451            "{}\n... ({omitted} lines omitted) ...\n{}",
452            first.join("\n"),
453            last.join("\n")
454        );
455        let compressed_tokens = count_tokens(&compressed);
456        if compressed_tokens < original_tokens {
457            let suffix = maybe_stats_suffix(original_tokens, compressed_tokens);
458            return format!("{compressed}{suffix}");
459        }
460    }
461
462    output.to_string()
463}
464
465/// Windows only: argument that passes one command string to the shell binary.
466/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
467fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
468    if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
469        "-Command"
470    } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
471        "/C"
472    } else {
473        // POSIX-style shells: Git Bash / MSYS (`bash`, `sh`, `zsh`, `fish`, …).
474        // `/C` is only valid for `cmd.exe`; using it with bash produced
475        // `/C: Is a directory` and exit 126 (see github.com/yvgude/lean-ctx/issues/7).
476        "-c"
477    }
478}
479
480pub fn shell_and_flag() -> (String, String) {
481    let shell = detect_shell();
482    let flag = if cfg!(windows) {
483        let name = std::path::Path::new(&shell)
484            .file_name()
485            .and_then(|n| n.to_str())
486            .unwrap_or("")
487            .to_ascii_lowercase();
488        windows_shell_flag_for_exe_basename(&name).to_string()
489    } else {
490        "-c".to_string()
491    };
492    (shell, flag)
493}
494
495fn detect_shell() -> String {
496    if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
497        return shell;
498    }
499
500    if let Ok(shell) = std::env::var("SHELL") {
501        let bin = std::path::Path::new(&shell)
502            .file_name()
503            .and_then(|n| n.to_str())
504            .unwrap_or("sh");
505
506        if bin == "lean-ctx" {
507            return find_real_shell();
508        }
509        return shell;
510    }
511
512    find_real_shell()
513}
514
515#[cfg(unix)]
516fn find_real_shell() -> String {
517    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
518        if std::path::Path::new(shell).exists() {
519            return shell.to_string();
520        }
521    }
522    "/bin/sh".to_string()
523}
524
525#[cfg(windows)]
526fn find_real_shell() -> String {
527    if is_running_in_powershell() {
528        if let Ok(pwsh) = which_powershell() {
529            return pwsh;
530        }
531    }
532    if let Ok(comspec) = std::env::var("COMSPEC") {
533        return comspec;
534    }
535    "cmd.exe".to_string()
536}
537
538#[cfg(windows)]
539fn is_running_in_powershell() -> bool {
540    std::env::var("PSModulePath").is_ok()
541}
542
543#[cfg(windows)]
544fn which_powershell() -> Result<String, ()> {
545    for candidate in &["pwsh.exe", "powershell.exe"] {
546        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
547            if output.status.success() {
548                if let Ok(path) = String::from_utf8(output.stdout) {
549                    if let Some(first_line) = path.lines().next() {
550                        let trimmed = first_line.trim();
551                        if !trimmed.is_empty() {
552                            return Ok(trimmed.to_string());
553                        }
554                    }
555                }
556            }
557        }
558    }
559    Err(())
560}
561
562pub fn save_tee(command: &str, output: &str) -> Option<String> {
563    let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
564    std::fs::create_dir_all(&tee_dir).ok()?;
565
566    cleanup_old_tee_logs(&tee_dir);
567
568    let cmd_slug: String = command
569        .chars()
570        .take(40)
571        .map(|c| {
572            if c.is_alphanumeric() || c == '-' {
573                c
574            } else {
575                '_'
576            }
577        })
578        .collect();
579    let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
580    let filename = format!("{ts}_{cmd_slug}.log");
581    let path = tee_dir.join(&filename);
582
583    let masked = mask_sensitive_data(output);
584    std::fs::write(&path, masked).ok()?;
585    Some(path.to_string_lossy().to_string())
586}
587
588fn mask_sensitive_data(input: &str) -> String {
589    use regex::Regex;
590
591    let patterns: Vec<(&str, Regex)> = vec![
592        ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
593        ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
594        ("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()),
595        ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
596        ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
597        ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
598        ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
599    ];
600
601    let mut result = input.to_string();
602    for (label, re) in &patterns {
603        result = re
604            .replace_all(&result, |caps: &regex::Captures| {
605                if let Some(prefix) = caps.get(1) {
606                    format!("{}[REDACTED:{}]", prefix.as_str(), label)
607                } else {
608                    format!("[REDACTED:{}]", label)
609                }
610            })
611            .to_string();
612    }
613    result
614}
615
616fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
617    let cutoff =
618        std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
619    let cutoff = match cutoff {
620        Some(t) => t,
621        None => return,
622    };
623
624    if let Ok(entries) = std::fs::read_dir(tee_dir) {
625        for entry in entries.flatten() {
626            if let Ok(meta) = entry.metadata() {
627                if let Ok(modified) = meta.modified() {
628                    if modified < cutoff {
629                        let _ = std::fs::remove_file(entry.path());
630                    }
631                }
632            }
633        }
634    }
635}
636
637/// Join multiple CLI arguments into a single command string, using quoting
638/// conventions appropriate for the detected shell.
639///
640/// On Unix, this always produces POSIX-compatible quoting.
641/// On Windows, the quoting adapts to the actual shell (PowerShell, cmd.exe,
642/// or Git Bash / MSYS).
643pub fn join_command(args: &[String]) -> String {
644    let (_, flag) = shell_and_flag();
645    join_command_for(args, &flag)
646}
647
648fn join_command_for(args: &[String], shell_flag: &str) -> String {
649    match shell_flag {
650        "-Command" => join_powershell(args),
651        "/C" => join_cmd(args),
652        _ => join_posix(args),
653    }
654}
655
656fn join_posix(args: &[String]) -> String {
657    args.iter()
658        .map(|a| quote_posix(a))
659        .collect::<Vec<_>>()
660        .join(" ")
661}
662
663fn join_powershell(args: &[String]) -> String {
664    let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
665    format!("& {}", quoted.join(" "))
666}
667
668fn join_cmd(args: &[String]) -> String {
669    args.iter()
670        .map(|a| quote_cmd(a))
671        .collect::<Vec<_>>()
672        .join(" ")
673}
674
675fn quote_posix(s: &str) -> String {
676    if s.is_empty() {
677        return "''".to_string();
678    }
679    if s.bytes()
680        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
681    {
682        return s.to_string();
683    }
684    format!("'{}'", s.replace('\'', "'\\''"))
685}
686
687fn quote_powershell(s: &str) -> String {
688    if s.is_empty() {
689        return "''".to_string();
690    }
691    if s.bytes()
692        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
693    {
694        return s.to_string();
695    }
696    format!("'{}'", s.replace('\'', "''"))
697}
698
699fn quote_cmd(s: &str) -> String {
700    if s.is_empty() {
701        return "\"\"".to_string();
702    }
703    if s.bytes()
704        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
705    {
706        return s.to_string();
707    }
708    format!("\"{}\"", s.replace('"', "\\\""))
709}
710
711#[cfg(test)]
712mod join_command_tests {
713    use super::*;
714
715    #[test]
716    fn posix_simple_args() {
717        let args: Vec<String> = vec!["git".into(), "status".into()];
718        assert_eq!(join_command_for(&args, "-c"), "git status");
719    }
720
721    #[test]
722    fn posix_path_with_spaces() {
723        let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
724        assert_eq!(
725            join_command_for(&args, "-c"),
726            "'/usr/local/my app/bin' --help"
727        );
728    }
729
730    #[test]
731    fn posix_single_quotes_escaped() {
732        let args: Vec<String> = vec!["echo".into(), "it's".into()];
733        assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
734    }
735
736    #[test]
737    fn posix_empty_arg() {
738        let args: Vec<String> = vec!["cmd".into(), "".into()];
739        assert_eq!(join_command_for(&args, "-c"), "cmd ''");
740    }
741
742    #[test]
743    fn powershell_simple_args() {
744        let args: Vec<String> = vec!["npm".into(), "install".into()];
745        assert_eq!(join_command_for(&args, "-Command"), "& npm install");
746    }
747
748    #[test]
749    fn powershell_path_with_spaces() {
750        let args: Vec<String> = vec![
751            "C:\\Program Files\\nodejs\\npm.cmd".into(),
752            "install".into(),
753        ];
754        assert_eq!(
755            join_command_for(&args, "-Command"),
756            "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
757        );
758    }
759
760    #[test]
761    fn powershell_single_quotes_escaped() {
762        let args: Vec<String> = vec!["echo".into(), "it's done".into()];
763        assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
764    }
765
766    #[test]
767    fn cmd_simple_args() {
768        let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
769        assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
770    }
771
772    #[test]
773    fn cmd_path_with_spaces() {
774        let args: Vec<String> = vec![
775            "C:\\Program Files\\nodejs\\npm.cmd".into(),
776            "install".into(),
777        ];
778        assert_eq!(
779            join_command_for(&args, "/C"),
780            "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
781        );
782    }
783
784    #[test]
785    fn cmd_double_quotes_escaped() {
786        let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
787        assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
788    }
789
790    #[test]
791    fn unknown_flag_uses_posix() {
792        let args: Vec<String> = vec!["ls".into(), "-la".into()];
793        assert_eq!(join_command_for(&args, "--exec"), "ls -la");
794    }
795}
796
797#[cfg(test)]
798mod windows_shell_flag_tests {
799    use super::windows_shell_flag_for_exe_basename;
800
801    #[test]
802    fn cmd_uses_slash_c() {
803        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
804        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
805    }
806
807    #[test]
808    fn powershell_uses_command() {
809        assert_eq!(
810            windows_shell_flag_for_exe_basename("powershell.exe"),
811            "-Command"
812        );
813        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
814    }
815
816    #[test]
817    fn posix_shells_use_dash_c() {
818        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
819        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
820        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
821        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
822        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
823    }
824}
825
826#[cfg(test)]
827mod passthrough_tests {
828    use super::is_excluded_command;
829
830    #[test]
831    fn turbo_is_passthrough() {
832        assert!(is_excluded_command("turbo run dev", &[]));
833        assert!(is_excluded_command("turbo run build", &[]));
834        assert!(is_excluded_command("pnpm turbo run dev", &[]));
835        assert!(is_excluded_command("npx turbo run dev", &[]));
836    }
837
838    #[test]
839    fn dev_servers_are_passthrough() {
840        assert!(is_excluded_command("next dev", &[]));
841        assert!(is_excluded_command("vite dev", &[]));
842        assert!(is_excluded_command("nuxt dev", &[]));
843        assert!(is_excluded_command("astro dev", &[]));
844        assert!(is_excluded_command("nodemon server.js", &[]));
845    }
846
847    #[test]
848    fn interactive_tools_are_passthrough() {
849        assert!(is_excluded_command("vim file.rs", &[]));
850        assert!(is_excluded_command("nvim", &[]));
851        assert!(is_excluded_command("htop", &[]));
852        assert!(is_excluded_command("ssh user@host", &[]));
853        assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
854    }
855
856    #[test]
857    fn docker_streaming_is_passthrough() {
858        assert!(is_excluded_command("docker logs my-container", &[]));
859        assert!(is_excluded_command("docker logs -f webapp", &[]));
860        assert!(is_excluded_command("docker attach my-container", &[]));
861        assert!(is_excluded_command("docker exec -it web bash", &[]));
862        assert!(is_excluded_command("docker exec -ti web bash", &[]));
863        assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
864        assert!(is_excluded_command("docker compose exec web bash", &[]));
865        assert!(is_excluded_command("docker stats", &[]));
866        assert!(is_excluded_command("docker events", &[]));
867    }
868
869    #[test]
870    fn kubectl_is_passthrough() {
871        assert!(is_excluded_command("kubectl logs my-pod", &[]));
872        assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
873        assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
874        assert!(is_excluded_command(
875            "kubectl port-forward svc/web 8080:80",
876            &[]
877        ));
878        assert!(is_excluded_command("kubectl attach my-pod", &[]));
879        assert!(is_excluded_command("kubectl proxy", &[]));
880    }
881
882    #[test]
883    fn database_repls_are_passthrough() {
884        assert!(is_excluded_command("psql -U user mydb", &[]));
885        assert!(is_excluded_command("mysql -u root -p", &[]));
886        assert!(is_excluded_command("sqlite3 data.db", &[]));
887        assert!(is_excluded_command("redis-cli", &[]));
888        assert!(is_excluded_command("mongosh", &[]));
889    }
890
891    #[test]
892    fn streaming_tools_are_passthrough() {
893        assert!(is_excluded_command("journalctl -f", &[]));
894        assert!(is_excluded_command("ping 8.8.8.8", &[]));
895        assert!(is_excluded_command("strace -p 1234", &[]));
896        assert!(is_excluded_command("tcpdump -i eth0", &[]));
897        assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
898        assert!(is_excluded_command("tmux new -s work", &[]));
899        assert!(is_excluded_command("screen -S dev", &[]));
900    }
901
902    #[test]
903    fn additional_dev_servers_are_passthrough() {
904        assert!(is_excluded_command("gatsby develop", &[]));
905        assert!(is_excluded_command("ng serve --port 4200", &[]));
906        assert!(is_excluded_command("remix dev", &[]));
907        assert!(is_excluded_command("wrangler dev", &[]));
908        assert!(is_excluded_command("hugo server", &[]));
909        assert!(is_excluded_command("bun dev", &[]));
910        assert!(is_excluded_command("cargo watch -x test", &[]));
911    }
912
913    #[test]
914    fn normal_commands_not_excluded() {
915        assert!(!is_excluded_command("git status", &[]));
916        assert!(!is_excluded_command("cargo test", &[]));
917        assert!(!is_excluded_command("npm run build", &[]));
918        assert!(!is_excluded_command("ls -la", &[]));
919    }
920
921    #[test]
922    fn user_exclusions_work() {
923        let excl = vec!["myapp".to_string()];
924        assert!(is_excluded_command("myapp serve", &excl));
925        assert!(!is_excluded_command("git status", &excl));
926    }
927
928    #[test]
929    fn is_container_returns_bool() {
930        let _ = super::is_container();
931    }
932
933    #[test]
934    fn is_non_interactive_returns_bool() {
935        let _ = super::is_non_interactive();
936    }
937
938    #[test]
939    fn auth_commands_excluded() {
940        assert!(is_excluded_command("az login --use-device-code", &[]));
941        assert!(is_excluded_command("gh auth login", &[]));
942        assert!(is_excluded_command("gcloud auth login", &[]));
943        assert!(is_excluded_command("aws sso login", &[]));
944        assert!(is_excluded_command("firebase login", &[]));
945        assert!(is_excluded_command("vercel login", &[]));
946        assert!(is_excluded_command("heroku login", &[]));
947        assert!(is_excluded_command("az login", &[]));
948        assert!(is_excluded_command("kubelogin convert-kubeconfig", &[]));
949        assert!(is_excluded_command("vault login -method=oidc", &[]));
950        assert!(is_excluded_command("flyctl auth login", &[]));
951    }
952
953    #[test]
954    fn auth_exclusion_does_not_affect_normal_commands() {
955        assert!(!is_excluded_command("git log", &[]));
956        assert!(!is_excluded_command("npm run build", &[]));
957        assert!(!is_excluded_command("cargo test", &[]));
958        assert!(!is_excluded_command("aws s3 ls", &[]));
959        assert!(!is_excluded_command("gcloud compute instances list", &[]));
960        assert!(!is_excluded_command("az vm list", &[]));
961    }
962}