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    // Package manager script runners (wrap dev servers via package.json)
289    "npm run dev",
290    "npm run start",
291    "npm run serve",
292    "npm run watch",
293    "npm run preview",
294    "npm run storybook",
295    "npm run test:watch",
296    "npm start",
297    "npx ",
298    "pnpm run dev",
299    "pnpm run start",
300    "pnpm run serve",
301    "pnpm run watch",
302    "pnpm run preview",
303    "pnpm run storybook",
304    "pnpm dev",
305    "pnpm start",
306    "pnpm preview",
307    "yarn dev",
308    "yarn start",
309    "yarn serve",
310    "yarn watch",
311    "yarn preview",
312    "yarn storybook",
313    "bun run dev",
314    "bun run start",
315    "bun run serve",
316    "bun run watch",
317    "bun run preview",
318    "bun start",
319    "deno task dev",
320    "deno task start",
321    "deno task serve",
322    "deno run --watch",
323    // Docker
324    "docker compose up",
325    "docker-compose up",
326    "docker compose logs",
327    "docker-compose logs",
328    "docker compose exec",
329    "docker-compose exec",
330    "docker compose run",
331    "docker-compose run",
332    "docker compose watch",
333    "docker-compose watch",
334    "docker logs",
335    "docker attach",
336    "docker exec -it",
337    "docker exec -ti",
338    "docker run -it",
339    "docker run -ti",
340    "docker stats",
341    "docker events",
342    // Kubernetes
343    "kubectl logs",
344    "kubectl exec -it",
345    "kubectl exec -ti",
346    "kubectl attach",
347    "kubectl port-forward",
348    "kubectl proxy",
349    // System monitors & streaming
350    "top",
351    "htop",
352    "btop",
353    "watch ",
354    "tail -f",
355    "tail -f ",
356    "journalctl -f",
357    "journalctl --follow",
358    "dmesg -w",
359    "dmesg --follow",
360    "strace",
361    "tcpdump",
362    "ping ",
363    "ping6 ",
364    "traceroute",
365    "mtr ",
366    "nmap ",
367    "iperf ",
368    "iperf3 ",
369    "ss -l",
370    "netstat -l",
371    "lsof -i",
372    "socat ",
373    // Editors & pagers
374    "less",
375    "more",
376    "vim",
377    "nvim",
378    "vi ",
379    "nano",
380    "micro ",
381    "helix ",
382    "hx ",
383    "emacs",
384    // Terminal multiplexers
385    "tmux",
386    "screen",
387    // Interactive shells & REPLs
388    "ssh ",
389    "telnet ",
390    "nc ",
391    "ncat ",
392    "psql",
393    "mysql",
394    "sqlite3",
395    "redis-cli",
396    "mongosh",
397    "mongo ",
398    "python3 -i",
399    "python -i",
400    "irb",
401    "rails console",
402    "rails c ",
403    "iex",
404    // Python servers, workers, watchers
405    "flask run",
406    "uvicorn ",
407    "gunicorn ",
408    "hypercorn ",
409    "daphne ",
410    "django-admin runserver",
411    "manage.py runserver",
412    "python manage.py runserver",
413    "python -m http.server",
414    "python3 -m http.server",
415    "streamlit run",
416    "gradio ",
417    "celery worker",
418    "celery -a",
419    "celery -b",
420    "dramatiq ",
421    "rq worker",
422    "watchmedo ",
423    "ptw ",
424    "pytest-watch",
425    // Ruby / Rails
426    "rails server",
427    "rails s",
428    "puma ",
429    "unicorn ",
430    "thin start",
431    "foreman start",
432    "overmind start",
433    "guard ",
434    "sidekiq",
435    "resque ",
436    // PHP / Laravel
437    "php artisan serve",
438    "php -s ",
439    "php artisan queue:work",
440    "php artisan queue:listen",
441    "php artisan horizon",
442    "php artisan tinker",
443    "sail up",
444    // Java / JVM
445    "./gradlew bootrun",
446    "gradlew bootrun",
447    "gradle bootrun",
448    "./gradlew run",
449    "mvn spring-boot:run",
450    "./mvnw spring-boot:run",
451    "mvnw spring-boot:run",
452    "mvn quarkus:dev",
453    "./mvnw quarkus:dev",
454    "sbt run",
455    "sbt ~compile",
456    "lein run",
457    "lein repl",
458    // Go
459    "go run ",
460    "air ",
461    "gin ",
462    "realize start",
463    "reflex ",
464    "gowatch ",
465    // .NET / C#
466    "dotnet run",
467    "dotnet watch",
468    "dotnet ef",
469    // Elixir / Erlang
470    "mix phx.server",
471    "iex -s mix",
472    // Swift
473    "swift run",
474    "swift package ",
475    "vapor serve",
476    // Zig
477    "zig build run",
478    // Rust
479    "cargo watch",
480    "cargo run",
481    "cargo leptos watch",
482    "bacon ",
483    // General watchers & task runners
484    "make dev",
485    "make serve",
486    "make watch",
487    "make run",
488    "make start",
489    "just dev",
490    "just serve",
491    "just watch",
492    "just start",
493    "just run",
494    "task dev",
495    "task serve",
496    "task watch",
497    "nix develop",
498    "devenv up",
499    // CI/CD & infrastructure (long-running)
500    "act ",
501    "skaffold dev",
502    "tilt up",
503    "garden dev",
504    "telepresence ",
505    // Load testing & benchmarking
506    "ab ",
507    "wrk ",
508    "hey ",
509    "vegeta ",
510    "k6 run",
511    "artillery run",
512    // Authentication flows (device code, OAuth, SSO)
513    "az login",
514    "az account",
515    "gh auth",
516    "gcloud auth",
517    "gcloud init",
518    "aws sso",
519    "aws configure sso",
520    "firebase login",
521    "netlify login",
522    "vercel login",
523    "heroku login",
524    "flyctl auth",
525    "fly auth",
526    "railway login",
527    "supabase login",
528    "wrangler login",
529    "doppler login",
530    "vault login",
531    "oc login",
532    "kubelogin",
533    "--use-device-code",
534];
535
536const SCRIPT_RUNNER_PREFIXES: &[&str] = &[
537    "npm run ",
538    "npm start",
539    "npx ",
540    "pnpm run ",
541    "pnpm dev",
542    "pnpm start",
543    "pnpm preview",
544    "yarn ",
545    "bun run ",
546    "bun start",
547    "deno task ",
548];
549
550const DEV_SCRIPT_KEYWORDS: &[&str] = &[
551    "dev",
552    "start",
553    "serve",
554    "watch",
555    "preview",
556    "storybook",
557    "hot",
558    "live",
559    "hmr",
560];
561
562fn is_dev_script_runner(cmd: &str) -> bool {
563    for prefix in SCRIPT_RUNNER_PREFIXES {
564        if let Some(rest) = cmd.strip_prefix(prefix) {
565            let script_name = rest.split_whitespace().next().unwrap_or("");
566            for kw in DEV_SCRIPT_KEYWORDS {
567                if script_name.contains(kw) {
568                    return true;
569                }
570            }
571        }
572    }
573    false
574}
575
576fn is_excluded_command(command: &str, excluded: &[String]) -> bool {
577    let cmd = command.trim().to_lowercase();
578    for pattern in BUILTIN_PASSTHROUGH {
579        if pattern.starts_with("--") {
580            if cmd.contains(pattern) {
581                return true;
582            }
583        } else if pattern.ends_with(' ') || pattern.ends_with('\t') {
584            if cmd == pattern.trim() || cmd.starts_with(pattern) {
585                return true;
586            }
587        } else if cmd == *pattern
588            || cmd.starts_with(&format!("{pattern} "))
589            || cmd.starts_with(&format!("{pattern}\t"))
590            || cmd.contains(&format!(" {pattern} "))
591            || cmd.contains(&format!(" {pattern}\t"))
592            || cmd.contains(&format!("|{pattern} "))
593            || cmd.contains(&format!("|{pattern}\t"))
594            || cmd.ends_with(&format!(" {pattern}"))
595            || cmd.ends_with(&format!("|{pattern}"))
596        {
597            return true;
598        }
599    }
600
601    if is_dev_script_runner(&cmd) {
602        return true;
603    }
604
605    if excluded.is_empty() {
606        return false;
607    }
608    excluded.iter().any(|excl| {
609        let excl_lower = excl.trim().to_lowercase();
610        cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} "))
611    })
612}
613
614pub fn interactive() {
615    let real_shell = detect_shell();
616
617    eprintln!(
618        "lean-ctx shell v{} (wrapping {real_shell})",
619        env!("CARGO_PKG_VERSION")
620    );
621    eprintln!("All command output is automatically compressed.");
622    eprintln!("Type 'exit' to quit.\n");
623
624    let stdin = io::stdin();
625    let mut stdout = io::stdout();
626
627    loop {
628        let _ = write!(stdout, "lean-ctx> ");
629        let _ = stdout.flush();
630
631        let mut line = String::new();
632        match stdin.lock().read_line(&mut line) {
633            Ok(0) => break,
634            Ok(_) => {}
635            Err(_) => break,
636        }
637
638        let cmd = line.trim();
639        if cmd.is_empty() {
640            continue;
641        }
642        if cmd == "exit" || cmd == "quit" {
643            break;
644        }
645        if cmd == "gain" {
646            println!("{}", stats::format_gain());
647            continue;
648        }
649
650        let exit_code = exec(cmd);
651
652        if exit_code != 0 {
653            let _ = writeln!(stdout, "[exit: {exit_code}]");
654        }
655    }
656}
657
658fn compress_and_measure(command: &str, stdout: &str, stderr: &str) -> (String, usize) {
659    let compressed_stdout = compress_if_beneficial(command, stdout);
660    let compressed_stderr = compress_if_beneficial(command, stderr);
661
662    let mut result = String::new();
663    if !compressed_stdout.is_empty() {
664        result.push_str(&compressed_stdout);
665    }
666    if !compressed_stderr.is_empty() {
667        if !result.is_empty() {
668            result.push('\n');
669        }
670        result.push_str(&compressed_stderr);
671    }
672
673    // Count tokens on content BEFORE the [lean-ctx: ...] footer to avoid
674    // counting the annotation overhead against savings.
675    let content_for_counting = if let Some(pos) = result.rfind("\n[lean-ctx: ") {
676        &result[..pos]
677    } else {
678        &result
679    };
680    let output_tokens = count_tokens(content_for_counting);
681    (result, output_tokens)
682}
683
684fn compress_if_beneficial(command: &str, output: &str) -> String {
685    if output.trim().is_empty() {
686        return String::new();
687    }
688
689    if crate::tools::ctx_shell::contains_auth_flow(output) {
690        return output.to_string();
691    }
692
693    let original_tokens = count_tokens(output);
694
695    if original_tokens < 50 {
696        return output.to_string();
697    }
698
699    let min_output_tokens = 5;
700
701    if let Some(compressed) = patterns::compress_output(command, output) {
702        if !compressed.trim().is_empty() {
703            let compressed_tokens = count_tokens(&compressed);
704            if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
705                let ratio = compressed_tokens as f64 / original_tokens as f64;
706                if ratio < 0.05 && original_tokens > 100 {
707                    eprintln!(
708                        "[lean-ctx] WARNING: compression removed >95% of content, returning original"
709                    );
710                    return output.to_string();
711                }
712                let saved = original_tokens - compressed_tokens;
713                let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
714                if pct >= 5 {
715                    return format!(
716                        "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
717                    );
718                }
719                return compressed;
720            }
721            if compressed_tokens < min_output_tokens {
722                return output.to_string();
723            }
724        }
725    }
726
727    let cleaned = crate::core::compressor::lightweight_cleanup(output);
728    let cleaned_tokens = count_tokens(&cleaned);
729    if cleaned_tokens < original_tokens {
730        let lines: Vec<&str> = cleaned.lines().collect();
731        if lines.len() > 30 {
732            let compressed = truncate_with_safety_scan(&lines, original_tokens);
733            if let Some(c) = compressed {
734                return c;
735            }
736        }
737        if cleaned_tokens < original_tokens {
738            let saved = original_tokens - cleaned_tokens;
739            let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
740            if pct >= 5 {
741                return format!(
742                    "{cleaned}\n[lean-ctx: {original_tokens}→{cleaned_tokens} tok, -{pct}%]"
743                );
744            }
745            return cleaned;
746        }
747    }
748
749    let lines: Vec<&str> = output.lines().collect();
750    if lines.len() > 30 {
751        if let Some(c) = truncate_with_safety_scan(&lines, original_tokens) {
752            return c;
753        }
754    }
755
756    output.to_string()
757}
758
759fn truncate_with_safety_scan(lines: &[&str], original_tokens: usize) -> Option<String> {
760    use crate::core::safety_needles;
761
762    let first = &lines[..5];
763    let last = &lines[lines.len() - 5..];
764    let middle = &lines[5..lines.len() - 5];
765
766    let safety_lines = safety_needles::extract_safety_lines(middle, 20);
767    let safety_count = safety_lines.len();
768    let omitted = middle.len() - safety_count;
769
770    let mut parts = Vec::new();
771    parts.push(first.join("\n"));
772    if safety_count > 0 {
773        parts.push(format!(
774            "[{omitted} lines omitted, {safety_count} safety-relevant lines preserved]"
775        ));
776        parts.push(safety_lines.join("\n"));
777    } else {
778        parts.push(format!("[{omitted} lines omitted]"));
779    }
780    parts.push(last.join("\n"));
781
782    let compressed = parts.join("\n");
783    let ct = count_tokens(&compressed);
784    if ct >= original_tokens {
785        return None;
786    }
787    let saved = original_tokens - ct;
788    let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
789    if pct >= 5 {
790        Some(format!(
791            "{compressed}\n[lean-ctx: {original_tokens}→{ct} tok, -{pct}%]"
792        ))
793    } else {
794        Some(compressed)
795    }
796}
797
798/// Windows only: argument that passes one command string to the shell binary.
799/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
800fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
801    if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
802        "-Command"
803    } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
804        "/C"
805    } else {
806        // POSIX-style shells: Git Bash / MSYS (`bash`, `sh`, `zsh`, `fish`, …).
807        // `/C` is only valid for `cmd.exe`; using it with bash produced
808        // `/C: Is a directory` and exit 126 (see github.com/yvgude/lean-ctx/issues/7).
809        "-c"
810    }
811}
812
813pub fn shell_and_flag() -> (String, String) {
814    let shell = detect_shell();
815    let flag = if cfg!(windows) {
816        let name = std::path::Path::new(&shell)
817            .file_name()
818            .and_then(|n| n.to_str())
819            .unwrap_or("")
820            .to_ascii_lowercase();
821        windows_shell_flag_for_exe_basename(&name).to_string()
822    } else {
823        "-c".to_string()
824    };
825    (shell, flag)
826}
827
828fn detect_shell() -> String {
829    if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
830        return shell;
831    }
832
833    if let Ok(shell) = std::env::var("SHELL") {
834        let bin = std::path::Path::new(&shell)
835            .file_name()
836            .and_then(|n| n.to_str())
837            .unwrap_or("sh");
838
839        if bin == "lean-ctx" {
840            return find_real_shell();
841        }
842        return shell;
843    }
844
845    find_real_shell()
846}
847
848#[cfg(unix)]
849fn find_real_shell() -> String {
850    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
851        if std::path::Path::new(shell).exists() {
852            return shell.to_string();
853        }
854    }
855    "/bin/sh".to_string()
856}
857
858#[cfg(windows)]
859fn find_real_shell() -> String {
860    if is_running_in_powershell() {
861        if let Ok(pwsh) = which_powershell() {
862            return pwsh;
863        }
864    }
865    if let Ok(comspec) = std::env::var("COMSPEC") {
866        return comspec;
867    }
868    "cmd.exe".to_string()
869}
870
871#[cfg(windows)]
872fn is_running_in_powershell() -> bool {
873    std::env::var("PSModulePath").is_ok()
874}
875
876#[cfg(windows)]
877fn which_powershell() -> Result<String, ()> {
878    for candidate in &["pwsh.exe", "powershell.exe"] {
879        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
880            if output.status.success() {
881                if let Ok(path) = String::from_utf8(output.stdout) {
882                    if let Some(first_line) = path.lines().next() {
883                        let trimmed = first_line.trim();
884                        if !trimmed.is_empty() {
885                            return Ok(trimmed.to_string());
886                        }
887                    }
888                }
889            }
890        }
891    }
892    Err(())
893}
894
895pub fn save_tee(command: &str, output: &str) -> Option<String> {
896    let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
897    std::fs::create_dir_all(&tee_dir).ok()?;
898
899    cleanup_old_tee_logs(&tee_dir);
900
901    let cmd_slug: String = command
902        .chars()
903        .take(40)
904        .map(|c| {
905            if c.is_alphanumeric() || c == '-' {
906                c
907            } else {
908                '_'
909            }
910        })
911        .collect();
912    let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
913    let filename = format!("{ts}_{cmd_slug}.log");
914    let path = tee_dir.join(&filename);
915
916    let masked = mask_sensitive_data(output);
917    std::fs::write(&path, masked).ok()?;
918    Some(path.to_string_lossy().to_string())
919}
920
921fn mask_sensitive_data(input: &str) -> String {
922    use regex::Regex;
923
924    let patterns: Vec<(&str, Regex)> = vec![
925        ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
926        ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
927        ("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()),
928        ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
929        ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
930        ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
931        ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
932    ];
933
934    let mut result = input.to_string();
935    for (label, re) in &patterns {
936        result = re
937            .replace_all(&result, |caps: &regex::Captures| {
938                if let Some(prefix) = caps.get(1) {
939                    format!("{}[REDACTED:{}]", prefix.as_str(), label)
940                } else {
941                    format!("[REDACTED:{}]", label)
942                }
943            })
944            .to_string();
945    }
946    result
947}
948
949fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
950    let cutoff =
951        std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
952    let cutoff = match cutoff {
953        Some(t) => t,
954        None => return,
955    };
956
957    if let Ok(entries) = std::fs::read_dir(tee_dir) {
958        for entry in entries.flatten() {
959            if let Ok(meta) = entry.metadata() {
960                if let Ok(modified) = meta.modified() {
961                    if modified < cutoff {
962                        let _ = std::fs::remove_file(entry.path());
963                    }
964                }
965            }
966        }
967    }
968}
969
970/// Join multiple CLI arguments into a single command string, using quoting
971/// conventions appropriate for the detected shell.
972///
973/// On Unix, this always produces POSIX-compatible quoting.
974/// On Windows, the quoting adapts to the actual shell (PowerShell, cmd.exe,
975/// or Git Bash / MSYS).
976pub fn join_command(args: &[String]) -> String {
977    let (_, flag) = shell_and_flag();
978    join_command_for(args, &flag)
979}
980
981fn join_command_for(args: &[String], shell_flag: &str) -> String {
982    match shell_flag {
983        "-Command" => join_powershell(args),
984        "/C" => join_cmd(args),
985        _ => join_posix(args),
986    }
987}
988
989fn join_posix(args: &[String]) -> String {
990    args.iter()
991        .map(|a| quote_posix(a))
992        .collect::<Vec<_>>()
993        .join(" ")
994}
995
996fn join_powershell(args: &[String]) -> String {
997    let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
998    format!("& {}", quoted.join(" "))
999}
1000
1001fn join_cmd(args: &[String]) -> String {
1002    args.iter()
1003        .map(|a| quote_cmd(a))
1004        .collect::<Vec<_>>()
1005        .join(" ")
1006}
1007
1008fn quote_posix(s: &str) -> String {
1009    if s.is_empty() {
1010        return "''".to_string();
1011    }
1012    if s.bytes()
1013        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
1014    {
1015        return s.to_string();
1016    }
1017    format!("'{}'", s.replace('\'', "'\\''"))
1018}
1019
1020fn quote_powershell(s: &str) -> String {
1021    if s.is_empty() {
1022        return "''".to_string();
1023    }
1024    if s.bytes()
1025        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
1026    {
1027        return s.to_string();
1028    }
1029    format!("'{}'", s.replace('\'', "''"))
1030}
1031
1032fn quote_cmd(s: &str) -> String {
1033    if s.is_empty() {
1034        return "\"\"".to_string();
1035    }
1036    if s.bytes()
1037        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
1038    {
1039        return s.to_string();
1040    }
1041    format!("\"{}\"", s.replace('"', "\\\""))
1042}
1043
1044#[cfg(test)]
1045mod join_command_tests {
1046    use super::*;
1047
1048    #[test]
1049    fn posix_simple_args() {
1050        let args: Vec<String> = vec!["git".into(), "status".into()];
1051        assert_eq!(join_command_for(&args, "-c"), "git status");
1052    }
1053
1054    #[test]
1055    fn posix_path_with_spaces() {
1056        let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
1057        assert_eq!(
1058            join_command_for(&args, "-c"),
1059            "'/usr/local/my app/bin' --help"
1060        );
1061    }
1062
1063    #[test]
1064    fn posix_single_quotes_escaped() {
1065        let args: Vec<String> = vec!["echo".into(), "it's".into()];
1066        assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
1067    }
1068
1069    #[test]
1070    fn posix_empty_arg() {
1071        let args: Vec<String> = vec!["cmd".into(), "".into()];
1072        assert_eq!(join_command_for(&args, "-c"), "cmd ''");
1073    }
1074
1075    #[test]
1076    fn powershell_simple_args() {
1077        let args: Vec<String> = vec!["npm".into(), "install".into()];
1078        assert_eq!(join_command_for(&args, "-Command"), "& npm install");
1079    }
1080
1081    #[test]
1082    fn powershell_path_with_spaces() {
1083        let args: Vec<String> = vec![
1084            "C:\\Program Files\\nodejs\\npm.cmd".into(),
1085            "install".into(),
1086        ];
1087        assert_eq!(
1088            join_command_for(&args, "-Command"),
1089            "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
1090        );
1091    }
1092
1093    #[test]
1094    fn powershell_single_quotes_escaped() {
1095        let args: Vec<String> = vec!["echo".into(), "it's done".into()];
1096        assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
1097    }
1098
1099    #[test]
1100    fn cmd_simple_args() {
1101        let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
1102        assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
1103    }
1104
1105    #[test]
1106    fn cmd_path_with_spaces() {
1107        let args: Vec<String> = vec![
1108            "C:\\Program Files\\nodejs\\npm.cmd".into(),
1109            "install".into(),
1110        ];
1111        assert_eq!(
1112            join_command_for(&args, "/C"),
1113            "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
1114        );
1115    }
1116
1117    #[test]
1118    fn cmd_double_quotes_escaped() {
1119        let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
1120        assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
1121    }
1122
1123    #[test]
1124    fn unknown_flag_uses_posix() {
1125        let args: Vec<String> = vec!["ls".into(), "-la".into()];
1126        assert_eq!(join_command_for(&args, "--exec"), "ls -la");
1127    }
1128}
1129
1130#[cfg(test)]
1131mod windows_shell_flag_tests {
1132    use super::windows_shell_flag_for_exe_basename;
1133
1134    #[test]
1135    fn cmd_uses_slash_c() {
1136        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
1137        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
1138    }
1139
1140    #[test]
1141    fn powershell_uses_command() {
1142        assert_eq!(
1143            windows_shell_flag_for_exe_basename("powershell.exe"),
1144            "-Command"
1145        );
1146        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
1147    }
1148
1149    #[test]
1150    fn posix_shells_use_dash_c() {
1151        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
1152        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
1153        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
1154        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
1155        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
1156    }
1157}
1158
1159#[cfg(test)]
1160mod passthrough_tests {
1161    use super::is_excluded_command;
1162
1163    #[test]
1164    fn turbo_is_passthrough() {
1165        assert!(is_excluded_command("turbo run dev", &[]));
1166        assert!(is_excluded_command("turbo run build", &[]));
1167        assert!(is_excluded_command("pnpm turbo run dev", &[]));
1168        assert!(is_excluded_command("npx turbo run dev", &[]));
1169    }
1170
1171    #[test]
1172    fn dev_servers_are_passthrough() {
1173        assert!(is_excluded_command("next dev", &[]));
1174        assert!(is_excluded_command("vite dev", &[]));
1175        assert!(is_excluded_command("nuxt dev", &[]));
1176        assert!(is_excluded_command("astro dev", &[]));
1177        assert!(is_excluded_command("nodemon server.js", &[]));
1178    }
1179
1180    #[test]
1181    fn interactive_tools_are_passthrough() {
1182        assert!(is_excluded_command("vim file.rs", &[]));
1183        assert!(is_excluded_command("nvim", &[]));
1184        assert!(is_excluded_command("htop", &[]));
1185        assert!(is_excluded_command("ssh user@host", &[]));
1186        assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
1187    }
1188
1189    #[test]
1190    fn docker_streaming_is_passthrough() {
1191        assert!(is_excluded_command("docker logs my-container", &[]));
1192        assert!(is_excluded_command("docker logs -f webapp", &[]));
1193        assert!(is_excluded_command("docker attach my-container", &[]));
1194        assert!(is_excluded_command("docker exec -it web bash", &[]));
1195        assert!(is_excluded_command("docker exec -ti web bash", &[]));
1196        assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
1197        assert!(is_excluded_command("docker compose exec web bash", &[]));
1198        assert!(is_excluded_command("docker stats", &[]));
1199        assert!(is_excluded_command("docker events", &[]));
1200    }
1201
1202    #[test]
1203    fn kubectl_is_passthrough() {
1204        assert!(is_excluded_command("kubectl logs my-pod", &[]));
1205        assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
1206        assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
1207        assert!(is_excluded_command(
1208            "kubectl port-forward svc/web 8080:80",
1209            &[]
1210        ));
1211        assert!(is_excluded_command("kubectl attach my-pod", &[]));
1212        assert!(is_excluded_command("kubectl proxy", &[]));
1213    }
1214
1215    #[test]
1216    fn database_repls_are_passthrough() {
1217        assert!(is_excluded_command("psql -U user mydb", &[]));
1218        assert!(is_excluded_command("mysql -u root -p", &[]));
1219        assert!(is_excluded_command("sqlite3 data.db", &[]));
1220        assert!(is_excluded_command("redis-cli", &[]));
1221        assert!(is_excluded_command("mongosh", &[]));
1222    }
1223
1224    #[test]
1225    fn streaming_tools_are_passthrough() {
1226        assert!(is_excluded_command("journalctl -f", &[]));
1227        assert!(is_excluded_command("ping 8.8.8.8", &[]));
1228        assert!(is_excluded_command("strace -p 1234", &[]));
1229        assert!(is_excluded_command("tcpdump -i eth0", &[]));
1230        assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
1231        assert!(is_excluded_command("tmux new -s work", &[]));
1232        assert!(is_excluded_command("screen -S dev", &[]));
1233    }
1234
1235    #[test]
1236    fn additional_dev_servers_are_passthrough() {
1237        assert!(is_excluded_command("gatsby develop", &[]));
1238        assert!(is_excluded_command("ng serve --port 4200", &[]));
1239        assert!(is_excluded_command("remix dev", &[]));
1240        assert!(is_excluded_command("wrangler dev", &[]));
1241        assert!(is_excluded_command("hugo server", &[]));
1242        assert!(is_excluded_command("bun dev", &[]));
1243        assert!(is_excluded_command("cargo watch -x test", &[]));
1244    }
1245
1246    #[test]
1247    fn normal_commands_not_excluded() {
1248        assert!(!is_excluded_command("git status", &[]));
1249        assert!(!is_excluded_command("cargo test", &[]));
1250        assert!(!is_excluded_command("npm run build", &[]));
1251        assert!(!is_excluded_command("ls -la", &[]));
1252    }
1253
1254    #[test]
1255    fn user_exclusions_work() {
1256        let excl = vec!["myapp".to_string()];
1257        assert!(is_excluded_command("myapp serve", &excl));
1258        assert!(!is_excluded_command("git status", &excl));
1259    }
1260
1261    #[test]
1262    fn is_container_returns_bool() {
1263        let _ = super::is_container();
1264    }
1265
1266    #[test]
1267    fn is_non_interactive_returns_bool() {
1268        let _ = super::is_non_interactive();
1269    }
1270
1271    #[test]
1272    fn auth_commands_excluded() {
1273        assert!(is_excluded_command("az login --use-device-code", &[]));
1274        assert!(is_excluded_command("gh auth login", &[]));
1275        assert!(is_excluded_command("gcloud auth login", &[]));
1276        assert!(is_excluded_command("aws sso login", &[]));
1277        assert!(is_excluded_command("firebase login", &[]));
1278        assert!(is_excluded_command("vercel login", &[]));
1279        assert!(is_excluded_command("heroku login", &[]));
1280        assert!(is_excluded_command("az login", &[]));
1281        assert!(is_excluded_command("kubelogin convert-kubeconfig", &[]));
1282        assert!(is_excluded_command("vault login -method=oidc", &[]));
1283        assert!(is_excluded_command("flyctl auth login", &[]));
1284    }
1285
1286    #[test]
1287    fn auth_exclusion_does_not_affect_normal_commands() {
1288        assert!(!is_excluded_command("git log", &[]));
1289        assert!(!is_excluded_command("npm run build", &[]));
1290        assert!(!is_excluded_command("cargo test", &[]));
1291        assert!(!is_excluded_command("aws s3 ls", &[]));
1292        assert!(!is_excluded_command("gcloud compute instances list", &[]));
1293        assert!(!is_excluded_command("az vm list", &[]));
1294    }
1295
1296    #[test]
1297    fn npm_script_runners_are_passthrough() {
1298        assert!(is_excluded_command("npm run dev", &[]));
1299        assert!(is_excluded_command("npm run start", &[]));
1300        assert!(is_excluded_command("npm run serve", &[]));
1301        assert!(is_excluded_command("npm run watch", &[]));
1302        assert!(is_excluded_command("npm run preview", &[]));
1303        assert!(is_excluded_command("npm run storybook", &[]));
1304        assert!(is_excluded_command("npm run test:watch", &[]));
1305        assert!(is_excluded_command("npm start", &[]));
1306        assert!(is_excluded_command("npx vite", &[]));
1307        assert!(is_excluded_command("npx next dev", &[]));
1308    }
1309
1310    #[test]
1311    fn pnpm_script_runners_are_passthrough() {
1312        assert!(is_excluded_command("pnpm run dev", &[]));
1313        assert!(is_excluded_command("pnpm run start", &[]));
1314        assert!(is_excluded_command("pnpm run serve", &[]));
1315        assert!(is_excluded_command("pnpm run watch", &[]));
1316        assert!(is_excluded_command("pnpm run preview", &[]));
1317        assert!(is_excluded_command("pnpm dev", &[]));
1318        assert!(is_excluded_command("pnpm start", &[]));
1319        assert!(is_excluded_command("pnpm preview", &[]));
1320    }
1321
1322    #[test]
1323    fn yarn_script_runners_are_passthrough() {
1324        assert!(is_excluded_command("yarn dev", &[]));
1325        assert!(is_excluded_command("yarn start", &[]));
1326        assert!(is_excluded_command("yarn serve", &[]));
1327        assert!(is_excluded_command("yarn watch", &[]));
1328        assert!(is_excluded_command("yarn preview", &[]));
1329        assert!(is_excluded_command("yarn storybook", &[]));
1330    }
1331
1332    #[test]
1333    fn bun_deno_script_runners_are_passthrough() {
1334        assert!(is_excluded_command("bun run dev", &[]));
1335        assert!(is_excluded_command("bun run start", &[]));
1336        assert!(is_excluded_command("bun run serve", &[]));
1337        assert!(is_excluded_command("bun run watch", &[]));
1338        assert!(is_excluded_command("bun run preview", &[]));
1339        assert!(is_excluded_command("bun start", &[]));
1340        assert!(is_excluded_command("deno task dev", &[]));
1341        assert!(is_excluded_command("deno task start", &[]));
1342        assert!(is_excluded_command("deno task serve", &[]));
1343        assert!(is_excluded_command("deno run --watch main.ts", &[]));
1344    }
1345
1346    #[test]
1347    fn python_servers_are_passthrough() {
1348        assert!(is_excluded_command("flask run --port 5000", &[]));
1349        assert!(is_excluded_command("uvicorn app:app --reload", &[]));
1350        assert!(is_excluded_command("gunicorn app:app -w 4", &[]));
1351        assert!(is_excluded_command("hypercorn app:app", &[]));
1352        assert!(is_excluded_command("daphne app.asgi:application", &[]));
1353        assert!(is_excluded_command(
1354            "django-admin runserver 0.0.0.0:8000",
1355            &[]
1356        ));
1357        assert!(is_excluded_command("python manage.py runserver", &[]));
1358        assert!(is_excluded_command("python -m http.server 8080", &[]));
1359        assert!(is_excluded_command("python3 -m http.server", &[]));
1360        assert!(is_excluded_command("streamlit run app.py", &[]));
1361        assert!(is_excluded_command("gradio app.py", &[]));
1362        assert!(is_excluded_command("celery worker -A app", &[]));
1363        assert!(is_excluded_command("celery -A app worker", &[]));
1364        assert!(is_excluded_command("celery -B", &[]));
1365        assert!(is_excluded_command("dramatiq tasks", &[]));
1366        assert!(is_excluded_command("rq worker", &[]));
1367        assert!(is_excluded_command("ptw tests/", &[]));
1368        assert!(is_excluded_command("pytest-watch", &[]));
1369    }
1370
1371    #[test]
1372    fn ruby_servers_are_passthrough() {
1373        assert!(is_excluded_command("rails server -p 3000", &[]));
1374        assert!(is_excluded_command("rails s", &[]));
1375        assert!(is_excluded_command("puma -C config.rb", &[]));
1376        assert!(is_excluded_command("unicorn -c config.rb", &[]));
1377        assert!(is_excluded_command("thin start", &[]));
1378        assert!(is_excluded_command("foreman start", &[]));
1379        assert!(is_excluded_command("overmind start", &[]));
1380        assert!(is_excluded_command("guard -G Guardfile", &[]));
1381        assert!(is_excluded_command("sidekiq", &[]));
1382        assert!(is_excluded_command("resque work", &[]));
1383    }
1384
1385    #[test]
1386    fn php_servers_are_passthrough() {
1387        assert!(is_excluded_command("php artisan serve", &[]));
1388        assert!(is_excluded_command("php -S localhost:8000", &[]));
1389        assert!(is_excluded_command("php artisan queue:work", &[]));
1390        assert!(is_excluded_command("php artisan queue:listen", &[]));
1391        assert!(is_excluded_command("php artisan horizon", &[]));
1392        assert!(is_excluded_command("php artisan tinker", &[]));
1393        assert!(is_excluded_command("sail up", &[]));
1394    }
1395
1396    #[test]
1397    fn java_servers_are_passthrough() {
1398        assert!(is_excluded_command("./gradlew bootRun", &[]));
1399        assert!(is_excluded_command("gradlew bootRun", &[]));
1400        assert!(is_excluded_command("gradle bootRun", &[]));
1401        assert!(is_excluded_command("mvn spring-boot:run", &[]));
1402        assert!(is_excluded_command("./mvnw spring-boot:run", &[]));
1403        assert!(is_excluded_command("mvn quarkus:dev", &[]));
1404        assert!(is_excluded_command("./mvnw quarkus:dev", &[]));
1405        assert!(is_excluded_command("sbt run", &[]));
1406        assert!(is_excluded_command("sbt ~compile", &[]));
1407        assert!(is_excluded_command("lein run", &[]));
1408        assert!(is_excluded_command("lein repl", &[]));
1409        assert!(is_excluded_command("./gradlew run", &[]));
1410    }
1411
1412    #[test]
1413    fn go_servers_are_passthrough() {
1414        assert!(is_excluded_command("go run main.go", &[]));
1415        assert!(is_excluded_command("go run ./cmd/server", &[]));
1416        assert!(is_excluded_command("air -c .air.toml", &[]));
1417        assert!(is_excluded_command("gin --port 3000", &[]));
1418        assert!(is_excluded_command("realize start", &[]));
1419        assert!(is_excluded_command("reflex -r '.go$' go run .", &[]));
1420        assert!(is_excluded_command("gowatch run", &[]));
1421    }
1422
1423    #[test]
1424    fn dotnet_servers_are_passthrough() {
1425        assert!(is_excluded_command("dotnet run", &[]));
1426        assert!(is_excluded_command("dotnet run --project src/Api", &[]));
1427        assert!(is_excluded_command("dotnet watch run", &[]));
1428        assert!(is_excluded_command("dotnet ef database update", &[]));
1429    }
1430
1431    #[test]
1432    fn elixir_servers_are_passthrough() {
1433        assert!(is_excluded_command("mix phx.server", &[]));
1434        assert!(is_excluded_command("iex -s mix phx.server", &[]));
1435        assert!(is_excluded_command("iex -S mix phx.server", &[]));
1436    }
1437
1438    #[test]
1439    fn swift_zig_servers_are_passthrough() {
1440        assert!(is_excluded_command("swift run MyApp", &[]));
1441        assert!(is_excluded_command("swift package resolve", &[]));
1442        assert!(is_excluded_command("vapor serve --port 8080", &[]));
1443        assert!(is_excluded_command("zig build run", &[]));
1444    }
1445
1446    #[test]
1447    fn rust_watchers_are_passthrough() {
1448        assert!(is_excluded_command("cargo watch -x test", &[]));
1449        assert!(is_excluded_command("cargo run --bin server", &[]));
1450        assert!(is_excluded_command("cargo leptos watch", &[]));
1451        assert!(is_excluded_command("bacon test", &[]));
1452    }
1453
1454    #[test]
1455    fn general_task_runners_are_passthrough() {
1456        assert!(is_excluded_command("make dev", &[]));
1457        assert!(is_excluded_command("make serve", &[]));
1458        assert!(is_excluded_command("make watch", &[]));
1459        assert!(is_excluded_command("make run", &[]));
1460        assert!(is_excluded_command("make start", &[]));
1461        assert!(is_excluded_command("just dev", &[]));
1462        assert!(is_excluded_command("just serve", &[]));
1463        assert!(is_excluded_command("just watch", &[]));
1464        assert!(is_excluded_command("just start", &[]));
1465        assert!(is_excluded_command("just run", &[]));
1466        assert!(is_excluded_command("task dev", &[]));
1467        assert!(is_excluded_command("task serve", &[]));
1468        assert!(is_excluded_command("task watch", &[]));
1469        assert!(is_excluded_command("nix develop", &[]));
1470        assert!(is_excluded_command("devenv up", &[]));
1471    }
1472
1473    #[test]
1474    fn cicd_infra_are_passthrough() {
1475        assert!(is_excluded_command("act push", &[]));
1476        assert!(is_excluded_command("docker compose watch", &[]));
1477        assert!(is_excluded_command("docker-compose watch", &[]));
1478        assert!(is_excluded_command("skaffold dev", &[]));
1479        assert!(is_excluded_command("tilt up", &[]));
1480        assert!(is_excluded_command("garden dev", &[]));
1481        assert!(is_excluded_command("telepresence connect", &[]));
1482    }
1483
1484    #[test]
1485    fn networking_monitoring_are_passthrough() {
1486        assert!(is_excluded_command("mtr 8.8.8.8", &[]));
1487        assert!(is_excluded_command("nmap -sV host", &[]));
1488        assert!(is_excluded_command("iperf -s", &[]));
1489        assert!(is_excluded_command("iperf3 -c host", &[]));
1490        assert!(is_excluded_command("socat TCP-LISTEN:8080,fork -", &[]));
1491    }
1492
1493    #[test]
1494    fn load_testing_is_passthrough() {
1495        assert!(is_excluded_command("ab -n 1000 http://localhost/", &[]));
1496        assert!(is_excluded_command("wrk -t12 -c400 http://localhost/", &[]));
1497        assert!(is_excluded_command("hey -n 10000 http://localhost/", &[]));
1498        assert!(is_excluded_command("vegeta attack", &[]));
1499        assert!(is_excluded_command("k6 run script.js", &[]));
1500        assert!(is_excluded_command("artillery run test.yml", &[]));
1501    }
1502
1503    #[test]
1504    fn smart_script_detection_works() {
1505        assert!(is_excluded_command("npm run dev:ssr", &[]));
1506        assert!(is_excluded_command("npm run dev:local", &[]));
1507        assert!(is_excluded_command("yarn start:production", &[]));
1508        assert!(is_excluded_command("pnpm run serve:local", &[]));
1509        assert!(is_excluded_command("bun run watch:css", &[]));
1510        assert!(is_excluded_command("deno task dev:api", &[]));
1511        assert!(is_excluded_command("npm run storybook:ci", &[]));
1512        assert!(is_excluded_command("yarn preview:staging", &[]));
1513        assert!(is_excluded_command("pnpm run hot-reload", &[]));
1514        assert!(is_excluded_command("npm run hmr-server", &[]));
1515        assert!(is_excluded_command("bun run live-server", &[]));
1516    }
1517
1518    #[test]
1519    fn smart_detection_does_not_false_positive() {
1520        assert!(!is_excluded_command("npm run build", &[]));
1521        assert!(!is_excluded_command("npm run lint", &[]));
1522        assert!(!is_excluded_command("npm run test", &[]));
1523        assert!(!is_excluded_command("npm run format", &[]));
1524        assert!(!is_excluded_command("yarn build", &[]));
1525        assert!(!is_excluded_command("yarn test", &[]));
1526        assert!(!is_excluded_command("pnpm run lint", &[]));
1527        assert!(!is_excluded_command("bun run build", &[]));
1528    }
1529}
1530
1531/// Public wrapper for integration tests to exercise the compression pipeline.
1532pub fn compress_if_beneficial_pub(command: &str, output: &str) -> String {
1533    compress_if_beneficial(command, output)
1534}