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