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