Skip to main content

vtcode_core/tools/
path_env.rs

1use hashbrown::HashSet;
2use std::ffi::{OsStr, OsString};
3use std::path::{Path, PathBuf};
4
5use regex::Regex;
6use std::sync::LazyLock;
7
8/// Regex pattern for Unix-style environment variables: $VAR or ${VAR}
9///
10/// # Panics
11///
12/// Panics at first access if the regex pattern fails to compile. The pattern
13/// is a string literal validated by tests; a panic here indicates a typo
14/// in the pattern itself, not a runtime failure.
15static UNIX_ENV_PATTERN: LazyLock<Regex> =
16    LazyLock::new(|| match Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)|\$\{([A-Za-z_][A-Za-z0-9_]*)\}") {
17        Ok(regex) => regex,
18        Err(error) => panic!("valid unix env regex must compile: {error}"),
19    });
20
21/// Regex pattern for Windows-style environment variables: %VAR%
22///
23/// # Panics
24///
25/// Panics at first access if the regex pattern fails to compile. The pattern
26/// is a string literal validated by tests; a panic here indicates a typo
27/// in the pattern itself, not a runtime failure.
28static WINDOWS_ENV_PATTERN: LazyLock<Regex> = LazyLock::new(|| match Regex::new(r"%([A-Za-z_][A-Za-z0-9_]*)%") {
29    Ok(regex) => regex,
30    Err(error) => panic!("valid windows env regex must compile: {error}"),
31});
32
33/// Expand environment variables and home directory within a path entry.
34fn expand_entry(entry: &str, workspace_root: &Path) -> Option<PathBuf> {
35    let trimmed = entry.trim();
36    if trimmed.is_empty() {
37        return None;
38    }
39
40    let expanded_env = expand_environment_variables(trimmed);
41    let mut path = if let Some(rest) = expanded_env.strip_prefix("~/") {
42        dirs::home_dir().map(|home| home.join(rest))?
43    } else if expanded_env == "~" {
44        dirs::home_dir()?
45    } else {
46        PathBuf::from(expanded_env)
47    };
48
49    if path.is_relative() {
50        path = workspace_root.join(path);
51    }
52
53    path.is_dir().then_some(path)
54}
55
56fn expand_environment_variables(input: &str) -> String {
57    let unix_expanded = UNIX_ENV_PATTERN
58        .replace_all(input, |caps: &regex::Captures<'_>| {
59            let var_name = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str()).unwrap_or_default();
60            // Try to get the environment variable, with special handling for HOME
61            match var_name {
62                "HOME" => std::env::var("HOME")
63                    .or_else(|_| std::env::var("USERPROFILE"))
64                    .unwrap_or_else(|_| dirs::home_dir().map(|p| p.display().to_string()).unwrap_or_default()),
65                _ => std::env::var(var_name).unwrap_or_default(),
66            }
67        })
68        .into_owned();
69
70    WINDOWS_ENV_PATTERN
71        .replace_all(&unix_expanded, |caps: &regex::Captures<'_>| {
72            let var_name = &caps[1];
73            // Try to get the environment variable, with special handling for HOME/USERPROFILE
74            match var_name {
75                "HOME" | "USERPROFILE" => std::env::var("USERPROFILE")
76                    .or_else(|_| std::env::var("HOME"))
77                    .unwrap_or_else(|_| dirs::home_dir().map(|p| p.display().to_string()).unwrap_or_default()),
78                _ => std::env::var(var_name).unwrap_or_default(),
79            }
80        })
81        .into_owned()
82}
83
84/// Compute the list of additional search paths for command execution.
85pub(crate) fn compute_extra_search_paths(entries: &[String], workspace_root: &Path) -> Vec<PathBuf> {
86    let mut results = Vec::new();
87    let mut seen = HashSet::new();
88
89    for entry in entries {
90        if let Some(path) = expand_entry(entry, workspace_root)
91            && seen.insert(path.clone())
92        {
93            results.push(path);
94        }
95    }
96
97    results
98}
99
100/// Attempt to resolve a program against the provided path iterator.
101// Function is deprecated but kept for explicit path iteration tests
102#[allow(
103    dead_code,
104    reason = "Deprecated path-iteration helper kept for explicit resolution tests."
105)]
106pub(crate) fn resolve_program_path_from_paths(program: &str, paths: impl Iterator<Item = PathBuf>) -> Option<String> {
107    for path_dir in paths {
108        let full_path = path_dir.join(program);
109        if full_path.is_file() {
110            return Some(full_path.to_string_lossy().into_owned());
111        }
112    }
113    None
114}
115
116// NOTE: Static resolution of program paths is intentionally deprecated in favor of
117// always executing commands through the user's login shell (via `resolve_fallback_shell`).
118// The `resolve_program_path_from_paths` helper remains for explicit path iteration tests.
119
120/// Merge additional search paths into an existing PATH environment value.
121pub(crate) fn merge_path_env(current: Option<&OsStr>, extra_paths: &[PathBuf]) -> Option<OsString> {
122    if extra_paths.is_empty() && current.is_none() {
123        return None;
124    }
125
126    let mut combined: Vec<PathBuf> = current.map(|value| std::env::split_paths(value).collect()).unwrap_or_default();
127
128    // Ensure common development tool paths are included for fallback
129    // These paths are often added by shell initialization files but we include them
130    // to ensure development tools work even if shell initialization is incomplete
131    let fallback_paths = [
132        "~/.cargo/bin",               // Rust toolchain (cargo, rustc)
133        "~/.local/bin",               // User-installed binaries
134        "~/.nvm/versions/node/*/bin", // Node Version Manager
135        "~/.bun/bin",                 // Bun package manager
136        "/opt/homebrew/bin",          // Homebrew on Apple Silicon
137        "/usr/local/bin",             // Local binaries
138        "/opt/local/bin",             // MacPorts
139    ];
140
141    for fallback_path_pattern in &fallback_paths {
142        // Expand tilde to home directory
143        if let Some(home) = dirs::home_dir() {
144            let expanded = fallback_path_pattern.replace("~", &home.display().to_string());
145
146            // For glob patterns like nvm, just add the base directory if home/nvm exists
147            if expanded.contains('*') {
148                let base_pattern = expanded.split('*').next().unwrap_or("");
149                let base_path = PathBuf::from(base_pattern.trim_end_matches('/'));
150                if base_path.exists() && !combined.iter().any(|existing| existing == &base_path) {
151                    combined.push(base_path);
152                }
153            } else {
154                let path = PathBuf::from(expanded);
155                if path.exists() && !combined.iter().any(|existing| existing == &path) {
156                    combined.push(path);
157                }
158            }
159        }
160    }
161
162    for path in extra_paths.iter().rev() {
163        if !combined.iter().any(|existing| existing == path) {
164            combined.insert(0, path.clone());
165        }
166    }
167
168    if combined.is_empty() {
169        return None;
170    }
171
172    std::env::join_paths(combined).ok()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn compute_extra_search_paths_expands_home_and_env() {
181        let workspace = std::env::current_dir().expect("workspace");
182        let home_dir = PathBuf::from(std::env::var("HOME").expect("HOME"));
183        let entries = vec!["~/does/not/exist".to_string(), "$HOME".to_string()];
184        let resolved = compute_extra_search_paths(&entries, &workspace);
185        assert_eq!(resolved, vec![home_dir]);
186    }
187
188    #[test]
189    fn merge_path_env_preprends_extra_entries() {
190        let extra = vec![PathBuf::from("/extra/bin"), PathBuf::from("/another/bin")];
191        let current = Some(OsStr::new("/usr/bin:/bin"));
192        let merged = merge_path_env(current, &extra).expect("merged path");
193        let paths: Vec<PathBuf> = std::env::split_paths(&merged).collect();
194        assert_eq!(paths[0], PathBuf::from("/extra/bin"));
195        assert_eq!(paths[1], PathBuf::from("/another/bin"));
196        assert_eq!(paths[2], PathBuf::from("/usr/bin"));
197    }
198
199    #[test]
200    fn resolve_program_path_uses_extra_dirs() {
201        let temp_dir = tempfile::tempdir().expect("tempdir");
202        let bin_dir = temp_dir.path();
203        let fake = bin_dir.join("fake-tool");
204        std::fs::write(&fake, b"#!/bin/sh\n").expect("write fake tool");
205        #[cfg(unix)]
206        {
207            use std::os::unix::fs::PermissionsExt;
208            let mut perms = std::fs::metadata(&fake).expect("metadata").permissions();
209            perms.set_mode(0o755);
210            std::fs::set_permissions(&fake, perms).expect("set perms");
211        }
212
213        let resolved = resolve_program_path_from_paths("fake-tool", [bin_dir.to_path_buf()].into_iter());
214        assert_eq!(resolved, Some(fake.to_string_lossy().into_owned()))
215    }
216}
217
218#[cfg(test)]
219mod regex_validation_tests {
220    #[test]
221    fn unix_env_regex_pattern_compiles() {
222        // Validates that the UNIX_ENV_PATTERN regex string literal is a valid
223        // regex. A failure here indicates a typo in the pattern, which would
224        // otherwise panic at first access in production.
225        let _ = &*super::UNIX_ENV_PATTERN;
226    }
227
228    #[test]
229    fn windows_env_regex_pattern_compiles() {
230        // Validates that the WINDOWS_ENV_PATTERN regex string literal is a valid
231        // regex. A failure here indicates a typo in the pattern, which would
232        // otherwise panic at first access in production.
233        let _ = &*super::WINDOWS_ENV_PATTERN;
234    }
235}