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