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