use hashbrown::HashSet;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use once_cell::sync::Lazy;
use regex::Regex;
static UNIX_ENV_PATTERN: Lazy<Regex> =
Lazy::new(
|| match Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)|\$\{([A-Za-z_][A-Za-z0-9_]*)\}") {
Ok(regex) => regex,
Err(error) => panic!("valid unix env regex must compile: {error}"),
},
);
static WINDOWS_ENV_PATTERN: Lazy<Regex> =
Lazy::new(|| match Regex::new(r"%([A-Za-z_][A-Za-z0-9_]*)%") {
Ok(regex) => regex,
Err(error) => panic!("valid windows env regex must compile: {error}"),
});
fn expand_entry(entry: &str, workspace_root: &Path) -> Option<PathBuf> {
let trimmed = entry.trim();
if trimmed.is_empty() {
return None;
}
let expanded_env = expand_environment_variables(trimmed);
let mut path = if let Some(rest) = expanded_env.strip_prefix("~/") {
dirs::home_dir().map(|home| home.join(rest))?
} else if expanded_env == "~" {
dirs::home_dir()?
} else {
PathBuf::from(expanded_env)
};
if path.is_relative() {
path = workspace_root.join(path);
}
if path.is_dir() { Some(path) } else { None }
}
fn expand_environment_variables(input: &str) -> String {
let unix_expanded = UNIX_ENV_PATTERN
.replace_all(input, |caps: ®ex::Captures<'_>| {
let var_name = caps
.get(1)
.or_else(|| caps.get(2))
.map(|m| m.as_str())
.unwrap_or_default();
match var_name {
"HOME" => std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| {
dirs::home_dir()
.map(|p| p.display().to_string())
.unwrap_or_default()
}),
_ => std::env::var(var_name).unwrap_or_default(),
}
})
.into_owned();
WINDOWS_ENV_PATTERN
.replace_all(&unix_expanded, |caps: ®ex::Captures<'_>| {
let var_name = &caps[1];
match var_name {
"HOME" | "USERPROFILE" => std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| {
dirs::home_dir()
.map(|p| p.display().to_string())
.unwrap_or_default()
}),
_ => std::env::var(var_name).unwrap_or_default(),
}
})
.into_owned()
}
pub(crate) fn compute_extra_search_paths(
entries: &[String],
workspace_root: &Path,
) -> Vec<PathBuf> {
let mut results = Vec::new();
let mut seen = HashSet::new();
for entry in entries {
if let Some(path) = expand_entry(entry, workspace_root)
&& seen.insert(path.clone())
{
results.push(path);
}
}
results
}
#[allow(dead_code)] pub(crate) fn resolve_program_path_from_paths(
program: &str,
paths: impl Iterator<Item = PathBuf>,
) -> Option<String> {
for path_dir in paths {
let full_path = path_dir.join(program);
if full_path.is_file() {
return Some(full_path.to_string_lossy().into_owned());
}
}
None
}
pub(crate) fn merge_path_env(current: Option<&OsStr>, extra_paths: &[PathBuf]) -> Option<OsString> {
if extra_paths.is_empty() && current.is_none() {
return None;
}
let mut combined: Vec<PathBuf> = current
.map(|value| std::env::split_paths(value).collect())
.unwrap_or_default();
let fallback_paths = [
"~/.cargo/bin", "~/.local/bin", "~/.nvm/versions/node/*/bin", "~/.bun/bin", "/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin", ];
for fallback_path_pattern in &fallback_paths {
if let Some(home) = dirs::home_dir() {
let expanded = fallback_path_pattern.replace("~", &home.display().to_string());
if expanded.contains('*') {
let base_pattern = expanded.split('*').next().unwrap_or("");
let base_path = PathBuf::from(base_pattern.trim_end_matches('/'));
if base_path.exists() && !combined.iter().any(|existing| existing == &base_path) {
combined.push(base_path);
}
} else {
let path = PathBuf::from(expanded);
if path.exists() && !combined.iter().any(|existing| existing == &path) {
combined.push(path);
}
}
}
}
for path in extra_paths.iter().rev() {
if !combined.iter().any(|existing| existing == path) {
combined.insert(0, path.clone());
}
}
if combined.is_empty() {
return None;
}
std::env::join_paths(combined).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compute_extra_search_paths_expands_home_and_env() {
let workspace = std::env::current_dir().expect("workspace");
let home_dir = PathBuf::from(std::env::var("HOME").expect("HOME"));
let entries = vec!["~/does/not/exist".to_string(), "$HOME".to_string()];
let resolved = compute_extra_search_paths(&entries, &workspace);
assert_eq!(resolved, vec![home_dir]);
}
#[test]
fn merge_path_env_preprends_extra_entries() {
let extra = vec![PathBuf::from("/extra/bin"), PathBuf::from("/another/bin")];
let current = Some(OsStr::new("/usr/bin:/bin"));
let merged = merge_path_env(current, &extra).expect("merged path");
let paths: Vec<PathBuf> = std::env::split_paths(&merged).collect();
assert_eq!(paths[0], PathBuf::from("/extra/bin"));
assert_eq!(paths[1], PathBuf::from("/another/bin"));
assert_eq!(paths[2], PathBuf::from("/usr/bin"));
}
#[test]
fn resolve_program_path_uses_extra_dirs() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let bin_dir = temp_dir.path();
let fake = bin_dir.join("fake-tool");
std::fs::write(&fake, b"#!/bin/sh\n").expect("write fake tool");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&fake).expect("metadata").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&fake, perms).expect("set perms");
}
let resolved =
resolve_program_path_from_paths("fake-tool", [bin_dir.to_path_buf()].into_iter());
assert_eq!(resolved, Some(fake.to_string_lossy().into_owned()))
}
}