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
82pub 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
107pub fn is_non_interactive() -> bool {
109 !io::stdin().is_terminal()
110}
111
112pub 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 "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 "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 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 "kubectl logs",
388 "kubectl exec -it",
389 "kubectl exec -ti",
390 "kubectl attach",
391 "kubectl port-forward",
392 "kubectl proxy",
393 "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 "less",
419 "more",
420 "vim",
421 "nvim",
422 "vi ",
423 "nano",
424 "micro ",
425 "helix ",
426 "hx ",
427 "emacs",
428 "tmux",
430 "screen",
431 "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 "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 "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 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 "./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 run ",
504 "air ",
505 "gin ",
506 "realize start",
507 "reflex ",
508 "gowatch ",
509 "dotnet run",
511 "dotnet watch",
512 "dotnet ef",
513 "mix phx.server",
515 "iex -s mix",
516 "swift run",
518 "swift package ",
519 "vapor serve",
520 "zig build run",
522 "cargo watch",
524 "cargo run",
525 "cargo leptos watch",
526 "bacon ",
527 "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 "act ",
545 "skaffold dev",
546 "tilt up",
547 "garden dev",
548 "telepresence ",
549 "ab ",
551 "wrk ",
552 "hey ",
553 "vegeta ",
554 "k6 run",
555 "artillery run",
556 "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 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
842fn 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 "-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
872pub 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: ®ex::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
1052pub 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
1750pub fn compress_if_beneficial_pub(command: &str, output: &str) -> String {
1752 compress_if_beneficial(command, output)
1753}