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 is_container() -> bool {
12 #[cfg(unix)]
13 {
14 if std::path::Path::new("/.dockerenv").exists() {
15 return true;
16 }
17 if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
18 if cgroup.contains("/docker/") || cgroup.contains("/lxc/") {
19 return true;
20 }
21 }
22 if let Ok(mounts) = std::fs::read_to_string("/proc/self/mountinfo") {
23 if mounts.contains("/docker/containers/") {
24 return true;
25 }
26 }
27 false
28 }
29 #[cfg(not(unix))]
30 {
31 false
32 }
33}
34
35pub fn is_non_interactive() -> bool {
37 !io::stdin().is_terminal()
38}
39
40pub fn exec(command: &str) -> i32 {
41 let (shell, shell_flag) = shell_and_flag();
42 let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
43 let command = command.as_str();
44
45 if std::env::var("LEAN_CTX_DISABLED").is_ok()
46 || std::env::var("LEAN_CTX_ACTIVE").is_ok()
47 {
48 return exec_inherit(command, &shell, &shell_flag);
49 }
50
51 let cfg = config::Config::load();
52 let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
53 let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
54
55 if raw_mode || (!force_compress && is_excluded_command(command, &cfg.excluded_commands)) {
56 return exec_inherit(command, &shell, &shell_flag);
57 }
58
59 if !force_compress {
60 if io::stdout().is_terminal() {
61 return exec_inherit_tracked(command, &shell, &shell_flag);
62 }
63 return exec_inherit(command, &shell, &shell_flag);
64 }
65
66 exec_buffered(command, &shell, &shell_flag, &cfg)
67}
68
69fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
70 let status = Command::new(shell)
71 .arg(shell_flag)
72 .arg(command)
73 .env("LEAN_CTX_ACTIVE", "1")
74 .stdin(Stdio::inherit())
75 .stdout(Stdio::inherit())
76 .stderr(Stdio::inherit())
77 .status();
78
79 match status {
80 Ok(s) => s.code().unwrap_or(1),
81 Err(e) => {
82 eprintln!("lean-ctx: failed to execute: {e}");
83 127
84 }
85 }
86}
87
88fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
89 let code = exec_inherit(command, shell, shell_flag);
90 stats::record(command, 0, 0);
91 code
92}
93
94fn combine_output(stdout: &str, stderr: &str) -> String {
95 if stderr.is_empty() {
96 stdout.to_string()
97 } else if stdout.is_empty() {
98 stderr.to_string()
99 } else {
100 format!("{stdout}\n{stderr}")
101 }
102}
103
104fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
105 let start = std::time::Instant::now();
106
107 let child = Command::new(shell)
108 .arg(shell_flag)
109 .arg(command)
110 .env("LEAN_CTX_ACTIVE", "1")
111 .env_remove("DISPLAY")
112 .env_remove("XAUTHORITY")
113 .env_remove("WAYLAND_DISPLAY")
114 .stdout(Stdio::piped())
115 .stderr(Stdio::piped())
116 .spawn();
117
118 let child = match child {
119 Ok(c) => c,
120 Err(e) => {
121 eprintln!("lean-ctx: failed to execute: {e}");
122 return 127;
123 }
124 };
125
126 let output = match child.wait_with_output() {
127 Ok(o) => o,
128 Err(e) => {
129 eprintln!("lean-ctx: failed to wait: {e}");
130 return 127;
131 }
132 };
133
134 let duration_ms = start.elapsed().as_millis();
135 let exit_code = output.status.code().unwrap_or(1);
136 let stdout = String::from_utf8_lossy(&output.stdout);
137 let stderr = String::from_utf8_lossy(&output.stderr);
138
139 let full_output = combine_output(&stdout, &stderr);
140 let input_tokens = count_tokens(&full_output);
141
142 let (compressed, output_tokens) = compress_and_measure(command, &stdout, &stderr);
143
144 stats::record(command, input_tokens, output_tokens);
145
146 if !compressed.is_empty() {
147 let _ = io::stdout().write_all(compressed.as_bytes());
148 if !compressed.ends_with('\n') {
149 let _ = io::stdout().write_all(b"\n");
150 }
151 }
152 let should_tee = match cfg.tee_mode {
153 config::TeeMode::Always => !full_output.trim().is_empty(),
154 config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
155 config::TeeMode::Never => false,
156 };
157 if should_tee {
158 if let Some(path) = save_tee(command, &full_output) {
159 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
160 }
161 }
162
163 let threshold = cfg.slow_command_threshold_ms;
164 if threshold > 0 && duration_ms >= threshold as u128 {
165 slow_log::record(command, duration_ms, exit_code);
166 }
167
168 exit_code
169}
170
171const BUILTIN_PASSTHROUGH: &[&str] = &[
172 "turbo",
174 "nx serve",
175 "nx dev",
176 "next dev",
177 "vite dev",
178 "vite preview",
179 "vitest",
180 "nuxt dev",
181 "astro dev",
182 "webpack serve",
183 "webpack-dev-server",
184 "nodemon",
185 "concurrently",
186 "pm2",
187 "pm2 logs",
188 "gatsby develop",
189 "expo start",
190 "react-scripts start",
191 "ng serve",
192 "remix dev",
193 "wrangler dev",
194 "hugo server",
195 "hugo serve",
196 "jekyll serve",
197 "bun dev",
198 "ember serve",
199 "docker compose up",
201 "docker-compose up",
202 "docker compose logs",
203 "docker-compose logs",
204 "docker compose exec",
205 "docker-compose exec",
206 "docker compose run",
207 "docker-compose run",
208 "docker logs",
209 "docker attach",
210 "docker exec -it",
211 "docker exec -ti",
212 "docker run -it",
213 "docker run -ti",
214 "docker stats",
215 "docker events",
216 "kubectl logs",
218 "kubectl exec -it",
219 "kubectl exec -ti",
220 "kubectl attach",
221 "kubectl port-forward",
222 "kubectl proxy",
223 "top",
225 "htop",
226 "btop",
227 "watch ",
228 "tail -f",
229 "tail -F",
230 "journalctl -f",
231 "journalctl --follow",
232 "dmesg -w",
233 "dmesg --follow",
234 "strace",
235 "tcpdump",
236 "ping ",
237 "ping6 ",
238 "traceroute",
239 "less",
241 "more",
242 "vim",
243 "nvim",
244 "vi ",
245 "nano",
246 "micro ",
247 "helix ",
248 "hx ",
249 "emacs",
250 "tmux",
252 "screen",
253 "ssh ",
255 "telnet ",
256 "nc ",
257 "ncat ",
258 "psql",
259 "mysql",
260 "sqlite3",
261 "redis-cli",
262 "mongosh",
263 "mongo ",
264 "python3 -i",
265 "python -i",
266 "irb",
267 "rails console",
268 "rails c ",
269 "iex",
270 "cargo watch",
272 "az login",
274 "az account",
275 "gh auth",
276 "gcloud auth",
277 "gcloud init",
278 "aws sso",
279 "aws configure sso",
280 "firebase login",
281 "netlify login",
282 "vercel login",
283 "heroku login",
284 "flyctl auth",
285 "fly auth",
286 "railway login",
287 "supabase login",
288 "wrangler login",
289 "doppler login",
290 "vault login",
291 "oc login",
292 "kubelogin",
293 "--use-device-code",
294];
295
296fn is_excluded_command(command: &str, excluded: &[String]) -> bool {
297 let cmd = command.trim().to_lowercase();
298 for pattern in BUILTIN_PASSTHROUGH {
299 if pattern.starts_with("--") {
300 if cmd.contains(pattern) {
301 return true;
302 }
303 } else if pattern.ends_with(' ') || pattern.ends_with('\t') {
304 if cmd == pattern.trim() || cmd.starts_with(pattern) {
305 return true;
306 }
307 } else if cmd == *pattern
308 || cmd.starts_with(&format!("{pattern} "))
309 || cmd.starts_with(&format!("{pattern}\t"))
310 || cmd.contains(&format!(" {pattern} "))
311 || cmd.contains(&format!(" {pattern}\t"))
312 || cmd.contains(&format!("|{pattern} "))
313 || cmd.contains(&format!("|{pattern}\t"))
314 || cmd.ends_with(&format!(" {pattern}"))
315 || cmd.ends_with(&format!("|{pattern}"))
316 {
317 return true;
318 }
319 }
320 if excluded.is_empty() {
321 return false;
322 }
323 excluded.iter().any(|excl| {
324 let excl_lower = excl.trim().to_lowercase();
325 cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} "))
326 })
327}
328
329pub fn interactive() {
330 let real_shell = detect_shell();
331
332 eprintln!(
333 "lean-ctx shell v{} (wrapping {real_shell})",
334 env!("CARGO_PKG_VERSION")
335 );
336 eprintln!("All command output is automatically compressed.");
337 eprintln!("Type 'exit' to quit.\n");
338
339 let stdin = io::stdin();
340 let mut stdout = io::stdout();
341
342 loop {
343 let _ = write!(stdout, "lean-ctx> ");
344 let _ = stdout.flush();
345
346 let mut line = String::new();
347 match stdin.lock().read_line(&mut line) {
348 Ok(0) => break,
349 Ok(_) => {}
350 Err(_) => break,
351 }
352
353 let cmd = line.trim();
354 if cmd.is_empty() {
355 continue;
356 }
357 if cmd == "exit" || cmd == "quit" {
358 break;
359 }
360 if cmd == "gain" {
361 println!("{}", stats::format_gain());
362 continue;
363 }
364
365 let exit_code = exec(cmd);
366
367 if exit_code != 0 {
368 let _ = writeln!(stdout, "[exit: {exit_code}]");
369 }
370 }
371}
372
373fn compress_and_measure(command: &str, stdout: &str, stderr: &str) -> (String, usize) {
374 let compressed_stdout = compress_if_beneficial(command, stdout);
375 let compressed_stderr = compress_if_beneficial(command, stderr);
376
377 let mut result = String::new();
378 if !compressed_stdout.is_empty() {
379 result.push_str(&compressed_stdout);
380 }
381 if !compressed_stderr.is_empty() {
382 if !result.is_empty() {
383 result.push('\n');
384 }
385 result.push_str(&compressed_stderr);
386 }
387
388 let content_for_counting = if let Some(pos) = result.rfind("\n[lean-ctx: ") {
391 &result[..pos]
392 } else {
393 &result
394 };
395 let output_tokens = count_tokens(content_for_counting);
396 (result, output_tokens)
397}
398
399fn compress_if_beneficial(command: &str, output: &str) -> String {
400 if output.trim().is_empty() {
401 return String::new();
402 }
403
404 if crate::tools::ctx_shell::contains_auth_flow(output) {
405 return output.to_string();
406 }
407
408 let original_tokens = count_tokens(output);
409
410 if original_tokens < 50 {
411 return output.to_string();
412 }
413
414 let min_output_tokens = 5;
415
416 if let Some(compressed) = patterns::compress_output(command, output) {
417 if !compressed.trim().is_empty() {
418 let compressed_tokens = count_tokens(&compressed);
419 if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
420 let saved = original_tokens - compressed_tokens;
421 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
422 if pct >= 5 {
423 return format!(
424 "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
425 );
426 }
427 return compressed;
428 }
429 if compressed_tokens < min_output_tokens {
430 return output.to_string();
431 }
432 }
433 }
434
435 let cleaned = crate::core::compressor::lightweight_cleanup(output);
437 let cleaned_tokens = count_tokens(&cleaned);
438 if cleaned_tokens < original_tokens {
439 let lines: Vec<&str> = cleaned.lines().collect();
440 if lines.len() > 30 {
441 let first = &lines[..5];
442 let last = &lines[lines.len() - 5..];
443 let omitted = lines.len() - 10;
444 let total = lines.len();
445 let compressed = format!(
446 "{}\n[truncated: showing 10/{total} lines, {omitted} omitted]\n{}",
447 first.join("\n"),
448 last.join("\n")
449 );
450 let ct = count_tokens(&compressed);
451 if ct < original_tokens {
452 let saved = original_tokens - ct;
453 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
454 if pct >= 5 {
455 return format!(
456 "{compressed}\n[lean-ctx: {original_tokens}→{ct} tok, -{pct}%]"
457 );
458 }
459 return compressed;
460 }
461 }
462 if cleaned_tokens < original_tokens {
463 let saved = original_tokens - cleaned_tokens;
464 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
465 if pct >= 5 {
466 return format!(
467 "{cleaned}\n[lean-ctx: {original_tokens}→{cleaned_tokens} tok, -{pct}%]"
468 );
469 }
470 return cleaned;
471 }
472 }
473
474 let lines: Vec<&str> = output.lines().collect();
475 if lines.len() > 30 {
476 let first = &lines[..5];
477 let last = &lines[lines.len() - 5..];
478 let omitted = lines.len() - 10;
479 let compressed = format!(
480 "{}\n... ({omitted} lines omitted) ...\n{}",
481 first.join("\n"),
482 last.join("\n")
483 );
484 let compressed_tokens = count_tokens(&compressed);
485 if compressed_tokens < original_tokens {
486 let saved = original_tokens - compressed_tokens;
487 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
488 if pct >= 5 {
489 return format!(
490 "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
491 );
492 }
493 return compressed;
494 }
495 }
496
497 output.to_string()
498}
499
500fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
503 if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
504 "-Command"
505 } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
506 "/C"
507 } else {
508 "-c"
512 }
513}
514
515pub fn shell_and_flag() -> (String, String) {
516 let shell = detect_shell();
517 let flag = if cfg!(windows) {
518 let name = std::path::Path::new(&shell)
519 .file_name()
520 .and_then(|n| n.to_str())
521 .unwrap_or("")
522 .to_ascii_lowercase();
523 windows_shell_flag_for_exe_basename(&name).to_string()
524 } else {
525 "-c".to_string()
526 };
527 (shell, flag)
528}
529
530fn detect_shell() -> String {
531 if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
532 return shell;
533 }
534
535 if let Ok(shell) = std::env::var("SHELL") {
536 let bin = std::path::Path::new(&shell)
537 .file_name()
538 .and_then(|n| n.to_str())
539 .unwrap_or("sh");
540
541 if bin == "lean-ctx" {
542 return find_real_shell();
543 }
544 return shell;
545 }
546
547 find_real_shell()
548}
549
550#[cfg(unix)]
551fn find_real_shell() -> String {
552 for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
553 if std::path::Path::new(shell).exists() {
554 return shell.to_string();
555 }
556 }
557 "/bin/sh".to_string()
558}
559
560#[cfg(windows)]
561fn find_real_shell() -> String {
562 if is_running_in_powershell() {
563 if let Ok(pwsh) = which_powershell() {
564 return pwsh;
565 }
566 }
567 if let Ok(comspec) = std::env::var("COMSPEC") {
568 return comspec;
569 }
570 "cmd.exe".to_string()
571}
572
573#[cfg(windows)]
574fn is_running_in_powershell() -> bool {
575 std::env::var("PSModulePath").is_ok()
576}
577
578#[cfg(windows)]
579fn which_powershell() -> Result<String, ()> {
580 for candidate in &["pwsh.exe", "powershell.exe"] {
581 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
582 if output.status.success() {
583 if let Ok(path) = String::from_utf8(output.stdout) {
584 if let Some(first_line) = path.lines().next() {
585 let trimmed = first_line.trim();
586 if !trimmed.is_empty() {
587 return Ok(trimmed.to_string());
588 }
589 }
590 }
591 }
592 }
593 }
594 Err(())
595}
596
597pub fn save_tee(command: &str, output: &str) -> Option<String> {
598 let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
599 std::fs::create_dir_all(&tee_dir).ok()?;
600
601 cleanup_old_tee_logs(&tee_dir);
602
603 let cmd_slug: String = command
604 .chars()
605 .take(40)
606 .map(|c| {
607 if c.is_alphanumeric() || c == '-' {
608 c
609 } else {
610 '_'
611 }
612 })
613 .collect();
614 let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
615 let filename = format!("{ts}_{cmd_slug}.log");
616 let path = tee_dir.join(&filename);
617
618 let masked = mask_sensitive_data(output);
619 std::fs::write(&path, masked).ok()?;
620 Some(path.to_string_lossy().to_string())
621}
622
623fn mask_sensitive_data(input: &str) -> String {
624 use regex::Regex;
625
626 let patterns: Vec<(&str, Regex)> = vec![
627 ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
628 ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
629 ("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()),
630 ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
631 ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
632 ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
633 ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
634 ];
635
636 let mut result = input.to_string();
637 for (label, re) in &patterns {
638 result = re
639 .replace_all(&result, |caps: ®ex::Captures| {
640 if let Some(prefix) = caps.get(1) {
641 format!("{}[REDACTED:{}]", prefix.as_str(), label)
642 } else {
643 format!("[REDACTED:{}]", label)
644 }
645 })
646 .to_string();
647 }
648 result
649}
650
651fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
652 let cutoff =
653 std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
654 let cutoff = match cutoff {
655 Some(t) => t,
656 None => return,
657 };
658
659 if let Ok(entries) = std::fs::read_dir(tee_dir) {
660 for entry in entries.flatten() {
661 if let Ok(meta) = entry.metadata() {
662 if let Ok(modified) = meta.modified() {
663 if modified < cutoff {
664 let _ = std::fs::remove_file(entry.path());
665 }
666 }
667 }
668 }
669 }
670}
671
672pub fn join_command(args: &[String]) -> String {
679 let (_, flag) = shell_and_flag();
680 join_command_for(args, &flag)
681}
682
683fn join_command_for(args: &[String], shell_flag: &str) -> String {
684 match shell_flag {
685 "-Command" => join_powershell(args),
686 "/C" => join_cmd(args),
687 _ => join_posix(args),
688 }
689}
690
691fn join_posix(args: &[String]) -> String {
692 args.iter()
693 .map(|a| quote_posix(a))
694 .collect::<Vec<_>>()
695 .join(" ")
696}
697
698fn join_powershell(args: &[String]) -> String {
699 let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
700 format!("& {}", quoted.join(" "))
701}
702
703fn join_cmd(args: &[String]) -> String {
704 args.iter()
705 .map(|a| quote_cmd(a))
706 .collect::<Vec<_>>()
707 .join(" ")
708}
709
710fn quote_posix(s: &str) -> String {
711 if s.is_empty() {
712 return "''".to_string();
713 }
714 if s.bytes()
715 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
716 {
717 return s.to_string();
718 }
719 format!("'{}'", s.replace('\'', "'\\''"))
720}
721
722fn quote_powershell(s: &str) -> String {
723 if s.is_empty() {
724 return "''".to_string();
725 }
726 if s.bytes()
727 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
728 {
729 return s.to_string();
730 }
731 format!("'{}'", s.replace('\'', "''"))
732}
733
734fn quote_cmd(s: &str) -> String {
735 if s.is_empty() {
736 return "\"\"".to_string();
737 }
738 if s.bytes()
739 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
740 {
741 return s.to_string();
742 }
743 format!("\"{}\"", s.replace('"', "\\\""))
744}
745
746#[cfg(test)]
747mod join_command_tests {
748 use super::*;
749
750 #[test]
751 fn posix_simple_args() {
752 let args: Vec<String> = vec!["git".into(), "status".into()];
753 assert_eq!(join_command_for(&args, "-c"), "git status");
754 }
755
756 #[test]
757 fn posix_path_with_spaces() {
758 let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
759 assert_eq!(
760 join_command_for(&args, "-c"),
761 "'/usr/local/my app/bin' --help"
762 );
763 }
764
765 #[test]
766 fn posix_single_quotes_escaped() {
767 let args: Vec<String> = vec!["echo".into(), "it's".into()];
768 assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
769 }
770
771 #[test]
772 fn posix_empty_arg() {
773 let args: Vec<String> = vec!["cmd".into(), "".into()];
774 assert_eq!(join_command_for(&args, "-c"), "cmd ''");
775 }
776
777 #[test]
778 fn powershell_simple_args() {
779 let args: Vec<String> = vec!["npm".into(), "install".into()];
780 assert_eq!(join_command_for(&args, "-Command"), "& npm install");
781 }
782
783 #[test]
784 fn powershell_path_with_spaces() {
785 let args: Vec<String> = vec![
786 "C:\\Program Files\\nodejs\\npm.cmd".into(),
787 "install".into(),
788 ];
789 assert_eq!(
790 join_command_for(&args, "-Command"),
791 "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
792 );
793 }
794
795 #[test]
796 fn powershell_single_quotes_escaped() {
797 let args: Vec<String> = vec!["echo".into(), "it's done".into()];
798 assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
799 }
800
801 #[test]
802 fn cmd_simple_args() {
803 let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
804 assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
805 }
806
807 #[test]
808 fn cmd_path_with_spaces() {
809 let args: Vec<String> = vec![
810 "C:\\Program Files\\nodejs\\npm.cmd".into(),
811 "install".into(),
812 ];
813 assert_eq!(
814 join_command_for(&args, "/C"),
815 "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
816 );
817 }
818
819 #[test]
820 fn cmd_double_quotes_escaped() {
821 let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
822 assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
823 }
824
825 #[test]
826 fn unknown_flag_uses_posix() {
827 let args: Vec<String> = vec!["ls".into(), "-la".into()];
828 assert_eq!(join_command_for(&args, "--exec"), "ls -la");
829 }
830}
831
832#[cfg(test)]
833mod windows_shell_flag_tests {
834 use super::windows_shell_flag_for_exe_basename;
835
836 #[test]
837 fn cmd_uses_slash_c() {
838 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
839 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
840 }
841
842 #[test]
843 fn powershell_uses_command() {
844 assert_eq!(
845 windows_shell_flag_for_exe_basename("powershell.exe"),
846 "-Command"
847 );
848 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
849 }
850
851 #[test]
852 fn posix_shells_use_dash_c() {
853 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
854 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
855 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
856 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
857 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
858 }
859}
860
861#[cfg(test)]
862mod passthrough_tests {
863 use super::is_excluded_command;
864
865 #[test]
866 fn turbo_is_passthrough() {
867 assert!(is_excluded_command("turbo run dev", &[]));
868 assert!(is_excluded_command("turbo run build", &[]));
869 assert!(is_excluded_command("pnpm turbo run dev", &[]));
870 assert!(is_excluded_command("npx turbo run dev", &[]));
871 }
872
873 #[test]
874 fn dev_servers_are_passthrough() {
875 assert!(is_excluded_command("next dev", &[]));
876 assert!(is_excluded_command("vite dev", &[]));
877 assert!(is_excluded_command("nuxt dev", &[]));
878 assert!(is_excluded_command("astro dev", &[]));
879 assert!(is_excluded_command("nodemon server.js", &[]));
880 }
881
882 #[test]
883 fn interactive_tools_are_passthrough() {
884 assert!(is_excluded_command("vim file.rs", &[]));
885 assert!(is_excluded_command("nvim", &[]));
886 assert!(is_excluded_command("htop", &[]));
887 assert!(is_excluded_command("ssh user@host", &[]));
888 assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
889 }
890
891 #[test]
892 fn docker_streaming_is_passthrough() {
893 assert!(is_excluded_command("docker logs my-container", &[]));
894 assert!(is_excluded_command("docker logs -f webapp", &[]));
895 assert!(is_excluded_command("docker attach my-container", &[]));
896 assert!(is_excluded_command("docker exec -it web bash", &[]));
897 assert!(is_excluded_command("docker exec -ti web bash", &[]));
898 assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
899 assert!(is_excluded_command("docker compose exec web bash", &[]));
900 assert!(is_excluded_command("docker stats", &[]));
901 assert!(is_excluded_command("docker events", &[]));
902 }
903
904 #[test]
905 fn kubectl_is_passthrough() {
906 assert!(is_excluded_command("kubectl logs my-pod", &[]));
907 assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
908 assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
909 assert!(is_excluded_command(
910 "kubectl port-forward svc/web 8080:80",
911 &[]
912 ));
913 assert!(is_excluded_command("kubectl attach my-pod", &[]));
914 assert!(is_excluded_command("kubectl proxy", &[]));
915 }
916
917 #[test]
918 fn database_repls_are_passthrough() {
919 assert!(is_excluded_command("psql -U user mydb", &[]));
920 assert!(is_excluded_command("mysql -u root -p", &[]));
921 assert!(is_excluded_command("sqlite3 data.db", &[]));
922 assert!(is_excluded_command("redis-cli", &[]));
923 assert!(is_excluded_command("mongosh", &[]));
924 }
925
926 #[test]
927 fn streaming_tools_are_passthrough() {
928 assert!(is_excluded_command("journalctl -f", &[]));
929 assert!(is_excluded_command("ping 8.8.8.8", &[]));
930 assert!(is_excluded_command("strace -p 1234", &[]));
931 assert!(is_excluded_command("tcpdump -i eth0", &[]));
932 assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
933 assert!(is_excluded_command("tmux new -s work", &[]));
934 assert!(is_excluded_command("screen -S dev", &[]));
935 }
936
937 #[test]
938 fn additional_dev_servers_are_passthrough() {
939 assert!(is_excluded_command("gatsby develop", &[]));
940 assert!(is_excluded_command("ng serve --port 4200", &[]));
941 assert!(is_excluded_command("remix dev", &[]));
942 assert!(is_excluded_command("wrangler dev", &[]));
943 assert!(is_excluded_command("hugo server", &[]));
944 assert!(is_excluded_command("bun dev", &[]));
945 assert!(is_excluded_command("cargo watch -x test", &[]));
946 }
947
948 #[test]
949 fn normal_commands_not_excluded() {
950 assert!(!is_excluded_command("git status", &[]));
951 assert!(!is_excluded_command("cargo test", &[]));
952 assert!(!is_excluded_command("npm run build", &[]));
953 assert!(!is_excluded_command("ls -la", &[]));
954 }
955
956 #[test]
957 fn user_exclusions_work() {
958 let excl = vec!["myapp".to_string()];
959 assert!(is_excluded_command("myapp serve", &excl));
960 assert!(!is_excluded_command("git status", &excl));
961 }
962
963 #[test]
964 fn is_container_returns_bool() {
965 let _ = super::is_container();
966 }
967
968 #[test]
969 fn is_non_interactive_returns_bool() {
970 let _ = super::is_non_interactive();
971 }
972
973 #[test]
974 fn auth_commands_excluded() {
975 assert!(is_excluded_command("az login --use-device-code", &[]));
976 assert!(is_excluded_command("gh auth login", &[]));
977 assert!(is_excluded_command("gcloud auth login", &[]));
978 assert!(is_excluded_command("aws sso login", &[]));
979 assert!(is_excluded_command("firebase login", &[]));
980 assert!(is_excluded_command("vercel login", &[]));
981 assert!(is_excluded_command("heroku login", &[]));
982 assert!(is_excluded_command("az login", &[]));
983 assert!(is_excluded_command("kubelogin convert-kubeconfig", &[]));
984 assert!(is_excluded_command("vault login -method=oidc", &[]));
985 assert!(is_excluded_command("flyctl auth login", &[]));
986 }
987
988 #[test]
989 fn auth_exclusion_does_not_affect_normal_commands() {
990 assert!(!is_excluded_command("git log", &[]));
991 assert!(!is_excluded_command("npm run build", &[]));
992 assert!(!is_excluded_command("cargo test", &[]));
993 assert!(!is_excluded_command("aws s3 ls", &[]));
994 assert!(!is_excluded_command("gcloud compute instances list", &[]));
995 assert!(!is_excluded_command("az vm list", &[]));
996 }
997}