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
872/// Returns a short, human-readable shell name (e.g. "bash", "zsh", "powershell", "cmd").
873pub fn shell_name() -> String {
874    let shell = detect_shell();
875    let basename = std::path::Path::new(&shell)
876        .file_name()
877        .and_then(|n| n.to_str())
878        .unwrap_or("sh")
879        .to_ascii_lowercase();
880    basename
881        .strip_suffix(".exe")
882        .unwrap_or(&basename)
883        .to_string()
884}
885
886fn detect_shell() -> String {
887    if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
888        return shell;
889    }
890
891    if let Ok(shell) = std::env::var("SHELL") {
892        let bin = std::path::Path::new(&shell)
893            .file_name()
894            .and_then(|n| n.to_str())
895            .unwrap_or("sh");
896
897        if bin == "lean-ctx" {
898            return find_real_shell();
899        }
900        return shell;
901    }
902
903    find_real_shell()
904}
905
906#[cfg(unix)]
907fn find_real_shell() -> String {
908    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
909        if std::path::Path::new(shell).exists() {
910            return shell.to_string();
911        }
912    }
913    "/bin/sh".to_string()
914}
915
916#[cfg(windows)]
917fn find_real_shell() -> String {
918    if is_running_in_msys_or_gitbash() {
919        for candidate in &["bash.exe", "sh.exe"] {
920            if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
921                if output.status.success() {
922                    if let Ok(path) = String::from_utf8(output.stdout) {
923                        if let Some(first_line) = path.lines().next() {
924                            let trimmed = first_line.trim();
925                            if !trimmed.is_empty() {
926                                return trimmed.to_string();
927                            }
928                        }
929                    }
930                }
931            }
932        }
933    }
934    if is_running_in_powershell() {
935        if let Ok(pwsh) = which_powershell() {
936            return pwsh;
937        }
938    }
939    if let Ok(comspec) = std::env::var("COMSPEC") {
940        return comspec;
941    }
942    "cmd.exe".to_string()
943}
944
945#[cfg(windows)]
946fn is_running_in_msys_or_gitbash() -> bool {
947    std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
948}
949
950#[cfg(windows)]
951fn is_running_in_powershell() -> bool {
952    if is_running_in_msys_or_gitbash() {
953        return false;
954    }
955    std::env::var("PSModulePath").is_ok()
956}
957
958#[cfg(windows)]
959fn which_powershell() -> Result<String, ()> {
960    for candidate in &["pwsh.exe", "powershell.exe"] {
961        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
962            if output.status.success() {
963                if let Ok(path) = String::from_utf8(output.stdout) {
964                    if let Some(first_line) = path.lines().next() {
965                        let trimmed = first_line.trim();
966                        if !trimmed.is_empty() {
967                            return Ok(trimmed.to_string());
968                        }
969                    }
970                }
971            }
972        }
973    }
974    Err(())
975}
976
977pub fn save_tee(command: &str, output: &str) -> Option<String> {
978    let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
979    std::fs::create_dir_all(&tee_dir).ok()?;
980
981    cleanup_old_tee_logs(&tee_dir);
982
983    let cmd_slug: String = command
984        .chars()
985        .take(40)
986        .map(|c| {
987            if c.is_alphanumeric() || c == '-' {
988                c
989            } else {
990                '_'
991            }
992        })
993        .collect();
994    let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
995    let filename = format!("{ts}_{cmd_slug}.log");
996    let path = tee_dir.join(&filename);
997
998    let masked = mask_sensitive_data(output);
999    std::fs::write(&path, masked).ok()?;
1000    Some(path.to_string_lossy().to_string())
1001}
1002
1003fn mask_sensitive_data(input: &str) -> String {
1004    use regex::Regex;
1005
1006    let patterns: Vec<(&str, Regex)> = vec![
1007        ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
1008        ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
1009        ("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()),
1010        ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
1011        ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
1012        ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
1013        ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
1014    ];
1015
1016    let mut result = input.to_string();
1017    for (label, re) in &patterns {
1018        result = re
1019            .replace_all(&result, |caps: &regex::Captures| {
1020                if let Some(prefix) = caps.get(1) {
1021                    format!("{}[REDACTED:{}]", prefix.as_str(), label)
1022                } else {
1023                    format!("[REDACTED:{}]", label)
1024                }
1025            })
1026            .to_string();
1027    }
1028    result
1029}
1030
1031fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
1032    let cutoff =
1033        std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
1034    let cutoff = match cutoff {
1035        Some(t) => t,
1036        None => return,
1037    };
1038
1039    if let Ok(entries) = std::fs::read_dir(tee_dir) {
1040        for entry in entries.flatten() {
1041            if let Ok(meta) = entry.metadata() {
1042                if let Ok(modified) = meta.modified() {
1043                    if modified < cutoff {
1044                        let _ = std::fs::remove_file(entry.path());
1045                    }
1046                }
1047            }
1048        }
1049    }
1050}
1051
1052/// Join multiple CLI arguments into a single command string, using quoting
1053/// conventions appropriate for the detected shell.
1054///
1055/// On Unix, this always produces POSIX-compatible quoting.
1056/// On Windows, the quoting adapts to the actual shell (PowerShell, cmd.exe,
1057/// or Git Bash / MSYS).
1058pub fn join_command(args: &[String]) -> String {
1059    let (_, flag) = shell_and_flag();
1060    join_command_for(args, &flag)
1061}
1062
1063fn join_command_for(args: &[String], shell_flag: &str) -> String {
1064    match shell_flag {
1065        "-Command" => join_powershell(args),
1066        "/C" => join_cmd(args),
1067        _ => join_posix(args),
1068    }
1069}
1070
1071fn join_posix(args: &[String]) -> String {
1072    args.iter()
1073        .map(|a| quote_posix(a))
1074        .collect::<Vec<_>>()
1075        .join(" ")
1076}
1077
1078fn join_powershell(args: &[String]) -> String {
1079    let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
1080    format!("& {}", quoted.join(" "))
1081}
1082
1083fn join_cmd(args: &[String]) -> String {
1084    args.iter()
1085        .map(|a| quote_cmd(a))
1086        .collect::<Vec<_>>()
1087        .join(" ")
1088}
1089
1090fn quote_posix(s: &str) -> String {
1091    if s.is_empty() {
1092        return "''".to_string();
1093    }
1094    if s.bytes()
1095        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
1096    {
1097        return s.to_string();
1098    }
1099    format!("'{}'", s.replace('\'', "'\\''"))
1100}
1101
1102fn quote_powershell(s: &str) -> String {
1103    if s.is_empty() {
1104        return "''".to_string();
1105    }
1106    if s.bytes()
1107        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
1108    {
1109        return s.to_string();
1110    }
1111    format!("'{}'", s.replace('\'', "''"))
1112}
1113
1114fn quote_cmd(s: &str) -> String {
1115    if s.is_empty() {
1116        return "\"\"".to_string();
1117    }
1118    if s.bytes()
1119        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
1120    {
1121        return s.to_string();
1122    }
1123    format!("\"{}\"", s.replace('"', "\\\""))
1124}
1125
1126#[cfg(test)]
1127mod join_command_tests {
1128    use super::*;
1129
1130    #[test]
1131    fn posix_simple_args() {
1132        let args: Vec<String> = vec!["git".into(), "status".into()];
1133        assert_eq!(join_command_for(&args, "-c"), "git status");
1134    }
1135
1136    #[test]
1137    fn posix_path_with_spaces() {
1138        let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
1139        assert_eq!(
1140            join_command_for(&args, "-c"),
1141            "'/usr/local/my app/bin' --help"
1142        );
1143    }
1144
1145    #[test]
1146    fn posix_single_quotes_escaped() {
1147        let args: Vec<String> = vec!["echo".into(), "it's".into()];
1148        assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
1149    }
1150
1151    #[test]
1152    fn posix_empty_arg() {
1153        let args: Vec<String> = vec!["cmd".into(), "".into()];
1154        assert_eq!(join_command_for(&args, "-c"), "cmd ''");
1155    }
1156
1157    #[test]
1158    fn powershell_simple_args() {
1159        let args: Vec<String> = vec!["npm".into(), "install".into()];
1160        assert_eq!(join_command_for(&args, "-Command"), "& npm install");
1161    }
1162
1163    #[test]
1164    fn powershell_path_with_spaces() {
1165        let args: Vec<String> = vec![
1166            "C:\\Program Files\\nodejs\\npm.cmd".into(),
1167            "install".into(),
1168        ];
1169        assert_eq!(
1170            join_command_for(&args, "-Command"),
1171            "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
1172        );
1173    }
1174
1175    #[test]
1176    fn powershell_single_quotes_escaped() {
1177        let args: Vec<String> = vec!["echo".into(), "it's done".into()];
1178        assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
1179    }
1180
1181    #[test]
1182    fn cmd_simple_args() {
1183        let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
1184        assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
1185    }
1186
1187    #[test]
1188    fn cmd_path_with_spaces() {
1189        let args: Vec<String> = vec![
1190            "C:\\Program Files\\nodejs\\npm.cmd".into(),
1191            "install".into(),
1192        ];
1193        assert_eq!(
1194            join_command_for(&args, "/C"),
1195            "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
1196        );
1197    }
1198
1199    #[test]
1200    fn cmd_double_quotes_escaped() {
1201        let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
1202        assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
1203    }
1204
1205    #[test]
1206    fn unknown_flag_uses_posix() {
1207        let args: Vec<String> = vec!["ls".into(), "-la".into()];
1208        assert_eq!(join_command_for(&args, "--exec"), "ls -la");
1209    }
1210}
1211
1212#[cfg(test)]
1213mod windows_shell_flag_tests {
1214    use super::windows_shell_flag_for_exe_basename;
1215
1216    #[test]
1217    fn cmd_uses_slash_c() {
1218        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
1219        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
1220    }
1221
1222    #[test]
1223    fn powershell_uses_command() {
1224        assert_eq!(
1225            windows_shell_flag_for_exe_basename("powershell.exe"),
1226            "-Command"
1227        );
1228        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
1229    }
1230
1231    #[test]
1232    fn posix_shells_use_dash_c() {
1233        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
1234        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
1235        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
1236        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
1237        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
1238    }
1239}
1240
1241#[cfg(test)]
1242mod passthrough_tests {
1243    use super::is_excluded_command;
1244
1245    #[test]
1246    fn turbo_is_passthrough() {
1247        assert!(is_excluded_command("turbo run dev", &[]));
1248        assert!(is_excluded_command("turbo run build", &[]));
1249        assert!(is_excluded_command("pnpm turbo run dev", &[]));
1250        assert!(is_excluded_command("npx turbo run dev", &[]));
1251    }
1252
1253    #[test]
1254    fn dev_servers_are_passthrough() {
1255        assert!(is_excluded_command("next dev", &[]));
1256        assert!(is_excluded_command("vite dev", &[]));
1257        assert!(is_excluded_command("nuxt dev", &[]));
1258        assert!(is_excluded_command("astro dev", &[]));
1259        assert!(is_excluded_command("nodemon server.js", &[]));
1260    }
1261
1262    #[test]
1263    fn interactive_tools_are_passthrough() {
1264        assert!(is_excluded_command("vim file.rs", &[]));
1265        assert!(is_excluded_command("nvim", &[]));
1266        assert!(is_excluded_command("htop", &[]));
1267        assert!(is_excluded_command("ssh user@host", &[]));
1268        assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
1269    }
1270
1271    #[test]
1272    fn docker_streaming_is_passthrough() {
1273        assert!(is_excluded_command("docker logs my-container", &[]));
1274        assert!(is_excluded_command("docker logs -f webapp", &[]));
1275        assert!(is_excluded_command("docker attach my-container", &[]));
1276        assert!(is_excluded_command("docker exec -it web bash", &[]));
1277        assert!(is_excluded_command("docker exec -ti web bash", &[]));
1278        assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
1279        assert!(is_excluded_command("docker compose exec web bash", &[]));
1280        assert!(is_excluded_command("docker stats", &[]));
1281        assert!(is_excluded_command("docker events", &[]));
1282    }
1283
1284    #[test]
1285    fn kubectl_is_passthrough() {
1286        assert!(is_excluded_command("kubectl logs my-pod", &[]));
1287        assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
1288        assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
1289        assert!(is_excluded_command(
1290            "kubectl port-forward svc/web 8080:80",
1291            &[]
1292        ));
1293        assert!(is_excluded_command("kubectl attach my-pod", &[]));
1294        assert!(is_excluded_command("kubectl proxy", &[]));
1295    }
1296
1297    #[test]
1298    fn database_repls_are_passthrough() {
1299        assert!(is_excluded_command("psql -U user mydb", &[]));
1300        assert!(is_excluded_command("mysql -u root -p", &[]));
1301        assert!(is_excluded_command("sqlite3 data.db", &[]));
1302        assert!(is_excluded_command("redis-cli", &[]));
1303        assert!(is_excluded_command("mongosh", &[]));
1304    }
1305
1306    #[test]
1307    fn streaming_tools_are_passthrough() {
1308        assert!(is_excluded_command("journalctl -f", &[]));
1309        assert!(is_excluded_command("ping 8.8.8.8", &[]));
1310        assert!(is_excluded_command("strace -p 1234", &[]));
1311        assert!(is_excluded_command("tcpdump -i eth0", &[]));
1312        assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
1313        assert!(is_excluded_command("tmux new -s work", &[]));
1314        assert!(is_excluded_command("screen -S dev", &[]));
1315    }
1316
1317    #[test]
1318    fn additional_dev_servers_are_passthrough() {
1319        assert!(is_excluded_command("gatsby develop", &[]));
1320        assert!(is_excluded_command("ng serve --port 4200", &[]));
1321        assert!(is_excluded_command("remix dev", &[]));
1322        assert!(is_excluded_command("wrangler dev", &[]));
1323        assert!(is_excluded_command("hugo server", &[]));
1324        assert!(is_excluded_command("bun dev", &[]));
1325        assert!(is_excluded_command("cargo watch -x test", &[]));
1326    }
1327
1328    #[test]
1329    fn normal_commands_not_excluded() {
1330        assert!(!is_excluded_command("git status", &[]));
1331        assert!(!is_excluded_command("cargo test", &[]));
1332        assert!(!is_excluded_command("npm run build", &[]));
1333        assert!(!is_excluded_command("ls -la", &[]));
1334    }
1335
1336    #[test]
1337    fn user_exclusions_work() {
1338        let excl = vec!["myapp".to_string()];
1339        assert!(is_excluded_command("myapp serve", &excl));
1340        assert!(!is_excluded_command("git status", &excl));
1341    }
1342
1343    #[test]
1344    fn is_container_returns_bool() {
1345        let _ = super::is_container();
1346    }
1347
1348    #[test]
1349    fn is_non_interactive_returns_bool() {
1350        let _ = super::is_non_interactive();
1351    }
1352
1353    #[test]
1354    fn auth_commands_excluded() {
1355        assert!(is_excluded_command("az login --use-device-code", &[]));
1356        assert!(is_excluded_command("gh auth login", &[]));
1357        assert!(is_excluded_command("gh pr close --comment 'done'", &[]));
1358        assert!(is_excluded_command("gh issue list", &[]));
1359        assert!(is_excluded_command("gcloud auth login", &[]));
1360        assert!(is_excluded_command("aws sso login", &[]));
1361        assert!(is_excluded_command("firebase login", &[]));
1362        assert!(is_excluded_command("vercel login", &[]));
1363        assert!(is_excluded_command("heroku login", &[]));
1364        assert!(is_excluded_command("az login", &[]));
1365        assert!(is_excluded_command("kubelogin convert-kubeconfig", &[]));
1366        assert!(is_excluded_command("vault login -method=oidc", &[]));
1367        assert!(is_excluded_command("flyctl auth login", &[]));
1368    }
1369
1370    #[test]
1371    fn auth_exclusion_does_not_affect_normal_commands() {
1372        assert!(!is_excluded_command("git log", &[]));
1373        assert!(!is_excluded_command("npm run build", &[]));
1374        assert!(!is_excluded_command("cargo test", &[]));
1375        assert!(!is_excluded_command("aws s3 ls", &[]));
1376        assert!(!is_excluded_command("gcloud compute instances list", &[]));
1377        assert!(!is_excluded_command("az vm list", &[]));
1378    }
1379
1380    #[test]
1381    fn npm_script_runners_are_passthrough() {
1382        assert!(is_excluded_command("npm run dev", &[]));
1383        assert!(is_excluded_command("npm run start", &[]));
1384        assert!(is_excluded_command("npm run serve", &[]));
1385        assert!(is_excluded_command("npm run watch", &[]));
1386        assert!(is_excluded_command("npm run preview", &[]));
1387        assert!(is_excluded_command("npm run storybook", &[]));
1388        assert!(is_excluded_command("npm run test:watch", &[]));
1389        assert!(is_excluded_command("npm start", &[]));
1390        assert!(is_excluded_command("npx vite", &[]));
1391        assert!(is_excluded_command("npx next dev", &[]));
1392    }
1393
1394    #[test]
1395    fn pnpm_script_runners_are_passthrough() {
1396        assert!(is_excluded_command("pnpm run dev", &[]));
1397        assert!(is_excluded_command("pnpm run start", &[]));
1398        assert!(is_excluded_command("pnpm run serve", &[]));
1399        assert!(is_excluded_command("pnpm run watch", &[]));
1400        assert!(is_excluded_command("pnpm run preview", &[]));
1401        assert!(is_excluded_command("pnpm dev", &[]));
1402        assert!(is_excluded_command("pnpm start", &[]));
1403        assert!(is_excluded_command("pnpm preview", &[]));
1404    }
1405
1406    #[test]
1407    fn yarn_script_runners_are_passthrough() {
1408        assert!(is_excluded_command("yarn dev", &[]));
1409        assert!(is_excluded_command("yarn start", &[]));
1410        assert!(is_excluded_command("yarn serve", &[]));
1411        assert!(is_excluded_command("yarn watch", &[]));
1412        assert!(is_excluded_command("yarn preview", &[]));
1413        assert!(is_excluded_command("yarn storybook", &[]));
1414    }
1415
1416    #[test]
1417    fn bun_deno_script_runners_are_passthrough() {
1418        assert!(is_excluded_command("bun run dev", &[]));
1419        assert!(is_excluded_command("bun run start", &[]));
1420        assert!(is_excluded_command("bun run serve", &[]));
1421        assert!(is_excluded_command("bun run watch", &[]));
1422        assert!(is_excluded_command("bun run preview", &[]));
1423        assert!(is_excluded_command("bun start", &[]));
1424        assert!(is_excluded_command("deno task dev", &[]));
1425        assert!(is_excluded_command("deno task start", &[]));
1426        assert!(is_excluded_command("deno task serve", &[]));
1427        assert!(is_excluded_command("deno run --watch main.ts", &[]));
1428    }
1429
1430    #[test]
1431    fn python_servers_are_passthrough() {
1432        assert!(is_excluded_command("flask run --port 5000", &[]));
1433        assert!(is_excluded_command("uvicorn app:app --reload", &[]));
1434        assert!(is_excluded_command("gunicorn app:app -w 4", &[]));
1435        assert!(is_excluded_command("hypercorn app:app", &[]));
1436        assert!(is_excluded_command("daphne app.asgi:application", &[]));
1437        assert!(is_excluded_command(
1438            "django-admin runserver 0.0.0.0:8000",
1439            &[]
1440        ));
1441        assert!(is_excluded_command("python manage.py runserver", &[]));
1442        assert!(is_excluded_command("python -m http.server 8080", &[]));
1443        assert!(is_excluded_command("python3 -m http.server", &[]));
1444        assert!(is_excluded_command("streamlit run app.py", &[]));
1445        assert!(is_excluded_command("gradio app.py", &[]));
1446        assert!(is_excluded_command("celery worker -A app", &[]));
1447        assert!(is_excluded_command("celery -A app worker", &[]));
1448        assert!(is_excluded_command("celery -B", &[]));
1449        assert!(is_excluded_command("dramatiq tasks", &[]));
1450        assert!(is_excluded_command("rq worker", &[]));
1451        assert!(is_excluded_command("ptw tests/", &[]));
1452        assert!(is_excluded_command("pytest-watch", &[]));
1453    }
1454
1455    #[test]
1456    fn ruby_servers_are_passthrough() {
1457        assert!(is_excluded_command("rails server -p 3000", &[]));
1458        assert!(is_excluded_command("rails s", &[]));
1459        assert!(is_excluded_command("puma -C config.rb", &[]));
1460        assert!(is_excluded_command("unicorn -c config.rb", &[]));
1461        assert!(is_excluded_command("thin start", &[]));
1462        assert!(is_excluded_command("foreman start", &[]));
1463        assert!(is_excluded_command("overmind start", &[]));
1464        assert!(is_excluded_command("guard -G Guardfile", &[]));
1465        assert!(is_excluded_command("sidekiq", &[]));
1466        assert!(is_excluded_command("resque work", &[]));
1467    }
1468
1469    #[test]
1470    fn php_servers_are_passthrough() {
1471        assert!(is_excluded_command("php artisan serve", &[]));
1472        assert!(is_excluded_command("php -S localhost:8000", &[]));
1473        assert!(is_excluded_command("php artisan queue:work", &[]));
1474        assert!(is_excluded_command("php artisan queue:listen", &[]));
1475        assert!(is_excluded_command("php artisan horizon", &[]));
1476        assert!(is_excluded_command("php artisan tinker", &[]));
1477        assert!(is_excluded_command("sail up", &[]));
1478    }
1479
1480    #[test]
1481    fn java_servers_are_passthrough() {
1482        assert!(is_excluded_command("./gradlew bootRun", &[]));
1483        assert!(is_excluded_command("gradlew bootRun", &[]));
1484        assert!(is_excluded_command("gradle bootRun", &[]));
1485        assert!(is_excluded_command("mvn spring-boot:run", &[]));
1486        assert!(is_excluded_command("./mvnw spring-boot:run", &[]));
1487        assert!(is_excluded_command("mvn quarkus:dev", &[]));
1488        assert!(is_excluded_command("./mvnw quarkus:dev", &[]));
1489        assert!(is_excluded_command("sbt run", &[]));
1490        assert!(is_excluded_command("sbt ~compile", &[]));
1491        assert!(is_excluded_command("lein run", &[]));
1492        assert!(is_excluded_command("lein repl", &[]));
1493        assert!(is_excluded_command("./gradlew run", &[]));
1494    }
1495
1496    #[test]
1497    fn go_servers_are_passthrough() {
1498        assert!(is_excluded_command("go run main.go", &[]));
1499        assert!(is_excluded_command("go run ./cmd/server", &[]));
1500        assert!(is_excluded_command("air -c .air.toml", &[]));
1501        assert!(is_excluded_command("gin --port 3000", &[]));
1502        assert!(is_excluded_command("realize start", &[]));
1503        assert!(is_excluded_command("reflex -r '.go$' go run .", &[]));
1504        assert!(is_excluded_command("gowatch run", &[]));
1505    }
1506
1507    #[test]
1508    fn dotnet_servers_are_passthrough() {
1509        assert!(is_excluded_command("dotnet run", &[]));
1510        assert!(is_excluded_command("dotnet run --project src/Api", &[]));
1511        assert!(is_excluded_command("dotnet watch run", &[]));
1512        assert!(is_excluded_command("dotnet ef database update", &[]));
1513    }
1514
1515    #[test]
1516    fn elixir_servers_are_passthrough() {
1517        assert!(is_excluded_command("mix phx.server", &[]));
1518        assert!(is_excluded_command("iex -s mix phx.server", &[]));
1519        assert!(is_excluded_command("iex -S mix phx.server", &[]));
1520    }
1521
1522    #[test]
1523    fn swift_zig_servers_are_passthrough() {
1524        assert!(is_excluded_command("swift run MyApp", &[]));
1525        assert!(is_excluded_command("swift package resolve", &[]));
1526        assert!(is_excluded_command("vapor serve --port 8080", &[]));
1527        assert!(is_excluded_command("zig build run", &[]));
1528    }
1529
1530    #[test]
1531    fn rust_watchers_are_passthrough() {
1532        assert!(is_excluded_command("cargo watch -x test", &[]));
1533        assert!(is_excluded_command("cargo run --bin server", &[]));
1534        assert!(is_excluded_command("cargo leptos watch", &[]));
1535        assert!(is_excluded_command("bacon test", &[]));
1536    }
1537
1538    #[test]
1539    fn general_task_runners_are_passthrough() {
1540        assert!(is_excluded_command("make dev", &[]));
1541        assert!(is_excluded_command("make serve", &[]));
1542        assert!(is_excluded_command("make watch", &[]));
1543        assert!(is_excluded_command("make run", &[]));
1544        assert!(is_excluded_command("make start", &[]));
1545        assert!(is_excluded_command("just dev", &[]));
1546        assert!(is_excluded_command("just serve", &[]));
1547        assert!(is_excluded_command("just watch", &[]));
1548        assert!(is_excluded_command("just start", &[]));
1549        assert!(is_excluded_command("just run", &[]));
1550        assert!(is_excluded_command("task dev", &[]));
1551        assert!(is_excluded_command("task serve", &[]));
1552        assert!(is_excluded_command("task watch", &[]));
1553        assert!(is_excluded_command("nix develop", &[]));
1554        assert!(is_excluded_command("devenv up", &[]));
1555    }
1556
1557    #[test]
1558    fn cicd_infra_are_passthrough() {
1559        assert!(is_excluded_command("act push", &[]));
1560        assert!(is_excluded_command("docker compose watch", &[]));
1561        assert!(is_excluded_command("docker-compose watch", &[]));
1562        assert!(is_excluded_command("skaffold dev", &[]));
1563        assert!(is_excluded_command("tilt up", &[]));
1564        assert!(is_excluded_command("garden dev", &[]));
1565        assert!(is_excluded_command("telepresence connect", &[]));
1566    }
1567
1568    #[test]
1569    fn networking_monitoring_are_passthrough() {
1570        assert!(is_excluded_command("mtr 8.8.8.8", &[]));
1571        assert!(is_excluded_command("nmap -sV host", &[]));
1572        assert!(is_excluded_command("iperf -s", &[]));
1573        assert!(is_excluded_command("iperf3 -c host", &[]));
1574        assert!(is_excluded_command("socat TCP-LISTEN:8080,fork -", &[]));
1575    }
1576
1577    #[test]
1578    fn load_testing_is_passthrough() {
1579        assert!(is_excluded_command("ab -n 1000 http://localhost/", &[]));
1580        assert!(is_excluded_command("wrk -t12 -c400 http://localhost/", &[]));
1581        assert!(is_excluded_command("hey -n 10000 http://localhost/", &[]));
1582        assert!(is_excluded_command("vegeta attack", &[]));
1583        assert!(is_excluded_command("k6 run script.js", &[]));
1584        assert!(is_excluded_command("artillery run test.yml", &[]));
1585    }
1586
1587    #[test]
1588    fn smart_script_detection_works() {
1589        assert!(is_excluded_command("npm run dev:ssr", &[]));
1590        assert!(is_excluded_command("npm run dev:local", &[]));
1591        assert!(is_excluded_command("yarn start:production", &[]));
1592        assert!(is_excluded_command("pnpm run serve:local", &[]));
1593        assert!(is_excluded_command("bun run watch:css", &[]));
1594        assert!(is_excluded_command("deno task dev:api", &[]));
1595        assert!(is_excluded_command("npm run storybook:ci", &[]));
1596        assert!(is_excluded_command("yarn preview:staging", &[]));
1597        assert!(is_excluded_command("pnpm run hot-reload", &[]));
1598        assert!(is_excluded_command("npm run hmr-server", &[]));
1599        assert!(is_excluded_command("bun run live-server", &[]));
1600    }
1601
1602    #[test]
1603    fn smart_detection_does_not_false_positive() {
1604        assert!(!is_excluded_command("npm run build", &[]));
1605        assert!(!is_excluded_command("npm run lint", &[]));
1606        assert!(!is_excluded_command("npm run test", &[]));
1607        assert!(!is_excluded_command("npm run format", &[]));
1608        assert!(!is_excluded_command("yarn build", &[]));
1609        assert!(!is_excluded_command("yarn test", &[]));
1610        assert!(!is_excluded_command("pnpm run lint", &[]));
1611        assert!(!is_excluded_command("bun run build", &[]));
1612    }
1613
1614    #[test]
1615    fn gh_fully_excluded() {
1616        assert!(is_excluded_command("gh", &[]));
1617        assert!(is_excluded_command(
1618            "gh pr close --comment 'closing — see #407'",
1619            &[]
1620        ));
1621        assert!(is_excluded_command(
1622            "gh issue create --title \"bug\" --body \"desc\"",
1623            &[]
1624        ));
1625        assert!(is_excluded_command("gh api repos/owner/repo/pulls", &[]));
1626        assert!(is_excluded_command("gh run list --limit 5", &[]));
1627    }
1628
1629    #[test]
1630    fn exec_direct_runs_true() {
1631        let code = super::exec_direct(&["true".to_string()]);
1632        assert_eq!(code, 0);
1633    }
1634
1635    #[test]
1636    fn exec_direct_runs_false() {
1637        let code = super::exec_direct(&["false".to_string()]);
1638        assert_ne!(code, 0);
1639    }
1640
1641    #[test]
1642    fn exec_direct_preserves_args_with_special_chars() {
1643        let code = super::exec_direct(&[
1644            "echo".to_string(),
1645            "hello world".to_string(),
1646            "it's here".to_string(),
1647            "a \"quoted\" thing".to_string(),
1648        ]);
1649        assert_eq!(code, 0);
1650    }
1651
1652    #[test]
1653    fn exec_direct_nonexistent_returns_127() {
1654        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
1655        assert_eq!(code, 127);
1656    }
1657
1658    #[test]
1659    fn exec_argv_empty_returns_127() {
1660        let code = super::exec_argv(&[]);
1661        assert_eq!(code, 127);
1662    }
1663
1664    #[test]
1665    fn exec_argv_runs_simple_command() {
1666        let code = super::exec_argv(&["true".to_string()]);
1667        assert_eq!(code, 0);
1668    }
1669
1670    #[test]
1671    fn exec_argv_passes_through_when_disabled() {
1672        std::env::set_var("LEAN_CTX_DISABLED", "1");
1673        let code = super::exec_argv(&["true".to_string()]);
1674        std::env::remove_var("LEAN_CTX_DISABLED");
1675        assert_eq!(code, 0);
1676    }
1677
1678    #[test]
1679    fn join_command_preserves_structure() {
1680        let args = vec![
1681            "git".to_string(),
1682            "commit".to_string(),
1683            "-m".to_string(),
1684            "my message".to_string(),
1685        ];
1686        let joined = super::join_command(&args);
1687        assert!(joined.contains("git"));
1688        assert!(joined.contains("commit"));
1689        assert!(joined.contains("my message") || joined.contains("'my message'"));
1690    }
1691
1692    #[test]
1693    fn quote_posix_handles_em_dash() {
1694        let result = super::quote_posix("closing — see #407");
1695        assert!(
1696            result.starts_with('\''),
1697            "em-dash args must be single-quoted: {result}"
1698        );
1699    }
1700
1701    #[test]
1702    fn quote_posix_handles_nested_single_quotes() {
1703        let result = super::quote_posix("it's a test");
1704        assert!(
1705            result.contains("\\'"),
1706            "single quotes must be escaped: {result}"
1707        );
1708    }
1709
1710    #[test]
1711    fn quote_posix_safe_chars_unquoted() {
1712        let result = super::quote_posix("simple_word");
1713        assert_eq!(result, "simple_word");
1714    }
1715
1716    #[test]
1717    fn quote_posix_empty_string() {
1718        let result = super::quote_posix("");
1719        assert_eq!(result, "''");
1720    }
1721
1722    #[test]
1723    fn quote_posix_dollar_expansion_protected() {
1724        let result = super::quote_posix("$HOME/test");
1725        assert!(
1726            result.starts_with('\''),
1727            "dollar signs must be single-quoted: {result}"
1728        );
1729    }
1730
1731    #[test]
1732    fn quote_posix_backtick_protected() {
1733        let result = super::quote_posix("echo `date`");
1734        assert!(
1735            result.starts_with('\''),
1736            "backticks must be single-quoted: {result}"
1737        );
1738    }
1739
1740    #[test]
1741    fn quote_posix_double_quotes_protected() {
1742        let result = super::quote_posix(r#"he said "hello""#);
1743        assert!(
1744            result.starts_with('\''),
1745            "double quotes must be single-quoted: {result}"
1746        );
1747    }
1748}
1749
1750/// Public wrapper for integration tests to exercise the compression pipeline.
1751pub fn compress_if_beneficial_pub(command: &str, output: &str) -> String {
1752    compress_if_beneficial(command, output)
1753}