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