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