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/// Windows only: argument that passes one command string to the shell binary.
112/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
113fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
114    if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
115        "-Command"
116    } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
117        "/C"
118    } else {
119        "-c"
120    }
121}
122
123pub fn shell_and_flag() -> (String, String) {
124    let shell = detect_shell();
125    let flag = if cfg!(windows) {
126        let name = std::path::Path::new(&shell)
127            .file_name()
128            .and_then(|n| n.to_str())
129            .unwrap_or("")
130            .to_ascii_lowercase();
131        windows_shell_flag_for_exe_basename(&name).to_string()
132    } else {
133        "-c".to_string()
134    };
135    (shell, flag)
136}
137
138/// Returns a short, human-readable shell name (e.g. "bash", "zsh", "powershell", "cmd").
139pub fn shell_name() -> String {
140    let shell = detect_shell();
141    let basename = std::path::Path::new(&shell)
142        .file_name()
143        .and_then(|n| n.to_str())
144        .unwrap_or("sh")
145        .to_ascii_lowercase();
146    basename
147        .strip_suffix(".exe")
148        .unwrap_or(&basename)
149        .to_string()
150}
151
152pub(super) fn detect_shell() -> String {
153    if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
154        return shell;
155    }
156
157    if let Ok(shell) = std::env::var("SHELL") {
158        let bin = std::path::Path::new(&shell)
159            .file_name()
160            .and_then(|n| n.to_str())
161            .unwrap_or("sh");
162
163        if bin == "lean-ctx" {
164            return find_real_shell();
165        }
166        return shell;
167    }
168
169    find_real_shell()
170}
171
172#[cfg(unix)]
173fn find_real_shell() -> String {
174    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
175        if std::path::Path::new(shell).exists() {
176            return shell.to_string();
177        }
178    }
179    "/bin/sh".to_string()
180}
181
182#[cfg(windows)]
183fn find_real_shell() -> String {
184    if is_running_in_msys_or_gitbash() {
185        for candidate in &["bash.exe", "sh.exe"] {
186            if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
187                if output.status.success() {
188                    if let Ok(path) = String::from_utf8(output.stdout) {
189                        if let Some(first_line) = path.lines().next() {
190                            let trimmed = first_line.trim();
191                            if !trimmed.is_empty() {
192                                return trimmed.to_string();
193                            }
194                        }
195                    }
196                }
197            }
198        }
199    }
200    if let Ok(pwsh) = which_powershell() {
201        return pwsh;
202    }
203    if let Ok(comspec) = std::env::var("COMSPEC") {
204        return comspec;
205    }
206    "cmd.exe".to_string()
207}
208
209#[cfg(windows)]
210fn is_running_in_msys_or_gitbash() -> bool {
211    std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
212}
213
214#[cfg(windows)]
215fn which_powershell() -> Result<String, ()> {
216    for candidate in &["pwsh.exe", "powershell.exe"] {
217        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
218            if output.status.success() {
219                if let Ok(path) = String::from_utf8(output.stdout) {
220                    if let Some(first_line) = path.lines().next() {
221                        let trimmed = first_line.trim();
222                        if !trimmed.is_empty() {
223                            return Ok(trimmed.to_string());
224                        }
225                    }
226                }
227            }
228        }
229    }
230    Err(())
231}
232
233/// Join multiple CLI arguments into a single command string, using quoting
234/// conventions appropriate for the detected shell.
235///
236/// On Unix, this always produces POSIX-compatible quoting.
237/// On Windows, the quoting adapts to the actual shell (PowerShell, cmd.exe,
238/// or Git Bash / MSYS).
239pub fn join_command(args: &[String]) -> String {
240    let (_, flag) = shell_and_flag();
241    join_command_for(args, &flag)
242}
243
244pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
245    match shell_flag {
246        "-Command" => join_powershell(args),
247        "/C" => join_cmd(args),
248        _ => join_posix(args),
249    }
250}
251
252fn join_posix(args: &[String]) -> String {
253    args.iter()
254        .map(|a| quote_posix(a))
255        .collect::<Vec<_>>()
256        .join(" ")
257}
258
259fn join_powershell(args: &[String]) -> String {
260    if args.len() == 1 && args[0].contains(' ') {
261        return args[0].clone();
262    }
263    let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
264    format!("& {}", quoted.join(" "))
265}
266
267fn join_cmd(args: &[String]) -> String {
268    args.iter()
269        .map(|a| quote_cmd(a))
270        .collect::<Vec<_>>()
271        .join(" ")
272}
273
274fn quote_posix(s: &str) -> String {
275    if s.is_empty() {
276        return "''".to_string();
277    }
278    if s.bytes()
279        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
280    {
281        return s.to_string();
282    }
283    format!("'{}'", s.replace('\'', "'\\''"))
284}
285
286fn quote_powershell(s: &str) -> String {
287    if s.is_empty() {
288        return "''".to_string();
289    }
290    if s.bytes()
291        .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
292    {
293        return s.to_string();
294    }
295    format!("'{}'", s.replace('\'', "''"))
296}
297
298fn quote_cmd(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
310#[cfg(test)]
311mod join_command_tests {
312    use super::*;
313
314    #[test]
315    fn posix_simple_args() {
316        let args: Vec<String> = vec!["git".into(), "status".into()];
317        assert_eq!(join_command_for(&args, "-c"), "git status");
318    }
319
320    #[test]
321    fn posix_path_with_spaces() {
322        let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
323        assert_eq!(
324            join_command_for(&args, "-c"),
325            "'/usr/local/my app/bin' --help"
326        );
327    }
328
329    #[test]
330    fn posix_single_quotes_escaped() {
331        let args: Vec<String> = vec!["echo".into(), "it's".into()];
332        assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
333    }
334
335    #[test]
336    fn posix_empty_arg() {
337        let args: Vec<String> = vec!["cmd".into(), String::new()];
338        assert_eq!(join_command_for(&args, "-c"), "cmd ''");
339    }
340
341    #[test]
342    fn powershell_simple_args() {
343        let args: Vec<String> = vec!["npm".into(), "install".into()];
344        assert_eq!(join_command_for(&args, "-Command"), "& npm install");
345    }
346
347    #[test]
348    fn powershell_path_with_spaces() {
349        let args: Vec<String> = vec![
350            "C:\\Program Files\\nodejs\\npm.cmd".into(),
351            "install".into(),
352        ];
353        assert_eq!(
354            join_command_for(&args, "-Command"),
355            "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
356        );
357    }
358
359    #[test]
360    fn powershell_single_quotes_escaped() {
361        let args: Vec<String> = vec!["echo".into(), "it's done".into()];
362        assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
363    }
364
365    #[test]
366    fn cmd_simple_args() {
367        let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
368        assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
369    }
370
371    #[test]
372    fn cmd_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, "/C"),
379            "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
380        );
381    }
382
383    #[test]
384    fn cmd_double_quotes_escaped() {
385        let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
386        assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
387    }
388
389    #[test]
390    fn unknown_flag_uses_posix() {
391        let args: Vec<String> = vec!["ls".into(), "-la".into()];
392        assert_eq!(join_command_for(&args, "--exec"), "ls -la");
393    }
394
395    #[test]
396    fn powershell_single_full_command_not_quoted() {
397        let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
398        let result = join_command_for(&args, "-Command");
399        assert_eq!(result, "git commit -m \"feat: add feature\"");
400        assert!(
401            !result.starts_with("& '"),
402            "must not wrap full command in & '...'"
403        );
404    }
405
406    #[test]
407    fn powershell_single_no_spaces_still_uses_call_operator() {
408        let args: Vec<String> = vec!["git".into()];
409        assert_eq!(join_command_for(&args, "-Command"), "& git");
410    }
411}
412
413#[cfg(test)]
414mod windows_shell_flag_tests {
415    use super::windows_shell_flag_for_exe_basename;
416
417    #[test]
418    fn cmd_uses_slash_c() {
419        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
420        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
421    }
422
423    #[test]
424    fn powershell_uses_command() {
425        assert_eq!(
426            windows_shell_flag_for_exe_basename("powershell.exe"),
427            "-Command"
428        );
429        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
430    }
431
432    #[test]
433    fn posix_shells_use_dash_c() {
434        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
435        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
436        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
437        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
438        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
439    }
440}
441
442#[cfg(test)]
443mod platform_tests {
444    #[test]
445    fn is_container_returns_bool() {
446        let _ = super::is_container();
447    }
448
449    #[test]
450    fn is_non_interactive_returns_bool() {
451        let _ = super::is_non_interactive();
452    }
453
454    #[test]
455    fn join_command_preserves_structure() {
456        let args = vec![
457            "git".to_string(),
458            "commit".to_string(),
459            "-m".to_string(),
460            "my message".to_string(),
461        ];
462        let joined = super::join_command(&args);
463        assert!(joined.contains("git"));
464        assert!(joined.contains("commit"));
465        assert!(joined.contains("my message") || joined.contains("'my message'"));
466    }
467
468    #[test]
469    fn quote_posix_handles_em_dash() {
470        let result = super::quote_posix("closing — see #407");
471        assert!(
472            result.starts_with('\''),
473            "em-dash args must be single-quoted: {result}"
474        );
475    }
476
477    #[test]
478    fn quote_posix_handles_nested_single_quotes() {
479        let result = super::quote_posix("it's a test");
480        assert!(
481            result.contains("\\'"),
482            "single quotes must be escaped: {result}"
483        );
484    }
485
486    #[test]
487    fn quote_posix_safe_chars_unquoted() {
488        let result = super::quote_posix("simple_word");
489        assert_eq!(result, "simple_word");
490    }
491
492    #[test]
493    fn quote_posix_empty_string() {
494        let result = super::quote_posix("");
495        assert_eq!(result, "''");
496    }
497
498    #[test]
499    fn quote_posix_dollar_expansion_protected() {
500        let result = super::quote_posix("$HOME/test");
501        assert!(
502            result.starts_with('\''),
503            "dollar signs must be single-quoted: {result}"
504        );
505    }
506
507    #[test]
508    fn quote_posix_backtick_protected() {
509        let result = super::quote_posix("echo `date`");
510        assert!(
511            result.starts_with('\''),
512            "backticks must be single-quoted: {result}"
513        );
514    }
515
516    #[test]
517    fn quote_posix_double_quotes_protected() {
518        let result = super::quote_posix(r#"he said "hello""#);
519        assert!(
520            result.starts_with('\''),
521            "double quotes must be single-quoted: {result}"
522        );
523    }
524}