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