vtcode_core/tools/
path_env.rs1use hashbrown::HashSet;
2use std::ffi::{OsStr, OsString};
3use std::path::{Path, PathBuf};
4
5use once_cell::sync::Lazy;
6use regex::Regex;
7
8static 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
17static 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
24fn 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: ®ex::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 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: ®ex::Captures<'_>| {
71 let var_name = &caps[1];
72 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
87pub(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#[expect(dead_code)] pub(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
121pub(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 let fallback_paths = [
139 "~/.cargo/bin", "~/.local/bin", "~/.nvm/versions/node/*/bin", "~/.bun/bin", "/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin", ];
147
148 for fallback_path_pattern in &fallback_paths {
149 if let Some(home) = dirs::home_dir() {
151 let expanded = fallback_path_pattern.replace("~", &home.display().to_string());
152
153 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}