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