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