Skip to main content

lean_ctx/shell/
platform.rs

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