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