Skip to main content

lean_ctx/shell/
platform.rs

1use std::io::{self, IsTerminal};
2
3/// Sets `LC_CTYPE=C.UTF-8` when no UTF-8 locale is inherited from the parent
4/// process. Without this, commands treat bytes >127 as non-printable (C locale),
5/// mangling Cyrillic, CJK, emoji, etc.
6pub(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
17/// Neutralizes inherited startup-file hooks so a non-login/non-interactive
18/// `sh -c` / `bash -c` cannot be hijacked into sourcing a profile or rc file
19/// (#451). lean-ctx runs the system POSIX shell profile-free and deterministic;
20/// a stray inherited `BASH_ENV` (read by bash even for `bash -c`) or `ENV`
21/// pointing at e.g. an `exec nu` snippet would otherwise silently replace the
22/// shell. Cleared to empty so the expansion names no file. No-op in effect for
23/// PowerShell/cmd, which ignore both variables.
24pub(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    // SAFETY: declares Win32 API symbols that exist in kernel32; signatures
55    // match the documented ABI.
56    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    // SAFETY: `GetACP` takes no arguments and only returns the active code
69    // page; it cannot fail or cause undefined behaviour.
70    let codepage = unsafe { GetACP() };
71    // SAFETY: called with a null destination and length 0 to measure the
72    // required buffer size; `bytes` is a live slice and every pointer/length
73    // argument is valid.
74    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    // SAFETY: `wide` is sized to the previously measured length and `bytes` is
89    // a live slice; the source and destination pointers/lengths are valid and
90    // do not overlap.
91    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    // SAFETY: declares a Win32 API symbol that exists in kernel32; the
109    // signature matches the documented ABI.
110    unsafe extern "system" {
111        fn SetConsoleOutputCP(id: u32) -> i32;
112    }
113    // SAFETY: `SetConsoleOutputCP` takes a code-page id (65001 = UTF-8) by
114    // value; it cannot cause undefined behaviour.
115    unsafe {
116        SetConsoleOutputCP(65001);
117    }
118}
119
120/// Detects if the current process runs inside a Docker/container environment.
121pub 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
145/// Returns true if stdin is NOT a terminal (pipe, /dev/null, etc.)
146pub fn is_non_interactive() -> bool {
147    !io::stdin().is_terminal()
148}
149
150/// Returns `true` when `shell_path` points to a PowerShell executable.
151pub(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
160/// Documented default location of the current-user PowerShell profile
161/// (`$PROFILE.CurrentUserCurrentHost`).
162///
163/// Windows PowerShell 7+ stores it under `Documents\PowerShell\…`, but **PowerShell
164/// (pwsh) on macOS/Linux reads `~/.config/powershell/…` instead** — and stat-ing
165/// anything inside `~/Documents` on macOS pops a TCC privacy prompt ("lean-ctx
166/// would like to access files in your Documents folder", #356). Resolving the
167/// profile per-OS keeps pwsh support everywhere while never touching `~/Documents`
168/// on non-Windows hosts.
169///
170/// This is the *default* path only. On Windows the real `Documents` folder is
171/// frequently redirected (OneDrive folder backup), so callers should prefer
172/// [`resolve_powershell_profile_path`], which asks PowerShell for the live location
173/// and uses this as the fallback.
174pub(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
183/// Resolve the **active** current-user PowerShell profile path.
184///
185/// On Windows the profile lives under the *Documents* known folder, which OneDrive
186/// folder backup (enabled by default on most installs) routinely redirects to e.g.
187/// `…\OneDrive\Documents\PowerShell\…`. A hardcoded `~\Documents` therefore misses
188/// the real `$PROFILE`, so `proxy enable` / shell-hook install silently write to a
189/// file PowerShell never reads and the proxy receives no traffic (#558). We ask
190/// PowerShell itself for `$PROFILE.CurrentUserCurrentHost` — authoritative under any
191/// folder redirection — preferring `pwsh` (7+) then Windows PowerShell, and fall back
192/// to [`powershell_profile_path`] only when no PowerShell host can be launched (in
193/// which case the profile is moot anyway).
194///
195/// Non-Windows hosts keep the static `~/.config/powershell` path (never `~/Documents`,
196/// #356) and never spawn a process.
197pub(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/// Ask PowerShell for `$PROFILE.CurrentUserCurrentHost`, the authoritative profile
208/// path under any `Documents` redirection. Returns `None` when no PowerShell host can
209/// be launched or the output is not a usable absolute path.
210#[cfg(windows)]
211fn query_active_powershell_profile() -> Option<std::path::PathBuf> {
212    // `pwsh` (7+) and `powershell.exe` (5.1) use different profile roots; prefer the
213    // modern host (matching the `Documents\PowerShell` default above), then fall back.
214    // Force UTF-8 output so redirected paths with non-ASCII characters survive the pipe.
215    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
236/// Windows only: argument that passes one command string to the shell binary.
237/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
238fn 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
263/// Returns a short, human-readable shell name (e.g. "bash", "zsh", "powershell", "cmd").
264pub 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        // #451: `$SHELL` is the user's *interactive* shell preference, not the
292        // agent's execution shell. Agents emit bash/POSIX command syntax, so an
293        // interactive-only shell like Nushell or Fish silently mis-runs it.
294        // Honor `$SHELL` only when it is POSIX-compatible; otherwise fall back
295        // to a real POSIX shell (deterministic, agent-trained). Set
296        // `LEAN_CTX_SHELL` to force a specific shell regardless of this gate.
297        if shell_acceptable_for_exec(&shell) {
298            return shell;
299        }
300        return find_real_shell();
301    }
302
303    find_real_shell()
304}
305
306/// Whether a `$SHELL` value may be auto-selected as the command-execution shell.
307///
308/// On Unix this rejects non-POSIX interactive shells (Nushell, Fish, Elvish,
309/// xonsh, PowerShell) so lean-ctx never feeds them bash/POSIX syntax (#451). On
310/// Windows the intended shells *are* PowerShell/cmd, so any `$SHELL` is honored
311/// (the POSIX gate would wrongly reject them).
312#[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/// `true` if `shell`'s basename is a POSIX-compatible shell that can run
323/// agent-authored bash/POSIX commands. Excludes interactive-only shells
324/// (`nu`, `fish`, `elvish`, `xonsh`) and non-POSIX shells (`pwsh`,
325/// `powershell`, `cmd`) where that syntax fails (#451).
326#[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
401/// Join multiple CLI arguments into a single command string, using quoting
402/// conventions appropriate for the detected shell.
403///
404/// On Unix, this always produces POSIX-compatible quoting.
405/// On Windows, the quoting adapts to the actual shell (PowerShell, cmd.exe,
406/// or Git Bash / MSYS).
407pub 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        // #356: stat-ing anything under ~/Documents pops a macOS TCC prompt, so the
635        // non-Windows profile path must live under ~/.config/powershell instead.
636        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        // Non-Windows never spawns a process: the resolver returns the static config
658        // path verbatim (and must never touch ~/Documents, #356).
659        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        // On Windows the resolver asks PowerShell for the live $PROFILE (OneDrive-safe,
670        // #558); whether it queries successfully or falls back, the result is an
671        // absolute path to the profile file.
672        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    // #451: a non-interactive `bash -c` sources $BASH_ENV. lean-ctx must run
791    // profile-free, so `apply_profile_free_env` has to neutralize an inherited
792    // BASH_ENV before it can pull in a contaminating startup file.
793    #[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; // no bash on this host → nothing to guard against
801        };
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        // #451: agent bash/POSIX syntax fails in these — never auto-select them.
850        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        // Serialize env mutation (set_var/remove_var are process-global).
871        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        // A POSIX $SHELL is honored verbatim.
888        crate::test_env::set_var("SHELL", "/bin/sh");
889        assert_eq!(detect_shell(), "/bin/sh");
890
891        // LEAN_CTX_SHELL always wins, even when pointing at a non-POSIX shell.
892        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}