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