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