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(super) fn detect_shell() -> String {
303 if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
304 return shell;
305 }
306
307 if let Ok(shell) = std::env::var("SHELL") {
308 let bin = std::path::Path::new(&shell)
309 .file_name()
310 .and_then(|n| n.to_str())
311 .unwrap_or("sh");
312
313 if bin == "lean-ctx" {
314 return find_real_shell();
315 }
316 if shell_acceptable_for_exec(&shell) {
323 return shell;
324 }
325 return find_real_shell();
326 }
327
328 find_real_shell()
329}
330
331#[cfg(unix)]
338fn shell_acceptable_for_exec(shell: &str) -> bool {
339 is_posix_compatible_shell(shell)
340}
341
342#[cfg(windows)]
343fn shell_acceptable_for_exec(_shell: &str) -> bool {
344 true
345}
346
347#[cfg(unix)]
352fn is_posix_compatible_shell(shell: &str) -> bool {
353 let name = std::path::Path::new(shell)
354 .file_name()
355 .and_then(|n| n.to_str())
356 .unwrap_or("")
357 .to_ascii_lowercase();
358 let name = name.strip_suffix(".exe").unwrap_or(&name);
359 matches!(
360 name,
361 "bash" | "zsh" | "sh" | "dash" | "ash" | "ksh" | "ksh93" | "mksh" | "busybox"
362 )
363}
364
365#[cfg(unix)]
366fn find_real_shell() -> String {
367 for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
368 if std::path::Path::new(shell).exists() {
369 return shell.to_string();
370 }
371 }
372 "/bin/sh".to_string()
373}
374
375#[cfg(windows)]
376fn find_real_shell() -> String {
377 if is_running_in_msys_or_gitbash() {
378 for candidate in &["bash.exe", "sh.exe"] {
379 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
380 if output.status.success() {
381 if let Ok(path) = String::from_utf8(output.stdout) {
382 if let Some(first_line) = path.lines().next() {
383 let trimmed = first_line.trim();
384 if !trimmed.is_empty() {
385 return trimmed.to_string();
386 }
387 }
388 }
389 }
390 }
391 }
392 }
393 if let Ok(pwsh) = which_powershell() {
394 return pwsh;
395 }
396 if let Ok(comspec) = std::env::var("COMSPEC") {
397 return comspec;
398 }
399 "cmd.exe".to_string()
400}
401
402#[cfg(windows)]
403fn is_running_in_msys_or_gitbash() -> bool {
404 std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
405}
406
407#[cfg(windows)]
408fn which_powershell() -> Result<String, ()> {
409 for candidate in &["pwsh.exe", "powershell.exe"] {
410 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
411 if output.status.success() {
412 if let Ok(path) = String::from_utf8(output.stdout) {
413 if let Some(first_line) = path.lines().next() {
414 let trimmed = first_line.trim();
415 if !trimmed.is_empty() {
416 return Ok(trimmed.to_string());
417 }
418 }
419 }
420 }
421 }
422 }
423 Err(())
424}
425
426pub fn join_command(args: &[String]) -> String {
433 let (_, flag) = shell_and_flag();
434 join_command_for(args, &flag)
435}
436
437pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
438 match shell_flag {
439 "-Command" => join_powershell(args),
440 "/C" => join_cmd(args),
441 _ => join_posix(args),
442 }
443}
444
445fn join_posix(args: &[String]) -> String {
446 args.iter()
447 .map(|a| quote_posix(a))
448 .collect::<Vec<_>>()
449 .join(" ")
450}
451
452fn join_powershell(args: &[String]) -> String {
453 if args.len() == 1 && args[0].contains(' ') {
454 return args[0].clone();
455 }
456 let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
457 format!("& {}", quoted.join(" "))
458}
459
460fn join_cmd(args: &[String]) -> String {
461 args.iter()
462 .map(|a| quote_cmd(a))
463 .collect::<Vec<_>>()
464 .join(" ")
465}
466
467fn quote_posix(s: &str) -> String {
468 if s.is_empty() {
469 return "''".to_string();
470 }
471 if s.bytes()
472 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
473 {
474 return s.to_string();
475 }
476 format!("'{}'", s.replace('\'', "'\\''"))
477}
478
479fn quote_powershell(s: &str) -> String {
480 if s.is_empty() {
481 return "''".to_string();
482 }
483 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 cmd_simple_args() {
560 let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
561 assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
562 }
563
564 #[test]
565 fn cmd_path_with_spaces() {
566 let args: Vec<String> = vec![
567 "C:\\Program Files\\nodejs\\npm.cmd".into(),
568 "install".into(),
569 ];
570 assert_eq!(
571 join_command_for(&args, "/C"),
572 "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
573 );
574 }
575
576 #[test]
577 fn cmd_double_quotes_escaped() {
578 let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
579 assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
580 }
581
582 #[test]
583 fn unknown_flag_uses_posix() {
584 let args: Vec<String> = vec!["ls".into(), "-la".into()];
585 assert_eq!(join_command_for(&args, "--exec"), "ls -la");
586 }
587
588 #[test]
589 fn powershell_single_full_command_not_quoted() {
590 let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
591 let result = join_command_for(&args, "-Command");
592 assert_eq!(result, "git commit -m \"feat: add feature\"");
593 assert!(
594 !result.starts_with("& '"),
595 "must not wrap full command in & '...'"
596 );
597 }
598
599 #[test]
600 fn powershell_single_no_spaces_still_uses_call_operator() {
601 let args: Vec<String> = vec!["git".into()];
602 assert_eq!(join_command_for(&args, "-Command"), "& git");
603 }
604}
605
606#[cfg(test)]
607mod is_powershell_tests {
608 use super::is_powershell;
609
610 #[test]
611 fn detects_pwsh_exe() {
612 assert!(is_powershell("pwsh.exe"));
613 }
614
615 #[test]
616 fn detects_powershell_exe() {
617 assert!(is_powershell("powershell.exe"));
618 }
619
620 #[test]
621 fn rejects_cmd() {
622 assert!(!is_powershell("cmd.exe"));
623 }
624
625 #[test]
626 fn rejects_bash() {
627 assert!(!is_powershell("/usr/bin/bash"));
628 }
629
630 #[test]
631 fn case_insensitive() {
632 assert!(is_powershell("PWSH.EXE"));
633 assert!(is_powershell("PowerShell.exe"));
634 }
635
636 #[test]
637 fn full_path_with_pwsh() {
638 assert!(is_powershell(
639 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
640 ));
641 assert!(is_powershell("/usr/local/bin/pwsh"));
642 }
643}
644
645#[cfg(test)]
646mod powershell_profile_tests {
647 use super::{powershell_profile_path, resolve_powershell_profile_path};
648 use std::path::Path;
649
650 #[test]
651 fn always_ends_with_profile_file() {
652 let p = powershell_profile_path(Path::new("/home/u"));
653 assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
654 }
655
656 #[cfg(not(windows))]
657 #[test]
658 fn non_windows_uses_config_powershell_never_documents() {
659 let p = powershell_profile_path(Path::new("/Users/jane"));
662 assert_eq!(
663 p,
664 Path::new("/Users/jane/.config/powershell/Microsoft.PowerShell_profile.ps1")
665 );
666 assert!(
667 !p.to_string_lossy().contains("Documents"),
668 "macOS/Linux PowerShell profile must never touch ~/Documents (#356)"
669 );
670 }
671
672 #[cfg(windows)]
673 #[test]
674 fn windows_uses_documents_powershell() {
675 let p = powershell_profile_path(Path::new("C:\\Users\\jane"));
676 assert!(p.ends_with("Documents\\PowerShell\\Microsoft.PowerShell_profile.ps1"));
677 }
678
679 #[cfg(not(windows))]
680 #[test]
681 fn resolver_matches_static_default_on_non_windows() {
682 let home = Path::new("/Users/jane");
685 assert_eq!(
686 resolve_powershell_profile_path(home),
687 powershell_profile_path(home)
688 );
689 }
690
691 #[cfg(windows)]
692 #[test]
693 fn resolver_returns_absolute_profile_on_windows() {
694 let p = resolve_powershell_profile_path(Path::new("C:\\Users\\jane"));
698 assert!(p.is_absolute(), "resolved profile must be absolute: {p:?}");
699 assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
700 }
701}
702
703#[cfg(test)]
704mod windows_shell_flag_tests {
705 use super::windows_shell_flag_for_exe_basename;
706
707 #[test]
708 fn cmd_uses_slash_c() {
709 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
710 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
711 }
712
713 #[test]
714 fn powershell_uses_command() {
715 assert_eq!(
716 windows_shell_flag_for_exe_basename("powershell.exe"),
717 "-Command"
718 );
719 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
720 }
721
722 #[test]
723 fn posix_shells_use_dash_c() {
724 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
725 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
726 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
727 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
728 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
729 }
730}
731
732#[cfg(test)]
733mod platform_tests {
734 #[test]
735 fn is_container_returns_bool() {
736 let _ = super::is_container();
737 }
738
739 #[test]
740 fn is_non_interactive_returns_bool() {
741 let _ = super::is_non_interactive();
742 }
743
744 #[test]
745 fn join_command_preserves_structure() {
746 let args = vec![
747 "git".to_string(),
748 "commit".to_string(),
749 "-m".to_string(),
750 "my message".to_string(),
751 ];
752 let joined = super::join_command(&args);
753 assert!(joined.contains("git"));
754 assert!(joined.contains("commit"));
755 assert!(joined.contains("my message") || joined.contains("'my message'"));
756 }
757
758 #[test]
759 fn quote_posix_handles_em_dash() {
760 let result = super::quote_posix("closing — see #407");
761 assert!(
762 result.starts_with('\''),
763 "em-dash args must be single-quoted: {result}"
764 );
765 }
766
767 #[test]
768 fn quote_posix_handles_nested_single_quotes() {
769 let result = super::quote_posix("it's a test");
770 assert!(
771 result.contains("\\'"),
772 "single quotes must be escaped: {result}"
773 );
774 }
775
776 #[test]
777 fn quote_posix_safe_chars_unquoted() {
778 let result = super::quote_posix("simple_word");
779 assert_eq!(result, "simple_word");
780 }
781
782 #[test]
783 fn quote_posix_empty_string() {
784 let result = super::quote_posix("");
785 assert_eq!(result, "''");
786 }
787
788 #[test]
789 fn quote_posix_dollar_expansion_protected() {
790 let result = super::quote_posix("$HOME/test");
791 assert!(
792 result.starts_with('\''),
793 "dollar signs must be single-quoted: {result}"
794 );
795 }
796
797 #[test]
798 fn quote_posix_backtick_protected() {
799 let result = super::quote_posix("echo `date`");
800 assert!(
801 result.starts_with('\''),
802 "backticks must be single-quoted: {result}"
803 );
804 }
805
806 #[test]
807 fn quote_posix_double_quotes_protected() {
808 let result = super::quote_posix(r#"he said "hello""#);
809 assert!(
810 result.starts_with('\''),
811 "double quotes must be single-quoted: {result}"
812 );
813 }
814
815 #[cfg(unix)]
819 #[test]
820 fn profile_free_env_blocks_bash_env_contamination() {
821 let Some(bash) = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
822 .into_iter()
823 .find(|p| std::path::Path::new(p).exists())
824 else {
825 return; };
827
828 let startup = std::env::temp_dir().join(format!(
829 "lean_ctx_bashenv_{}_{}.sh",
830 std::process::id(),
831 "guard"
832 ));
833 std::fs::write(&startup, "echo CONTAMINATED\n").expect("write startup file");
834
835 let mut cmd = std::process::Command::new(bash);
836 cmd.arg("-c").arg("echo clean").env("BASH_ENV", &startup);
837 super::apply_profile_free_env(&mut cmd);
838 let out = cmd.output().expect("run bash");
839 let stdout = String::from_utf8_lossy(&out.stdout);
840
841 let _ = std::fs::remove_file(&startup);
842
843 assert!(stdout.contains("clean"), "command output missing: {stdout}");
844 assert!(
845 !stdout.contains("CONTAMINATED"),
846 "apply_profile_free_env must neutralize BASH_ENV, got: {stdout}"
847 );
848 }
849}
850
851#[cfg(all(test, unix))]
852mod posix_shell_gate_tests {
853 use super::{detect_shell, is_posix_compatible_shell};
854
855 #[test]
856 fn accepts_posix_shells() {
857 for s in [
858 "/bin/bash",
859 "/bin/zsh",
860 "/bin/sh",
861 "/usr/bin/dash",
862 "/bin/ash",
863 "/usr/bin/ksh",
864 "/usr/bin/mksh",
865 "bash",
866 "zsh",
867 ] {
868 assert!(is_posix_compatible_shell(s), "{s} must be POSIX-compatible");
869 }
870 }
871
872 #[test]
873 fn rejects_interactive_and_nonposix_shells() {
874 for s in [
876 "/usr/bin/nu",
877 "/opt/homebrew/bin/nu",
878 "/usr/bin/fish",
879 "/usr/local/bin/elvish",
880 "/usr/bin/xonsh",
881 "/usr/bin/pwsh",
882 "powershell.exe",
883 "cmd.exe",
884 ] {
885 assert!(
886 !is_posix_compatible_shell(s),
887 "{s} must be rejected by the POSIX gate"
888 );
889 }
890 }
891
892 #[test]
893 #[cfg_attr(miri, ignore)]
894 fn detect_shell_falls_back_when_shell_is_nushell() {
895 let _lock = crate::core::data_dir::test_env_lock();
897 let saved_shell = std::env::var_os("SHELL");
898 let saved_override = std::env::var_os("LEAN_CTX_SHELL");
899
900 crate::test_env::remove_var("LEAN_CTX_SHELL");
901 crate::test_env::set_var("SHELL", "/usr/bin/nu");
902 let resolved = detect_shell();
903 assert!(
904 is_posix_compatible_shell(&resolved),
905 "a non-POSIX $SHELL (nu) must resolve to a POSIX shell, got {resolved}"
906 );
907 assert!(
908 !resolved.ends_with("/nu") && resolved != "/usr/bin/nu",
909 "must not run agent commands in Nushell, got {resolved}"
910 );
911
912 crate::test_env::set_var("SHELL", "/bin/sh");
914 assert_eq!(detect_shell(), "/bin/sh");
915
916 crate::test_env::set_var("LEAN_CTX_SHELL", "/usr/bin/nu");
918 assert_eq!(detect_shell(), "/usr/bin/nu");
919
920 match saved_shell {
921 Some(v) => crate::test_env::set_var("SHELL", v),
922 None => crate::test_env::remove_var("SHELL"),
923 }
924 match saved_override {
925 Some(v) => crate::test_env::set_var("LEAN_CTX_SHELL", v),
926 None => crate::test_env::remove_var("LEAN_CTX_SHELL"),
927 }
928 }
929}
930
931#[cfg(test)]
932mod carriage_return_tests {
933 use super::resolve_carriage_returns;
934
935 #[test]
936 fn no_cr_passthrough() {
937 assert_eq!(resolve_carriage_returns("hello\nworld\n"), "hello\nworld\n");
938 }
939
940 #[test]
941 fn progress_overwrite_keeps_last_segment() {
942 assert_eq!(resolve_carriage_returns("first\rsecond"), "second");
943 }
944
945 #[test]
946 fn git_rebase_progress() {
947 let input = "Rebasing (1/1)\rSuccessfully rebased and updated refs/heads/feat.";
948 assert_eq!(
949 resolve_carriage_returns(input),
950 "Successfully rebased and updated refs/heads/feat."
951 );
952 }
953
954 #[test]
955 fn crlf_line_endings_stripped() {
956 assert_eq!(
957 resolve_carriage_returns("line1\r\nline2\r\n"),
958 "line1\nline2\n"
959 );
960 }
961
962 #[test]
963 fn multiple_cr_on_one_line() {
964 assert_eq!(resolve_carriage_returns("a\rb\rc"), "c");
965 }
966
967 #[test]
968 fn mixed_lines() {
969 let input = "clean line\nprogress\rdone\nalso clean\n";
970 assert_eq!(
971 resolve_carriage_returns(input),
972 "clean line\ndone\nalso clean\n"
973 );
974 }
975
976 #[test]
977 fn trailing_cr_stripped_as_crlf() {
978 assert_eq!(resolve_carriage_returns("visible\r"), "visible");
979 }
980
981 #[test]
982 fn empty_string_passthrough() {
983 assert_eq!(resolve_carriage_returns(""), "");
984 }
985}