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