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