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
17pub fn decode_output(bytes: &[u8]) -> String {
18    match String::from_utf8(bytes.to_vec()) {
19        Ok(s) => s,
20        Err(_) => {
21            #[cfg(windows)]
22            {
23                decode_windows_output(bytes)
24            }
25            #[cfg(not(windows))]
26            {
27                String::from_utf8_lossy(bytes).into_owned()
28            }
29        }
30    }
31}
32
33#[cfg(windows)]
34fn decode_windows_output(bytes: &[u8]) -> String {
35    use std::os::windows::ffi::OsStringExt;
36
37    let lossy = String::from_utf8_lossy(bytes);
38    let replacement_count = lossy.chars().filter(|&c| c == '\u{FFFD}').count();
39    if replacement_count == 0 {
40        return lossy.into_owned();
41    }
42
43    // SAFETY: declares Win32 API symbols that exist in kernel32; signatures
44    // match the documented ABI.
45    unsafe extern "system" {
46        fn GetACP() -> u32;
47        fn MultiByteToWideChar(
48            cp: u32,
49            flags: u32,
50            src: *const u8,
51            srclen: i32,
52            dst: *mut u16,
53            dstlen: i32,
54        ) -> i32;
55    }
56
57    // SAFETY: `GetACP` takes no arguments and only returns the active code
58    // page; it cannot fail or cause undefined behaviour.
59    let codepage = unsafe { GetACP() };
60    // SAFETY: called with a null destination and length 0 to measure the
61    // required buffer size; `bytes` is a live slice and every pointer/length
62    // argument is valid.
63    let wide_len = unsafe {
64        MultiByteToWideChar(
65            codepage,
66            0,
67            bytes.as_ptr(),
68            bytes.len() as i32,
69            std::ptr::null_mut(),
70            0,
71        )
72    };
73    if wide_len <= 0 {
74        return lossy.into_owned();
75    }
76    let mut wide: Vec<u16> = vec![0u16; wide_len as usize];
77    // SAFETY: `wide` is sized to the previously measured length and `bytes` is
78    // a live slice; the source and destination pointers/lengths are valid and
79    // do not overlap.
80    unsafe {
81        MultiByteToWideChar(
82            codepage,
83            0,
84            bytes.as_ptr(),
85            bytes.len() as i32,
86            wide.as_mut_ptr(),
87            wide_len,
88        );
89    }
90    std::ffi::OsString::from_wide(&wide)
91        .to_string_lossy()
92        .into_owned()
93}
94
95#[cfg(windows)]
96pub(super) fn set_console_utf8() {
97    // SAFETY: declares a Win32 API symbol that exists in kernel32; the
98    // signature matches the documented ABI.
99    unsafe extern "system" {
100        fn SetConsoleOutputCP(id: u32) -> i32;
101    }
102    // SAFETY: `SetConsoleOutputCP` takes a code-page id (65001 = UTF-8) by
103    // value; it cannot cause undefined behaviour.
104    unsafe {
105        SetConsoleOutputCP(65001);
106    }
107}
108
109/// Detects if the current process runs inside a Docker/container environment.
110pub fn is_container() -> bool {
111    #[cfg(unix)]
112    {
113        if std::path::Path::new("/.dockerenv").exists() {
114            return true;
115        }
116        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
117            && (cgroup.contains("/docker/") || cgroup.contains("/lxc/"))
118        {
119            return true;
120        }
121        if let Ok(mounts) = std::fs::read_to_string("/proc/self/mountinfo")
122            && mounts.contains("/docker/containers/")
123        {
124            return true;
125        }
126        false
127    }
128    #[cfg(not(unix))]
129    {
130        false
131    }
132}
133
134/// Returns true if stdin is NOT a terminal (pipe, /dev/null, etc.)
135pub fn is_non_interactive() -> bool {
136    !io::stdin().is_terminal()
137}
138
139/// Returns `true` when `shell_path` points to a PowerShell executable.
140pub(crate) fn is_powershell(shell_path: &str) -> bool {
141    let name = std::path::Path::new(shell_path)
142        .file_name()
143        .and_then(|n| n.to_str())
144        .unwrap_or("")
145        .to_ascii_lowercase();
146    name.contains("powershell") || name.contains("pwsh")
147}
148
149/// Path to the current-user PowerShell profile (`$PROFILE.CurrentUserCurrentHost`).
150///
151/// Windows PowerShell stores it under `Documents\PowerShell\…`, but **PowerShell
152/// (pwsh) on macOS/Linux reads `~/.config/powershell/…` instead** — and stat-ing
153/// anything inside `~/Documents` on macOS pops a TCC privacy prompt ("lean-ctx
154/// would like to access files in your Documents folder", #356). Resolving the
155/// profile per-OS keeps pwsh support everywhere while never touching `~/Documents`
156/// on non-Windows hosts.
157pub(crate) fn powershell_profile_path(home: &std::path::Path) -> std::path::PathBuf {
158    const PROFILE_FILE: &str = "Microsoft.PowerShell_profile.ps1";
159    if cfg!(windows) {
160        home.join("Documents").join("PowerShell").join(PROFILE_FILE)
161    } else {
162        home.join(".config").join("powershell").join(PROFILE_FILE)
163    }
164}
165
166/// Windows only: argument that passes one command string to the shell binary.
167/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
168fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
169    if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
170        "-Command"
171    } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
172        "/C"
173    } else {
174        "-c"
175    }
176}
177
178pub fn shell_and_flag() -> (String, String) {
179    let shell = detect_shell();
180    let flag = if cfg!(windows) {
181        let name = std::path::Path::new(&shell)
182            .file_name()
183            .and_then(|n| n.to_str())
184            .unwrap_or("")
185            .to_ascii_lowercase();
186        windows_shell_flag_for_exe_basename(&name).to_string()
187    } else {
188        "-c".to_string()
189    };
190    (shell, flag)
191}
192
193/// Returns a short, human-readable shell name (e.g. "bash", "zsh", "powershell", "cmd").
194pub fn shell_name() -> String {
195    let shell = detect_shell();
196    let basename = std::path::Path::new(&shell)
197        .file_name()
198        .and_then(|n| n.to_str())
199        .unwrap_or("sh")
200        .to_ascii_lowercase();
201    basename
202        .strip_suffix(".exe")
203        .unwrap_or(&basename)
204        .to_string()
205}
206
207pub(super) fn detect_shell() -> String {
208    if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
209        return shell;
210    }
211
212    if let Ok(shell) = std::env::var("SHELL") {
213        let bin = std::path::Path::new(&shell)
214            .file_name()
215            .and_then(|n| n.to_str())
216            .unwrap_or("sh");
217
218        if bin == "lean-ctx" {
219            return find_real_shell();
220        }
221        return shell;
222    }
223
224    find_real_shell()
225}
226
227#[cfg(unix)]
228fn find_real_shell() -> String {
229    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
230        if std::path::Path::new(shell).exists() {
231            return shell.to_string();
232        }
233    }
234    "/bin/sh".to_string()
235}
236
237#[cfg(windows)]
238fn find_real_shell() -> String {
239    if is_running_in_msys_or_gitbash() {
240        for candidate in &["bash.exe", "sh.exe"] {
241            if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
242                if output.status.success() {
243                    if let Ok(path) = String::from_utf8(output.stdout) {
244                        if let Some(first_line) = path.lines().next() {
245                            let trimmed = first_line.trim();
246                            if !trimmed.is_empty() {
247                                return trimmed.to_string();
248                            }
249                        }
250                    }
251                }
252            }
253        }
254    }
255    if let Ok(pwsh) = which_powershell() {
256        return pwsh;
257    }
258    if let Ok(comspec) = std::env::var("COMSPEC") {
259        return comspec;
260    }
261    "cmd.exe".to_string()
262}
263
264#[cfg(windows)]
265fn is_running_in_msys_or_gitbash() -> bool {
266    std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
267}
268
269#[cfg(windows)]
270fn which_powershell() -> Result<String, ()> {
271    for candidate in &["pwsh.exe", "powershell.exe"] {
272        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
273            if output.status.success() {
274                if let Ok(path) = String::from_utf8(output.stdout) {
275                    if let Some(first_line) = path.lines().next() {
276                        let trimmed = first_line.trim();
277                        if !trimmed.is_empty() {
278                            return Ok(trimmed.to_string());
279                        }
280                    }
281                }
282            }
283        }
284    }
285    Err(())
286}
287
288/// Join multiple CLI arguments into a single command string, using quoting
289/// conventions appropriate for the detected shell.
290///
291/// On Unix, this always produces POSIX-compatible quoting.
292/// On Windows, the quoting adapts to the actual shell (PowerShell, cmd.exe,
293/// or Git Bash / MSYS).
294pub fn join_command(args: &[String]) -> String {
295    let (_, flag) = shell_and_flag();
296    join_command_for(args, &flag)
297}
298
299pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
300    match shell_flag {
301        "-Command" => join_powershell(args),
302        "/C" => join_cmd(args),
303        _ => join_posix(args),
304    }
305}
306
307fn join_posix(args: &[String]) -> String {
308    args.iter()
309        .map(|a| quote_posix(a))
310        .collect::<Vec<_>>()
311        .join(" ")
312}
313
314fn join_powershell(args: &[String]) -> String {
315    if args.len() == 1 && args[0].contains(' ') {
316        return args[0].clone();
317    }
318    let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
319    format!("& {}", quoted.join(" "))
320}
321
322fn join_cmd(args: &[String]) -> String {
323    args.iter()
324        .map(|a| quote_cmd(a))
325        .collect::<Vec<_>>()
326        .join(" ")
327}
328
329fn quote_posix(s: &str) -> String {
330    if s.is_empty() {
331        return "''".to_string();
332    }
333    if s.bytes()
334        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
335    {
336        return s.to_string();
337    }
338    format!("'{}'", s.replace('\'', "'\\''"))
339}
340
341fn quote_powershell(s: &str) -> String {
342    if s.is_empty() {
343        return "''".to_string();
344    }
345    if s.bytes()
346        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
347    {
348        return s.to_string();
349    }
350    format!("'{}'", s.replace('\'', "''"))
351}
352
353fn quote_cmd(s: &str) -> String {
354    if s.is_empty() {
355        return "\"\"".to_string();
356    }
357    if s.bytes()
358        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
359    {
360        return s.to_string();
361    }
362    format!("\"{}\"", s.replace('"', "\\\""))
363}
364
365#[cfg(test)]
366mod join_command_tests {
367    use super::*;
368
369    #[test]
370    fn posix_simple_args() {
371        let args: Vec<String> = vec!["git".into(), "status".into()];
372        assert_eq!(join_command_for(&args, "-c"), "git status");
373    }
374
375    #[test]
376    fn posix_path_with_spaces() {
377        let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
378        assert_eq!(
379            join_command_for(&args, "-c"),
380            "'/usr/local/my app/bin' --help"
381        );
382    }
383
384    #[test]
385    fn posix_single_quotes_escaped() {
386        let args: Vec<String> = vec!["echo".into(), "it's".into()];
387        assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
388    }
389
390    #[test]
391    fn posix_empty_arg() {
392        let args: Vec<String> = vec!["cmd".into(), String::new()];
393        assert_eq!(join_command_for(&args, "-c"), "cmd ''");
394    }
395
396    #[test]
397    fn powershell_simple_args() {
398        let args: Vec<String> = vec!["npm".into(), "install".into()];
399        assert_eq!(join_command_for(&args, "-Command"), "& npm install");
400    }
401
402    #[test]
403    fn powershell_path_with_spaces() {
404        let args: Vec<String> = vec![
405            "C:\\Program Files\\nodejs\\npm.cmd".into(),
406            "install".into(),
407        ];
408        assert_eq!(
409            join_command_for(&args, "-Command"),
410            "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
411        );
412    }
413
414    #[test]
415    fn powershell_single_quotes_escaped() {
416        let args: Vec<String> = vec!["echo".into(), "it's done".into()];
417        assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
418    }
419
420    #[test]
421    fn cmd_simple_args() {
422        let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
423        assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
424    }
425
426    #[test]
427    fn cmd_path_with_spaces() {
428        let args: Vec<String> = vec![
429            "C:\\Program Files\\nodejs\\npm.cmd".into(),
430            "install".into(),
431        ];
432        assert_eq!(
433            join_command_for(&args, "/C"),
434            "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
435        );
436    }
437
438    #[test]
439    fn cmd_double_quotes_escaped() {
440        let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
441        assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
442    }
443
444    #[test]
445    fn unknown_flag_uses_posix() {
446        let args: Vec<String> = vec!["ls".into(), "-la".into()];
447        assert_eq!(join_command_for(&args, "--exec"), "ls -la");
448    }
449
450    #[test]
451    fn powershell_single_full_command_not_quoted() {
452        let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
453        let result = join_command_for(&args, "-Command");
454        assert_eq!(result, "git commit -m \"feat: add feature\"");
455        assert!(
456            !result.starts_with("& '"),
457            "must not wrap full command in & '...'"
458        );
459    }
460
461    #[test]
462    fn powershell_single_no_spaces_still_uses_call_operator() {
463        let args: Vec<String> = vec!["git".into()];
464        assert_eq!(join_command_for(&args, "-Command"), "& git");
465    }
466}
467
468#[cfg(test)]
469mod is_powershell_tests {
470    use super::is_powershell;
471
472    #[test]
473    fn detects_pwsh_exe() {
474        assert!(is_powershell("pwsh.exe"));
475    }
476
477    #[test]
478    fn detects_powershell_exe() {
479        assert!(is_powershell("powershell.exe"));
480    }
481
482    #[test]
483    fn rejects_cmd() {
484        assert!(!is_powershell("cmd.exe"));
485    }
486
487    #[test]
488    fn rejects_bash() {
489        assert!(!is_powershell("/usr/bin/bash"));
490    }
491
492    #[test]
493    fn case_insensitive() {
494        assert!(is_powershell("PWSH.EXE"));
495        assert!(is_powershell("PowerShell.exe"));
496    }
497
498    #[test]
499    fn full_path_with_pwsh() {
500        assert!(is_powershell(
501            "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
502        ));
503        assert!(is_powershell("/usr/local/bin/pwsh"));
504    }
505}
506
507#[cfg(test)]
508mod powershell_profile_tests {
509    use super::powershell_profile_path;
510    use std::path::Path;
511
512    #[test]
513    fn always_ends_with_profile_file() {
514        let p = powershell_profile_path(Path::new("/home/u"));
515        assert!(p.ends_with("Microsoft.PowerShell_profile.ps1"));
516    }
517
518    #[cfg(not(windows))]
519    #[test]
520    fn non_windows_uses_config_powershell_never_documents() {
521        // #356: stat-ing anything under ~/Documents pops a macOS TCC prompt, so the
522        // non-Windows profile path must live under ~/.config/powershell instead.
523        let p = powershell_profile_path(Path::new("/Users/jane"));
524        assert_eq!(
525            p,
526            Path::new("/Users/jane/.config/powershell/Microsoft.PowerShell_profile.ps1")
527        );
528        assert!(
529            !p.to_string_lossy().contains("Documents"),
530            "macOS/Linux PowerShell profile must never touch ~/Documents (#356)"
531        );
532    }
533
534    #[cfg(windows)]
535    #[test]
536    fn windows_uses_documents_powershell() {
537        let p = powershell_profile_path(Path::new("C:\\Users\\jane"));
538        assert!(p.ends_with("Documents\\PowerShell\\Microsoft.PowerShell_profile.ps1"));
539    }
540}
541
542#[cfg(test)]
543mod windows_shell_flag_tests {
544    use super::windows_shell_flag_for_exe_basename;
545
546    #[test]
547    fn cmd_uses_slash_c() {
548        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
549        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
550    }
551
552    #[test]
553    fn powershell_uses_command() {
554        assert_eq!(
555            windows_shell_flag_for_exe_basename("powershell.exe"),
556            "-Command"
557        );
558        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
559    }
560
561    #[test]
562    fn posix_shells_use_dash_c() {
563        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
564        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
565        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
566        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
567        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
568    }
569}
570
571#[cfg(test)]
572mod platform_tests {
573    #[test]
574    fn is_container_returns_bool() {
575        let _ = super::is_container();
576    }
577
578    #[test]
579    fn is_non_interactive_returns_bool() {
580        let _ = super::is_non_interactive();
581    }
582
583    #[test]
584    fn join_command_preserves_structure() {
585        let args = vec![
586            "git".to_string(),
587            "commit".to_string(),
588            "-m".to_string(),
589            "my message".to_string(),
590        ];
591        let joined = super::join_command(&args);
592        assert!(joined.contains("git"));
593        assert!(joined.contains("commit"));
594        assert!(joined.contains("my message") || joined.contains("'my message'"));
595    }
596
597    #[test]
598    fn quote_posix_handles_em_dash() {
599        let result = super::quote_posix("closing — see #407");
600        assert!(
601            result.starts_with('\''),
602            "em-dash args must be single-quoted: {result}"
603        );
604    }
605
606    #[test]
607    fn quote_posix_handles_nested_single_quotes() {
608        let result = super::quote_posix("it's a test");
609        assert!(
610            result.contains("\\'"),
611            "single quotes must be escaped: {result}"
612        );
613    }
614
615    #[test]
616    fn quote_posix_safe_chars_unquoted() {
617        let result = super::quote_posix("simple_word");
618        assert_eq!(result, "simple_word");
619    }
620
621    #[test]
622    fn quote_posix_empty_string() {
623        let result = super::quote_posix("");
624        assert_eq!(result, "''");
625    }
626
627    #[test]
628    fn quote_posix_dollar_expansion_protected() {
629        let result = super::quote_posix("$HOME/test");
630        assert!(
631            result.starts_with('\''),
632            "dollar signs must be single-quoted: {result}"
633        );
634    }
635
636    #[test]
637    fn quote_posix_backtick_protected() {
638        let result = super::quote_posix("echo `date`");
639        assert!(
640            result.starts_with('\''),
641            "backticks must be single-quoted: {result}"
642        );
643    }
644
645    #[test]
646    fn quote_posix_double_quotes_protected() {
647        let result = super::quote_posix(r#"he said "hello""#);
648        assert!(
649            result.starts_with('\''),
650            "double quotes must be single-quoted: {result}"
651        );
652    }
653}