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
81pub 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
106pub fn is_non_interactive() -> bool {
108 !io::stdin().is_terminal()
109}
110
111pub(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
121fn 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
148pub 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
243pub 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}