1#[cfg(any(test, feature = "test-utils"))]
2use std::cell::RefCell;
3use std::collections::HashMap;
4#[cfg(any(test, feature = "test-utils"))]
5use std::marker::PhantomData;
6#[cfg(target_os = "windows")]
7use std::path::Path;
8use std::path::PathBuf;
9#[cfg(not(target_os = "windows"))]
10use std::process::Stdio;
11#[cfg(any(test, feature = "test-utils"))]
12use std::rc::Rc;
13use std::sync::{OnceLock, RwLock};
14use std::time::{Duration, Instant};
15
16#[cfg(not(target_os = "windows"))]
17use tokio::sync::Mutex as AsyncMutex;
18
19#[cfg(target_os = "windows")]
20const CREATE_NO_WINDOW: u32 = 0x0800_0000;
21const PYTHON_TRIED_PREVIEW_LIMIT: usize = 6;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ShellCommand {
25 pub program: String,
26 pub arg: &'static str,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum CommandEnvironmentSource {
31 InheritedProcess,
32 UnixLoginShell,
33}
34
35impl CommandEnvironmentSource {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::InheritedProcess => "process_env",
39 Self::UnixLoginShell => "unix_login_shell",
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct PythonDiscoveryDiagnostics {
46 pub configured: Option<String>,
47 pub resolved: Option<String>,
48 pub invocation: Option<String>,
49 pub source: Option<String>,
50 pub tried: Vec<String>,
51 pub tried_preview: Vec<String>,
52 pub tried_total: usize,
53 pub tried_truncated: bool,
54 pub hint: Option<String>,
55}
56
57impl PythonDiscoveryDiagnostics {
58 fn none() -> Self {
59 Self {
60 configured: None,
61 resolved: None,
62 invocation: None,
63 source: None,
64 tried: Vec::new(),
65 tried_preview: Vec::new(),
66 tried_total: 0,
67 tried_truncated: false,
68 hint: None,
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct CommandEnvironmentDiagnostics {
75 pub source: CommandEnvironmentSource,
76 pub import_shell: Option<String>,
77 pub import_error: Option<String>,
78 pub path: Option<String>,
79 pub path_entries: Option<usize>,
80 pub python: PythonDiscoveryDiagnostics,
81}
82
83impl CommandEnvironmentDiagnostics {
84 fn inherited_process(import_error: Option<String>) -> Self {
85 Self {
86 source: CommandEnvironmentSource::InheritedProcess,
87 import_shell: None,
88 import_error,
89 path: None,
90 path_entries: None,
91 python: PythonDiscoveryDiagnostics::none(),
92 }
93 }
94
95 fn unix_login_shell(import_shell: String) -> Self {
96 Self {
97 source: CommandEnvironmentSource::UnixLoginShell,
98 import_shell: Some(import_shell),
99 import_error: None,
100 path: None,
101 path_entries: None,
102 python: PythonDiscoveryDiagnostics::none(),
103 }
104 }
105
106 pub fn summary(&self) -> String {
107 let mut parts = vec![format!("env_source={}", self.source.as_str())];
108 if let Some(shell) = self.import_shell.as_deref() {
109 parts.push(format!("import_shell={shell}"));
110 }
111 if let Some(entries) = self.path_entries {
112 parts.push(format!("path_entries={entries}"));
113 }
114 if let Some(error) = self.import_error.as_deref() {
115 parts.push(format!("import_error={error}"));
116 }
117 parts.join(", ")
118 }
119}
120
121#[derive(Debug, Clone)]
122pub struct PreparedCommandEnvironment {
123 pub env: HashMap<String, String>,
124 pub diagnostics: CommandEnvironmentDiagnostics,
125}
126
127impl PreparedCommandEnvironment {
128 pub fn apply_to_tokio_command(&self, command: &mut tokio::process::Command) {
129 for (key, value) in &self.env {
130 command.env(key, value);
131 }
132 }
133}
134
135#[derive(Debug, Clone)]
136struct ImportedCommandEnvironment {
137 env: HashMap<String, String>,
138 diagnostics: CommandEnvironmentDiagnostics,
139}
140
141#[cfg(any(test, feature = "test-utils"))]
142thread_local! {
143 static COMMAND_ENVIRONMENT_OVERRIDE_FOR_TESTS:
149 RefCell<Option<ImportedCommandEnvironment>> = const { RefCell::new(None) };
150}
151
152#[cfg(any(test, feature = "test-utils"))]
159#[must_use = "keep the guard alive for the full command-environment test scope"]
160pub(crate) struct CommandEnvironmentOverrideGuard {
161 previous: Option<ImportedCommandEnvironment>,
162 _same_thread: PhantomData<Rc<()>>,
163}
164
165#[cfg(any(test, feature = "test-utils"))]
166impl Drop for CommandEnvironmentOverrideGuard {
167 fn drop(&mut self) {
168 let previous = self.previous.take();
169 COMMAND_ENVIRONMENT_OVERRIDE_FOR_TESTS.with(|slot| {
170 slot.replace(previous);
171 });
172 }
173}
174
175#[cfg(any(test, feature = "test-utils"))]
176pub(crate) fn override_command_environment_for_tests(
177 env: HashMap<String, String>,
178 diagnostics: CommandEnvironmentDiagnostics,
179) -> CommandEnvironmentOverrideGuard {
180 let imported = ImportedCommandEnvironment { env, diagnostics };
181 let previous = COMMAND_ENVIRONMENT_OVERRIDE_FOR_TESTS.with(|slot| slot.replace(Some(imported)));
182 CommandEnvironmentOverrideGuard {
183 previous,
184 _same_thread: PhantomData,
185 }
186}
187
188#[cfg(any(test, feature = "test-utils"))]
189fn read_command_environment_override_for_tests() -> Option<ImportedCommandEnvironment> {
190 COMMAND_ENVIRONMENT_OVERRIDE_FOR_TESTS.with(|slot| slot.borrow().clone())
191}
192
193#[cfg(not(target_os = "windows"))]
194#[derive(Debug, Clone)]
195struct CachedUnixShellEnvironment {
196 imported: ImportedCommandEnvironment,
197 expires_at: Instant,
198}
199
200#[cfg(not(target_os = "windows"))]
201const UNIX_SHELL_ENV_CACHE_TTL: Duration = Duration::from_secs(60);
202#[cfg(not(target_os = "windows"))]
203const UNIX_SHELL_ENV_FALLBACK_TTL: Duration = Duration::from_secs(10);
204#[cfg(not(target_os = "windows"))]
205const UNIX_SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(10);
206
207#[cfg(not(target_os = "windows"))]
208static UNIX_SHELL_ENV_CACHE: OnceLock<RwLock<Option<CachedUnixShellEnvironment>>> = OnceLock::new();
209#[cfg(not(target_os = "windows"))]
210static UNIX_SHELL_ENV_REFRESH_LOCK: OnceLock<AsyncMutex<()>> = OnceLock::new();
211
212#[cfg(not(target_os = "windows"))]
213fn unix_shell_env_cache() -> &'static RwLock<Option<CachedUnixShellEnvironment>> {
214 UNIX_SHELL_ENV_CACHE.get_or_init(|| RwLock::new(None))
215}
216
217#[cfg(not(target_os = "windows"))]
218fn unix_shell_env_refresh_lock() -> &'static AsyncMutex<()> {
219 UNIX_SHELL_ENV_REFRESH_LOCK.get_or_init(|| AsyncMutex::new(()))
220}
221
222pub async fn build_command_environment(
223 overrides: &HashMap<String, String>,
224) -> PreparedCommandEnvironment {
225 let base = imported_command_environment().await;
226 let mut env = base.env;
227 env.extend(
228 overrides
229 .iter()
230 .map(|(key, value)| (key.clone(), value.clone())),
231 );
232
233 let mut diagnostics = base.diagnostics;
234 diagnostics.path = env.get("PATH").cloned();
235 diagnostics.path_entries = diagnostics.path.as_deref().map(count_path_entries);
236 diagnostics.python = resolve_python_diagnostics(&env);
237
238 PreparedCommandEnvironment { env, diagnostics }
239}
240
241async fn imported_command_environment() -> ImportedCommandEnvironment {
242 #[cfg(any(test, feature = "test-utils"))]
243 if let Some(imported) = read_command_environment_override_for_tests() {
244 return imported;
245 }
246
247 #[cfg(target_os = "windows")]
248 {
249 ImportedCommandEnvironment::from_process_env(None)
250 }
251
252 #[cfg(not(target_os = "windows"))]
253 {
254 imported_unix_shell_environment_cached().await
255 }
256}
257
258impl ImportedCommandEnvironment {
259 fn from_process_env(import_error: Option<String>) -> Self {
260 let env = current_process_env_map();
261 let mut diagnostics = CommandEnvironmentDiagnostics::inherited_process(import_error);
262 diagnostics.path = env.get("PATH").cloned();
263 diagnostics.path_entries = diagnostics.path.as_deref().map(count_path_entries);
264 Self { env, diagnostics }
265 }
266}
267
268fn current_process_env_map() -> HashMap<String, String> {
269 std::env::vars().collect()
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
273struct PythonCandidate {
274 source: String,
275 program: String,
276 args: Vec<String>,
277 path_hint: Option<PathBuf>,
278}
279
280impl PythonCandidate {
281 fn configured(program: String) -> Self {
282 Self {
283 source: "configured".to_string(),
284 program,
285 args: Vec::new(),
286 path_hint: None,
287 }
288 }
289
290 fn command<S: Into<String>>(source: &str, program: S, args: &[&str]) -> Self {
291 Self {
292 source: source.to_string(),
293 program: program.into(),
294 args: args.iter().map(|value| value.to_string()).collect(),
295 path_hint: None,
296 }
297 }
298
299 fn hinted_path<P: Into<PathBuf>>(source: &str, path: P) -> Self {
300 let path = path.into();
301 Self {
302 source: source.to_string(),
303 program: path.to_string_lossy().to_string(),
304 args: Vec::new(),
305 path_hint: Some(path),
306 }
307 }
308
309 fn display(&self) -> String {
310 render_command_line(&self.program, self.args.iter().map(String::as_str))
311 }
312}
313
314#[cfg(target_os = "windows")]
315fn windows_executable_extensions() -> Vec<String> {
316 std::env::var("PATHEXT")
317 .ok()
318 .map(|value| {
319 value
320 .split(';')
321 .map(str::trim)
322 .filter(|part| !part.is_empty())
323 .map(|part| part.to_ascii_lowercase())
324 .collect::<Vec<_>>()
325 })
326 .filter(|exts| !exts.is_empty())
327 .unwrap_or_else(|| {
328 vec![
329 ".exe".to_string(),
330 ".cmd".to_string(),
331 ".bat".to_string(),
332 ".com".to_string(),
333 ]
334 })
335}
336
337#[cfg(target_os = "windows")]
338fn windows_path_name_candidates(name: &str) -> Vec<String> {
339 let path = Path::new(name);
340 let ext = path
341 .extension()
342 .and_then(|value| value.to_str())
343 .unwrap_or_default();
344 if !ext.is_empty() {
345 return vec![name.to_string()];
346 }
347
348 let mut candidates = vec![name.to_string()];
349 for ext in windows_executable_extensions() {
350 candidates.push(format!("{name}{ext}"));
351 }
352 candidates
353}
354
355fn resolve_executable_from_env_path(path_env: Option<&str>, name: &str) -> Option<PathBuf> {
356 let path_env = path_env?;
357 let path_dirs: Vec<PathBuf> = std::env::split_paths(path_env).collect();
358
359 #[cfg(target_os = "windows")]
360 {
361 for dir in &path_dirs {
362 for candidate_name in windows_path_name_candidates(name) {
363 let candidate = dir.join(candidate_name);
364 if candidate.is_file() {
365 return Some(candidate);
366 }
367 }
368 }
369 return None;
370 }
371
372 #[cfg(not(target_os = "windows"))]
373 {
374 path_dirs
375 .into_iter()
376 .map(|dir| dir.join(name))
377 .find(|candidate| candidate.is_file())
378 }
379}
380
381#[cfg(target_os = "windows")]
382fn windows_common_python_paths(env: &HashMap<String, String>) -> Vec<PathBuf> {
383 let mut preferred = Vec::new();
384 let mut low_priority = Vec::new();
385
386 let local_app_data = env
387 .get("LocalAppData")
388 .cloned()
389 .or_else(|| std::env::var("LocalAppData").ok());
390 let app_data = env
391 .get("AppData")
392 .cloned()
393 .or_else(|| std::env::var("AppData").ok());
394 let user_profile = env
395 .get("USERPROFILE")
396 .cloned()
397 .or_else(|| std::env::var("USERPROFILE").ok());
398
399 for key in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
400 if let Some(base) = env.get(key).cloned().or_else(|| std::env::var(key).ok()) {
401 let base = PathBuf::from(base);
402 for version in [
403 "Python313",
404 "Python312",
405 "Python311",
406 "Python310",
407 "Python39",
408 ] {
409 preferred.push(base.join("Python").join(version).join("python.exe"));
410 }
411 preferred.push(base.join("Python").join("Launcher").join("py.exe"));
412 preferred.push(base.join("Python311").join("python.exe"));
413 preferred.push(base.join("Python312").join("python.exe"));
414 preferred.push(base.join("Python313").join("python.exe"));
415 preferred.push(base.join("Anaconda3").join("python.exe"));
416 preferred.push(base.join("Miniconda3").join("python.exe"));
417 }
418 }
419
420 if let Some(local_app_data) = local_app_data {
421 let base = PathBuf::from(local_app_data);
422 for version in [
423 "Python313",
424 "Python312",
425 "Python311",
426 "Python310",
427 "Python39",
428 ] {
429 preferred.push(
430 base.join("Programs")
431 .join("Python")
432 .join(version)
433 .join("python.exe"),
434 );
435 }
436 preferred.push(
437 base.join("Programs")
438 .join("Python")
439 .join("Launcher")
440 .join("py.exe"),
441 );
442 preferred.push(
443 base.join("Programs")
444 .join("Python")
445 .join("Python312")
446 .join("python.exe"),
447 );
448 preferred.push(
449 base.join("Programs")
450 .join("Python")
451 .join("Python311")
452 .join("python.exe"),
453 );
454 low_priority.push(
455 base.join("Microsoft")
456 .join("WindowsApps")
457 .join("python.exe"),
458 );
459 low_priority.push(
460 base.join("Microsoft")
461 .join("WindowsApps")
462 .join("python3.exe"),
463 );
464 }
465
466 if let Some(app_data) = app_data {
467 let roaming = PathBuf::from(app_data);
468 preferred.push(roaming.join("Python").join("Python312").join("python.exe"));
469 preferred.push(roaming.join("Python").join("Python311").join("python.exe"));
470 preferred.push(
471 roaming
472 .join("pyenv")
473 .join("pyenv-win")
474 .join("shims")
475 .join("python.exe"),
476 );
477 preferred.push(
478 roaming
479 .join("pyenv")
480 .join("pyenv-win")
481 .join("shims")
482 .join("python3.exe"),
483 );
484 preferred.push(
485 roaming
486 .join("pyenv")
487 .join("pyenv-win")
488 .join("bin")
489 .join("pyenv.bat"),
490 );
491 }
492
493 if let Some(user_profile) = user_profile {
494 let home = PathBuf::from(user_profile);
495 preferred.push(
496 home.join("AppData")
497 .join("Local")
498 .join("Programs")
499 .join("Python")
500 .join("Python312")
501 .join("python.exe"),
502 );
503 preferred.push(
504 home.join("AppData")
505 .join("Local")
506 .join("Programs")
507 .join("Python")
508 .join("Python311")
509 .join("python.exe"),
510 );
511 preferred.push(home.join("miniconda3").join("python.exe"));
512 preferred.push(home.join("anaconda3").join("python.exe"));
513 preferred.push(
514 home.join(".pyenv")
515 .join("pyenv-win")
516 .join("shims")
517 .join("python.exe"),
518 );
519 preferred.push(
520 home.join(".pyenv")
521 .join("pyenv-win")
522 .join("shims")
523 .join("python3.exe"),
524 );
525 }
526
527 preferred.extend(low_priority);
528 preferred
529}
530
531#[cfg(target_os = "windows")]
532fn windows_python_candidate_dedupe_key(candidate: &PythonCandidate) -> String {
533 let program = candidate.program.replace('/', "\\").to_ascii_lowercase();
534 let args = candidate
535 .args
536 .iter()
537 .map(|value| value.to_ascii_lowercase())
538 .collect::<Vec<_>>()
539 .join(" ");
540 format!("{}|{}", program, args)
541}
542
543fn dedupe_python_candidates(candidates: Vec<PythonCandidate>) -> Vec<PythonCandidate> {
544 #[cfg(target_os = "windows")]
545 {
546 let mut seen = std::collections::HashSet::new();
547 let mut deduped = Vec::new();
548 for candidate in candidates {
549 let key = windows_python_candidate_dedupe_key(&candidate);
550 if seen.insert(key) {
551 deduped.push(candidate);
552 }
553 }
554 return deduped;
555 }
556
557 #[cfg(not(target_os = "windows"))]
558 {
559 let mut seen = std::collections::HashSet::new();
560 let mut deduped = Vec::new();
561 for candidate in candidates {
562 let key = candidate.display();
563 if seen.insert(key) {
564 deduped.push(candidate);
565 }
566 }
567 deduped
568 }
569}
570
571fn python_resolution_hint(diagnostics: &PythonDiscoveryDiagnostics) -> Option<String> {
572 if diagnostics.resolved.is_some() {
573 return None;
574 }
575
576 #[cfg(target_os = "windows")]
577 {
578 return Some(
579 "Python was not resolved. Try `py -3`, `python`, set `BAMBOO_PYTHON`, or install Python 3 and restart Bamboo.".to_string(),
580 );
581 }
582
583 #[cfg(not(target_os = "windows"))]
584 {
585 Some(
586 "Python was not resolved. Try `python3`, set `BAMBOO_PYTHON`, or install Python 3 and restart Bamboo.".to_string(),
587 )
588 }
589}
590
591fn finalize_python_diagnostics(
592 mut diagnostics: PythonDiscoveryDiagnostics,
593) -> PythonDiscoveryDiagnostics {
594 diagnostics.tried_total = diagnostics.tried.len();
595 diagnostics.tried_preview = diagnostics
596 .tried
597 .iter()
598 .take(PYTHON_TRIED_PREVIEW_LIMIT)
599 .cloned()
600 .collect();
601 diagnostics.tried_truncated = diagnostics.tried_total > diagnostics.tried_preview.len();
602 diagnostics.hint = python_resolution_hint(&diagnostics);
603 diagnostics
604}
605
606fn python_candidate_sequence(
607 env: &HashMap<String, String>,
608 configured: Option<&str>,
609) -> (Vec<PythonCandidate>, Option<String>) {
610 let configured = configured
611 .map(str::trim)
612 .filter(|value| !value.is_empty())
613 .map(str::to_string);
614
615 let mut candidates = Vec::new();
616 if let Some(configured_path) = configured.clone() {
617 candidates.push(PythonCandidate::configured(configured_path));
618 }
619
620 #[cfg(target_os = "windows")]
621 {
622 if let Some(virtual_env) = env
623 .get("VIRTUAL_ENV")
624 .filter(|value| !value.trim().is_empty())
625 {
626 candidates.push(PythonCandidate::hinted_path(
627 "virtual_env",
628 PathBuf::from(virtual_env)
629 .join("Scripts")
630 .join("python.exe"),
631 ));
632 }
633 if let Some(conda_prefix) = env
634 .get("CONDA_PREFIX")
635 .filter(|value| !value.trim().is_empty())
636 {
637 candidates.push(PythonCandidate::hinted_path(
638 "conda_env",
639 PathBuf::from(conda_prefix).join("python.exe"),
640 ));
641 }
642 candidates.push(PythonCandidate::command("launcher", "py", &["-3"]));
643 candidates.push(PythonCandidate::command("path", "py", &[]));
644 candidates.push(PythonCandidate::command("path", "python", &[]));
645 candidates.push(PythonCandidate::command("path", "python3", &[]));
646 for path in windows_common_python_paths(env) {
647 candidates.push(PythonCandidate::hinted_path("common_path", path));
648 }
649 }
650
651 #[cfg(not(target_os = "windows"))]
652 {
653 if let Some(virtual_env) = env
654 .get("VIRTUAL_ENV")
655 .filter(|value| !value.trim().is_empty())
656 {
657 candidates.push(PythonCandidate::hinted_path(
658 "virtual_env",
659 PathBuf::from(virtual_env).join("bin").join("python"),
660 ));
661 }
662 if let Some(conda_prefix) = env
663 .get("CONDA_PREFIX")
664 .filter(|value| !value.trim().is_empty())
665 {
666 candidates.push(PythonCandidate::hinted_path(
667 "conda_env",
668 PathBuf::from(conda_prefix).join("bin").join("python"),
669 ));
670 }
671 candidates.push(PythonCandidate::command("path", "python3", &[]));
672 candidates.push(PythonCandidate::command("path", "python", &[]));
673 }
674
675 #[cfg(target_os = "macos")]
676 {
677 candidates.push(PythonCandidate::hinted_path(
678 "common_path",
679 "/opt/homebrew/bin/python3",
680 ));
681 candidates.push(PythonCandidate::hinted_path(
682 "common_path",
683 "/usr/local/bin/python3",
684 ));
685 }
686
687 (dedupe_python_candidates(candidates), configured)
688}
689
690fn resolve_python_candidate(
691 path_env: Option<&str>,
692 candidate: &PythonCandidate,
693) -> Option<(PathBuf, String)> {
694 if let Some(path_hint) = candidate.path_hint.as_ref() {
695 if path_hint.is_file() {
696 return Some((path_hint.clone(), candidate.display()));
697 }
698 }
699
700 let resolved = if candidate.source == "configured" {
701 let configured = PathBuf::from(&candidate.program);
702 configured.is_file().then_some(configured)
703 } else {
704 resolve_executable_from_env_path(path_env, &candidate.program)
705 }?;
706
707 let invocation = if candidate.args.is_empty() {
708 resolved.to_string_lossy().to_string()
709 } else {
710 render_command_line(
711 &resolved.to_string_lossy(),
712 candidate.args.iter().map(String::as_str),
713 )
714 };
715
716 Some((resolved, invocation))
717}
718
719fn resolve_python_diagnostics(env: &HashMap<String, String>) -> PythonDiscoveryDiagnostics {
720 let configured = env
721 .get("BAMBOO_PYTHON")
722 .or_else(|| env.get("PYTHON_BIN"))
723 .or_else(|| env.get("PYTHON"))
724 .cloned();
725 let path_env = env.get("PATH").map(String::as_str);
726 let (candidates, configured_copy) = python_candidate_sequence(env, configured.as_deref());
727
728 let mut diagnostics = PythonDiscoveryDiagnostics {
729 configured: configured_copy,
730 resolved: None,
731 invocation: None,
732 source: None,
733 tried: Vec::new(),
734 tried_preview: Vec::new(),
735 tried_total: 0,
736 tried_truncated: false,
737 hint: None,
738 };
739
740 for candidate in candidates {
741 diagnostics.tried.push(candidate.display());
742
743 if let Some((resolved, invocation)) = resolve_python_candidate(path_env, &candidate) {
744 diagnostics.resolved = Some(resolved.to_string_lossy().to_string());
745 diagnostics.invocation = Some(invocation);
746 diagnostics.source = Some(candidate.source.clone());
747 break;
748 }
749 }
750
751 finalize_python_diagnostics(diagnostics)
752}
753
754fn count_path_entries(path: &str) -> usize {
755 std::env::split_paths(path).count()
756}
757
758fn env_entry_start(line: &str) -> Option<usize> {
759 let eq_index = line.find('=')?;
760 if eq_index == 0 {
761 return None;
762 }
763
764 let key = &line[..eq_index];
765 let mut chars = key.chars();
766 let first = chars.next()?;
767 if !(first == '_' || first.is_ascii_alphabetic()) {
768 return None;
769 }
770 if chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
771 Some(eq_index)
772 } else {
773 None
774 }
775}
776
777fn parse_env_output(output: &str) -> HashMap<String, String> {
778 let mut env = HashMap::new();
779 let mut current_key: Option<String> = None;
780 let mut current_value = String::new();
781
782 for line in output.split_terminator('\n') {
783 if let Some(eq_index) = env_entry_start(line) {
784 if let Some(previous_key) = current_key.replace(line[..eq_index].to_string()) {
785 env.insert(previous_key, std::mem::take(&mut current_value));
786 }
787 current_value.push_str(&line[eq_index + 1..]);
788 continue;
789 }
790
791 if current_key.is_some() {
792 current_value.push('\n');
793 current_value.push_str(line);
794 }
795 }
796
797 if let Some(last_key) = current_key {
798 env.insert(last_key, current_value);
799 }
800
801 env
802}
803
804#[cfg(not(target_os = "windows"))]
805fn find_unix_shell_on_path(name: &str) -> Option<PathBuf> {
806 let path_env = std::env::var_os("PATH")?;
807 std::env::split_paths(&path_env)
808 .map(|dir| dir.join(name))
809 .find(|candidate| candidate.is_file())
810}
811
812#[cfg(not(target_os = "windows"))]
813fn preferred_unix_env_import_shell() -> Option<PathBuf> {
814 if let Some(configured_shell) = std::env::var_os("SHELL") {
815 let configured_shell = PathBuf::from(configured_shell);
816 if configured_shell.is_file() {
817 return Some(configured_shell);
818 }
819 if let Some(file_name) = configured_shell
820 .file_name()
821 .and_then(|value| value.to_str())
822 {
823 if let Some(found) = find_unix_shell_on_path(file_name) {
824 return Some(found);
825 }
826 }
827 }
828
829 let fallbacks = if cfg!(target_os = "macos") {
830 ["/bin/zsh", "/bin/bash", "/bin/sh"]
831 } else {
832 ["/bin/bash", "/bin/zsh", "/bin/sh"]
833 };
834
835 fallbacks
836 .iter()
837 .map(PathBuf::from)
838 .find(|candidate| candidate.is_file())
839}
840
841#[cfg(not(target_os = "windows"))]
842fn read_cached_unix_shell_environment() -> Option<ImportedCommandEnvironment> {
843 let guard = unix_shell_env_cache().read().ok()?;
844 let cached = guard.as_ref()?;
845 if Instant::now() < cached.expires_at {
846 Some(cached.imported.clone())
847 } else {
848 None
849 }
850}
851
852#[cfg(not(target_os = "windows"))]
853fn write_cached_unix_shell_environment(imported: ImportedCommandEnvironment) {
854 let ttl = match imported.diagnostics.source {
855 CommandEnvironmentSource::UnixLoginShell => UNIX_SHELL_ENV_CACHE_TTL,
856 CommandEnvironmentSource::InheritedProcess => UNIX_SHELL_ENV_FALLBACK_TTL,
857 };
858 let expires_at = Instant::now() + ttl;
859 if let Ok(mut guard) = unix_shell_env_cache().write() {
860 *guard = Some(CachedUnixShellEnvironment {
861 imported,
862 expires_at,
863 });
864 }
865}
866
867#[cfg(not(target_os = "windows"))]
868async fn imported_unix_shell_environment_cached() -> ImportedCommandEnvironment {
869 if let Some(cached) = read_cached_unix_shell_environment() {
870 return cached;
871 }
872
873 let _refresh = unix_shell_env_refresh_lock().lock().await;
874 if let Some(cached) = read_cached_unix_shell_environment() {
875 return cached;
876 }
877
878 let imported = match import_unix_shell_environment().await {
879 Ok(imported) => imported,
880 Err(error) => ImportedCommandEnvironment::from_process_env(Some(error)),
881 };
882 write_cached_unix_shell_environment(imported.clone());
883 imported
884}
885
886#[cfg(not(target_os = "windows"))]
887async fn import_unix_shell_environment() -> Result<ImportedCommandEnvironment, String> {
888 let shell = preferred_unix_env_import_shell()
889 .ok_or_else(|| "No Unix login shell available for environment import".to_string())?;
890 let shell_display = shell.to_string_lossy().to_string();
891
892 let mut command = tokio::process::Command::new(&shell);
893 hide_window_for_tokio_command(&mut command);
894 command
895 .arg("-lc")
896 .arg("env")
897 .stdin(Stdio::null())
898 .stdout(Stdio::piped())
899 .stderr(Stdio::piped())
900 .kill_on_drop(true);
901
902 let output = tokio::time::timeout(UNIX_SHELL_ENV_TIMEOUT, command.output())
903 .await
904 .map_err(|_| {
905 format!(
906 "Timed out after {}s while importing environment from {}",
907 UNIX_SHELL_ENV_TIMEOUT.as_secs(),
908 shell_display
909 )
910 })
911 .and_then(|result| {
912 result.map_err(|error| {
913 format!(
914 "Failed to spawn login shell {} for environment import: {}",
915 shell_display, error
916 )
917 })
918 })?;
919
920 if !output.status.success() {
921 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
922 return Err(format!(
923 "Login shell {} exited with status {} while importing environment{}",
924 shell_display,
925 output.status,
926 if stderr.is_empty() {
927 String::new()
928 } else {
929 format!(": {stderr}")
930 }
931 ));
932 }
933
934 let stdout = String::from_utf8_lossy(&output.stdout);
935 let env = parse_env_output(&stdout);
936 if env.is_empty() {
937 return Err(format!(
938 "Login shell {} returned no parseable environment variables",
939 shell_display
940 ));
941 }
942
943 let mut diagnostics = CommandEnvironmentDiagnostics::unix_login_shell(shell_display);
944 diagnostics.path = env.get("PATH").cloned();
945 diagnostics.path_entries = diagnostics.path.as_deref().map(count_path_entries);
946
947 Ok(ImportedCommandEnvironment { env, diagnostics })
948}
949
950#[cfg(any(test, feature = "test-utils"))]
959pub fn clear_command_environment_cache_for_tests() {
960 #[cfg(not(target_os = "windows"))]
961 if let Ok(mut guard) = unix_shell_env_cache().write() {
962 *guard = None;
963 }
964}
965
966#[cfg(any(test, feature = "test-utils"))]
975pub fn prime_command_environment_cache_for_tests(
976 env: HashMap<String, String>,
977 diagnostics: CommandEnvironmentDiagnostics,
978) {
979 #[cfg(not(target_os = "windows"))]
980 write_cached_unix_shell_environment(ImportedCommandEnvironment { env, diagnostics });
981
982 #[cfg(target_os = "windows")]
983 let _ = (env, diagnostics);
984}
985
986#[cfg(target_os = "windows")]
987fn parse_truthy_flag(raw: &str) -> bool {
988 matches!(
989 raw.trim().to_ascii_lowercase().as_str(),
990 "1" | "true" | "yes" | "on"
991 )
992}
993
994pub fn render_command_line<S, I>(program: &str, args: I) -> String
996where
997 S: AsRef<str>,
998 I: IntoIterator<Item = S>,
999{
1000 fn quote(part: &str) -> String {
1001 if part.is_empty()
1002 || part.chars().any(char::is_whitespace)
1003 || part.contains('"')
1004 || part.contains('\'')
1005 {
1006 format!("{part:?}")
1007 } else {
1008 part.to_string()
1009 }
1010 }
1011
1012 let mut parts = vec![quote(program)];
1013 for arg in args {
1014 parts.push(quote(arg.as_ref()));
1015 }
1016 parts.join(" ")
1017}
1018
1019pub fn windows_command_trace_enabled() -> bool {
1025 #[cfg(target_os = "windows")]
1026 {
1027 const ENV_KEYS: [&str; 2] = ["BAMBOO_WINDOWS_CMD_TRACE", "BODHI_WINDOWS_CMD_TRACE"];
1028 ENV_KEYS.iter().any(|key| {
1029 std::env::var(key)
1030 .map(|value| parse_truthy_flag(&value))
1031 .unwrap_or(false)
1032 })
1033 }
1034
1035 #[cfg(not(target_os = "windows"))]
1036 {
1037 false
1038 }
1039}
1040
1041pub fn trace_windows_command<S, I>(scope: &str, program: &str, args: I)
1043where
1044 S: AsRef<str>,
1045 I: IntoIterator<Item = S>,
1046{
1047 #[cfg(target_os = "windows")]
1048 {
1049 if windows_command_trace_enabled() {
1050 let command_line = render_command_line(program, args);
1051 tracing::info!("[windows-cmd-trace] {}: {}", scope, command_line);
1052 }
1053 }
1054
1055 #[cfg(not(target_os = "windows"))]
1056 {
1057 let _ = (scope, program, args);
1058 }
1059}
1060
1061pub fn decode_process_line_lossy(bytes: &mut Vec<u8>) -> String {
1062 if bytes.last() == Some(&b'\n') {
1063 bytes.pop();
1064 if bytes.last() == Some(&b'\r') {
1065 bytes.pop();
1066 }
1067 }
1068
1069 let line = String::from_utf8_lossy(bytes).into_owned();
1070 bytes.clear();
1071 line
1072}
1073
1074#[cfg(target_os = "windows")]
1075fn canonicalize_for_match(path: &Path) -> String {
1076 path.to_string_lossy()
1077 .replace('/', "\\")
1078 .to_ascii_lowercase()
1079}
1080
1081#[cfg(target_os = "windows")]
1082fn looks_like_git_bash(path: &Path) -> bool {
1083 let lower = canonicalize_for_match(path);
1084 if !lower.ends_with("\\bash.exe") {
1085 return false;
1086 }
1087 if lower.ends_with("\\system32\\bash.exe") {
1088 return false;
1089 }
1090 lower.contains("git")
1091}
1092
1093#[cfg(target_os = "windows")]
1094fn first_existing<I>(paths: I) -> Option<PathBuf>
1095where
1096 I: IntoIterator<Item = PathBuf>,
1097{
1098 paths
1099 .into_iter()
1100 .find(|path| path.is_file() && looks_like_git_bash(path))
1101}
1102
1103#[cfg(target_os = "windows")]
1104fn find_git_bash() -> Option<PathBuf> {
1105 if let Some(override_path) = std::env::var_os("BAMBOO_WINDOWS_BASH_PATH") {
1106 let path = PathBuf::from(override_path);
1107 if path.is_file() {
1108 return Some(path);
1109 }
1110 }
1111
1112 let mut known = Vec::new();
1113 for key in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
1114 if let Some(base) = std::env::var_os(key) {
1115 let base = PathBuf::from(base);
1116 known.push(base.join("Git").join("bin").join("bash.exe"));
1117 known.push(base.join("Git").join("usr").join("bin").join("bash.exe"));
1118 }
1119 }
1120 if let Some(local_app_data) = std::env::var_os("LocalAppData") {
1121 let base = PathBuf::from(local_app_data).join("Programs").join("Git");
1122 known.push(base.join("bin").join("bash.exe"));
1123 known.push(base.join("usr").join("bin").join("bash.exe"));
1124 }
1125
1126 if let Some(path) = first_existing(known) {
1127 return Some(path);
1128 }
1129
1130 let path_env = std::env::var_os("PATH")?;
1131 let path_candidates = std::env::split_paths(&path_env).map(|dir| dir.join("bash.exe"));
1132 first_existing(path_candidates)
1133}
1134
1135pub fn preferred_bash_shell() -> ShellCommand {
1136 #[cfg(target_os = "windows")]
1137 {
1138 static WINDOWS_SHELL: OnceLock<ShellCommand> = OnceLock::new();
1139 return WINDOWS_SHELL
1140 .get_or_init(|| {
1141 if let Some(bash) = find_git_bash() {
1142 ShellCommand {
1143 program: bash.to_string_lossy().to_string(),
1144 arg: "-lc",
1145 }
1146 } else {
1147 ShellCommand {
1148 program: "cmd".to_string(),
1149 arg: "/c",
1150 }
1151 }
1152 })
1153 .clone();
1154 }
1155
1156 #[cfg(not(target_os = "windows"))]
1157 {
1158 ShellCommand {
1159 program: "sh".to_string(),
1160 arg: "-c",
1161 }
1162 }
1163}
1164
1165pub fn hide_window_for_std_command(command: &mut std::process::Command) {
1168 #[cfg(target_os = "windows")]
1169 {
1170 use std::os::windows::process::CommandExt;
1171 command.creation_flags(CREATE_NO_WINDOW);
1172 }
1173
1174 #[cfg(not(target_os = "windows"))]
1175 {
1176 let _ = command;
1177 }
1178}
1179
1180pub fn hide_window_for_tokio_command(command: &mut tokio::process::Command) {
1183 #[cfg(target_os = "windows")]
1184 {
1185 command.creation_flags(CREATE_NO_WINDOW);
1186 }
1187
1188 #[cfg(not(target_os = "windows"))]
1189 {
1190 let _ = command;
1191 }
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197
1198 fn overridden_environment(marker: &str) -> ImportedCommandEnvironment {
1199 ImportedCommandEnvironment {
1200 env: HashMap::from([
1201 ("PATH".to_string(), "/usr/bin:/bin".to_string()),
1202 ("BAMBOO_TEST_ENV_MARKER".to_string(), marker.to_string()),
1203 ]),
1204 diagnostics: CommandEnvironmentDiagnostics::inherited_process(Some(marker.to_string())),
1205 }
1206 }
1207
1208 #[tokio::test]
1209 async fn command_environment_override_restores_nested_scope() {
1210 assert!(read_command_environment_override_for_tests().is_none());
1211
1212 let outer = overridden_environment("outer");
1213 let outer_guard =
1214 override_command_environment_for_tests(outer.env.clone(), outer.diagnostics.clone());
1215 let prepared = build_command_environment(&HashMap::new()).await;
1216 assert_eq!(
1217 prepared.env.get("BAMBOO_TEST_ENV_MARKER"),
1218 Some(&"outer".to_string())
1219 );
1220
1221 {
1222 let inner = overridden_environment("inner");
1223 let _inner_guard = override_command_environment_for_tests(
1224 inner.env.clone(),
1225 inner.diagnostics.clone(),
1226 );
1227 let prepared = build_command_environment(&HashMap::new()).await;
1228 assert_eq!(
1229 prepared.env.get("BAMBOO_TEST_ENV_MARKER"),
1230 Some(&"inner".to_string())
1231 );
1232 }
1233
1234 let prepared = build_command_environment(&HashMap::new()).await;
1235 assert_eq!(
1236 prepared.env.get("BAMBOO_TEST_ENV_MARKER"),
1237 Some(&"outer".to_string())
1238 );
1239
1240 drop(outer_guard);
1241 assert!(read_command_environment_override_for_tests().is_none());
1242 }
1243
1244 #[cfg(not(target_os = "windows"))]
1245 #[test]
1246 fn command_environment_overrides_survive_parallel_real_cache_mutation() {
1247 use std::sync::{Arc, Barrier};
1248
1249 const WORKERS: usize = 8;
1250 const ITERATIONS: usize = 128;
1251
1252 let _cache_test_guard = crate::test_support::env_cache_lock_acquire();
1256 let previous_cache = unix_shell_env_cache()
1257 .read()
1258 .unwrap_or_else(|poisoned| poisoned.into_inner())
1259 .clone();
1260 let start = Arc::new(Barrier::new(WORKERS + 1));
1261
1262 let workers = (0..WORKERS)
1263 .map(|worker| {
1264 let start = Arc::clone(&start);
1265 std::thread::spawn(move || {
1266 let marker = format!("worker-{worker}");
1267 let imported = overridden_environment(&marker);
1268 let guard =
1269 override_command_environment_for_tests(imported.env, imported.diagnostics);
1270 start.wait();
1271
1272 let runtime = tokio::runtime::Builder::new_current_thread()
1273 .enable_all()
1274 .build()
1275 .expect("build current-thread runtime");
1276 runtime.block_on(async {
1277 for _ in 0..ITERATIONS {
1278 let prepared = build_command_environment(&HashMap::new()).await;
1279 assert_eq!(
1280 prepared
1281 .env
1282 .get("BAMBOO_TEST_ENV_MARKER")
1283 .map(String::as_str),
1284 Some(marker.as_str())
1285 );
1286 assert_eq!(
1287 prepared.diagnostics.import_error.as_deref(),
1288 Some(marker.as_str())
1289 );
1290 tokio::task::yield_now().await;
1291 }
1292 });
1293
1294 drop(guard);
1295 assert!(read_command_environment_override_for_tests().is_none());
1296 })
1297 })
1298 .collect::<Vec<_>>();
1299
1300 let mutator = std::thread::spawn({
1301 let start = Arc::clone(&start);
1302 move || {
1303 start.wait();
1304 for iteration in 0..(WORKERS * ITERATIONS) {
1305 if iteration % 2 == 0 {
1306 if let Ok(mut cache) = unix_shell_env_cache().write() {
1307 *cache = None;
1308 }
1309 } else {
1310 let mut imported = overridden_environment("production-cache");
1311 imported.diagnostics =
1312 CommandEnvironmentDiagnostics::unix_login_shell("/bin/sh".to_string());
1313 write_cached_unix_shell_environment(imported);
1314 }
1315 std::thread::yield_now();
1316 }
1317 }
1318 });
1319
1320 let worker_results = workers
1321 .into_iter()
1322 .map(std::thread::JoinHandle::join)
1323 .collect::<Vec<_>>();
1324 let mutator_result = mutator.join();
1325
1326 *unix_shell_env_cache()
1327 .write()
1328 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous_cache;
1329
1330 for result in worker_results {
1331 result.expect("parallel command-environment worker");
1332 }
1333 mutator_result.expect("production cache mutator");
1334 }
1335
1336 #[test]
1337 fn parse_env_output_ignores_leading_noise_and_handles_multiline_values() {
1338 let parsed = parse_env_output(
1339 "warning before env\nPATH=/usr/bin:/bin\nMULTI=line1\nline2\nHOME=/Users/test\n",
1340 );
1341
1342 assert_eq!(
1343 parsed.get("PATH").map(String::as_str),
1344 Some("/usr/bin:/bin")
1345 );
1346 assert_eq!(
1347 parsed.get("MULTI").map(String::as_str),
1348 Some("line1\nline2")
1349 );
1350 assert_eq!(parsed.get("HOME").map(String::as_str), Some("/Users/test"));
1351 }
1352
1353 #[test]
1354 fn diagnostics_summary_mentions_source_and_path_entries() {
1355 let diagnostics = CommandEnvironmentDiagnostics {
1356 source: CommandEnvironmentSource::UnixLoginShell,
1357 import_shell: Some("/bin/zsh".to_string()),
1358 import_error: None,
1359 path: Some("/usr/bin:/bin".to_string()),
1360 path_entries: Some(2),
1361 python: PythonDiscoveryDiagnostics {
1362 configured: Some("python3".to_string()),
1363 resolved: Some("/usr/bin/python3".to_string()),
1364 invocation: Some("/usr/bin/python3".to_string()),
1365 source: Some("path".to_string()),
1366 tried: vec!["python3".to_string(), "python".to_string()],
1367 tried_preview: vec!["python3".to_string(), "python".to_string()],
1368 tried_total: 2,
1369 tried_truncated: false,
1370 hint: None,
1371 },
1372 };
1373
1374 let summary = diagnostics.summary();
1375 assert!(summary.contains("env_source=unix_login_shell"));
1376 assert!(summary.contains("import_shell=/bin/zsh"));
1377 assert!(summary.contains("path_entries=2"));
1378 }
1379
1380 #[test]
1381 fn test_render_command_line_simple() {
1382 let result = render_command_line("echo", vec!["hello", "world"]);
1383 assert_eq!(result, "echo hello world");
1384 }
1385
1386 #[test]
1387 fn test_render_command_line_with_spaces() {
1388 let result = render_command_line("cmd", vec!["arg with spaces", "normal"]);
1389 assert_eq!(result, r#"cmd "arg with spaces" normal"#);
1390 }
1391
1392 #[test]
1393 fn test_render_command_line_with_quotes() {
1394 let result = render_command_line("cmd", vec!["arg\"with\"quotes"]);
1395 assert_eq!(result, r#"cmd "arg\"with\"quotes""#);
1396 }
1397
1398 #[test]
1399 fn test_render_command_line_with_single_quotes() {
1400 let result = render_command_line("cmd", vec!["arg'with'single"]);
1401 assert_eq!(result, r#"cmd "arg'with'single""#);
1402 }
1403
1404 #[test]
1405 fn test_render_command_line_empty_args() {
1406 let result = render_command_line("program", Vec::<&str>::new());
1407 assert_eq!(result, "program");
1408 }
1409
1410 #[test]
1411 fn test_render_command_line_empty_arg() {
1412 let result = render_command_line("cmd", vec![""]);
1413 assert_eq!(result, r#"cmd """#);
1414 }
1415
1416 #[test]
1417 fn test_render_command_line_multiple_empty_args() {
1418 let result = render_command_line("cmd", vec!["", "valid", ""]);
1419 assert_eq!(result, r#"cmd "" valid """#);
1420 }
1421
1422 #[test]
1423 fn test_render_command_line_program_with_spaces() {
1424 let result = render_command_line("my program", vec!["arg1"]);
1425 assert_eq!(result, r#""my program" arg1"#);
1426 }
1427
1428 #[test]
1429 fn test_render_command_line_no_args() {
1430 let result = render_command_line("standalone", Vec::<&str>::new());
1431 assert_eq!(result, "standalone");
1432 }
1433
1434 #[test]
1435 fn test_render_command_line_whitespace_in_arg() {
1436 let result = render_command_line("cmd", vec!["arg\twith\ttabs"]);
1437 assert!(result.starts_with("cmd \""));
1439 assert!(result.ends_with("\""));
1440 }
1441
1442 #[test]
1443 fn test_render_command_line_newline_in_arg() {
1444 let result = render_command_line("cmd", vec!["arg\nwith\nnewline"]);
1445 assert!(result.starts_with("cmd \""));
1447 assert!(result.ends_with("\""));
1448 }
1449
1450 #[test]
1451 fn test_render_command_line_complex() {
1452 let result = render_command_line(
1453 "my program",
1454 vec![
1455 "simple",
1456 "with spaces",
1457 "with\"quote",
1458 "with'apostrophe",
1459 "",
1460 ],
1461 );
1462 assert!(result.contains("my program"));
1463 assert!(result.contains("simple"));
1464 assert!(result.contains("with spaces"));
1465 }
1466
1467 #[test]
1468 fn test_render_command_line_special_chars() {
1469 let result = render_command_line("cmd", vec!["arg$var", "arg*glob"]);
1470 assert_eq!(result, "cmd arg$var arg*glob");
1471 }
1472
1473 #[test]
1474 fn test_render_command_line_backslash() {
1475 let result = render_command_line("cmd", vec![r"arg\with\backslash"]);
1476 assert_eq!(result, r"cmd arg\with\backslash");
1477 }
1478
1479 #[test]
1480 fn test_render_command_line_unicode() {
1481 let result = render_command_line("cmd", vec!["unicode中文", "emoji😀"]);
1482 assert_eq!(result, "cmd unicode中文 emoji😀");
1483 }
1484
1485 #[test]
1486 fn test_decode_process_line_lossy_strips_newline() {
1487 let mut bytes = b"hello\r\n".to_vec();
1488 let decoded = decode_process_line_lossy(&mut bytes);
1489 assert_eq!(decoded, "hello");
1490 assert!(bytes.is_empty());
1491 }
1492
1493 #[test]
1494 fn test_decode_process_line_lossy_allows_invalid_utf8() {
1495 let mut bytes = vec![0xff, b'\n'];
1496 let decoded = decode_process_line_lossy(&mut bytes);
1497 assert_eq!(decoded, "\u{fffd}");
1498 assert!(bytes.is_empty());
1499 }
1500
1501 #[test]
1502 fn resolve_python_diagnostics_prefers_configured_existing_file() {
1503 let dir = tempfile::tempdir().unwrap();
1504 let configured = dir.path().join(if cfg!(target_os = "windows") {
1505 "python.exe"
1506 } else {
1507 "python3"
1508 });
1509 std::fs::write(&configured, b"").unwrap();
1510
1511 let mut env = HashMap::new();
1512 env.insert(
1513 "BAMBOO_PYTHON".to_string(),
1514 configured.to_string_lossy().to_string(),
1515 );
1516 env.insert("PATH".to_string(), String::new());
1517
1518 let diagnostics = resolve_python_diagnostics(&env);
1519 assert_eq!(
1520 diagnostics.configured,
1521 Some(configured.to_string_lossy().to_string())
1522 );
1523 assert_eq!(
1524 diagnostics.resolved,
1525 Some(configured.to_string_lossy().to_string())
1526 );
1527 assert_eq!(
1528 diagnostics.invocation,
1529 Some(configured.to_string_lossy().to_string())
1530 );
1531 assert_eq!(diagnostics.source, Some("configured".to_string()));
1532 assert_eq!(diagnostics.tried_total, diagnostics.tried.len());
1533 assert!(!diagnostics.tried_preview.is_empty());
1534 assert!(diagnostics.hint.is_none());
1535 }
1536
1537 #[test]
1538 fn resolve_python_diagnostics_finds_python_on_path() {
1539 let dir = tempfile::tempdir().unwrap();
1540 let python_name = if cfg!(target_os = "windows") {
1541 "python.exe"
1542 } else {
1543 "python3"
1544 };
1545 let python = dir.path().join(python_name);
1546 std::fs::write(&python, b"").unwrap();
1547
1548 let mut env = HashMap::new();
1549 env.insert(
1550 "PATH".to_string(),
1551 std::env::join_paths([dir.path()])
1552 .unwrap()
1553 .to_string_lossy()
1554 .to_string(),
1555 );
1556
1557 let diagnostics = resolve_python_diagnostics(&env);
1558 assert_eq!(
1559 diagnostics.resolved,
1560 Some(python.to_string_lossy().to_string())
1561 );
1562 assert_eq!(
1563 diagnostics.invocation,
1564 Some(python.to_string_lossy().to_string())
1565 );
1566 assert_eq!(diagnostics.source, Some("path".to_string()));
1567 assert_eq!(diagnostics.tried_total, diagnostics.tried.len());
1568 assert!(!diagnostics.tried_preview.is_empty());
1569 assert!(diagnostics.hint.is_none());
1570 }
1571
1572 #[test]
1573 fn finalize_python_diagnostics_adds_preview_and_hint_for_unresolved_case() {
1574 let diagnostics = finalize_python_diagnostics(PythonDiscoveryDiagnostics {
1575 configured: None,
1576 resolved: None,
1577 invocation: None,
1578 source: None,
1579 tried: vec![
1580 "py -3".to_string(),
1581 "py".to_string(),
1582 "python".to_string(),
1583 "python3".to_string(),
1584 "custom/python".to_string(),
1585 "another/python".to_string(),
1586 "last/python".to_string(),
1587 ],
1588 tried_preview: Vec::new(),
1589 tried_total: 0,
1590 tried_truncated: false,
1591 hint: None,
1592 });
1593
1594 assert!(diagnostics.resolved.is_none());
1595 assert_eq!(diagnostics.tried_total, 7);
1596 assert_eq!(diagnostics.tried_preview.len(), PYTHON_TRIED_PREVIEW_LIMIT);
1597 assert!(diagnostics.tried_truncated);
1598 assert!(diagnostics.hint.is_some());
1599 }
1600
1601 #[test]
1602 fn python_candidate_sequence_includes_default_names() {
1603 let env = HashMap::new();
1604 let (candidates, configured) = python_candidate_sequence(&env, Some("/custom/python"));
1605 assert_eq!(configured, Some("/custom/python".to_string()));
1606 assert_eq!(
1607 candidates.first().map(PythonCandidate::display).as_deref(),
1608 Some("/custom/python")
1609 );
1610 assert!(candidates
1611 .iter()
1612 .any(|candidate| candidate.program == "python"));
1613 #[cfg(target_os = "windows")]
1614 {
1615 assert!(candidates.iter().any(|candidate| candidate.program == "py"));
1616 assert!(candidates
1617 .iter()
1618 .any(|candidate| candidate.display() == "py -3"));
1619 }
1620 #[cfg(not(target_os = "windows"))]
1621 assert!(candidates
1622 .iter()
1623 .any(|candidate| candidate.program == "python3"));
1624 }
1625
1626 #[cfg(target_os = "windows")]
1627 #[test]
1628 fn windows_path_resolution_supports_pathext_executables() {
1629 let dir = tempfile::tempdir().unwrap();
1630 let python = dir.path().join("python.exe");
1631 std::fs::write(&python, b"").unwrap();
1632
1633 let path = std::env::join_paths([dir.path()])
1634 .unwrap()
1635 .to_string_lossy()
1636 .to_string();
1637 let resolved = resolve_executable_from_env_path(Some(&path), "python");
1638 assert_eq!(resolved, Some(python));
1639 }
1640
1641 #[cfg(target_os = "windows")]
1642 #[test]
1643 fn windows_python_candidates_include_launcher_and_common_paths() {
1644 let mut env = HashMap::new();
1645 env.insert(
1646 "LocalAppData".to_string(),
1647 r"C:\Users\dev\AppData\Local".to_string(),
1648 );
1649 env.insert(
1650 "AppData".to_string(),
1651 r"C:\Users\dev\AppData\Roaming".to_string(),
1652 );
1653 env.insert("USERPROFILE".to_string(), r"C:\Users\dev".to_string());
1654 env.insert("ProgramFiles".to_string(), r"C:\Program Files".to_string());
1655
1656 let (candidates, _) = python_candidate_sequence(&env, None);
1657 assert!(candidates
1658 .iter()
1659 .any(|candidate| candidate.display() == "py -3"));
1660 assert!(candidates.iter().any(|candidate| {
1661 candidate
1662 .path_hint
1663 .as_ref()
1664 .map(|path| canonicalize_for_match(path).ends_with("\\python312\\python.exe"))
1665 .unwrap_or(false)
1666 }));
1667 }
1668
1669 #[cfg(target_os = "windows")]
1670 #[test]
1671 fn test_looks_like_git_bash_accepts_git_paths() {
1672 assert!(looks_like_git_bash(Path::new(
1673 r"C:\Program Files\Git\bin\bash.exe"
1674 )));
1675 assert!(looks_like_git_bash(Path::new(
1676 r"C:\Users\dev\scoop\apps\git\current\usr\bin\bash.exe"
1677 )));
1678 }
1679
1680 #[cfg(target_os = "windows")]
1681 #[test]
1682 fn test_looks_like_git_bash_rejects_system32_bash() {
1683 assert!(!looks_like_git_bash(Path::new(
1684 r"C:\Windows\System32\bash.exe"
1685 )));
1686 }
1687}