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