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
248fn 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    let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
265    format!("& {}", quoted.join(" "))
266}
267
268fn join_cmd(args: &[String]) -> String {
269    args.iter()
270        .map(|a| quote_cmd(a))
271        .collect::<Vec<_>>()
272        .join(" ")
273}
274
275fn quote_posix(s: &str) -> String {
276    if s.is_empty() {
277        return "''".to_string();
278    }
279    if s.bytes()
280        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
281    {
282        return s.to_string();
283    }
284    format!("'{}'", s.replace('\'', "'\\''"))
285}
286
287fn quote_powershell(s: &str) -> String {
288    if s.is_empty() {
289        return "''".to_string();
290    }
291    if s.bytes()
292        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
293    {
294        return s.to_string();
295    }
296    format!("'{}'", s.replace('\'', "''"))
297}
298
299fn quote_cmd(s: &str) -> String {
300    if s.is_empty() {
301        return "\"\"".to_string();
302    }
303    if s.bytes()
304        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
305    {
306        return s.to_string();
307    }
308    format!("\"{}\"", s.replace('"', "\\\""))
309}
310
311#[cfg(test)]
312mod join_command_tests {
313    use super::*;
314
315    #[test]
316    fn posix_simple_args() {
317        let args: Vec<String> = vec!["git".into(), "status".into()];
318        assert_eq!(join_command_for(&args, "-c"), "git status");
319    }
320
321    #[test]
322    fn posix_path_with_spaces() {
323        let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
324        assert_eq!(
325            join_command_for(&args, "-c"),
326            "'/usr/local/my app/bin' --help"
327        );
328    }
329
330    #[test]
331    fn posix_single_quotes_escaped() {
332        let args: Vec<String> = vec!["echo".into(), "it's".into()];
333        assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
334    }
335
336    #[test]
337    fn posix_empty_arg() {
338        let args: Vec<String> = vec!["cmd".into(), String::new()];
339        assert_eq!(join_command_for(&args, "-c"), "cmd ''");
340    }
341
342    #[test]
343    fn powershell_simple_args() {
344        let args: Vec<String> = vec!["npm".into(), "install".into()];
345        assert_eq!(join_command_for(&args, "-Command"), "& npm install");
346    }
347
348    #[test]
349    fn powershell_path_with_spaces() {
350        let args: Vec<String> = vec![
351            "C:\\Program Files\\nodejs\\npm.cmd".into(),
352            "install".into(),
353        ];
354        assert_eq!(
355            join_command_for(&args, "-Command"),
356            "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
357        );
358    }
359
360    #[test]
361    fn powershell_single_quotes_escaped() {
362        let args: Vec<String> = vec!["echo".into(), "it's done".into()];
363        assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
364    }
365
366    #[test]
367    fn cmd_simple_args() {
368        let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
369        assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
370    }
371
372    #[test]
373    fn cmd_path_with_spaces() {
374        let args: Vec<String> = vec![
375            "C:\\Program Files\\nodejs\\npm.cmd".into(),
376            "install".into(),
377        ];
378        assert_eq!(
379            join_command_for(&args, "/C"),
380            "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
381        );
382    }
383
384    #[test]
385    fn cmd_double_quotes_escaped() {
386        let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
387        assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
388    }
389
390    #[test]
391    fn unknown_flag_uses_posix() {
392        let args: Vec<String> = vec!["ls".into(), "-la".into()];
393        assert_eq!(join_command_for(&args, "--exec"), "ls -la");
394    }
395}
396
397#[cfg(test)]
398mod windows_shell_flag_tests {
399    use super::windows_shell_flag_for_exe_basename;
400
401    #[test]
402    fn cmd_uses_slash_c() {
403        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
404        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
405    }
406
407    #[test]
408    fn powershell_uses_command() {
409        assert_eq!(
410            windows_shell_flag_for_exe_basename("powershell.exe"),
411            "-Command"
412        );
413        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
414    }
415
416    #[test]
417    fn posix_shells_use_dash_c() {
418        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
419        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
420        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
421        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
422        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
423    }
424}
425
426#[cfg(test)]
427mod platform_tests {
428    #[test]
429    fn is_container_returns_bool() {
430        let _ = super::is_container();
431    }
432
433    #[test]
434    fn is_non_interactive_returns_bool() {
435        let _ = super::is_non_interactive();
436    }
437
438    #[test]
439    fn join_command_preserves_structure() {
440        let args = vec![
441            "git".to_string(),
442            "commit".to_string(),
443            "-m".to_string(),
444            "my message".to_string(),
445        ];
446        let joined = super::join_command(&args);
447        assert!(joined.contains("git"));
448        assert!(joined.contains("commit"));
449        assert!(joined.contains("my message") || joined.contains("'my message'"));
450    }
451
452    #[test]
453    fn quote_posix_handles_em_dash() {
454        let result = super::quote_posix("closing — see #407");
455        assert!(
456            result.starts_with('\''),
457            "em-dash args must be single-quoted: {result}"
458        );
459    }
460
461    #[test]
462    fn quote_posix_handles_nested_single_quotes() {
463        let result = super::quote_posix("it's a test");
464        assert!(
465            result.contains("\\'"),
466            "single quotes must be escaped: {result}"
467        );
468    }
469
470    #[test]
471    fn quote_posix_safe_chars_unquoted() {
472        let result = super::quote_posix("simple_word");
473        assert_eq!(result, "simple_word");
474    }
475
476    #[test]
477    fn quote_posix_empty_string() {
478        let result = super::quote_posix("");
479        assert_eq!(result, "''");
480    }
481
482    #[test]
483    fn quote_posix_dollar_expansion_protected() {
484        let result = super::quote_posix("$HOME/test");
485        assert!(
486            result.starts_with('\''),
487            "dollar signs must be single-quoted: {result}"
488        );
489    }
490
491    #[test]
492    fn quote_posix_backtick_protected() {
493        let result = super::quote_posix("echo `date`");
494        assert!(
495            result.starts_with('\''),
496            "backticks must be single-quoted: {result}"
497        );
498    }
499
500    #[test]
501    fn quote_posix_double_quotes_protected() {
502        let result = super::quote_posix(r#"he said "hello""#);
503        assert!(
504            result.starts_with('\''),
505            "double quotes must be single-quoted: {result}"
506        );
507    }
508}