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 Some(path) = crate::core::pathutil::find_on_path(candidate) {
396 return path.to_string_lossy().into_owned();
397 }
398 }
399 }
400 if let Ok(pwsh) = which_powershell() {
401 return pwsh;
402 }
403 if let Ok(comspec) = std::env::var("COMSPEC") {
404 return comspec;
405 }
406 "cmd.exe".to_string()
407}
408
409#[cfg(windows)]
410fn is_running_in_msys_or_gitbash() -> bool {
411 std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
412}
413
414#[cfg(windows)]
415fn which_powershell() -> Result<String, ()> {
416 for candidate in &["pwsh.exe", "powershell.exe"] {
417 if let Some(path) = crate::core::pathutil::find_on_path(candidate) {
418 return Ok(path.to_string_lossy().into_owned());
419 }
420 }
421 Err(())
422}
423
424pub fn join_command(args: &[String]) -> String {
431 let (_, flag) = shell_and_flag();
432 join_command_for(args, &flag)
433}
434
435pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
436 match shell_flag {
437 "-Command" => join_powershell(args),
438 "/C" => join_cmd(args),
439 _ => join_posix(args),
440 }
441}
442
443fn join_posix(args: &[String]) -> String {
444 args.iter()
445 .map(|a| quote_posix(a))
446 .collect::<Vec<_>>()
447 .join(" ")
448}
449
450fn join_powershell(args: &[String]) -> String {
451 if args.len() == 1 && args[0].contains(' ') {
452 return args[0].clone();
453 }
454 let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
455 format!("& {}", quoted.join(" "))
456}
457
458fn join_cmd(args: &[String]) -> String {
459 args.iter()
460 .map(|a| quote_cmd(a))
461 .collect::<Vec<_>>()
462 .join(" ")
463}
464
465fn quote_posix(s: &str) -> String {
466 if s.is_empty() {
467 return "''".to_string();
468 }
469 if s.bytes()
470 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
471 {
472 return s.to_string();
473 }
474 format!("'{}'", s.replace('\'', "'\\''"))
475}
476
477fn quote_powershell(s: &str) -> String {
478 if s.is_empty() {
479 return "''".to_string();
480 }
481 if s.bytes()
484 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:+%^".contains(&b))
485 {
486 return s.to_string();
487 }
488 format!("'{}'", s.replace('\'', "''"))
489}
490
491fn quote_cmd(s: &str) -> String {
492 if s.is_empty() {
493 return "\"\"".to_string();
494 }
495 if s.bytes()
496 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
497 {
498 return s.to_string();
499 }
500 format!("\"{}\"", s.replace('"', "\\\""))
501}
502
503#[cfg(test)]
504mod join_command_tests {
505 use super::*;
506
507 #[test]
508 fn posix_simple_args() {
509 let args: Vec<String> = vec!["git".into(), "status".into()];
510 assert_eq!(join_command_for(&args, "-c"), "git status");
511 }
512
513 #[test]
514 fn posix_path_with_spaces() {
515 let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
516 assert_eq!(
517 join_command_for(&args, "-c"),
518 "'/usr/local/my app/bin' --help"
519 );
520 }
521
522 #[test]
523 fn posix_single_quotes_escaped() {
524 let args: Vec<String> = vec!["echo".into(), "it's".into()];
525 assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
526 }
527
528 #[test]
529 fn posix_empty_arg() {
530 let args: Vec<String> = vec!["cmd".into(), String::new()];
531 assert_eq!(join_command_for(&args, "-c"), "cmd ''");
532 }
533
534 #[test]
535 fn powershell_simple_args() {
536 let args: Vec<String> = vec!["npm".into(), "install".into()];
537 assert_eq!(join_command_for(&args, "-Command"), "& npm install");
538 }
539
540 #[test]
541 fn powershell_path_with_spaces() {
542 let args: Vec<String> = vec![
543 "C:\\Program Files\\nodejs\\npm.cmd".into(),
544 "install".into(),
545 ];
546 assert_eq!(
547 join_command_for(&args, "-Command"),
548 "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
549 );
550 }
551
552 #[test]
553 fn powershell_single_quotes_escaped() {
554 let args: Vec<String> = vec!["echo".into(), "it's done".into()];
555 assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
556 }
557
558 #[test]
559 fn powershell_quotes_splat_and_array_syntax() {
560 let args: Vec<String> = vec!["echo".into(), "@args".into(), "a,b".into()];
561 assert_eq!(join_command_for(&args, "-Command"), "& echo '@args' 'a,b'");
562 }
563
564 #[test]
565 fn cmd_simple_args() {
566 let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
567 assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
568 }
569
570 #[test]
571 fn cmd_path_with_spaces() {
572 let args: Vec<String> = vec![
573 "C:\\Program Files\\nodejs\\npm.cmd".into(),
574 "install".into(),
575 ];
576 assert_eq!(
577 join_command_for(&args, "/C"),
578 "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
579 );
580 }
581
582 #[test]
583 fn cmd_double_quotes_escaped() {
584 let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
585 assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
586 }
587
588 #[test]
589 fn unknown_flag_uses_posix() {
590 let args: Vec<String> = vec!["ls".into(), "-la".into()];
591 assert_eq!(join_command_for(&args, "--exec"), "ls -la");
592 }
593
594 #[test]
595 fn powershell_single_full_command_not_quoted() {
596 let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
597 let result = join_command_for(&args, "-Command");
598 assert_eq!(result, "git commit -m \"feat: add feature\"");
599 assert!(
600 !result.starts_with("& '"),
601 "must not wrap full command in & '...'"
602 );
603 }
604
605 #[test]
606 fn powershell_single_no_spaces_still_uses_call_operator() {
607 let args: Vec<String> = vec!["git".into()];
608 assert_eq!(join_command_for(&args, "-Command"), "& git");
609 }
610}
611
612#[cfg(test)]
613mod is_powershell_tests {
614 use super::is_powershell;
615
616 #[test]
617 fn detects_pwsh_exe() {
618 assert!(is_powershell("pwsh.exe"));
619 }
620
621 #[test]
622 fn detects_powershell_exe() {
623 assert!(is_powershell("powershell.exe"));
624 }
625
626 #[test]
627 fn rejects_cmd() {
628 assert!(!is_powershell("cmd.exe"));
629 }
630
631 #[test]
632 fn rejects_bash() {
633 assert!(!is_powershell("/usr/bin/bash"));
634 }
635
636 #[test]
637 fn case_insensitive() {
638 assert!(is_powershell("PWSH.EXE"));
639 assert!(is_powershell("PowerShell.exe"));
640 }
641
642 #[test]
643 fn full_path_with_pwsh() {
644 assert!(is_powershell(
645 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
646 ));
647 assert!(is_powershell("/usr/local/bin/pwsh"));
648 }
649}
650
651#[cfg(test)]
652mod powershell_profile_tests {
653 use super::{powershell_profile_path, resolve_powershell_profile_path};
654 use std::path::Path;
655
656 #[test]
657 fn always_ends_with_profile_file() {
658 let p = powershell_profile_path(Path::new("/home/u"));
659 assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
660 }
661
662 #[cfg(not(windows))]
663 #[test]
664 fn non_windows_uses_config_powershell_never_documents() {
665 let p = powershell_profile_path(Path::new("/Users/jane"));
668 assert_eq!(
669 p,
670 Path::new("/Users/jane/.config/powershell/Microsoft.PowerShell_profile.ps1")
671 );
672 assert!(
673 !p.to_string_lossy().contains("Documents"),
674 "macOS/Linux PowerShell profile must never touch ~/Documents (#356)"
675 );
676 }
677
678 #[cfg(windows)]
679 #[test]
680 fn windows_uses_documents_powershell() {
681 let p = powershell_profile_path(Path::new("C:\\Users\\jane"));
682 assert!(p.ends_with("Documents\\PowerShell\\Microsoft.PowerShell_profile.ps1"));
683 }
684
685 #[cfg(not(windows))]
686 #[test]
687 fn resolver_matches_static_default_on_non_windows() {
688 let home = Path::new("/Users/jane");
691 assert_eq!(
692 resolve_powershell_profile_path(home),
693 powershell_profile_path(home)
694 );
695 }
696
697 #[cfg(windows)]
698 #[test]
699 fn resolver_returns_absolute_profile_on_windows() {
700 let p = resolve_powershell_profile_path(Path::new("C:\\Users\\jane"));
704 assert!(p.is_absolute(), "resolved profile must be absolute: {p:?}");
705 assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
706 }
707}
708
709#[cfg(test)]
710mod windows_shell_flag_tests {
711 use super::windows_shell_flag_for_exe_basename;
712
713 #[test]
714 fn cmd_uses_slash_c() {
715 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
716 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
717 }
718
719 #[test]
720 fn powershell_uses_command() {
721 assert_eq!(
722 windows_shell_flag_for_exe_basename("powershell.exe"),
723 "-Command"
724 );
725 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
726 }
727
728 #[test]
729 fn posix_shells_use_dash_c() {
730 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
731 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
732 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
733 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
734 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
735 }
736}
737
738#[cfg(test)]
739mod platform_tests {
740 #[test]
741 fn is_container_returns_bool() {
742 let _ = super::is_container();
743 }
744
745 #[test]
746 fn is_non_interactive_returns_bool() {
747 let _ = super::is_non_interactive();
748 }
749
750 #[test]
751 fn join_command_preserves_structure() {
752 let args = vec![
753 "git".to_string(),
754 "commit".to_string(),
755 "-m".to_string(),
756 "my message".to_string(),
757 ];
758 let joined = super::join_command(&args);
759 assert!(joined.contains("git"));
760 assert!(joined.contains("commit"));
761 assert!(joined.contains("my message") || joined.contains("'my message'"));
762 }
763
764 #[test]
765 fn quote_posix_handles_em_dash() {
766 let result = super::quote_posix("closing — see #407");
767 assert!(
768 result.starts_with('\''),
769 "em-dash args must be single-quoted: {result}"
770 );
771 }
772
773 #[test]
774 fn quote_posix_handles_nested_single_quotes() {
775 let result = super::quote_posix("it's a test");
776 assert!(
777 result.contains("\\'"),
778 "single quotes must be escaped: {result}"
779 );
780 }
781
782 #[test]
783 fn quote_posix_safe_chars_unquoted() {
784 let result = super::quote_posix("simple_word");
785 assert_eq!(result, "simple_word");
786 }
787
788 #[test]
789 fn quote_posix_empty_string() {
790 let result = super::quote_posix("");
791 assert_eq!(result, "''");
792 }
793
794 #[test]
795 fn quote_posix_dollar_expansion_protected() {
796 let result = super::quote_posix("$HOME/test");
797 assert!(
798 result.starts_with('\''),
799 "dollar signs must be single-quoted: {result}"
800 );
801 }
802
803 #[test]
804 fn quote_posix_backtick_protected() {
805 let result = super::quote_posix("echo `date`");
806 assert!(
807 result.starts_with('\''),
808 "backticks must be single-quoted: {result}"
809 );
810 }
811
812 #[test]
813 fn quote_posix_double_quotes_protected() {
814 let result = super::quote_posix(r#"he said "hello""#);
815 assert!(
816 result.starts_with('\''),
817 "double quotes must be single-quoted: {result}"
818 );
819 }
820
821 #[cfg(unix)]
825 #[test]
826 fn profile_free_env_blocks_bash_env_contamination() {
827 let Some(bash) = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
828 .into_iter()
829 .find(|p| std::path::Path::new(p).exists())
830 else {
831 return; };
833
834 let startup = std::env::temp_dir().join(format!(
835 "lean_ctx_bashenv_{}_{}.sh",
836 std::process::id(),
837 "guard"
838 ));
839 std::fs::write(&startup, "echo CONTAMINATED\n").expect("write startup file");
840
841 let mut cmd = std::process::Command::new(bash);
842 cmd.arg("-c").arg("echo clean").env("BASH_ENV", &startup);
843 super::apply_profile_free_env(&mut cmd);
844 let out = cmd.output().expect("run bash");
845 let stdout = String::from_utf8_lossy(&out.stdout);
846
847 let _ = std::fs::remove_file(&startup);
848
849 assert!(stdout.contains("clean"), "command output missing: {stdout}");
850 assert!(
851 !stdout.contains("CONTAMINATED"),
852 "apply_profile_free_env must neutralize BASH_ENV, got: {stdout}"
853 );
854 }
855}
856
857#[cfg(all(test, unix))]
858mod posix_shell_gate_tests {
859 use super::{detect_shell, is_posix_compatible_shell};
860
861 #[test]
862 fn accepts_posix_shells() {
863 for s in [
864 "/bin/bash",
865 "/bin/zsh",
866 "/bin/sh",
867 "/usr/bin/dash",
868 "/bin/ash",
869 "/usr/bin/ksh",
870 "/usr/bin/mksh",
871 "bash",
872 "zsh",
873 ] {
874 assert!(is_posix_compatible_shell(s), "{s} must be POSIX-compatible");
875 }
876 }
877
878 #[test]
879 fn rejects_interactive_and_nonposix_shells() {
880 for s in [
882 "/usr/bin/nu",
883 "/opt/homebrew/bin/nu",
884 "/usr/bin/fish",
885 "/usr/local/bin/elvish",
886 "/usr/bin/xonsh",
887 "/usr/bin/pwsh",
888 "powershell.exe",
889 "cmd.exe",
890 ] {
891 assert!(
892 !is_posix_compatible_shell(s),
893 "{s} must be rejected by the POSIX gate"
894 );
895 }
896 }
897
898 #[test]
899 #[cfg_attr(miri, ignore)]
900 fn detect_shell_falls_back_when_shell_is_nushell() {
901 let _lock = crate::core::data_dir::test_env_lock();
903 let saved_shell = std::env::var_os("SHELL");
904 let saved_override = std::env::var_os("LEAN_CTX_SHELL");
905
906 crate::test_env::remove_var("LEAN_CTX_SHELL");
907 crate::test_env::set_var("SHELL", "/usr/bin/nu");
908 let resolved = detect_shell();
909 assert!(
910 is_posix_compatible_shell(&resolved),
911 "a non-POSIX $SHELL (nu) must resolve to a POSIX shell, got {resolved}"
912 );
913 assert!(
914 !resolved.ends_with("/nu") && resolved != "/usr/bin/nu",
915 "must not run agent commands in Nushell, got {resolved}"
916 );
917
918 crate::test_env::set_var("SHELL", "/bin/sh");
920 assert_eq!(detect_shell(), "/bin/sh");
921
922 crate::test_env::set_var("LEAN_CTX_SHELL", "/usr/bin/nu");
924 assert_eq!(detect_shell(), "/usr/bin/nu");
925
926 match saved_shell {
927 Some(v) => crate::test_env::set_var("SHELL", v),
928 None => crate::test_env::remove_var("SHELL"),
929 }
930 match saved_override {
931 Some(v) => crate::test_env::set_var("LEAN_CTX_SHELL", v),
932 None => crate::test_env::remove_var("LEAN_CTX_SHELL"),
933 }
934 }
935}
936
937#[cfg(test)]
938mod carriage_return_tests {
939 use super::resolve_carriage_returns;
940
941 #[test]
942 fn no_cr_passthrough() {
943 assert_eq!(resolve_carriage_returns("hello\nworld\n"), "hello\nworld\n");
944 }
945
946 #[test]
947 fn progress_overwrite_keeps_last_segment() {
948 assert_eq!(resolve_carriage_returns("first\rsecond"), "second");
949 }
950
951 #[test]
952 fn git_rebase_progress() {
953 let input = "Rebasing (1/1)\rSuccessfully rebased and updated refs/heads/feat.";
954 assert_eq!(
955 resolve_carriage_returns(input),
956 "Successfully rebased and updated refs/heads/feat."
957 );
958 }
959
960 #[test]
961 fn crlf_line_endings_stripped() {
962 assert_eq!(
963 resolve_carriage_returns("line1\r\nline2\r\n"),
964 "line1\nline2\n"
965 );
966 }
967
968 #[test]
969 fn multiple_cr_on_one_line() {
970 assert_eq!(resolve_carriage_returns("a\rb\rc"), "c");
971 }
972
973 #[test]
974 fn mixed_lines() {
975 let input = "clean line\nprogress\rdone\nalso clean\n";
976 assert_eq!(
977 resolve_carriage_returns(input),
978 "clean line\ndone\nalso clean\n"
979 );
980 }
981
982 #[test]
983 fn trailing_cr_stripped_as_crlf() {
984 assert_eq!(resolve_carriage_returns("visible\r"), "visible");
985 }
986
987 #[test]
988 fn empty_string_passthrough() {
989 assert_eq!(resolve_carriage_returns(""), "");
990 }
991}
992
993#[cfg(test)]
994mod zsh_safe_tests {
995 use super::zsh_safe_command;
996
997 #[test]
998 #[cfg(unix)]
999 fn prepends_nonomatch_noequals_for_zsh() {
1000 let cmd = zsh_safe_command("grep --include=*.go pattern", "/bin/zsh");
1001 assert_eq!(
1002 cmd,
1003 "setopt nonomatch noequals; grep --include=*.go pattern"
1004 );
1005 }
1006
1007 #[test]
1008 #[cfg(unix)]
1009 fn prepends_nonomatch_noequals_for_usr_bin_zsh() {
1010 let cmd = zsh_safe_command("echo *.rs", "/usr/bin/zsh");
1011 assert_eq!(cmd, "setopt nonomatch noequals; echo *.rs");
1012 }
1013
1014 #[test]
1015 #[cfg(unix)]
1016 fn equals_separator_safe_under_zsh() {
1017 let cmd = zsh_safe_command("echo ===", "/bin/zsh");
1018 assert!(cmd.starts_with("setopt nonomatch noequals;"));
1019 assert!(cmd.contains("echo ==="));
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}