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