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