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
248fn 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 let quoted: Vec<String> = args.iter().map(|a| quote_powershell(a)).collect();
265 format!("& {}", quoted.join(" "))
266}
267
268fn join_cmd(args: &[String]) -> String {
269 args.iter()
270 .map(|a| quote_cmd(a))
271 .collect::<Vec<_>>()
272 .join(" ")
273}
274
275fn quote_posix(s: &str) -> String {
276 if s.is_empty() {
277 return "''".to_string();
278 }
279 if s.bytes()
280 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
281 {
282 return s.to_string();
283 }
284 format!("'{}'", s.replace('\'', "'\\''"))
285}
286
287fn quote_powershell(s: &str) -> String {
288 if s.is_empty() {
289 return "''".to_string();
290 }
291 if s.bytes()
292 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^".contains(&b))
293 {
294 return s.to_string();
295 }
296 format!("'{}'", s.replace('\'', "''"))
297}
298
299fn quote_cmd(s: &str) -> String {
300 if s.is_empty() {
301 return "\"\"".to_string();
302 }
303 if s.bytes()
304 .all(|b| b.is_ascii_alphanumeric() || b"-_./=:@,+%^\\".contains(&b))
305 {
306 return s.to_string();
307 }
308 format!("\"{}\"", s.replace('"', "\\\""))
309}
310
311#[cfg(test)]
312mod join_command_tests {
313 use super::*;
314
315 #[test]
316 fn posix_simple_args() {
317 let args: Vec<String> = vec!["git".into(), "status".into()];
318 assert_eq!(join_command_for(&args, "-c"), "git status");
319 }
320
321 #[test]
322 fn posix_path_with_spaces() {
323 let args: Vec<String> = vec!["/usr/local/my app/bin".into(), "--help".into()];
324 assert_eq!(
325 join_command_for(&args, "-c"),
326 "'/usr/local/my app/bin' --help"
327 );
328 }
329
330 #[test]
331 fn posix_single_quotes_escaped() {
332 let args: Vec<String> = vec!["echo".into(), "it's".into()];
333 assert_eq!(join_command_for(&args, "-c"), "echo 'it'\\''s'");
334 }
335
336 #[test]
337 fn posix_empty_arg() {
338 let args: Vec<String> = vec!["cmd".into(), String::new()];
339 assert_eq!(join_command_for(&args, "-c"), "cmd ''");
340 }
341
342 #[test]
343 fn powershell_simple_args() {
344 let args: Vec<String> = vec!["npm".into(), "install".into()];
345 assert_eq!(join_command_for(&args, "-Command"), "& npm install");
346 }
347
348 #[test]
349 fn powershell_path_with_spaces() {
350 let args: Vec<String> = vec![
351 "C:\\Program Files\\nodejs\\npm.cmd".into(),
352 "install".into(),
353 ];
354 assert_eq!(
355 join_command_for(&args, "-Command"),
356 "& 'C:\\Program Files\\nodejs\\npm.cmd' install"
357 );
358 }
359
360 #[test]
361 fn powershell_single_quotes_escaped() {
362 let args: Vec<String> = vec!["echo".into(), "it's done".into()];
363 assert_eq!(join_command_for(&args, "-Command"), "& echo 'it''s done'");
364 }
365
366 #[test]
367 fn cmd_simple_args() {
368 let args: Vec<String> = vec!["npm.cmd".into(), "install".into()];
369 assert_eq!(join_command_for(&args, "/C"), "npm.cmd install");
370 }
371
372 #[test]
373 fn cmd_path_with_spaces() {
374 let args: Vec<String> = vec![
375 "C:\\Program Files\\nodejs\\npm.cmd".into(),
376 "install".into(),
377 ];
378 assert_eq!(
379 join_command_for(&args, "/C"),
380 "\"C:\\Program Files\\nodejs\\npm.cmd\" install"
381 );
382 }
383
384 #[test]
385 fn cmd_double_quotes_escaped() {
386 let args: Vec<String> = vec!["echo".into(), "say \"hello\"".into()];
387 assert_eq!(join_command_for(&args, "/C"), "echo \"say \\\"hello\\\"\"");
388 }
389
390 #[test]
391 fn unknown_flag_uses_posix() {
392 let args: Vec<String> = vec!["ls".into(), "-la".into()];
393 assert_eq!(join_command_for(&args, "--exec"), "ls -la");
394 }
395}
396
397#[cfg(test)]
398mod windows_shell_flag_tests {
399 use super::windows_shell_flag_for_exe_basename;
400
401 #[test]
402 fn cmd_uses_slash_c() {
403 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
404 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
405 }
406
407 #[test]
408 fn powershell_uses_command() {
409 assert_eq!(
410 windows_shell_flag_for_exe_basename("powershell.exe"),
411 "-Command"
412 );
413 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
414 }
415
416 #[test]
417 fn posix_shells_use_dash_c() {
418 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
419 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
420 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
421 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
422 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
423 }
424}
425
426#[cfg(test)]
427mod platform_tests {
428 #[test]
429 fn is_container_returns_bool() {
430 let _ = super::is_container();
431 }
432
433 #[test]
434 fn is_non_interactive_returns_bool() {
435 let _ = super::is_non_interactive();
436 }
437
438 #[test]
439 fn join_command_preserves_structure() {
440 let args = vec![
441 "git".to_string(),
442 "commit".to_string(),
443 "-m".to_string(),
444 "my message".to_string(),
445 ];
446 let joined = super::join_command(&args);
447 assert!(joined.contains("git"));
448 assert!(joined.contains("commit"));
449 assert!(joined.contains("my message") || joined.contains("'my message'"));
450 }
451
452 #[test]
453 fn quote_posix_handles_em_dash() {
454 let result = super::quote_posix("closing — see #407");
455 assert!(
456 result.starts_with('\''),
457 "em-dash args must be single-quoted: {result}"
458 );
459 }
460
461 #[test]
462 fn quote_posix_handles_nested_single_quotes() {
463 let result = super::quote_posix("it's a test");
464 assert!(
465 result.contains("\\'"),
466 "single quotes must be escaped: {result}"
467 );
468 }
469
470 #[test]
471 fn quote_posix_safe_chars_unquoted() {
472 let result = super::quote_posix("simple_word");
473 assert_eq!(result, "simple_word");
474 }
475
476 #[test]
477 fn quote_posix_empty_string() {
478 let result = super::quote_posix("");
479 assert_eq!(result, "''");
480 }
481
482 #[test]
483 fn quote_posix_dollar_expansion_protected() {
484 let result = super::quote_posix("$HOME/test");
485 assert!(
486 result.starts_with('\''),
487 "dollar signs must be single-quoted: {result}"
488 );
489 }
490
491 #[test]
492 fn quote_posix_backtick_protected() {
493 let result = super::quote_posix("echo `date`");
494 assert!(
495 result.starts_with('\''),
496 "backticks must be single-quoted: {result}"
497 );
498 }
499
500 #[test]
501 fn quote_posix_double_quotes_protected() {
502 let result = super::quote_posix(r#"he said "hello""#);
503 assert!(
504 result.starts_with('\''),
505 "double quotes must be single-quoted: {result}"
506 );
507 }
508}