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