vtcode_core/tools/
path_env.rs1use hashbrown::HashSet;
2use std::ffi::{OsStr, OsString};
3use std::path::{Path, PathBuf};
4
5use regex::Regex;
6use std::sync::LazyLock;
7
8static 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
21static 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
33fn 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: ®ex::Captures<'_>| {
59 let var_name = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str()).unwrap_or_default();
60 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: ®ex::Captures<'_>| {
72 let var_name = &caps[1];
73 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
84pub(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#[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
116pub(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 let fallback_paths = [
132 "~/.cargo/bin", "~/.local/bin", "~/.nvm/versions/node/*/bin", "~/.bun/bin", "/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin", ];
140
141 for fallback_path_pattern in &fallback_paths {
142 if let Some(home) = dirs::home_dir() {
144 let expanded = fallback_path_pattern.replace("~", &home.display().to_string());
145
146 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 let _ = &*super::UNIX_ENV_PATTERN;
226 }
227
228 #[test]
229 fn windows_env_regex_pattern_compiles() {
230 let _ = &*super::WINDOWS_ENV_PATTERN;
234 }
235}