1use std::io::{self, IsTerminal};
2
3pub(crate) fn apply_utf8_locale(cmd: &mut std::process::Command) {
7 let has_utf8 = std::env::var("LC_ALL")
8 .or_else(|_| std::env::var("LC_CTYPE"))
9 .or_else(|_| std::env::var("LANG"))
10 .is_ok_and(|v| v.to_ascii_lowercase().contains("utf"));
11
12 if !has_utf8 {
13 cmd.env("LC_CTYPE", "C.UTF-8");
14 }
15}
16
17pub(crate) fn apply_profile_free_env(cmd: &mut std::process::Command) {
25 cmd.env("BASH_ENV", "").env("ENV", "");
26}
27
28pub fn decode_output(bytes: &[u8]) -> String {
29 match String::from_utf8(bytes.to_vec()) {
30 Ok(s) => s,
31 Err(_) => {
32 #[cfg(windows)]
33 {
34 decode_windows_output(bytes)
35 }
36 #[cfg(not(windows))]
37 {
38 String::from_utf8_lossy(bytes).into_owned()
39 }
40 }
41 }
42}
43pub fn resolve_carriage_returns(s: &str) -> String {
51 if !s.contains('\r') {
52 return s.to_string();
53 }
54 let mut out = String::with_capacity(s.len());
55 for (i, line) in s.split('\n').enumerate() {
56 if i > 0 {
57 out.push('\n');
58 }
59 let line = line.strip_suffix('\r').unwrap_or(line);
60 if let Some(pos) = line.rfind('\r') {
61 out.push_str(&line[pos + 1..]);
62 } else {
63 out.push_str(line);
64 }
65 }
66 out
67}
68
69#[cfg(windows)]
70fn decode_windows_output(bytes: &[u8]) -> String {
71 use std::os::windows::ffi::OsStringExt;
72
73 let lossy = String::from_utf8_lossy(bytes);
74 let replacement_count = lossy.chars().filter(|&c| c == '\u{FFFD}').count();
75 if replacement_count == 0 {
76 return lossy.into_owned();
77 }
78
79 unsafe extern "system" {
82 fn GetACP() -> u32;
83 fn MultiByteToWideChar(
84 cp: u32,
85 flags: u32,
86 src: *const u8,
87 srclen: i32,
88 dst: *mut u16,
89 dstlen: i32,
90 ) -> i32;
91 }
92
93 let codepage = unsafe { GetACP() };
96 let wide_len = unsafe {
100 MultiByteToWideChar(
101 codepage,
102 0,
103 bytes.as_ptr(),
104 bytes.len() as i32,
105 std::ptr::null_mut(),
106 0,
107 )
108 };
109 if wide_len <= 0 {
110 return lossy.into_owned();
111 }
112 let mut wide: Vec<u16> = vec![0u16; wide_len as usize];
113 unsafe {
117 MultiByteToWideChar(
118 codepage,
119 0,
120 bytes.as_ptr(),
121 bytes.len() as i32,
122 wide.as_mut_ptr(),
123 wide_len,
124 );
125 }
126 std::ffi::OsString::from_wide(&wide)
127 .to_string_lossy()
128 .into_owned()
129}
130
131#[cfg(windows)]
132pub(super) fn set_console_utf8() {
133 unsafe extern "system" {
136 fn SetConsoleOutputCP(id: u32) -> i32;
137 }
138 unsafe {
141 SetConsoleOutputCP(65001);
142 }
143}
144
145pub fn is_container() -> bool {
147 #[cfg(unix)]
148 {
149 if std::path::Path::new("/.dockerenv").exists() {
150 return true;
151 }
152 if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
153 && (cgroup.contains("/docker/") || cgroup.contains("/lxc/"))
154 {
155 return true;
156 }
157 if let Ok(mounts) = std::fs::read_to_string("/proc/self/mountinfo")
158 && mounts.contains("/docker/containers/")
159 {
160 return true;
161 }
162 false
163 }
164 #[cfg(not(unix))]
165 {
166 false
167 }
168}
169
170pub fn is_non_interactive() -> bool {
172 !io::stdin().is_terminal()
173}
174
175pub(crate) fn is_powershell(shell_path: &str) -> bool {
177 let name = std::path::Path::new(shell_path)
178 .file_name()
179 .and_then(|n| n.to_str())
180 .unwrap_or("")
181 .to_ascii_lowercase();
182 name.contains("powershell") || name.contains("pwsh")
183}
184
185pub(crate) fn powershell_profile_path(home: &std::path::Path) -> std::path::PathBuf {
200 const PROFILE_FILE: &str = "Microsoft.PowerShell_profile.ps1";
201 if cfg!(windows) {
202 home.join("Documents").join("PowerShell").join(PROFILE_FILE)
203 } else {
204 home.join(".config").join("powershell").join(PROFILE_FILE)
205 }
206}
207
208pub(crate) fn resolve_powershell_profile_path(home: &std::path::Path) -> std::path::PathBuf {
223 #[cfg(windows)]
224 {
225 if let Some(active) = query_active_powershell_profile() {
226 return active;
227 }
228 }
229 powershell_profile_path(home)
230}
231
232#[cfg(windows)]
236fn query_active_powershell_profile() -> Option<std::path::PathBuf> {
237 for exe in ["pwsh", "powershell"] {
241 let output = match std::process::Command::new(exe)
242 .args([
243 "-NoProfile",
244 "-NonInteractive",
245 "-Command",
246 "[Console]::OutputEncoding=[Text.Encoding]::UTF8; $PROFILE.CurrentUserCurrentHost",
247 ])
248 .output()
249 {
250 Ok(out) if out.status.success() => out,
251 _ => continue,
252 };
253 let path = std::path::PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
254 if path.is_absolute() && path.file_name().is_some() {
255 return Some(path);
256 }
257 }
258 None
259}
260
261fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
264 if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
265 "-Command"
266 } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
267 "/C"
268 } else {
269 "-c"
270 }
271}
272
273pub fn shell_and_flag() -> (String, String) {
274 let shell = detect_shell();
275 let flag = if cfg!(windows) {
276 let name = std::path::Path::new(&shell)
277 .file_name()
278 .and_then(|n| n.to_str())
279 .unwrap_or("")
280 .to_ascii_lowercase();
281 windows_shell_flag_for_exe_basename(&name).to_string()
282 } else {
283 "-c".to_string()
284 };
285 (shell, flag)
286}
287
288pub fn shell_name() -> String {
290 let shell = detect_shell();
291 let basename = std::path::Path::new(&shell)
292 .file_name()
293 .and_then(|n| n.to_str())
294 .unwrap_or("sh")
295 .to_ascii_lowercase();
296 basename
297 .strip_suffix(".exe")
298 .unwrap_or(&basename)
299 .to_string()
300}
301
302pub(crate) fn zsh_safe_command(command: &str, shell: &str) -> String {
307 if cfg!(unix) {
308 let basename = std::path::Path::new(shell)
309 .file_name()
310 .and_then(|n| n.to_str())
311 .unwrap_or("");
312 if basename == "zsh" {
313 return format!("setopt nonomatch; {command}");
314 }
315 }
316 command.to_string()
317}
318
319pub(super) fn detect_shell() -> String {
320 if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
321 return shell;
322 }
323
324 if let Ok(shell) = std::env::var("SHELL") {
325 let bin = std::path::Path::new(&shell)
326 .file_name()
327 .and_then(|n| n.to_str())
328 .unwrap_or("sh");
329
330 if bin == "lean-ctx" {
331 return find_real_shell();
332 }
333 if shell_acceptable_for_exec(&shell) {
340 return shell;
341 }
342 return find_real_shell();
343 }
344
345 find_real_shell()
346}
347
348#[cfg(unix)]
355fn shell_acceptable_for_exec(shell: &str) -> bool {
356 is_posix_compatible_shell(shell)
357}
358
359#[cfg(windows)]
360fn shell_acceptable_for_exec(_shell: &str) -> bool {
361 true
362}
363
364#[cfg(unix)]
369fn is_posix_compatible_shell(shell: &str) -> bool {
370 let name = std::path::Path::new(shell)
371 .file_name()
372 .and_then(|n| n.to_str())
373 .unwrap_or("")
374 .to_ascii_lowercase();
375 let name = name.strip_suffix(".exe").unwrap_or(&name);
376 matches!(
377 name,
378 "bash" | "zsh" | "sh" | "dash" | "ash" | "ksh" | "ksh93" | "mksh" | "busybox"
379 )
380}
381
382#[cfg(unix)]
383fn find_real_shell() -> String {
384 for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
385 if std::path::Path::new(shell).exists() {
386 return shell.to_string();
387 }
388 }
389 "/bin/sh".to_string()
390}
391
392#[cfg(windows)]
393fn find_real_shell() -> String {
394 if is_running_in_msys_or_gitbash() {
395 for candidate in &["bash.exe", "sh.exe"] {
396 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
397 if output.status.success() {
398 if let Ok(path) = String::from_utf8(output.stdout) {
399 if let Some(first_line) = path.lines().next() {
400 let trimmed = first_line.trim();
401 if !trimmed.is_empty() {
402 return trimmed.to_string();
403 }
404 }
405 }
406 }
407 }
408 }
409 }
410 if let Ok(pwsh) = which_powershell() {
411 return pwsh;
412 }
413 if let Ok(comspec) = std::env::var("COMSPEC") {
414 return comspec;
415 }
416 "cmd.exe".to_string()
417}
418
419#[cfg(windows)]
420fn is_running_in_msys_or_gitbash() -> bool {
421 std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
422}
423
424#[cfg(windows)]
425fn which_powershell() -> Result<String, ()> {
426 for candidate in &["pwsh.exe", "powershell.exe"] {
427 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
428 if output.status.success() {
429 if let Ok(path) = String::from_utf8(output.stdout) {
430 if let Some(first_line) = path.lines().next() {
431 let trimmed = first_line.trim();
432 if !trimmed.is_empty() {
433 return Ok(trimmed.to_string());
434 }
435 }
436 }
437 }
438 }
439 }
440 Err(())
441}
442
443pub fn join_command(args: &[String]) -> String {
450 let (_, flag) = shell_and_flag();
451 join_command_for(args, &flag)
452}
453
454pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
455 match shell_flag {
456 "-Command" => join_powershell(args),
457 "/C" => join_cmd(args),
458 _ => join_posix(args),
459 }
460}
461
462fn join_posix(args: &[String]) -> String {
463 args.iter()
464 .map(|a| quote_posix(a))
465 .collect::<Vec<_>>()
466 .join(" ")
467}
468
469fn join_powershell(args: &[String]) -> String {
470 if args.len() == 1 && args[0].contains(' ') {
471 return args[0].clone();
472 }
473 let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
474 format!("& {}", quoted.join(" "))
475}
476
477fn join_cmd(args: &[String]) -> String {
478 args.iter()
479 .map(|a| quote_cmd(a))
480 .collect::<Vec<_>>()
481 .join(" ")
482}
483
484fn quote_posix(s: &str) -> String {
485 if s.is_empty() {
486 return "''".to_string();
487 }
488 if s.bytes()
489 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
490 {
491 return s.to_string();
492 }
493 format!("'{}'", s.replace('\'', "'\\''"))
494}
495
496fn quote_powershell(s: &str) -> String {
497 if s.is_empty() {
498 return "''".to_string();
499 }
500 if s.bytes()
501 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
502 {
503 return s.to_string();
504 }
505 format!("'{}'", s.replace('\'', "''"))
506}
507
508fn quote_cmd(s: &str) -> String {
509 if s.is_empty() {
510 return "\"\"".to_string();
511 }
512 if s.bytes()
513 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
514 {
515 return s.to_string();
516 }
517 format!("\"{}\"", s.replace('"', "\\\""))
518}
519
520#[cfg(test)]
521mod join_command_tests {
522 use super::*;
523
524 #[test]
525 fn posix_simple_args() {
526 let args: Vec<String> = vec!["git".into(), "status".into()];
527 assert_eq!(join_command_for(&args, "-c"), "git status");
528 }
529
530 #[test]
531 fn posix_path_with_spaces() {
532 let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
533 assert_eq!(
534 join_command_for(&args, "-c"),
535 "'/usr/local/my app/bin' --help"
536 );
537 }
538
539 #[test]
540 fn posix_single_quotes_escaped() {
541 let args: Vec<String> = vec!["echo".into(), "it's".into()];
542 assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
543 }
544
545 #[test]
546 fn posix_empty_arg() {
547 let args: Vec<String> = vec!["cmd".into(), String::new()];
548 assert_eq!(join_command_for(&args, "-c"), "cmd ''");
549 }
550
551 #[test]
552 fn powershell_simple_args() {
553 let args: Vec<String> = vec!["npm".into(), "install".into()];
554 assert_eq!(join_command_for(&args, "-Command"), "& npm install");
555 }
556
557 #[test]
558 fn powershell_path_with_spaces() {
559 let args: Vec<String> = vec![
560 "C:\\Program Files\\nodejs\\npm.cmd".into(),
561 "install".into(),
562 ];
563 assert_eq!(
564 join_command_for(&args, "-Command"),
565 "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
566 );
567 }
568
569 #[test]
570 fn powershell_single_quotes_escaped() {
571 let args: Vec<String> = vec!["echo".into(), "it's done".into()];
572 assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
573 }
574
575 #[test]
576 fn cmd_simple_args() {
577 let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
578 assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
579 }
580
581 #[test]
582 fn cmd_path_with_spaces() {
583 let args: Vec<String> = vec![
584 "C:\\Program Files\\nodejs\\npm.cmd".into(),
585 "install".into(),
586 ];
587 assert_eq!(
588 join_command_for(&args, "/C"),
589 "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
590 );
591 }
592
593 #[test]
594 fn cmd_double_quotes_escaped() {
595 let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
596 assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
597 }
598
599 #[test]
600 fn unknown_flag_uses_posix() {
601 let args: Vec<String> = vec!["ls".into(), "-la".into()];
602 assert_eq!(join_command_for(&args, "--exec"), "ls -la");
603 }
604
605 #[test]
606 fn powershell_single_full_command_not_quoted() {
607 let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
608 let result = join_command_for(&args, "-Command");
609 assert_eq!(result, "git commit -m \"feat: add feature\"");
610 assert!(
611 !result.starts_with("& '"),
612 "must not wrap full command in & '...'"
613 );
614 }
615
616 #[test]
617 fn powershell_single_no_spaces_still_uses_call_operator() {
618 let args: Vec<String> = vec!["git".into()];
619 assert_eq!(join_command_for(&args, "-Command"), "& git");
620 }
621}
622
623#[cfg(test)]
624mod is_powershell_tests {
625 use super::is_powershell;
626
627 #[test]
628 fn detects_pwsh_exe() {
629 assert!(is_powershell("pwsh.exe"));
630 }
631
632 #[test]
633 fn detects_powershell_exe() {
634 assert!(is_powershell("powershell.exe"));
635 }
636
637 #[test]
638 fn rejects_cmd() {
639 assert!(!is_powershell("cmd.exe"));
640 }
641
642 #[test]
643 fn rejects_bash() {
644 assert!(!is_powershell("/usr/bin/bash"));
645 }
646
647 #[test]
648 fn case_insensitive() {
649 assert!(is_powershell("PWSH.EXE"));
650 assert!(is_powershell("PowerShell.exe"));
651 }
652
653 #[test]
654 fn full_path_with_pwsh() {
655 assert!(is_powershell(
656 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
657 ));
658 assert!(is_powershell("/usr/local/bin/pwsh"));
659 }
660}
661
662#[cfg(test)]
663mod powershell_profile_tests {
664 use super::{powershell_profile_path, resolve_powershell_profile_path};
665 use std::path::Path;
666
667 #[test]
668 fn always_ends_with_profile_file() {
669 let p = powershell_profile_path(Path::new("/home/u"));
670 assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
671 }
672
673 #[cfg(not(windows))]
674 #[test]
675 fn non_windows_uses_config_powershell_never_documents() {
676 let p = powershell_profile_path(Path::new("/Users/jane"));
679 assert_eq!(
680 p,
681 Path::new("/Users/jane/.config/powershell/Microsoft.PowerShell_profile.ps1")
682 );
683 assert!(
684 !p.to_string_lossy().contains("Documents"),
685 "macOS/Linux PowerShell profile must never touch ~/Documents (#356)"
686 );
687 }
688
689 #[cfg(windows)]
690 #[test]
691 fn windows_uses_documents_powershell() {
692 let p = powershell_profile_path(Path::new("C:\\Users\\jane"));
693 assert!(p.ends_with("Documents\\PowerShell\\Microsoft.PowerShell_profile.ps1"));
694 }
695
696 #[cfg(not(windows))]
697 #[test]
698 fn resolver_matches_static_default_on_non_windows() {
699 let home = Path::new("/Users/jane");
702 assert_eq!(
703 resolve_powershell_profile_path(home),
704 powershell_profile_path(home)
705 );
706 }
707
708 #[cfg(windows)]
709 #[test]
710 fn resolver_returns_absolute_profile_on_windows() {
711 let p = resolve_powershell_profile_path(Path::new("C:\\Users\\jane"));
715 assert!(p.is_absolute(), "resolved profile must be absolute: {p:?}");
716 assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
717 }
718}
719
720#[cfg(test)]
721mod windows_shell_flag_tests {
722 use super::windows_shell_flag_for_exe_basename;
723
724 #[test]
725 fn cmd_uses_slash_c() {
726 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
727 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
728 }
729
730 #[test]
731 fn powershell_uses_command() {
732 assert_eq!(
733 windows_shell_flag_for_exe_basename("powershell.exe"),
734 "-Command"
735 );
736 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
737 }
738
739 #[test]
740 fn posix_shells_use_dash_c() {
741 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
742 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
743 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
744 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
745 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
746 }
747}
748
749#[cfg(test)]
750mod platform_tests {
751 #[test]
752 fn is_container_returns_bool() {
753 let _ = super::is_container();
754 }
755
756 #[test]
757 fn is_non_interactive_returns_bool() {
758 let _ = super::is_non_interactive();
759 }
760
761 #[test]
762 fn join_command_preserves_structure() {
763 let args = vec![
764 "git".to_string(),
765 "commit".to_string(),
766 "-m".to_string(),
767 "my message".to_string(),
768 ];
769 let joined = super::join_command(&args);
770 assert!(joined.contains("git"));
771 assert!(joined.contains("commit"));
772 assert!(joined.contains("my message") || joined.contains("'my message'"));
773 }
774
775 #[test]
776 fn quote_posix_handles_em_dash() {
777 let result = super::quote_posix("closing — see #407");
778 assert!(
779 result.starts_with('\''),
780 "em-dash args must be single-quoted: {result}"
781 );
782 }
783
784 #[test]
785 fn quote_posix_handles_nested_single_quotes() {
786 let result = super::quote_posix("it's a test");
787 assert!(
788 result.contains("\\'"),
789 "single quotes must be escaped: {result}"
790 );
791 }
792
793 #[test]
794 fn quote_posix_safe_chars_unquoted() {
795 let result = super::quote_posix("simple_word");
796 assert_eq!(result, "simple_word");
797 }
798
799 #[test]
800 fn quote_posix_empty_string() {
801 let result = super::quote_posix("");
802 assert_eq!(result, "''");
803 }
804
805 #[test]
806 fn quote_posix_dollar_expansion_protected() {
807 let result = super::quote_posix("$HOME/test");
808 assert!(
809 result.starts_with('\''),
810 "dollar signs must be single-quoted: {result}"
811 );
812 }
813
814 #[test]
815 fn quote_posix_backtick_protected() {
816 let result = super::quote_posix("echo `date`");
817 assert!(
818 result.starts_with('\''),
819 "backticks must be single-quoted: {result}"
820 );
821 }
822
823 #[test]
824 fn quote_posix_double_quotes_protected() {
825 let result = super::quote_posix(r#"he said "hello""#);
826 assert!(
827 result.starts_with('\''),
828 "double quotes must be single-quoted: {result}"
829 );
830 }
831
832 #[cfg(unix)]
836 #[test]
837 fn profile_free_env_blocks_bash_env_contamination() {
838 let Some(bash) = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
839 .into_iter()
840 .find(|p| std::path::Path::new(p).exists())
841 else {
842 return; };
844
845 let startup = std::env::temp_dir().join(format!(
846 "lean_ctx_bashenv_{}_{}.sh",
847 std::process::id(),
848 "guard"
849 ));
850 std::fs::write(&startup, "echo CONTAMINATED\n").expect("write startup file");
851
852 let mut cmd = std::process::Command::new(bash);
853 cmd.arg("-c").arg("echo clean").env("BASH_ENV", &startup);
854 super::apply_profile_free_env(&mut cmd);
855 let out = cmd.output().expect("run bash");
856 let stdout = String::from_utf8_lossy(&out.stdout);
857
858 let _ = std::fs::remove_file(&startup);
859
860 assert!(stdout.contains("clean"), "command output missing: {stdout}");
861 assert!(
862 !stdout.contains("CONTAMINATED"),
863 "apply_profile_free_env must neutralize BASH_ENV, got: {stdout}"
864 );
865 }
866}
867
868#[cfg(all(test, unix))]
869mod posix_shell_gate_tests {
870 use super::{detect_shell, is_posix_compatible_shell};
871
872 #[test]
873 fn accepts_posix_shells() {
874 for s in [
875 "/bin/bash",
876 "/bin/zsh",
877 "/bin/sh",
878 "/usr/bin/dash",
879 "/bin/ash",
880 "/usr/bin/ksh",
881 "/usr/bin/mksh",
882 "bash",
883 "zsh",
884 ] {
885 assert!(is_posix_compatible_shell(s), "{s} must be POSIX-compatible");
886 }
887 }
888
889 #[test]
890 fn rejects_interactive_and_nonposix_shells() {
891 for s in [
893 "/usr/bin/nu",
894 "/opt/homebrew/bin/nu",
895 "/usr/bin/fish",
896 "/usr/local/bin/elvish",
897 "/usr/bin/xonsh",
898 "/usr/bin/pwsh",
899 "powershell.exe",
900 "cmd.exe",
901 ] {
902 assert!(
903 !is_posix_compatible_shell(s),
904 "{s} must be rejected by the POSIX gate"
905 );
906 }
907 }
908
909 #[test]
910 #[cfg_attr(miri, ignore)]
911 fn detect_shell_falls_back_when_shell_is_nushell() {
912 let _lock = crate::core::data_dir::test_env_lock();
914 let saved_shell = std::env::var_os("SHELL");
915 let saved_override = std::env::var_os("LEAN_CTX_SHELL");
916
917 crate::test_env::remove_var("LEAN_CTX_SHELL");
918 crate::test_env::set_var("SHELL", "/usr/bin/nu");
919 let resolved = detect_shell();
920 assert!(
921 is_posix_compatible_shell(&resolved),
922 "a non-POSIX $SHELL (nu) must resolve to a POSIX shell, got {resolved}"
923 );
924 assert!(
925 !resolved.ends_with("/nu") && resolved != "/usr/bin/nu",
926 "must not run agent commands in Nushell, got {resolved}"
927 );
928
929 crate::test_env::set_var("SHELL", "/bin/sh");
931 assert_eq!(detect_shell(), "/bin/sh");
932
933 crate::test_env::set_var("LEAN_CTX_SHELL", "/usr/bin/nu");
935 assert_eq!(detect_shell(), "/usr/bin/nu");
936
937 match saved_shell {
938 Some(v) => crate::test_env::set_var("SHELL", v),
939 None => crate::test_env::remove_var("SHELL"),
940 }
941 match saved_override {
942 Some(v) => crate::test_env::set_var("LEAN_CTX_SHELL", v),
943 None => crate::test_env::remove_var("LEAN_CTX_SHELL"),
944 }
945 }
946}
947
948#[cfg(test)]
949mod carriage_return_tests {
950 use super::resolve_carriage_returns;
951
952 #[test]
953 fn no_cr_passthrough() {
954 assert_eq!(resolve_carriage_returns("hello\nworld\n"), "hello\nworld\n");
955 }
956
957 #[test]
958 fn progress_overwrite_keeps_last_segment() {
959 assert_eq!(resolve_carriage_returns("first\rsecond"), "second");
960 }
961
962 #[test]
963 fn git_rebase_progress() {
964 let input = "Rebasing (1/1)\rSuccessfully rebased and updated refs/heads/feat.";
965 assert_eq!(
966 resolve_carriage_returns(input),
967 "Successfully rebased and updated refs/heads/feat."
968 );
969 }
970
971 #[test]
972 fn crlf_line_endings_stripped() {
973 assert_eq!(
974 resolve_carriage_returns("line1\r\nline2\r\n"),
975 "line1\nline2\n"
976 );
977 }
978
979 #[test]
980 fn multiple_cr_on_one_line() {
981 assert_eq!(resolve_carriage_returns("a\rb\rc"), "c");
982 }
983
984 #[test]
985 fn mixed_lines() {
986 let input = "clean line\nprogress\rdone\nalso clean\n";
987 assert_eq!(
988 resolve_carriage_returns(input),
989 "clean line\ndone\nalso clean\n"
990 );
991 }
992
993 #[test]
994 fn trailing_cr_stripped_as_crlf() {
995 assert_eq!(resolve_carriage_returns("visible\r"), "visible");
996 }
997
998 #[test]
999 fn empty_string_passthrough() {
1000 assert_eq!(resolve_carriage_returns(""), "");
1001 }
1002}
1003
1004#[cfg(test)]
1005mod zsh_safe_tests {
1006 use super::zsh_safe_command;
1007
1008 #[test]
1009 #[cfg(unix)]
1010 fn prepends_nonomatch_for_zsh() {
1011 let cmd = zsh_safe_command("grep --include=*.go pattern", "/bin/zsh");
1012 assert_eq!(cmd, "setopt nonomatch; grep --include=*.go pattern");
1013 }
1014
1015 #[test]
1016 #[cfg(unix)]
1017 fn prepends_nonomatch_for_usr_bin_zsh() {
1018 let cmd = zsh_safe_command("echo *.rs", "/usr/bin/zsh");
1019 assert_eq!(cmd, "setopt nonomatch; echo *.rs");
1020 }
1021
1022 #[test]
1023 fn passthrough_for_bash() {
1024 let cmd = zsh_safe_command("grep --include=*.go pattern", "/bin/bash");
1025 assert_eq!(cmd, "grep --include=*.go pattern");
1026 }
1027
1028 #[test]
1029 fn passthrough_for_sh() {
1030 let cmd = zsh_safe_command("find -name *.ts", "/bin/sh");
1031 assert_eq!(cmd, "find -name *.ts");
1032 }
1033}