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 compress_if_beneficial(command: &str, output: &str) -> String {
371    if output.trim().is_empty() {
372        return String::new();
373    }
374
375    if crate::tools::ctx_shell::contains_auth_flow(output) {
376        return output.to_string();
377    }
378
379    let original_tokens = count_tokens(output);
380
381    if original_tokens < 50 {
382        return output.to_string();
383    }
384
385    let min_output_tokens = 5;
386
387    if let Some(compressed) = patterns::compress_output(command, output) {
388        if !compressed.trim().is_empty() {
389            let compressed_tokens = count_tokens(&compressed);
390            if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
391                let saved = original_tokens - compressed_tokens;
392                let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
393                return format!(
394                    "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
395                );
396            }
397            if compressed_tokens < min_output_tokens {
398                return output.to_string();
399            }
400        }
401    }
402
403    // Apply lightweight cleanup to remove whitespace-only lines and collapse braces
404    let cleaned = crate::core::compressor::lightweight_cleanup(output);
405    let cleaned_tokens = count_tokens(&cleaned);
406    if cleaned_tokens < original_tokens {
407        let lines: Vec<&str> = cleaned.lines().collect();
408        if lines.len() > 30 {
409            let first = &lines[..5];
410            let last = &lines[lines.len() - 5..];
411            let omitted = lines.len() - 10;
412            let total = lines.len();
413            let compressed = format!(
414                "{}\n[truncated: showing 10/{total} lines, {omitted} omitted]\n{}",
415                first.join("\n"),
416                last.join("\n")
417            );
418            let ct = count_tokens(&compressed);
419            if ct < original_tokens {
420                let saved = original_tokens - ct;
421                let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
422                return format!("{compressed}\n[lean-ctx: {original_tokens}→{ct} tok, -{pct}%]");
423            }
424        }
425        if cleaned_tokens < original_tokens {
426            let saved = original_tokens - cleaned_tokens;
427            let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
428            return format!(
429                "{cleaned}\n[lean-ctx: {original_tokens}→{cleaned_tokens} tok, -{pct}%]"
430            );
431        }
432    }
433
434    let lines: Vec<&str> = output.lines().collect();
435    if lines.len() > 30 {
436        let first = &lines[..5];
437        let last = &lines[lines.len() - 5..];
438        let omitted = lines.len() - 10;
439        let compressed = format!(
440            "{}\n... ({omitted} lines omitted) ...\n{}",
441            first.join("\n"),
442            last.join("\n")
443        );
444        let compressed_tokens = count_tokens(&compressed);
445        if compressed_tokens < original_tokens {
446            let saved = original_tokens - compressed_tokens;
447            let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
448            return format!(
449                "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
450            );
451        }
452    }
453
454    output.to_string()
455}
456
457/// Windows only: argument that passes one command string to the shell binary.
458/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
459fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
460    if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
461        "-Command"
462    } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
463        "/C"
464    } else {
465        // POSIX-style shells: Git Bash / MSYS (`bash`, `sh`, `zsh`, `fish`, …).
466        // `/C` is only valid for `cmd.exe`; using it with bash produced
467        // `/C: Is a directory` and exit 126 (see github.com/yvgude/lean-ctx/issues/7).
468        "-c"
469    }
470}
471
472pub fn shell_and_flag() -> (String, String) {
473    let shell = detect_shell();
474    let flag = if cfg!(windows) {
475        let name = std::path::Path::new(&shell)
476            .file_name()
477            .and_then(|n| n.to_str())
478            .unwrap_or("")
479            .to_ascii_lowercase();
480        windows_shell_flag_for_exe_basename(&name).to_string()
481    } else {
482        "-c".to_string()
483    };
484    (shell, flag)
485}
486
487fn detect_shell() -> String {
488    if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
489        return shell;
490    }
491
492    if let Ok(shell) = std::env::var("SHELL") {
493        let bin = std::path::Path::new(&shell)
494            .file_name()
495            .and_then(|n| n.to_str())
496            .unwrap_or("sh");
497
498        if bin == "lean-ctx" {
499            return find_real_shell();
500        }
501        return shell;
502    }
503
504    find_real_shell()
505}
506
507#[cfg(unix)]
508fn find_real_shell() -> String {
509    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
510        if std::path::Path::new(shell).exists() {
511            return shell.to_string();
512        }
513    }
514    "/bin/sh".to_string()
515}
516
517#[cfg(windows)]
518fn find_real_shell() -> String {
519    if is_running_in_powershell() {
520        if let Ok(pwsh) = which_powershell() {
521            return pwsh;
522        }
523    }
524    if let Ok(comspec) = std::env::var("COMSPEC") {
525        return comspec;
526    }
527    "cmd.exe".to_string()
528}
529
530#[cfg(windows)]
531fn is_running_in_powershell() -> bool {
532    std::env::var("PSModulePath").is_ok()
533}
534
535#[cfg(windows)]
536fn which_powershell() -> Result<String, ()> {
537    for candidate in &["pwsh.exe", "powershell.exe"] {
538        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
539            if output.status.success() {
540                if let Ok(path) = String::from_utf8(output.stdout) {
541                    if let Some(first_line) = path.lines().next() {
542                        let trimmed = first_line.trim();
543                        if !trimmed.is_empty() {
544                            return Ok(trimmed.to_string());
545                        }
546                    }
547                }
548            }
549        }
550    }
551    Err(())
552}
553
554pub fn save_tee(command: &str, output: &str) -> Option<String> {
555    let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
556    std::fs::create_dir_all(&tee_dir).ok()?;
557
558    cleanup_old_tee_logs(&tee_dir);
559
560    let cmd_slug: String = command
561        .chars()
562        .take(40)
563        .map(|c| {
564            if c.is_alphanumeric() || c == '-' {
565                c
566            } else {
567                '_'
568            }
569        })
570        .collect();
571    let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
572    let filename = format!("{ts}_{cmd_slug}.log");
573    let path = tee_dir.join(&filename);
574
575    let masked = mask_sensitive_data(output);
576    std::fs::write(&path, masked).ok()?;
577    Some(path.to_string_lossy().to_string())
578}
579
580fn mask_sensitive_data(input: &str) -> String {
581    use regex::Regex;
582
583    let patterns: Vec<(&str, Regex)> = vec![
584        ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
585        ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
586        ("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()),
587        ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
588        ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
589        ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
590        ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
591    ];
592
593    let mut result = input.to_string();
594    for (label, re) in &patterns {
595        result = re
596            .replace_all(&result, |caps: &regex::Captures| {
597                if let Some(prefix) = caps.get(1) {
598                    format!("{}[REDACTED:{}]", prefix.as_str(), label)
599                } else {
600                    format!("[REDACTED:{}]", label)
601                }
602            })
603            .to_string();
604    }
605    result
606}
607
608fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
609    let cutoff =
610        std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
611    let cutoff = match cutoff {
612        Some(t) => t,
613        None => return,
614    };
615
616    if let Ok(entries) = std::fs::read_dir(tee_dir) {
617        for entry in entries.flatten() {
618            if let Ok(meta) = entry.metadata() {
619                if let Ok(modified) = meta.modified() {
620                    if modified < cutoff {
621                        let _ = std::fs::remove_file(entry.path());
622                    }
623                }
624            }
625        }
626    }
627}
628
629/// Join multiple CLI arguments into a single command string, using quoting
630/// conventions appropriate for the detected shell.
631///
632/// On Unix, this always produces POSIX-compatible quoting.
633/// On Windows, the quoting adapts to the actual shell (PowerShell, cmd.exe,
634/// or Git Bash / MSYS).
635pub fn join_command(args: &[String]) -> String {
636    let (_, flag) = shell_and_flag();
637    join_command_for(args, &flag)
638}
639
640fn join_command_for(args: &[String], shell_flag: &str) -> String {
641    match shell_flag {
642        "-Command" => join_powershell(args),
643        "/C" => join_cmd(args),
644        _ => join_posix(args),
645    }
646}
647
648fn join_posix(args: &[String]) -> String {
649    args.iter()
650        .map(|a| quote_posix(a))
651        .collect::<Vec<_>>()
652        .join(" ")
653}
654
655fn join_powershell(args: &[String]) -> String {
656    let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
657    format!("& {}", quoted.join(" "))
658}
659
660fn join_cmd(args: &[String]) -> String {
661    args.iter()
662        .map(|a| quote_cmd(a))
663        .collect::<Vec<_>>()
664        .join(" ")
665}
666
667fn quote_posix(s: &str) -> String {
668    if s.is_empty() {
669        return "''".to_string();
670    }
671    if s.bytes()
672        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
673    {
674        return s.to_string();
675    }
676    format!("'{}'", s.replace('\'', "'\\''"))
677}
678
679fn quote_powershell(s: &str) -> String {
680    if s.is_empty() {
681        return "''".to_string();
682    }
683    if s.bytes()
684        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
685    {
686        return s.to_string();
687    }
688    format!("'{}'", s.replace('\'', "''"))
689}
690
691fn quote_cmd(s: &str) -> String {
692    if s.is_empty() {
693        return "\"\"".to_string();
694    }
695    if s.bytes()
696        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
697    {
698        return s.to_string();
699    }
700    format!("\"{}\"", s.replace('"', "\\\""))
701}
702
703#[cfg(test)]
704mod join_command_tests {
705    use super::*;
706
707    #[test]
708    fn posix_simple_args() {
709        let args: Vec<String> = vec!["git".into(), "status".into()];
710        assert_eq!(join_command_for(&args, "-c"), "git status");
711    }
712
713    #[test]
714    fn posix_path_with_spaces() {
715        let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
716        assert_eq!(
717            join_command_for(&args, "-c"),
718            "'/usr/local/my app/bin' --help"
719        );
720    }
721
722    #[test]
723    fn posix_single_quotes_escaped() {
724        let args: Vec<String> = vec!["echo".into(), "it's".into()];
725        assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
726    }
727
728    #[test]
729    fn posix_empty_arg() {
730        let args: Vec<String> = vec!["cmd".into(), "".into()];
731        assert_eq!(join_command_for(&args, "-c"), "cmd ''");
732    }
733
734    #[test]
735    fn powershell_simple_args() {
736        let args: Vec<String> = vec!["npm".into(), "install".into()];
737        assert_eq!(join_command_for(&args, "-Command"), "& npm install");
738    }
739
740    #[test]
741    fn powershell_path_with_spaces() {
742        let args: Vec<String> = vec![
743            "C:\\Program Files\\nodejs\\npm.cmd".into(),
744            "install".into(),
745        ];
746        assert_eq!(
747            join_command_for(&args, "-Command"),
748            "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
749        );
750    }
751
752    #[test]
753    fn powershell_single_quotes_escaped() {
754        let args: Vec<String> = vec!["echo".into(), "it's done".into()];
755        assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
756    }
757
758    #[test]
759    fn cmd_simple_args() {
760        let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
761        assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
762    }
763
764    #[test]
765    fn cmd_path_with_spaces() {
766        let args: Vec<String> = vec![
767            "C:\\Program Files\\nodejs\\npm.cmd".into(),
768            "install".into(),
769        ];
770        assert_eq!(
771            join_command_for(&args, "/C"),
772            "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
773        );
774    }
775
776    #[test]
777    fn cmd_double_quotes_escaped() {
778        let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
779        assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
780    }
781
782    #[test]
783    fn unknown_flag_uses_posix() {
784        let args: Vec<String> = vec!["ls".into(), "-la".into()];
785        assert_eq!(join_command_for(&args, "--exec"), "ls -la");
786    }
787}
788
789#[cfg(test)]
790mod windows_shell_flag_tests {
791    use super::windows_shell_flag_for_exe_basename;
792
793    #[test]
794    fn cmd_uses_slash_c() {
795        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
796        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
797    }
798
799    #[test]
800    fn powershell_uses_command() {
801        assert_eq!(
802            windows_shell_flag_for_exe_basename("powershell.exe"),
803            "-Command"
804        );
805        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
806    }
807
808    #[test]
809    fn posix_shells_use_dash_c() {
810        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
811        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
812        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
813        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
814        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
815    }
816}
817
818#[cfg(test)]
819mod passthrough_tests {
820    use super::is_excluded_command;
821
822    #[test]
823    fn turbo_is_passthrough() {
824        assert!(is_excluded_command("turbo run dev", &[]));
825        assert!(is_excluded_command("turbo run build", &[]));
826        assert!(is_excluded_command("pnpm turbo run dev", &[]));
827        assert!(is_excluded_command("npx turbo run dev", &[]));
828    }
829
830    #[test]
831    fn dev_servers_are_passthrough() {
832        assert!(is_excluded_command("next dev", &[]));
833        assert!(is_excluded_command("vite dev", &[]));
834        assert!(is_excluded_command("nuxt dev", &[]));
835        assert!(is_excluded_command("astro dev", &[]));
836        assert!(is_excluded_command("nodemon server.js", &[]));
837    }
838
839    #[test]
840    fn interactive_tools_are_passthrough() {
841        assert!(is_excluded_command("vim file.rs", &[]));
842        assert!(is_excluded_command("nvim", &[]));
843        assert!(is_excluded_command("htop", &[]));
844        assert!(is_excluded_command("ssh user@host", &[]));
845        assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
846    }
847
848    #[test]
849    fn docker_streaming_is_passthrough() {
850        assert!(is_excluded_command("docker logs my-container", &[]));
851        assert!(is_excluded_command("docker logs -f webapp", &[]));
852        assert!(is_excluded_command("docker attach my-container", &[]));
853        assert!(is_excluded_command("docker exec -it web bash", &[]));
854        assert!(is_excluded_command("docker exec -ti web bash", &[]));
855        assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
856        assert!(is_excluded_command("docker compose exec web bash", &[]));
857        assert!(is_excluded_command("docker stats", &[]));
858        assert!(is_excluded_command("docker events", &[]));
859    }
860
861    #[test]
862    fn kubectl_is_passthrough() {
863        assert!(is_excluded_command("kubectl logs my-pod", &[]));
864        assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
865        assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
866        assert!(is_excluded_command(
867            "kubectl port-forward svc/web 8080:80",
868            &[]
869        ));
870        assert!(is_excluded_command("kubectl attach my-pod", &[]));
871        assert!(is_excluded_command("kubectl proxy", &[]));
872    }
873
874    #[test]
875    fn database_repls_are_passthrough() {
876        assert!(is_excluded_command("psql -U user mydb", &[]));
877        assert!(is_excluded_command("mysql -u root -p", &[]));
878        assert!(is_excluded_command("sqlite3 data.db", &[]));
879        assert!(is_excluded_command("redis-cli", &[]));
880        assert!(is_excluded_command("mongosh", &[]));
881    }
882
883    #[test]
884    fn streaming_tools_are_passthrough() {
885        assert!(is_excluded_command("journalctl -f", &[]));
886        assert!(is_excluded_command("ping 8.8.8.8", &[]));
887        assert!(is_excluded_command("strace -p 1234", &[]));
888        assert!(is_excluded_command("tcpdump -i eth0", &[]));
889        assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
890        assert!(is_excluded_command("tmux new -s work", &[]));
891        assert!(is_excluded_command("screen -S dev", &[]));
892    }
893
894    #[test]
895    fn additional_dev_servers_are_passthrough() {
896        assert!(is_excluded_command("gatsby develop", &[]));
897        assert!(is_excluded_command("ng serve --port 4200", &[]));
898        assert!(is_excluded_command("remix dev", &[]));
899        assert!(is_excluded_command("wrangler dev", &[]));
900        assert!(is_excluded_command("hugo server", &[]));
901        assert!(is_excluded_command("bun dev", &[]));
902        assert!(is_excluded_command("cargo watch -x test", &[]));
903    }
904
905    #[test]
906    fn normal_commands_not_excluded() {
907        assert!(!is_excluded_command("git status", &[]));
908        assert!(!is_excluded_command("cargo test", &[]));
909        assert!(!is_excluded_command("npm run build", &[]));
910        assert!(!is_excluded_command("ls -la", &[]));
911    }
912
913    #[test]
914    fn user_exclusions_work() {
915        let excl = vec!["myapp".to_string()];
916        assert!(is_excluded_command("myapp serve", &excl));
917        assert!(!is_excluded_command("git status", &excl));
918    }
919
920    #[test]
921    fn auth_commands_excluded() {
922        assert!(is_excluded_command("az login --use-device-code", &[]));
923        assert!(is_excluded_command("gh auth login", &[]));
924        assert!(is_excluded_command("gcloud auth login", &[]));
925        assert!(is_excluded_command("aws sso login", &[]));
926        assert!(is_excluded_command("firebase login", &[]));
927        assert!(is_excluded_command("vercel login", &[]));
928        assert!(is_excluded_command("heroku login", &[]));
929        assert!(is_excluded_command("az login", &[]));
930        assert!(is_excluded_command("kubelogin convert-kubeconfig", &[]));
931        assert!(is_excluded_command("vault login -method=oidc", &[]));
932        assert!(is_excluded_command("flyctl auth login", &[]));
933    }
934
935    #[test]
936    fn auth_exclusion_does_not_affect_normal_commands() {
937        assert!(!is_excluded_command("git log", &[]));
938        assert!(!is_excluded_command("npm run build", &[]));
939        assert!(!is_excluded_command("cargo test", &[]));
940        assert!(!is_excluded_command("aws s3 ls", &[]));
941        assert!(!is_excluded_command("gcloud compute instances list", &[]));
942        assert!(!is_excluded_command("az vm list", &[]));
943    }
944
945    #[test]
946    fn is_container_returns_bool() {
947        let result = super::is_container();
948        assert!(result || !result);
949    }
950
951    #[test]
952    fn is_non_interactive_returns_bool() {
953        let result = super::is_non_interactive();
954        assert!(result || !result);
955    }
956}