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