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