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 show_shell_stats() -> bool {
371 std::env::var("LEAN_CTX_SHELL_STATS")
372 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
373 .unwrap_or(false)
374}
375
376fn maybe_stats_suffix(original: usize, compressed: usize) -> String {
377 if !show_shell_stats() {
378 return String::new();
379 }
380 let saved = original.saturating_sub(compressed);
381 let pct = if original > 0 {
382 (saved as f64 / original as f64 * 100.0).round() as usize
383 } else {
384 0
385 };
386 format!("\n[lean-ctx: {original}→{compressed} tok, -{pct}%]")
387}
388
389fn compress_if_beneficial(command: &str, output: &str) -> String {
390 if output.trim().is_empty() {
391 return String::new();
392 }
393
394 if crate::tools::ctx_shell::contains_auth_flow(output) {
395 return output.to_string();
396 }
397
398 let original_tokens = count_tokens(output);
399
400 if original_tokens < 50 {
401 return output.to_string();
402 }
403
404 let min_output_tokens = 5;
405
406 if let Some(compressed) = patterns::compress_output(command, output) {
407 if !compressed.trim().is_empty() {
408 let compressed_tokens = count_tokens(&compressed);
409 if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
410 let suffix = maybe_stats_suffix(original_tokens, compressed_tokens);
411 return format!("{compressed}{suffix}");
412 }
413 if compressed_tokens < min_output_tokens {
414 return output.to_string();
415 }
416 }
417 }
418
419 let cleaned = crate::core::compressor::lightweight_cleanup(output);
420 let cleaned_tokens = count_tokens(&cleaned);
421 if cleaned_tokens < original_tokens {
422 let lines: Vec<&str> = cleaned.lines().collect();
423 if lines.len() > 30 {
424 let first = &lines[..5];
425 let last = &lines[lines.len() - 5..];
426 let omitted = lines.len() - 10;
427 let total = lines.len();
428 let compressed = format!(
429 "{}\n[truncated: showing 10/{total} lines, {omitted} omitted]\n{}",
430 first.join("\n"),
431 last.join("\n")
432 );
433 let ct = count_tokens(&compressed);
434 if ct < original_tokens {
435 let suffix = maybe_stats_suffix(original_tokens, ct);
436 return format!("{compressed}{suffix}");
437 }
438 }
439 if cleaned_tokens < original_tokens {
440 let suffix = maybe_stats_suffix(original_tokens, cleaned_tokens);
441 return format!("{cleaned}{suffix}");
442 }
443 }
444
445 let lines: Vec<&str> = output.lines().collect();
446 if lines.len() > 30 {
447 let first = &lines[..5];
448 let last = &lines[lines.len() - 5..];
449 let omitted = lines.len() - 10;
450 let compressed = format!(
451 "{}\n... ({omitted} lines omitted) ...\n{}",
452 first.join("\n"),
453 last.join("\n")
454 );
455 let compressed_tokens = count_tokens(&compressed);
456 if compressed_tokens < original_tokens {
457 let suffix = maybe_stats_suffix(original_tokens, compressed_tokens);
458 return format!("{compressed}{suffix}");
459 }
460 }
461
462 output.to_string()
463}
464
465fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
468 if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
469 "-Command"
470 } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
471 "/C"
472 } else {
473 "-c"
477 }
478}
479
480pub fn shell_and_flag() -> (String, String) {
481 let shell = detect_shell();
482 let flag = if cfg!(windows) {
483 let name = std::path::Path::new(&shell)
484 .file_name()
485 .and_then(|n| n.to_str())
486 .unwrap_or("")
487 .to_ascii_lowercase();
488 windows_shell_flag_for_exe_basename(&name).to_string()
489 } else {
490 "-c".to_string()
491 };
492 (shell, flag)
493}
494
495fn detect_shell() -> String {
496 if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
497 return shell;
498 }
499
500 if let Ok(shell) = std::env::var("SHELL") {
501 let bin = std::path::Path::new(&shell)
502 .file_name()
503 .and_then(|n| n.to_str())
504 .unwrap_or("sh");
505
506 if bin == "lean-ctx" {
507 return find_real_shell();
508 }
509 return shell;
510 }
511
512 find_real_shell()
513}
514
515#[cfg(unix)]
516fn find_real_shell() -> String {
517 for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
518 if std::path::Path::new(shell).exists() {
519 return shell.to_string();
520 }
521 }
522 "/bin/sh".to_string()
523}
524
525#[cfg(windows)]
526fn find_real_shell() -> String {
527 if is_running_in_powershell() {
528 if let Ok(pwsh) = which_powershell() {
529 return pwsh;
530 }
531 }
532 if let Ok(comspec) = std::env::var("COMSPEC") {
533 return comspec;
534 }
535 "cmd.exe".to_string()
536}
537
538#[cfg(windows)]
539fn is_running_in_powershell() -> bool {
540 std::env::var("PSModulePath").is_ok()
541}
542
543#[cfg(windows)]
544fn which_powershell() -> Result<String, ()> {
545 for candidate in &["pwsh.exe", "powershell.exe"] {
546 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
547 if output.status.success() {
548 if let Ok(path) = String::from_utf8(output.stdout) {
549 if let Some(first_line) = path.lines().next() {
550 let trimmed = first_line.trim();
551 if !trimmed.is_empty() {
552 return Ok(trimmed.to_string());
553 }
554 }
555 }
556 }
557 }
558 }
559 Err(())
560}
561
562pub fn save_tee(command: &str, output: &str) -> Option<String> {
563 let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
564 std::fs::create_dir_all(&tee_dir).ok()?;
565
566 cleanup_old_tee_logs(&tee_dir);
567
568 let cmd_slug: String = command
569 .chars()
570 .take(40)
571 .map(|c| {
572 if c.is_alphanumeric() || c == '-' {
573 c
574 } else {
575 '_'
576 }
577 })
578 .collect();
579 let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
580 let filename = format!("{ts}_{cmd_slug}.log");
581 let path = tee_dir.join(&filename);
582
583 let masked = mask_sensitive_data(output);
584 std::fs::write(&path, masked).ok()?;
585 Some(path.to_string_lossy().to_string())
586}
587
588fn mask_sensitive_data(input: &str) -> String {
589 use regex::Regex;
590
591 let patterns: Vec<(&str, Regex)> = vec![
592 ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
593 ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
594 ("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()),
595 ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
596 ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
597 ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
598 ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
599 ];
600
601 let mut result = input.to_string();
602 for (label, re) in &patterns {
603 result = re
604 .replace_all(&result, |caps: ®ex::Captures| {
605 if let Some(prefix) = caps.get(1) {
606 format!("{}[REDACTED:{}]", prefix.as_str(), label)
607 } else {
608 format!("[REDACTED:{}]", label)
609 }
610 })
611 .to_string();
612 }
613 result
614}
615
616fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
617 let cutoff =
618 std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
619 let cutoff = match cutoff {
620 Some(t) => t,
621 None => return,
622 };
623
624 if let Ok(entries) = std::fs::read_dir(tee_dir) {
625 for entry in entries.flatten() {
626 if let Ok(meta) = entry.metadata() {
627 if let Ok(modified) = meta.modified() {
628 if modified < cutoff {
629 let _ = std::fs::remove_file(entry.path());
630 }
631 }
632 }
633 }
634 }
635}
636
637pub fn join_command(args: &[String]) -> String {
644 let (_, flag) = shell_and_flag();
645 join_command_for(args, &flag)
646}
647
648fn join_command_for(args: &[String], shell_flag: &str) -> String {
649 match shell_flag {
650 "-Command" => join_powershell(args),
651 "/C" => join_cmd(args),
652 _ => join_posix(args),
653 }
654}
655
656fn join_posix(args: &[String]) -> String {
657 args.iter()
658 .map(|a| quote_posix(a))
659 .collect::<Vec<_>>()
660 .join(" ")
661}
662
663fn join_powershell(args: &[String]) -> String {
664 let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
665 format!("& {}", quoted.join(" "))
666}
667
668fn join_cmd(args: &[String]) -> String {
669 args.iter()
670 .map(|a| quote_cmd(a))
671 .collect::<Vec<_>>()
672 .join(" ")
673}
674
675fn quote_posix(s: &str) -> String {
676 if s.is_empty() {
677 return "''".to_string();
678 }
679 if s.bytes()
680 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
681 {
682 return s.to_string();
683 }
684 format!("'{}'", s.replace('\'', "'\\''"))
685}
686
687fn quote_powershell(s: &str) -> String {
688 if s.is_empty() {
689 return "''".to_string();
690 }
691 if s.bytes()
692 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
693 {
694 return s.to_string();
695 }
696 format!("'{}'", s.replace('\'', "''"))
697}
698
699fn quote_cmd(s: &str) -> String {
700 if s.is_empty() {
701 return "\"\"".to_string();
702 }
703 if s.bytes()
704 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
705 {
706 return s.to_string();
707 }
708 format!("\"{}\"", s.replace('"', "\\\""))
709}
710
711#[cfg(test)]
712mod join_command_tests {
713 use super::*;
714
715 #[test]
716 fn posix_simple_args() {
717 let args: Vec<String> = vec!["git".into(), "status".into()];
718 assert_eq!(join_command_for(&args, "-c"), "git status");
719 }
720
721 #[test]
722 fn posix_path_with_spaces() {
723 let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
724 assert_eq!(
725 join_command_for(&args, "-c"),
726 "'/usr/local/my app/bin' --help"
727 );
728 }
729
730 #[test]
731 fn posix_single_quotes_escaped() {
732 let args: Vec<String> = vec!["echo".into(), "it's".into()];
733 assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
734 }
735
736 #[test]
737 fn posix_empty_arg() {
738 let args: Vec<String> = vec!["cmd".into(), "".into()];
739 assert_eq!(join_command_for(&args, "-c"), "cmd ''");
740 }
741
742 #[test]
743 fn powershell_simple_args() {
744 let args: Vec<String> = vec!["npm".into(), "install".into()];
745 assert_eq!(join_command_for(&args, "-Command"), "& npm install");
746 }
747
748 #[test]
749 fn powershell_path_with_spaces() {
750 let args: Vec<String> = vec![
751 "C:\\Program Files\\nodejs\\npm.cmd".into(),
752 "install".into(),
753 ];
754 assert_eq!(
755 join_command_for(&args, "-Command"),
756 "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
757 );
758 }
759
760 #[test]
761 fn powershell_single_quotes_escaped() {
762 let args: Vec<String> = vec!["echo".into(), "it's done".into()];
763 assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
764 }
765
766 #[test]
767 fn cmd_simple_args() {
768 let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
769 assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
770 }
771
772 #[test]
773 fn cmd_path_with_spaces() {
774 let args: Vec<String> = vec![
775 "C:\\Program Files\\nodejs\\npm.cmd".into(),
776 "install".into(),
777 ];
778 assert_eq!(
779 join_command_for(&args, "/C"),
780 "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
781 );
782 }
783
784 #[test]
785 fn cmd_double_quotes_escaped() {
786 let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
787 assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
788 }
789
790 #[test]
791 fn unknown_flag_uses_posix() {
792 let args: Vec<String> = vec!["ls".into(), "-la".into()];
793 assert_eq!(join_command_for(&args, "--exec"), "ls -la");
794 }
795}
796
797#[cfg(test)]
798mod windows_shell_flag_tests {
799 use super::windows_shell_flag_for_exe_basename;
800
801 #[test]
802 fn cmd_uses_slash_c() {
803 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
804 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
805 }
806
807 #[test]
808 fn powershell_uses_command() {
809 assert_eq!(
810 windows_shell_flag_for_exe_basename("powershell.exe"),
811 "-Command"
812 );
813 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
814 }
815
816 #[test]
817 fn posix_shells_use_dash_c() {
818 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
819 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
820 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
821 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
822 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
823 }
824}
825
826#[cfg(test)]
827mod passthrough_tests {
828 use super::is_excluded_command;
829
830 #[test]
831 fn turbo_is_passthrough() {
832 assert!(is_excluded_command("turbo run dev", &[]));
833 assert!(is_excluded_command("turbo run build", &[]));
834 assert!(is_excluded_command("pnpm turbo run dev", &[]));
835 assert!(is_excluded_command("npx turbo run dev", &[]));
836 }
837
838 #[test]
839 fn dev_servers_are_passthrough() {
840 assert!(is_excluded_command("next dev", &[]));
841 assert!(is_excluded_command("vite dev", &[]));
842 assert!(is_excluded_command("nuxt dev", &[]));
843 assert!(is_excluded_command("astro dev", &[]));
844 assert!(is_excluded_command("nodemon server.js", &[]));
845 }
846
847 #[test]
848 fn interactive_tools_are_passthrough() {
849 assert!(is_excluded_command("vim file.rs", &[]));
850 assert!(is_excluded_command("nvim", &[]));
851 assert!(is_excluded_command("htop", &[]));
852 assert!(is_excluded_command("ssh user@host", &[]));
853 assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
854 }
855
856 #[test]
857 fn docker_streaming_is_passthrough() {
858 assert!(is_excluded_command("docker logs my-container", &[]));
859 assert!(is_excluded_command("docker logs -f webapp", &[]));
860 assert!(is_excluded_command("docker attach my-container", &[]));
861 assert!(is_excluded_command("docker exec -it web bash", &[]));
862 assert!(is_excluded_command("docker exec -ti web bash", &[]));
863 assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
864 assert!(is_excluded_command("docker compose exec web bash", &[]));
865 assert!(is_excluded_command("docker stats", &[]));
866 assert!(is_excluded_command("docker events", &[]));
867 }
868
869 #[test]
870 fn kubectl_is_passthrough() {
871 assert!(is_excluded_command("kubectl logs my-pod", &[]));
872 assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
873 assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
874 assert!(is_excluded_command(
875 "kubectl port-forward svc/web 8080:80",
876 &[]
877 ));
878 assert!(is_excluded_command("kubectl attach my-pod", &[]));
879 assert!(is_excluded_command("kubectl proxy", &[]));
880 }
881
882 #[test]
883 fn database_repls_are_passthrough() {
884 assert!(is_excluded_command("psql -U user mydb", &[]));
885 assert!(is_excluded_command("mysql -u root -p", &[]));
886 assert!(is_excluded_command("sqlite3 data.db", &[]));
887 assert!(is_excluded_command("redis-cli", &[]));
888 assert!(is_excluded_command("mongosh", &[]));
889 }
890
891 #[test]
892 fn streaming_tools_are_passthrough() {
893 assert!(is_excluded_command("journalctl -f", &[]));
894 assert!(is_excluded_command("ping 8.8.8.8", &[]));
895 assert!(is_excluded_command("strace -p 1234", &[]));
896 assert!(is_excluded_command("tcpdump -i eth0", &[]));
897 assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
898 assert!(is_excluded_command("tmux new -s work", &[]));
899 assert!(is_excluded_command("screen -S dev", &[]));
900 }
901
902 #[test]
903 fn additional_dev_servers_are_passthrough() {
904 assert!(is_excluded_command("gatsby develop", &[]));
905 assert!(is_excluded_command("ng serve --port 4200", &[]));
906 assert!(is_excluded_command("remix dev", &[]));
907 assert!(is_excluded_command("wrangler dev", &[]));
908 assert!(is_excluded_command("hugo server", &[]));
909 assert!(is_excluded_command("bun dev", &[]));
910 assert!(is_excluded_command("cargo watch -x test", &[]));
911 }
912
913 #[test]
914 fn normal_commands_not_excluded() {
915 assert!(!is_excluded_command("git status", &[]));
916 assert!(!is_excluded_command("cargo test", &[]));
917 assert!(!is_excluded_command("npm run build", &[]));
918 assert!(!is_excluded_command("ls -la", &[]));
919 }
920
921 #[test]
922 fn user_exclusions_work() {
923 let excl = vec!["myapp".to_string()];
924 assert!(is_excluded_command("myapp serve", &excl));
925 assert!(!is_excluded_command("git status", &excl));
926 }
927
928 #[test]
929 fn is_container_returns_bool() {
930 let _ = super::is_container();
931 }
932
933 #[test]
934 fn is_non_interactive_returns_bool() {
935 let _ = super::is_non_interactive();
936 }
937
938 #[test]
939 fn auth_commands_excluded() {
940 assert!(is_excluded_command("az login --use-device-code", &[]));
941 assert!(is_excluded_command("gh auth login", &[]));
942 assert!(is_excluded_command("gcloud auth login", &[]));
943 assert!(is_excluded_command("aws sso login", &[]));
944 assert!(is_excluded_command("firebase login", &[]));
945 assert!(is_excluded_command("vercel login", &[]));
946 assert!(is_excluded_command("heroku login", &[]));
947 assert!(is_excluded_command("az login", &[]));
948 assert!(is_excluded_command("kubelogin convert-kubeconfig", &[]));
949 assert!(is_excluded_command("vault login -method=oidc", &[]));
950 assert!(is_excluded_command("flyctl auth login", &[]));
951 }
952
953 #[test]
954 fn auth_exclusion_does_not_affect_normal_commands() {
955 assert!(!is_excluded_command("git log", &[]));
956 assert!(!is_excluded_command("npm run build", &[]));
957 assert!(!is_excluded_command("cargo test", &[]));
958 assert!(!is_excluded_command("aws s3 ls", &[]));
959 assert!(!is_excluded_command("gcloud compute instances list", &[]));
960 assert!(!is_excluded_command("az vm list", &[]));
961 }
962}