Skip to main content

bamboo_infrastructure/process/
process_utils.rs

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    /// Test-only environment selection is deliberately separate from the
144    /// process-global production cache. Rust's test harness runs tests on
145    /// parallel OS threads, so a scoped thread-local override lets each test
146    /// observe its own fixture without clearing, priming, or racing a real
147    /// login-shell refresh.
148    static COMMAND_ENVIRONMENT_OVERRIDE_FOR_TESTS:
149        RefCell<Option<ImportedCommandEnvironment>> = const { RefCell::new(None) };
150}
151
152/// Restores the previous test-only command environment when its scope ends.
153///
154/// The `Rc` marker deliberately makes this guard `!Send` and `!Sync`: callers
155/// must execute `build_command_environment` on the same thread that installed
156/// the override. This matches the default current-thread Tokio test runtime and
157/// prevents a multi-threaded test from silently losing its thread-local fixture.
158#[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/// Clear the process-global command-environment cache for legacy test callers.
951///
952/// New tests should prefer
953/// [`crate::test_support::override_command_environment`], whose scoped
954/// thread-local fixture cannot race parallel tests or an in-flight cache
955/// refresh. This compatibility helper intentionally retains its original
956/// global mutation semantics and is available only in test builds or with the
957/// `test-utils` feature.
958#[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/// Prime the process-global command-environment cache for legacy test callers.
967///
968/// New tests should prefer
969/// [`crate::test_support::override_command_environment`], whose scoped
970/// thread-local fixture cannot race parallel tests or an in-flight cache
971/// refresh. This compatibility helper intentionally retains its original
972/// global mutation semantics and is available only in test builds or with the
973/// `test-utils` feature.
974#[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
994/// Render a command line for diagnostics/logging.
995pub 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
1019/// Whether Windows command tracing is enabled.
1020///
1021/// Supported env vars:
1022/// - `BAMBOO_WINDOWS_CMD_TRACE`
1023/// - `BODHI_WINDOWS_CMD_TRACE`
1024pub 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
1041/// Emit a command trace log on Windows when trace switch is enabled.
1042pub 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
1165/// Configure a standard-library process command to avoid showing a console
1166/// window on Windows. No-op on non-Windows platforms.
1167pub 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
1180/// Configure a Tokio process command to avoid showing a console window on
1181/// Windows. No-op on non-Windows platforms.
1182pub 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        // Keep any other process-global cache test out of this mutation window.
1253        // Downstream tests do not need this lock: their scoped overrides never
1254        // touch the production cache.
1255        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        // Tabs should trigger quoting
1438        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        // Newlines should trigger quoting
1446        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}