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