Skip to main content

lean_ctx/shell/
platform.rs

1/// Sets `LC_CTYPE=C.UTF-8` when no UTF-8 locale is inherited from the parent
2/// process. Without this, commands treat bytes >127 as non-printable (C locale),
3/// mangling Cyrillic, CJK, emoji, etc.
4pub 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
15/// Neutralizes inherited startup-file hooks so a non-login/non-interactive
16/// `sh -c` / `bash -c` cannot be hijacked into sourcing a profile or rc file
17/// (#451). lean-ctx runs the system POSIX shell profile-free and deterministic;
18/// a stray inherited `BASH_ENV` (read by bash even for `bash -c`) or `ENV`
19/// pointing at e.g. an `exec nu` snippet would otherwise silently replace the
20/// shell. Cleared to empty so the expansion names no file. No-op in effect for
21/// PowerShell/cmd, which ignore both variables.
22pub 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}
41/// Resolve carriage-return (`\r`) progress overwrites in captured output.
42///
43/// CLI tools like `git`, `cargo`, and `npm` use `\r` to draw in-place progress
44/// updates. When stdout is captured (not a TTY), these bytes survive as literal
45/// `\r` characters and produce glued-together lines (#1140). This function
46/// emulates terminal behaviour: for each line, only the content after the last
47/// `\r` is kept.
48pub 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    // SAFETY: declares Win32 API symbols that exist in kernel32; signatures
78    // match the documented ABI.
79    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    // SAFETY: `GetACP` takes no arguments and only returns the active code
92    // page; it cannot fail or cause undefined behaviour.
93    let codepage = unsafe { GetACP() };
94    // SAFETY: called with a null destination and length 0 to measure the
95    // required buffer size; `bytes` is a live slice and every pointer/length
96    // argument is valid.
97    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    // SAFETY: `wide` is sized to the previously measured length and `bytes` is
112    // a live slice; the source and destination pointers/lengths are valid and
113    // do not overlap.
114    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    // SAFETY: declares a Win32 API symbol that exists in kernel32; the
132    // signature matches the documented ABI.
133    unsafe extern "system" {
134        fn SetConsoleOutputCP(id: u32) -> i32;
135    }
136    // SAFETY: `SetConsoleOutputCP` takes a code-page id (65001 = UTF-8) by
137    // value; it cannot cause undefined behaviour.
138    unsafe {
139        SetConsoleOutputCP(65001);
140    }
141}
142
143/// Detects if the current process runs inside a Docker/container environment.
144pub 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
168/// Returns true if stdin is NOT a terminal (pipe, /dev/null, etc.)
169pub fn is_non_interactive() -> bool {
170    !crate::core::host_env::stdin_is_terminal()
171}
172
173/// Returns `true` when `shell_path` points to a PowerShell executable.
174pub 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
183/// Documented default location of the current-user PowerShell profile
184/// (`$PROFILE.CurrentUserCurrentHost`).
185///
186/// Windows PowerShell 7+ stores it under `Documents\PowerShell\…`, but **PowerShell
187/// (pwsh) on macOS/Linux reads `~/.config/powershell/…` instead** — and stat-ing
188/// anything inside `~/Documents` on macOS pops a TCC privacy prompt ("lean-ctx
189/// would like to access files in your Documents folder", #356). Resolving the
190/// profile per-OS keeps pwsh support everywhere while never touching `~/Documents`
191/// on non-Windows hosts.
192///
193/// This is the *default* path only. On Windows the real `Documents` folder is
194/// frequently redirected (OneDrive folder backup), so callers should prefer
195/// [`resolve_powershell_profile_path`], which asks PowerShell for the live location
196/// and uses this as the fallback.
197pub(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
206/// Resolve the **active** current-user PowerShell profile path.
207///
208/// On Windows the profile lives under the *Documents* known folder, which OneDrive
209/// folder backup (enabled by default on most installs) routinely redirects to e.g.
210/// `…\OneDrive\Documents\PowerShell\…`. A hardcoded `~\Documents` therefore misses
211/// the real `$PROFILE`, so `proxy enable` / shell-hook install silently write to a
212/// file PowerShell never reads and the proxy receives no traffic (#558). We ask
213/// PowerShell itself for `$PROFILE.CurrentUserCurrentHost` — authoritative under any
214/// folder redirection — preferring `pwsh` (7+) then Windows PowerShell, and fall back
215/// to `powershell_profile_path` only when no PowerShell host can be launched (in
216/// which case the profile is moot anyway).
217///
218/// Non-Windows hosts keep the static `~/.config/powershell` path (never `~/Documents`,
219/// #356) and never spawn a process.
220pub 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/// Ask PowerShell for `$PROFILE.CurrentUserCurrentHost`, the authoritative profile
231/// path under any `Documents` redirection. Returns `None` when no PowerShell host can
232/// be launched or the output is not a usable absolute path.
233#[cfg(windows)]
234fn query_active_powershell_profile() -> Option<std::path::PathBuf> {
235    // `pwsh` (7+) and `powershell.exe` (5.1) use different profile roots; prefer the
236    // modern host (matching the `Documents\PowerShell` default above), then fall back.
237    // Force UTF-8 output so redirected paths with non-ASCII characters survive the pipe.
238    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
259/// Windows only: argument that passes one command string to the shell binary.
260/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
261fn 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
286/// Returns a short, human-readable shell name (e.g. "bash", "zsh", "powershell", "cmd").
287pub 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
300/// Wrap a command to disable zsh `nomatch` and `equals` (GH #1439, #1448).
301/// - `nomatch`: zsh aborts when unquoted globs match no files; POSIX passes
302///   the word through unchanged. Agents emit `--include=*.go` etc.
303/// - `equals`: zsh expands `=foo` to the path of the `foo` binary, so
304///   `echo ===` fails with "= not found". Agents use `===` as separators.
305pub 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        // #451: `$SHELL` is the user's *interactive* shell preference, not the
333        // agent's execution shell. Agents emit bash/POSIX command syntax, so an
334        // interactive-only shell like Nushell or Fish silently mis-runs it.
335        // Honor `$SHELL` only when it is POSIX-compatible; otherwise fall back
336        // to a real POSIX shell (deterministic, agent-trained). Set
337        // `LEAN_CTX_SHELL` to force a specific shell regardless of this gate.
338        if shell_acceptable_for_exec(&shell) {
339            return shell;
340        }
341        return find_real_shell();
342    }
343
344    find_real_shell()
345}
346
347/// Whether a `$SHELL` value may be auto-selected as the command-execution shell.
348///
349/// On Unix this rejects non-POSIX interactive shells (Nushell, Fish, Elvish,
350/// xonsh, PowerShell) so lean-ctx never feeds them bash/POSIX syntax (#451). On
351/// Windows the intended shells *are* PowerShell/cmd, so any `$SHELL` is honored
352/// (the POSIX gate would wrongly reject them).
353#[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/// `true` if `shell`'s basename is a POSIX-compatible shell that can run
364/// agent-authored bash/POSIX commands. Excludes interactive-only shells
365/// (`nu`, `fish`, `elvish`, `xonsh`) and non-POSIX shells (`pwsh`,
366/// `powershell`, `cmd`) where that syntax fails (#451).
367#[cfg(unix)]
368fn is_posix_compatible_shell(shell: &str) -> bool {
369    let name = std::path::Path::new(shell)
370        .file_name()
371        .and_then(|n| n.to_str())
372        .unwrap_or("")
373        .to_ascii_lowercase();
374    let name = name.strip_suffix(".exe").unwrap_or(&name);
375    matches!(
376        name,
377        "bash" | "zsh" | "sh" | "dash" | "ash" | "ksh" | "ksh93" | "mksh" | "busybox"
378    )
379}
380
381#[cfg(unix)]
382fn find_real_shell() -> String {
383    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
384        if std::path::Path::new(shell).exists() {
385            return shell.to_string();
386        }
387    }
388    "/bin/sh".to_string()
389}
390
391#[cfg(windows)]
392fn find_real_shell() -> String {
393    if is_running_in_msys_or_gitbash() {
394        for candidate in &["bash.exe", "sh.exe"] {
395            if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
396                if output.status.success() {
397                    if let Ok(path) = String::from_utf8(output.stdout) {
398                        if let Some(first_line) = path.lines().next() {
399                            let trimmed = first_line.trim();
400                            if !trimmed.is_empty() {
401                                return trimmed.to_string();
402                            }
403                        }
404                    }
405                }
406            }
407        }
408    }
409    if let Ok(pwsh) = which_powershell() {
410        return pwsh;
411    }
412    if let Ok(comspec) = std::env::var("COMSPEC") {
413        return comspec;
414    }
415    "cmd.exe".to_string()
416}
417
418#[cfg(windows)]
419fn is_running_in_msys_or_gitbash() -> bool {
420    std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
421}
422
423#[cfg(windows)]
424fn which_powershell() -> Result<String, ()> {
425    for candidate in &["pwsh.exe", "powershell.exe"] {
426        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
427            if output.status.success() {
428                if let Ok(path) = String::from_utf8(output.stdout) {
429                    if let Some(first_line) = path.lines().next() {
430                        let trimmed = first_line.trim();
431                        if !trimmed.is_empty() {
432                            return Ok(trimmed.to_string());
433                        }
434                    }
435                }
436            }
437        }
438    }
439    Err(())
440}
441
442/// Join multiple CLI arguments into a single command string, using quoting
443/// conventions appropriate for the detected shell.
444///
445/// On Unix, this always produces POSIX-compatible quoting.
446/// On Windows, the quoting adapts to the actual shell (PowerShell, cmd.exe,
447/// or Git Bash / MSYS).
448pub fn join_command(args: &[String]) -> String {
449    let (_, flag) = shell_and_flag();
450    join_command_for(args, &flag)
451}
452
453pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
454    match shell_flag {
455        "-Command" => join_powershell(args),
456        "/C" => join_cmd(args),
457        _ => join_posix(args),
458    }
459}
460
461fn join_posix(args: &[String]) -> String {
462    args.iter()
463        .map(|a| quote_posix(a))
464        .collect::<Vec<_>>()
465        .join(" ")
466}
467
468fn join_powershell(args: &[String]) -> String {
469    if args.len() == 1 && args[0].contains(' ') {
470        return args[0].clone();
471    }
472    let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
473    format!("& {}", quoted.join(" "))
474}
475
476fn join_cmd(args: &[String]) -> String {
477    args.iter()
478        .map(|a| quote_cmd(a))
479        .collect::<Vec<_>>()
480        .join(" ")
481}
482
483fn quote_posix(s: &str) -> String {
484    if s.is_empty() {
485        return "''".to_string();
486    }
487    if s.bytes()
488        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
489    {
490        return s.to_string();
491    }
492    format!("'{}'", s.replace('\'', "'\\''"))
493}
494
495fn quote_powershell(s: &str) -> String {
496    if s.is_empty() {
497        return "''".to_string();
498    }
499    // Unlike POSIX, `@` (splatting, `@(…)`) and `,` (array operator) are
500    // syntax in PowerShell, so they are not safe bare (#1848).
501    if s.bytes()
502        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:+%^".contains(&b))
503    {
504        return s.to_string();
505    }
506    format!("'{}'", s.replace('\'', "''"))
507}
508
509fn quote_cmd(s: &str) -> String {
510    if s.is_empty() {
511        return "\"\"".to_string();
512    }
513    if s.bytes()
514        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
515    {
516        return s.to_string();
517    }
518    format!("\"{}\"", s.replace('"', "\\\""))
519}
520
521#[cfg(test)]
522mod join_command_tests {
523    use super::*;
524
525    #[test]
526    fn posix_simple_args() {
527        let args: Vec<String> = vec!["git".into(), "status".into()];
528        assert_eq!(join_command_for(&args, "-c"), "git status");
529    }
530
531    #[test]
532    fn posix_path_with_spaces() {
533        let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
534        assert_eq!(
535            join_command_for(&args, "-c"),
536            "'/usr/local/my app/bin' --help"
537        );
538    }
539
540    #[test]
541    fn posix_single_quotes_escaped() {
542        let args: Vec<String> = vec!["echo".into(), "it's".into()];
543        assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
544    }
545
546    #[test]
547    fn posix_empty_arg() {
548        let args: Vec<String> = vec!["cmd".into(), String::new()];
549        assert_eq!(join_command_for(&args, "-c"), "cmd ''");
550    }
551
552    #[test]
553    fn powershell_simple_args() {
554        let args: Vec<String> = vec!["npm".into(), "install".into()];
555        assert_eq!(join_command_for(&args, "-Command"), "& npm install");
556    }
557
558    #[test]
559    fn powershell_path_with_spaces() {
560        let args: Vec<String> = vec![
561            "C:\\Program Files\\nodejs\\npm.cmd".into(),
562            "install".into(),
563        ];
564        assert_eq!(
565            join_command_for(&args, "-Command"),
566            "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
567        );
568    }
569
570    #[test]
571    fn powershell_single_quotes_escaped() {
572        let args: Vec<String> = vec!["echo".into(), "it's done".into()];
573        assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
574    }
575
576    #[test]
577    fn powershell_quotes_splat_and_array_syntax() {
578        let args: Vec<String> = vec!["echo".into(), "@args".into(), "a,b".into()];
579        assert_eq!(join_command_for(&args, "-Command"), "& echo '@args' 'a,b'");
580    }
581
582    #[test]
583    fn cmd_simple_args() {
584        let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
585        assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
586    }
587
588    #[test]
589    fn cmd_path_with_spaces() {
590        let args: Vec<String> = vec![
591            "C:\\Program Files\\nodejs\\npm.cmd".into(),
592            "install".into(),
593        ];
594        assert_eq!(
595            join_command_for(&args, "/C"),
596            "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
597        );
598    }
599
600    #[test]
601    fn cmd_double_quotes_escaped() {
602        let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
603        assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
604    }
605
606    #[test]
607    fn unknown_flag_uses_posix() {
608        let args: Vec<String> = vec!["ls".into(), "-la".into()];
609        assert_eq!(join_command_for(&args, "--exec"), "ls -la");
610    }
611
612    #[test]
613    fn powershell_single_full_command_not_quoted() {
614        let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
615        let result = join_command_for(&args, "-Command");
616        assert_eq!(result, "git commit -m \"feat: add feature\"");
617        assert!(
618            !result.starts_with("& '"),
619            "must not wrap full command in & '...'"
620        );
621    }
622
623    #[test]
624    fn powershell_single_no_spaces_still_uses_call_operator() {
625        let args: Vec<String> = vec!["git".into()];
626        assert_eq!(join_command_for(&args, "-Command"), "& git");
627    }
628}
629
630#[cfg(test)]
631mod is_powershell_tests {
632    use super::is_powershell;
633
634    #[test]
635    fn detects_pwsh_exe() {
636        assert!(is_powershell("pwsh.exe"));
637    }
638
639    #[test]
640    fn detects_powershell_exe() {
641        assert!(is_powershell("powershell.exe"));
642    }
643
644    #[test]
645    fn rejects_cmd() {
646        assert!(!is_powershell("cmd.exe"));
647    }
648
649    #[test]
650    fn rejects_bash() {
651        assert!(!is_powershell("/usr/bin/bash"));
652    }
653
654    #[test]
655    fn case_insensitive() {
656        assert!(is_powershell("PWSH.EXE"));
657        assert!(is_powershell("PowerShell.exe"));
658    }
659
660    #[test]
661    fn full_path_with_pwsh() {
662        assert!(is_powershell(
663            "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
664        ));
665        assert!(is_powershell("/usr/local/bin/pwsh"));
666    }
667}
668
669#[cfg(test)]
670mod powershell_profile_tests {
671    use super::{powershell_profile_path, resolve_powershell_profile_path};
672    use std::path::Path;
673
674    #[test]
675    fn always_ends_with_profile_file() {
676        let p = powershell_profile_path(Path::new("/home/u"));
677        assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
678    }
679
680    #[cfg(not(windows))]
681    #[test]
682    fn non_windows_uses_config_powershell_never_documents() {
683        // #356: stat-ing anything under ~/Documents pops a macOS TCC prompt, so the
684        // non-Windows profile path must live under ~/.config/powershell instead.
685        let p = powershell_profile_path(Path::new("/Users/jane"));
686        assert_eq!(
687            p,
688            Path::new("/Users/jane/.config/powershell/Microsoft.PowerShell_profile.ps1")
689        );
690        assert!(
691            !p.to_string_lossy().contains("Documents"),
692            "macOS/Linux PowerShell profile must never touch ~/Documents (#356)"
693        );
694    }
695
696    #[cfg(windows)]
697    #[test]
698    fn windows_uses_documents_powershell() {
699        let p = powershell_profile_path(Path::new("C:\\Users\\jane"));
700        assert!(p.ends_with("Documents\\PowerShell\\Microsoft.PowerShell_profile.ps1"));
701    }
702
703    #[cfg(not(windows))]
704    #[test]
705    fn resolver_matches_static_default_on_non_windows() {
706        // Non-Windows never spawns a process: the resolver returns the static config
707        // path verbatim (and must never touch ~/Documents, #356).
708        let home = Path::new("/Users/jane");
709        assert_eq!(
710            resolve_powershell_profile_path(home),
711            powershell_profile_path(home)
712        );
713    }
714
715    #[cfg(windows)]
716    #[test]
717    fn resolver_returns_absolute_profile_on_windows() {
718        // On Windows the resolver asks PowerShell for the live $PROFILE (OneDrive-safe,
719        // #558); whether it queries successfully or falls back, the result is an
720        // absolute path to the profile file.
721        let p = resolve_powershell_profile_path(Path::new("C:\\Users\\jane"));
722        assert!(p.is_absolute(), "resolved profile must be absolute: {p:?}");
723        assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
724    }
725}
726
727#[cfg(test)]
728mod windows_shell_flag_tests {
729    use super::windows_shell_flag_for_exe_basename;
730
731    #[test]
732    fn cmd_uses_slash_c() {
733        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
734        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
735    }
736
737    #[test]
738    fn powershell_uses_command() {
739        assert_eq!(
740            windows_shell_flag_for_exe_basename("powershell.exe"),
741            "-Command"
742        );
743        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
744    }
745
746    #[test]
747    fn posix_shells_use_dash_c() {
748        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
749        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
750        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
751        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
752        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
753    }
754}
755
756#[cfg(test)]
757mod platform_tests {
758    #[test]
759    fn is_container_returns_bool() {
760        let _ = super::is_container();
761    }
762
763    #[test]
764    fn is_non_interactive_returns_bool() {
765        let _ = super::is_non_interactive();
766    }
767
768    #[test]
769    fn join_command_preserves_structure() {
770        let args = vec![
771            "git".to_string(),
772            "commit".to_string(),
773            "-m".to_string(),
774            "my message".to_string(),
775        ];
776        let joined = super::join_command(&args);
777        assert!(joined.contains("git"));
778        assert!(joined.contains("commit"));
779        assert!(joined.contains("my message") || joined.contains("'my message'"));
780    }
781
782    #[test]
783    fn quote_posix_handles_em_dash() {
784        let result = super::quote_posix("closing — see #407");
785        assert!(
786            result.starts_with('\''),
787            "em-dash args must be single-quoted: {result}"
788        );
789    }
790
791    #[test]
792    fn quote_posix_handles_nested_single_quotes() {
793        let result = super::quote_posix("it's a test");
794        assert!(
795            result.contains("\\'"),
796            "single quotes must be escaped: {result}"
797        );
798    }
799
800    #[test]
801    fn quote_posix_safe_chars_unquoted() {
802        let result = super::quote_posix("simple_word");
803        assert_eq!(result, "simple_word");
804    }
805
806    #[test]
807    fn quote_posix_empty_string() {
808        let result = super::quote_posix("");
809        assert_eq!(result, "''");
810    }
811
812    #[test]
813    fn quote_posix_dollar_expansion_protected() {
814        let result = super::quote_posix("$HOME/test");
815        assert!(
816            result.starts_with('\''),
817            "dollar signs must be single-quoted: {result}"
818        );
819    }
820
821    #[test]
822    fn quote_posix_backtick_protected() {
823        let result = super::quote_posix("echo `date`");
824        assert!(
825            result.starts_with('\''),
826            "backticks must be single-quoted: {result}"
827        );
828    }
829
830    #[test]
831    fn quote_posix_double_quotes_protected() {
832        let result = super::quote_posix(r#"he said "hello""#);
833        assert!(
834            result.starts_with('\''),
835            "double quotes must be single-quoted: {result}"
836        );
837    }
838
839    // #451: a non-interactive `bash -c` sources $BASH_ENV. lean-ctx must run
840    // profile-free, so `apply_profile_free_env` has to neutralize an inherited
841    // BASH_ENV before it can pull in a contaminating startup file.
842    #[cfg(unix)]
843    #[test]
844    fn profile_free_env_blocks_bash_env_contamination() {
845        let Some(bash) = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
846            .into_iter()
847            .find(|p| std::path::Path::new(p).exists())
848        else {
849            return; // no bash on this host → nothing to guard against
850        };
851
852        let startup = std::env::temp_dir().join(format!(
853            "lean_ctx_bashenv_{}_{}.sh",
854            std::process::id(),
855            "guard"
856        ));
857        std::fs::write(&startup, "echo CONTAMINATED\n").expect("write startup file");
858
859        let mut cmd = std::process::Command::new(bash);
860        cmd.arg("-c").arg("echo clean").env("BASH_ENV", &startup);
861        super::apply_profile_free_env(&mut cmd);
862        let out = cmd.output().expect("run bash");
863        let stdout = String::from_utf8_lossy(&out.stdout);
864
865        let _ = std::fs::remove_file(&startup);
866
867        assert!(stdout.contains("clean"), "command output missing: {stdout}");
868        assert!(
869            !stdout.contains("CONTAMINATED"),
870            "apply_profile_free_env must neutralize BASH_ENV, got: {stdout}"
871        );
872    }
873}
874
875#[cfg(all(test, unix))]
876mod posix_shell_gate_tests {
877    use super::{detect_shell, is_posix_compatible_shell};
878
879    #[test]
880    fn accepts_posix_shells() {
881        for s in [
882            "/bin/bash",
883            "/bin/zsh",
884            "/bin/sh",
885            "/usr/bin/dash",
886            "/bin/ash",
887            "/usr/bin/ksh",
888            "/usr/bin/mksh",
889            "bash",
890            "zsh",
891        ] {
892            assert!(is_posix_compatible_shell(s), "{s} must be POSIX-compatible");
893        }
894    }
895
896    #[test]
897    fn rejects_interactive_and_nonposix_shells() {
898        // #451: agent bash/POSIX syntax fails in these — never auto-select them.
899        for s in [
900            "/usr/bin/nu",
901            "/opt/homebrew/bin/nu",
902            "/usr/bin/fish",
903            "/usr/local/bin/elvish",
904            "/usr/bin/xonsh",
905            "/usr/bin/pwsh",
906            "powershell.exe",
907            "cmd.exe",
908        ] {
909            assert!(
910                !is_posix_compatible_shell(s),
911                "{s} must be rejected by the POSIX gate"
912            );
913        }
914    }
915
916    #[test]
917    #[cfg_attr(miri, ignore)]
918    fn detect_shell_falls_back_when_shell_is_nushell() {
919        // Serialize env mutation (set_var/remove_var are process-global).
920        let _lock = crate::core::data_dir::test_env_lock();
921        let saved_shell = std::env::var_os("SHELL");
922        let saved_override = std::env::var_os("LEAN_CTX_SHELL");
923
924        crate::test_env::remove_var("LEAN_CTX_SHELL");
925        crate::test_env::set_var("SHELL", "/usr/bin/nu");
926        let resolved = detect_shell();
927        assert!(
928            is_posix_compatible_shell(&resolved),
929            "a non-POSIX $SHELL (nu) must resolve to a POSIX shell, got {resolved}"
930        );
931        assert!(
932            !resolved.ends_with("/nu") && resolved != "/usr/bin/nu",
933            "must not run agent commands in Nushell, got {resolved}"
934        );
935
936        // A POSIX $SHELL is honored verbatim.
937        crate::test_env::set_var("SHELL", "/bin/sh");
938        assert_eq!(detect_shell(), "/bin/sh");
939
940        // LEAN_CTX_SHELL always wins, even when pointing at a non-POSIX shell.
941        crate::test_env::set_var("LEAN_CTX_SHELL", "/usr/bin/nu");
942        assert_eq!(detect_shell(), "/usr/bin/nu");
943
944        match saved_shell {
945            Some(v) => crate::test_env::set_var("SHELL", v),
946            None => crate::test_env::remove_var("SHELL"),
947        }
948        match saved_override {
949            Some(v) => crate::test_env::set_var("LEAN_CTX_SHELL", v),
950            None => crate::test_env::remove_var("LEAN_CTX_SHELL"),
951        }
952    }
953}
954
955#[cfg(test)]
956mod carriage_return_tests {
957    use super::resolve_carriage_returns;
958
959    #[test]
960    fn no_cr_passthrough() {
961        assert_eq!(resolve_carriage_returns("hello\nworld\n"), "hello\nworld\n");
962    }
963
964    #[test]
965    fn progress_overwrite_keeps_last_segment() {
966        assert_eq!(resolve_carriage_returns("first\rsecond"), "second");
967    }
968
969    #[test]
970    fn git_rebase_progress() {
971        let input = "Rebasing (1/1)\rSuccessfully rebased and updated refs/heads/feat.";
972        assert_eq!(
973            resolve_carriage_returns(input),
974            "Successfully rebased and updated refs/heads/feat."
975        );
976    }
977
978    #[test]
979    fn crlf_line_endings_stripped() {
980        assert_eq!(
981            resolve_carriage_returns("line1\r\nline2\r\n"),
982            "line1\nline2\n"
983        );
984    }
985
986    #[test]
987    fn multiple_cr_on_one_line() {
988        assert_eq!(resolve_carriage_returns("a\rb\rc"), "c");
989    }
990
991    #[test]
992    fn mixed_lines() {
993        let input = "clean line\nprogress\rdone\nalso clean\n";
994        assert_eq!(
995            resolve_carriage_returns(input),
996            "clean line\ndone\nalso clean\n"
997        );
998    }
999
1000    #[test]
1001    fn trailing_cr_stripped_as_crlf() {
1002        assert_eq!(resolve_carriage_returns("visible\r"), "visible");
1003    }
1004
1005    #[test]
1006    fn empty_string_passthrough() {
1007        assert_eq!(resolve_carriage_returns(""), "");
1008    }
1009}
1010
1011#[cfg(test)]
1012mod zsh_safe_tests {
1013    use super::zsh_safe_command;
1014
1015    #[test]
1016    #[cfg(unix)]
1017    fn prepends_nonomatch_noequals_for_zsh() {
1018        let cmd = zsh_safe_command("grep --include=*.go pattern", "/bin/zsh");
1019        assert_eq!(
1020            cmd,
1021            "setopt nonomatch noequals; grep --include=*.go pattern"
1022        );
1023    }
1024
1025    #[test]
1026    #[cfg(unix)]
1027    fn prepends_nonomatch_noequals_for_usr_bin_zsh() {
1028        let cmd = zsh_safe_command("echo *.rs", "/usr/bin/zsh");
1029        assert_eq!(cmd, "setopt nonomatch noequals; echo *.rs");
1030    }
1031
1032    #[test]
1033    #[cfg(unix)]
1034    fn equals_separator_safe_under_zsh() {
1035        let cmd = zsh_safe_command("echo ===", "/bin/zsh");
1036        assert!(cmd.starts_with("setopt nonomatch noequals;"));
1037        assert!(cmd.contains("echo ==="));
1038    }
1039
1040    #[test]
1041    fn passthrough_for_bash() {
1042        let cmd = zsh_safe_command("grep --include=*.go pattern", "/bin/bash");
1043        assert_eq!(cmd, "grep --include=*.go pattern");
1044    }
1045
1046    #[test]
1047    fn passthrough_for_sh() {
1048        let cmd = zsh_safe_command("find -name *.ts", "/bin/sh");
1049        assert_eq!(cmd, "find -name *.ts");
1050    }
1051}