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