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}
43
44#[cfg(windows)]
45fn decode_windows_output(bytes: &[u8]) -> String {
46 use std::os::windows::ffi::OsStringExt;
47
48 let lossy = String::from_utf8_lossy(bytes);
49 let replacement_count = lossy.chars().filter(|&c| c == '\u{FFFD}').count();
50 if replacement_count == 0 {
51 return lossy.into_owned();
52 }
53
54 unsafe extern "system" {
57 fn GetACP() -> u32;
58 fn MultiByteToWideChar(
59 cp: u32,
60 flags: u32,
61 src: *const u8,
62 srclen: i32,
63 dst: *mut u16,
64 dstlen: i32,
65 ) -> i32;
66 }
67
68 let codepage = unsafe { GetACP() };
71 let wide_len = unsafe {
75 MultiByteToWideChar(
76 codepage,
77 0,
78 bytes.as_ptr(),
79 bytes.len() as i32,
80 std::ptr::null_mut(),
81 0,
82 )
83 };
84 if wide_len <= 0 {
85 return lossy.into_owned();
86 }
87 let mut wide: Vec<u16> = vec![0u16; wide_len as usize];
88 unsafe {
92 MultiByteToWideChar(
93 codepage,
94 0,
95 bytes.as_ptr(),
96 bytes.len() as i32,
97 wide.as_mut_ptr(),
98 wide_len,
99 );
100 }
101 std::ffi::OsString::from_wide(&wide)
102 .to_string_lossy()
103 .into_owned()
104}
105
106#[cfg(windows)]
107pub(super) fn set_console_utf8() {
108 unsafe extern "system" {
111 fn SetConsoleOutputCP(id: u32) -> i32;
112 }
113 unsafe {
116 SetConsoleOutputCP(65001);
117 }
118}
119
120pub fn is_container() -> bool {
122 #[cfg(unix)]
123 {
124 if std::path::Path::new("/.dockerenv").exists() {
125 return true;
126 }
127 if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
128 && (cgroup.contains("/docker/") || cgroup.contains("/lxc/"))
129 {
130 return true;
131 }
132 if let Ok(mounts) = std::fs::read_to_string("/proc/self/mountinfo")
133 && mounts.contains("/docker/containers/")
134 {
135 return true;
136 }
137 false
138 }
139 #[cfg(not(unix))]
140 {
141 false
142 }
143}
144
145pub fn is_non_interactive() -> bool {
147 !io::stdin().is_terminal()
148}
149
150pub(crate) fn is_powershell(shell_path: &str) -> bool {
152 let name = std::path::Path::new(shell_path)
153 .file_name()
154 .and_then(|n| n.to_str())
155 .unwrap_or("")
156 .to_ascii_lowercase();
157 name.contains("powershell") || name.contains("pwsh")
158}
159
160pub(crate) fn powershell_profile_path(home: &std::path::Path) -> std::path::PathBuf {
175 const PROFILE_FILE: &str = "Microsoft.PowerShell_profile.ps1";
176 if cfg!(windows) {
177 home.join("Documents").join("PowerShell").join(PROFILE_FILE)
178 } else {
179 home.join(".config").join("powershell").join(PROFILE_FILE)
180 }
181}
182
183pub(crate) fn resolve_powershell_profile_path(home: &std::path::Path) -> std::path::PathBuf {
198 #[cfg(windows)]
199 {
200 if let Some(active) = query_active_powershell_profile() {
201 return active;
202 }
203 }
204 powershell_profile_path(home)
205}
206
207#[cfg(windows)]
211fn query_active_powershell_profile() -> Option<std::path::PathBuf> {
212 for exe in ["pwsh", "powershell"] {
216 let output = match std::process::Command::new(exe)
217 .args([
218 "-NoProfile",
219 "-NonInteractive",
220 "-Command",
221 "[Console]::OutputEncoding=[Text.Encoding]::UTF8; $PROFILE.CurrentUserCurrentHost",
222 ])
223 .output()
224 {
225 Ok(out) if out.status.success() => out,
226 _ => continue,
227 };
228 let path = std::path::PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
229 if path.is_absolute() && path.file_name().is_some() {
230 return Some(path);
231 }
232 }
233 None
234}
235
236fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
239 if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
240 "-Command"
241 } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
242 "/C"
243 } else {
244 "-c"
245 }
246}
247
248pub fn shell_and_flag() -> (String, String) {
249 let shell = detect_shell();
250 let flag = if cfg!(windows) {
251 let name = std::path::Path::new(&shell)
252 .file_name()
253 .and_then(|n| n.to_str())
254 .unwrap_or("")
255 .to_ascii_lowercase();
256 windows_shell_flag_for_exe_basename(&name).to_string()
257 } else {
258 "-c".to_string()
259 };
260 (shell, flag)
261}
262
263pub fn shell_name() -> String {
265 let shell = detect_shell();
266 let basename = std::path::Path::new(&shell)
267 .file_name()
268 .and_then(|n| n.to_str())
269 .unwrap_or("sh")
270 .to_ascii_lowercase();
271 basename
272 .strip_suffix(".exe")
273 .unwrap_or(&basename)
274 .to_string()
275}
276
277pub(super) fn detect_shell() -> String {
278 if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
279 return shell;
280 }
281
282 if let Ok(shell) = std::env::var("SHELL") {
283 let bin = std::path::Path::new(&shell)
284 .file_name()
285 .and_then(|n| n.to_str())
286 .unwrap_or("sh");
287
288 if bin == "lean-ctx" {
289 return find_real_shell();
290 }
291 if shell_acceptable_for_exec(&shell) {
298 return shell;
299 }
300 return find_real_shell();
301 }
302
303 find_real_shell()
304}
305
306#[cfg(unix)]
313fn shell_acceptable_for_exec(shell: &str) -> bool {
314 is_posix_compatible_shell(shell)
315}
316
317#[cfg(windows)]
318fn shell_acceptable_for_exec(_shell: &str) -> bool {
319 true
320}
321
322#[cfg(unix)]
327fn is_posix_compatible_shell(shell: &str) -> bool {
328 let name = std::path::Path::new(shell)
329 .file_name()
330 .and_then(|n| n.to_str())
331 .unwrap_or("")
332 .to_ascii_lowercase();
333 let name = name.strip_suffix(".exe").unwrap_or(&name);
334 matches!(
335 name,
336 "bash" | "zsh" | "sh" | "dash" | "ash" | "ksh" | "ksh93" | "mksh" | "busybox"
337 )
338}
339
340#[cfg(unix)]
341fn find_real_shell() -> String {
342 for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
343 if std::path::Path::new(shell).exists() {
344 return shell.to_string();
345 }
346 }
347 "/bin/sh".to_string()
348}
349
350#[cfg(windows)]
351fn find_real_shell() -> String {
352 if is_running_in_msys_or_gitbash() {
353 for candidate in &["bash.exe", "sh.exe"] {
354 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
355 if output.status.success() {
356 if let Ok(path) = String::from_utf8(output.stdout) {
357 if let Some(first_line) = path.lines().next() {
358 let trimmed = first_line.trim();
359 if !trimmed.is_empty() {
360 return trimmed.to_string();
361 }
362 }
363 }
364 }
365 }
366 }
367 }
368 if let Ok(pwsh) = which_powershell() {
369 return pwsh;
370 }
371 if let Ok(comspec) = std::env::var("COMSPEC") {
372 return comspec;
373 }
374 "cmd.exe".to_string()
375}
376
377#[cfg(windows)]
378fn is_running_in_msys_or_gitbash() -> bool {
379 std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
380}
381
382#[cfg(windows)]
383fn which_powershell() -> Result<String, ()> {
384 for candidate in &["pwsh.exe", "powershell.exe"] {
385 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
386 if output.status.success() {
387 if let Ok(path) = String::from_utf8(output.stdout) {
388 if let Some(first_line) = path.lines().next() {
389 let trimmed = first_line.trim();
390 if !trimmed.is_empty() {
391 return Ok(trimmed.to_string());
392 }
393 }
394 }
395 }
396 }
397 }
398 Err(())
399}
400
401pub fn join_command(args: &[String]) -> String {
408 let (_, flag) = shell_and_flag();
409 join_command_for(args, &flag)
410}
411
412pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
413 match shell_flag {
414 "-Command" => join_powershell(args),
415 "/C" => join_cmd(args),
416 _ => join_posix(args),
417 }
418}
419
420fn join_posix(args: &[String]) -> String {
421 args.iter()
422 .map(|a| quote_posix(a))
423 .collect::<Vec<_>>()
424 .join(" ")
425}
426
427fn join_powershell(args: &[String]) -> String {
428 if args.len() == 1 && args[0].contains(' ') {
429 return args[0].clone();
430 }
431 let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
432 format!("& {}", quoted.join(" "))
433}
434
435fn join_cmd(args: &[String]) -> String {
436 args.iter()
437 .map(|a| quote_cmd(a))
438 .collect::<Vec<_>>()
439 .join(" ")
440}
441
442fn quote_posix(s: &str) -> String {
443 if s.is_empty() {
444 return "''".to_string();
445 }
446 if s.bytes()
447 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
448 {
449 return s.to_string();
450 }
451 format!("'{}'", s.replace('\'', "'\\''"))
452}
453
454fn quote_powershell(s: &str) -> String {
455 if s.is_empty() {
456 return "''".to_string();
457 }
458 if s.bytes()
459 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
460 {
461 return s.to_string();
462 }
463 format!("'{}'", s.replace('\'', "''"))
464}
465
466fn quote_cmd(s: &str) -> String {
467 if s.is_empty() {
468 return "\"\"".to_string();
469 }
470 if s.bytes()
471 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
472 {
473 return s.to_string();
474 }
475 format!("\"{}\"", s.replace('"', "\\\""))
476}
477
478#[cfg(test)]
479mod join_command_tests {
480 use super::*;
481
482 #[test]
483 fn posix_simple_args() {
484 let args: Vec<String> = vec!["git".into(), "status".into()];
485 assert_eq!(join_command_for(&args, "-c"), "git status");
486 }
487
488 #[test]
489 fn posix_path_with_spaces() {
490 let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
491 assert_eq!(
492 join_command_for(&args, "-c"),
493 "'/usr/local/my app/bin' --help"
494 );
495 }
496
497 #[test]
498 fn posix_single_quotes_escaped() {
499 let args: Vec<String> = vec!["echo".into(), "it's".into()];
500 assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
501 }
502
503 #[test]
504 fn posix_empty_arg() {
505 let args: Vec<String> = vec!["cmd".into(), String::new()];
506 assert_eq!(join_command_for(&args, "-c"), "cmd ''");
507 }
508
509 #[test]
510 fn powershell_simple_args() {
511 let args: Vec<String> = vec!["npm".into(), "install".into()];
512 assert_eq!(join_command_for(&args, "-Command"), "& npm install");
513 }
514
515 #[test]
516 fn powershell_path_with_spaces() {
517 let args: Vec<String> = vec![
518 "C:\\Program Files\\nodejs\\npm.cmd".into(),
519 "install".into(),
520 ];
521 assert_eq!(
522 join_command_for(&args, "-Command"),
523 "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
524 );
525 }
526
527 #[test]
528 fn powershell_single_quotes_escaped() {
529 let args: Vec<String> = vec!["echo".into(), "it's done".into()];
530 assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
531 }
532
533 #[test]
534 fn cmd_simple_args() {
535 let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
536 assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
537 }
538
539 #[test]
540 fn cmd_path_with_spaces() {
541 let args: Vec<String> = vec![
542 "C:\\Program Files\\nodejs\\npm.cmd".into(),
543 "install".into(),
544 ];
545 assert_eq!(
546 join_command_for(&args, "/C"),
547 "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
548 );
549 }
550
551 #[test]
552 fn cmd_double_quotes_escaped() {
553 let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
554 assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
555 }
556
557 #[test]
558 fn unknown_flag_uses_posix() {
559 let args: Vec<String> = vec!["ls".into(), "-la".into()];
560 assert_eq!(join_command_for(&args, "--exec"), "ls -la");
561 }
562
563 #[test]
564 fn powershell_single_full_command_not_quoted() {
565 let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
566 let result = join_command_for(&args, "-Command");
567 assert_eq!(result, "git commit -m \"feat: add feature\"");
568 assert!(
569 !result.starts_with("& '"),
570 "must not wrap full command in & '...'"
571 );
572 }
573
574 #[test]
575 fn powershell_single_no_spaces_still_uses_call_operator() {
576 let args: Vec<String> = vec!["git".into()];
577 assert_eq!(join_command_for(&args, "-Command"), "& git");
578 }
579}
580
581#[cfg(test)]
582mod is_powershell_tests {
583 use super::is_powershell;
584
585 #[test]
586 fn detects_pwsh_exe() {
587 assert!(is_powershell("pwsh.exe"));
588 }
589
590 #[test]
591 fn detects_powershell_exe() {
592 assert!(is_powershell("powershell.exe"));
593 }
594
595 #[test]
596 fn rejects_cmd() {
597 assert!(!is_powershell("cmd.exe"));
598 }
599
600 #[test]
601 fn rejects_bash() {
602 assert!(!is_powershell("/usr/bin/bash"));
603 }
604
605 #[test]
606 fn case_insensitive() {
607 assert!(is_powershell("PWSH.EXE"));
608 assert!(is_powershell("PowerShell.exe"));
609 }
610
611 #[test]
612 fn full_path_with_pwsh() {
613 assert!(is_powershell(
614 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
615 ));
616 assert!(is_powershell("/usr/local/bin/pwsh"));
617 }
618}
619
620#[cfg(test)]
621mod powershell_profile_tests {
622 use super::{powershell_profile_path, resolve_powershell_profile_path};
623 use std::path::Path;
624
625 #[test]
626 fn always_ends_with_profile_file() {
627 let p = powershell_profile_path(Path::new("/home/u"));
628 assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
629 }
630
631 #[cfg(not(windows))]
632 #[test]
633 fn non_windows_uses_config_powershell_never_documents() {
634 let p = powershell_profile_path(Path::new("/Users/jane"));
637 assert_eq!(
638 p,
639 Path::new("/Users/jane/.config/powershell/Microsoft.PowerShell_profile.ps1")
640 );
641 assert!(
642 !p.to_string_lossy().contains("Documents"),
643 "macOS/Linux PowerShell profile must never touch ~/Documents (#356)"
644 );
645 }
646
647 #[cfg(windows)]
648 #[test]
649 fn windows_uses_documents_powershell() {
650 let p = powershell_profile_path(Path::new("C:\\Users\\jane"));
651 assert!(p.ends_with("Documents\\PowerShell\\Microsoft.PowerShell_profile.ps1"));
652 }
653
654 #[cfg(not(windows))]
655 #[test]
656 fn resolver_matches_static_default_on_non_windows() {
657 let home = Path::new("/Users/jane");
660 assert_eq!(
661 resolve_powershell_profile_path(home),
662 powershell_profile_path(home)
663 );
664 }
665
666 #[cfg(windows)]
667 #[test]
668 fn resolver_returns_absolute_profile_on_windows() {
669 let p = resolve_powershell_profile_path(Path::new("C:\\Users\\jane"));
673 assert!(p.is_absolute(), "resolved profile must be absolute: {p:?}");
674 assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
675 }
676}
677
678#[cfg(test)]
679mod windows_shell_flag_tests {
680 use super::windows_shell_flag_for_exe_basename;
681
682 #[test]
683 fn cmd_uses_slash_c() {
684 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
685 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
686 }
687
688 #[test]
689 fn powershell_uses_command() {
690 assert_eq!(
691 windows_shell_flag_for_exe_basename("powershell.exe"),
692 "-Command"
693 );
694 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
695 }
696
697 #[test]
698 fn posix_shells_use_dash_c() {
699 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
700 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
701 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
702 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
703 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
704 }
705}
706
707#[cfg(test)]
708mod platform_tests {
709 #[test]
710 fn is_container_returns_bool() {
711 let _ = super::is_container();
712 }
713
714 #[test]
715 fn is_non_interactive_returns_bool() {
716 let _ = super::is_non_interactive();
717 }
718
719 #[test]
720 fn join_command_preserves_structure() {
721 let args = vec![
722 "git".to_string(),
723 "commit".to_string(),
724 "-m".to_string(),
725 "my message".to_string(),
726 ];
727 let joined = super::join_command(&args);
728 assert!(joined.contains("git"));
729 assert!(joined.contains("commit"));
730 assert!(joined.contains("my message") || joined.contains("'my message'"));
731 }
732
733 #[test]
734 fn quote_posix_handles_em_dash() {
735 let result = super::quote_posix("closing — see #407");
736 assert!(
737 result.starts_with('\''),
738 "em-dash args must be single-quoted: {result}"
739 );
740 }
741
742 #[test]
743 fn quote_posix_handles_nested_single_quotes() {
744 let result = super::quote_posix("it's a test");
745 assert!(
746 result.contains("\\'"),
747 "single quotes must be escaped: {result}"
748 );
749 }
750
751 #[test]
752 fn quote_posix_safe_chars_unquoted() {
753 let result = super::quote_posix("simple_word");
754 assert_eq!(result, "simple_word");
755 }
756
757 #[test]
758 fn quote_posix_empty_string() {
759 let result = super::quote_posix("");
760 assert_eq!(result, "''");
761 }
762
763 #[test]
764 fn quote_posix_dollar_expansion_protected() {
765 let result = super::quote_posix("$HOME/test");
766 assert!(
767 result.starts_with('\''),
768 "dollar signs must be single-quoted: {result}"
769 );
770 }
771
772 #[test]
773 fn quote_posix_backtick_protected() {
774 let result = super::quote_posix("echo `date`");
775 assert!(
776 result.starts_with('\''),
777 "backticks must be single-quoted: {result}"
778 );
779 }
780
781 #[test]
782 fn quote_posix_double_quotes_protected() {
783 let result = super::quote_posix(r#"he said "hello""#);
784 assert!(
785 result.starts_with('\''),
786 "double quotes must be single-quoted: {result}"
787 );
788 }
789
790 #[cfg(unix)]
794 #[test]
795 fn profile_free_env_blocks_bash_env_contamination() {
796 let Some(bash) = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
797 .into_iter()
798 .find(|p| std::path::Path::new(p).exists())
799 else {
800 return; };
802
803 let startup = std::env::temp_dir().join(format!(
804 "lean_ctx_bashenv_{}_{}.sh",
805 std::process::id(),
806 "guard"
807 ));
808 std::fs::write(&startup, "echo CONTAMINATED\n").expect("write startup file");
809
810 let mut cmd = std::process::Command::new(bash);
811 cmd.arg("-c").arg("echo clean").env("BASH_ENV", &startup);
812 super::apply_profile_free_env(&mut cmd);
813 let out = cmd.output().expect("run bash");
814 let stdout = String::from_utf8_lossy(&out.stdout);
815
816 let _ = std::fs::remove_file(&startup);
817
818 assert!(stdout.contains("clean"), "command output missing: {stdout}");
819 assert!(
820 !stdout.contains("CONTAMINATED"),
821 "apply_profile_free_env must neutralize BASH_ENV, got: {stdout}"
822 );
823 }
824}
825
826#[cfg(all(test, unix))]
827mod posix_shell_gate_tests {
828 use super::{detect_shell, is_posix_compatible_shell};
829
830 #[test]
831 fn accepts_posix_shells() {
832 for s in [
833 "/bin/bash",
834 "/bin/zsh",
835 "/bin/sh",
836 "/usr/bin/dash",
837 "/bin/ash",
838 "/usr/bin/ksh",
839 "/usr/bin/mksh",
840 "bash",
841 "zsh",
842 ] {
843 assert!(is_posix_compatible_shell(s), "{s} must be POSIX-compatible");
844 }
845 }
846
847 #[test]
848 fn rejects_interactive_and_nonposix_shells() {
849 for s in [
851 "/usr/bin/nu",
852 "/opt/homebrew/bin/nu",
853 "/usr/bin/fish",
854 "/usr/local/bin/elvish",
855 "/usr/bin/xonsh",
856 "/usr/bin/pwsh",
857 "powershell.exe",
858 "cmd.exe",
859 ] {
860 assert!(
861 !is_posix_compatible_shell(s),
862 "{s} must be rejected by the POSIX gate"
863 );
864 }
865 }
866
867 #[test]
868 #[cfg_attr(miri, ignore)]
869 fn detect_shell_falls_back_when_shell_is_nushell() {
870 let _lock = crate::core::data_dir::test_env_lock();
872 let saved_shell = std::env::var_os("SHELL");
873 let saved_override = std::env::var_os("LEAN_CTX_SHELL");
874
875 crate::test_env::remove_var("LEAN_CTX_SHELL");
876 crate::test_env::set_var("SHELL", "/usr/bin/nu");
877 let resolved = detect_shell();
878 assert!(
879 is_posix_compatible_shell(&resolved),
880 "a non-POSIX $SHELL (nu) must resolve to a POSIX shell, got {resolved}"
881 );
882 assert!(
883 !resolved.ends_with("/nu") && resolved != "/usr/bin/nu",
884 "must not run agent commands in Nushell, got {resolved}"
885 );
886
887 crate::test_env::set_var("SHELL", "/bin/sh");
889 assert_eq!(detect_shell(), "/bin/sh");
890
891 crate::test_env::set_var("LEAN_CTX_SHELL", "/usr/bin/nu");
893 assert_eq!(detect_shell(), "/usr/bin/nu");
894
895 match saved_shell {
896 Some(v) => crate::test_env::set_var("SHELL", v),
897 None => crate::test_env::remove_var("SHELL"),
898 }
899 match saved_override {
900 Some(v) => crate::test_env::set_var("LEAN_CTX_SHELL", v),
901 None => crate::test_env::remove_var("LEAN_CTX_SHELL"),
902 }
903 }
904}