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
111fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
114 if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
115 "-Command"
116 } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
117 "/C"
118 } else {
119 "-c"
120 }
121}
122
123pub fn shell_and_flag() -> (String, String) {
124 let shell = detect_shell();
125 let flag = if cfg!(windows) {
126 let name = std::path::Path::new(&shell)
127 .file_name()
128 .and_then(|n| n.to_str())
129 .unwrap_or("")
130 .to_ascii_lowercase();
131 windows_shell_flag_for_exe_basename(&name).to_string()
132 } else {
133 "-c".to_string()
134 };
135 (shell, flag)
136}
137
138pub fn shell_name() -> String {
140 let shell = detect_shell();
141 let basename = std::path::Path::new(&shell)
142 .file_name()
143 .and_then(|n| n.to_str())
144 .unwrap_or("sh")
145 .to_ascii_lowercase();
146 basename
147 .strip_suffix(".exe")
148 .unwrap_or(&basename)
149 .to_string()
150}
151
152pub(super) fn detect_shell() -> String {
153 if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
154 return shell;
155 }
156
157 if let Ok(shell) = std::env::var("SHELL") {
158 let bin = std::path::Path::new(&shell)
159 .file_name()
160 .and_then(|n| n.to_str())
161 .unwrap_or("sh");
162
163 if bin == "lean-ctx" {
164 return find_real_shell();
165 }
166 return shell;
167 }
168
169 find_real_shell()
170}
171
172#[cfg(unix)]
173fn find_real_shell() -> String {
174 for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
175 if std::path::Path::new(shell).exists() {
176 return shell.to_string();
177 }
178 }
179 "/bin/sh".to_string()
180}
181
182#[cfg(windows)]
183fn find_real_shell() -> String {
184 if is_running_in_msys_or_gitbash() {
185 for candidate in &["bash.exe", "sh.exe"] {
186 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
187 if output.status.success() {
188 if let Ok(path) = String::from_utf8(output.stdout) {
189 if let Some(first_line) = path.lines().next() {
190 let trimmed = first_line.trim();
191 if !trimmed.is_empty() {
192 return trimmed.to_string();
193 }
194 }
195 }
196 }
197 }
198 }
199 }
200 if let Ok(pwsh) = which_powershell() {
201 return pwsh;
202 }
203 if let Ok(comspec) = std::env::var("COMSPEC") {
204 return comspec;
205 }
206 "cmd.exe".to_string()
207}
208
209#[cfg(windows)]
210fn is_running_in_msys_or_gitbash() -> bool {
211 std::env::var("MSYSTEM").is_ok() || std::env::var("MINGW_PREFIX").is_ok()
212}
213
214#[cfg(windows)]
215fn which_powershell() -> Result<String, ()> {
216 for candidate in &["pwsh.exe", "powershell.exe"] {
217 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
218 if output.status.success() {
219 if let Ok(path) = String::from_utf8(output.stdout) {
220 if let Some(first_line) = path.lines().next() {
221 let trimmed = first_line.trim();
222 if !trimmed.is_empty() {
223 return Ok(trimmed.to_string());
224 }
225 }
226 }
227 }
228 }
229 }
230 Err(())
231}
232
233pub fn join_command(args: &[String]) -> String {
240 let (_, flag) = shell_and_flag();
241 join_command_for(args, &flag)
242}
243
244pub fn join_command_for(args: &[String], shell_flag: &str) -> String {
245 match shell_flag {
246 "-Command" => join_powershell(args),
247 "/C" => join_cmd(args),
248 _ => join_posix(args),
249 }
250}
251
252fn join_posix(args: &[String]) -> String {
253 args.iter()
254 .map(|a| quote_posix(a))
255 .collect::<Vec<_>>()
256 .join(" ")
257}
258
259fn join_powershell(args: &[String]) -> String {
260 if args.len() == 1 && args[0].contains(' ') {
261 return args[0].clone();
262 }
263 let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
264 format!("& {}", quoted.join(" "))
265}
266
267fn join_cmd(args: &[String]) -> String {
268 args.iter()
269 .map(|a| quote_cmd(a))
270 .collect::<Vec<_>>()
271 .join(" ")
272}
273
274fn quote_posix(s: &str) -> String {
275 if s.is_empty() {
276 return "''".to_string();
277 }
278 if s.bytes()
279 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
280 {
281 return s.to_string();
282 }
283 format!("'{}'", s.replace('\'', "'\\''"))
284}
285
286fn quote_powershell(s: &str) -> String {
287 if s.is_empty() {
288 return "''".to_string();
289 }
290 if s.bytes()
291 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
292 {
293 return s.to_string();
294 }
295 format!("'{}'", s.replace('\'', "''"))
296}
297
298fn quote_cmd(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
310#[cfg(test)]
311mod join_command_tests {
312 use super::*;
313
314 #[test]
315 fn posix_simple_args() {
316 let args: Vec<String> = vec!["git".into(), "status".into()];
317 assert_eq!(join_command_for(&args, "-c"), "git status");
318 }
319
320 #[test]
321 fn posix_path_with_spaces() {
322 let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
323 assert_eq!(
324 join_command_for(&args, "-c"),
325 "'/usr/local/my app/bin' --help"
326 );
327 }
328
329 #[test]
330 fn posix_single_quotes_escaped() {
331 let args: Vec<String> = vec!["echo".into(), "it's".into()];
332 assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
333 }
334
335 #[test]
336 fn posix_empty_arg() {
337 let args: Vec<String> = vec!["cmd".into(), String::new()];
338 assert_eq!(join_command_for(&args, "-c"), "cmd ''");
339 }
340
341 #[test]
342 fn powershell_simple_args() {
343 let args: Vec<String> = vec!["npm".into(), "install".into()];
344 assert_eq!(join_command_for(&args, "-Command"), "& npm install");
345 }
346
347 #[test]
348 fn powershell_path_with_spaces() {
349 let args: Vec<String> = vec![
350 "C:\\Program Files\\nodejs\\npm.cmd".into(),
351 "install".into(),
352 ];
353 assert_eq!(
354 join_command_for(&args, "-Command"),
355 "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
356 );
357 }
358
359 #[test]
360 fn powershell_single_quotes_escaped() {
361 let args: Vec<String> = vec!["echo".into(), "it's done".into()];
362 assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
363 }
364
365 #[test]
366 fn cmd_simple_args() {
367 let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
368 assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
369 }
370
371 #[test]
372 fn cmd_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, "/C"),
379 "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
380 );
381 }
382
383 #[test]
384 fn cmd_double_quotes_escaped() {
385 let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
386 assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
387 }
388
389 #[test]
390 fn unknown_flag_uses_posix() {
391 let args: Vec<String> = vec!["ls".into(), "-la".into()];
392 assert_eq!(join_command_for(&args, "--exec"), "ls -la");
393 }
394
395 #[test]
396 fn powershell_single_full_command_not_quoted() {
397 let args: Vec<String> = vec!["git commit -m \"feat: add feature\"".into()];
398 let result = join_command_for(&args, "-Command");
399 assert_eq!(result, "git commit -m \"feat: add feature\"");
400 assert!(
401 !result.starts_with("& '"),
402 "must not wrap full command in & '...'"
403 );
404 }
405
406 #[test]
407 fn powershell_single_no_spaces_still_uses_call_operator() {
408 let args: Vec<String> = vec!["git".into()];
409 assert_eq!(join_command_for(&args, "-Command"), "& git");
410 }
411}
412
413#[cfg(test)]
414mod windows_shell_flag_tests {
415 use super::windows_shell_flag_for_exe_basename;
416
417 #[test]
418 fn cmd_uses_slash_c() {
419 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
420 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
421 }
422
423 #[test]
424 fn powershell_uses_command() {
425 assert_eq!(
426 windows_shell_flag_for_exe_basename("powershell.exe"),
427 "-Command"
428 );
429 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
430 }
431
432 #[test]
433 fn posix_shells_use_dash_c() {
434 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
435 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
436 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
437 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
438 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
439 }
440}
441
442#[cfg(test)]
443mod platform_tests {
444 #[test]
445 fn is_container_returns_bool() {
446 let _ = super::is_container();
447 }
448
449 #[test]
450 fn is_non_interactive_returns_bool() {
451 let _ = super::is_non_interactive();
452 }
453
454 #[test]
455 fn join_command_preserves_structure() {
456 let args = vec![
457 "git".to_string(),
458 "commit".to_string(),
459 "-m".to_string(),
460 "my message".to_string(),
461 ];
462 let joined = super::join_command(&args);
463 assert!(joined.contains("git"));
464 assert!(joined.contains("commit"));
465 assert!(joined.contains("my message") || joined.contains("'my message'"));
466 }
467
468 #[test]
469 fn quote_posix_handles_em_dash() {
470 let result = super::quote_posix("closing — see #407");
471 assert!(
472 result.starts_with('\''),
473 "em-dash args must be single-quoted: {result}"
474 );
475 }
476
477 #[test]
478 fn quote_posix_handles_nested_single_quotes() {
479 let result = super::quote_posix("it's a test");
480 assert!(
481 result.contains("\\'"),
482 "single quotes must be escaped: {result}"
483 );
484 }
485
486 #[test]
487 fn quote_posix_safe_chars_unquoted() {
488 let result = super::quote_posix("simple_word");
489 assert_eq!(result, "simple_word");
490 }
491
492 #[test]
493 fn quote_posix_empty_string() {
494 let result = super::quote_posix("");
495 assert_eq!(result, "''");
496 }
497
498 #[test]
499 fn quote_posix_dollar_expansion_protected() {
500 let result = super::quote_posix("$HOME/test");
501 assert!(
502 result.starts_with('\''),
503 "dollar signs must be single-quoted: {result}"
504 );
505 }
506
507 #[test]
508 fn quote_posix_backtick_protected() {
509 let result = super::quote_posix("echo `date`");
510 assert!(
511 result.starts_with('\''),
512 "backticks must be single-quoted: {result}"
513 );
514 }
515
516 #[test]
517 fn quote_posix_double_quotes_protected() {
518 let result = super::quote_posix(r#"he said "hello""#);
519 assert!(
520 result.starts_with('\''),
521 "double quotes must be single-quoted: {result}"
522 );
523 }
524}