use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::LazyLock;
static BRACED_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap());
static UNBRACED_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").unwrap());
#[derive(Debug, Clone)]
pub struct EnvContext {
pub home_dir: PathBuf,
pub current_dir: PathBuf,
pub username: String,
pub tool_paths: HashMap<String, PathBuf>,
pub custom_vars: HashMap<String, String>,
}
impl Default for EnvContext {
fn default() -> Self {
Self::new()
}
}
impl EnvContext {
pub fn new() -> Self {
Self {
home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")),
current_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
username: std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string()),
tool_paths: HashMap::new(),
custom_vars: HashMap::new(),
}
}
#[allow(dead_code)] pub fn with_tool_path(mut self, tool: &str, path: PathBuf) -> Self {
self.tool_paths.insert(tool.to_string(), path);
self
}
#[allow(dead_code)] pub fn with_var(mut self, key: &str, value: &str) -> Self {
self.custom_vars.insert(key.to_string(), value.to_string());
self
}
}
pub fn expand_value(value: &str, ctx: &EnvContext) -> String {
let mut result = value.to_string();
result = BRACED_RE
.replace_all(&result, |caps: ®ex::Captures| {
let var_name = &caps[1];
resolve_variable(var_name, ctx)
})
.to_string();
result = UNBRACED_RE
.replace_all(&result, |caps: ®ex::Captures| {
let var_name = &caps[1];
resolve_variable(var_name, ctx)
})
.to_string();
result
}
fn resolve_variable(name: &str, ctx: &EnvContext) -> String {
match name {
"HOME" => return ctx.home_dir.to_string_lossy().to_string(),
"PWD" => return ctx.current_dir.to_string_lossy().to_string(),
"USER" => return ctx.username.clone(),
_ => {}
}
if name.starts_with("JARVY_") && name.ends_with("_PATH") {
let tool_name = name
.strip_prefix("JARVY_")
.expect("guarded by starts_with check")
.strip_suffix("_PATH")
.expect("guarded by ends_with check")
.to_lowercase();
if let Some(path) = ctx.tool_paths.get(&tool_name) {
return path.to_string_lossy().to_string();
}
}
if let Some(value) = ctx.custom_vars.get(name) {
return value.clone();
}
std::env::var(name).unwrap_or_else(|_| format!("${}", name))
}
pub fn expand_path(path: &str, ctx: &EnvContext) -> PathBuf {
let expanded = if path.starts_with('~') {
path.replacen('~', &ctx.home_dir.to_string_lossy(), 1)
} else {
path.to_string()
};
PathBuf::from(expand_value(&expanded, ctx))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_home() {
let ctx = EnvContext::new();
let result = expand_value("$HOME/bin", &ctx);
assert!(result.contains("/bin"));
assert!(!result.contains("$HOME"));
}
#[test]
fn test_expand_braced() {
let ctx = EnvContext::new();
let result = expand_value("${HOME}/projects/${USER}", &ctx);
assert!(!result.contains("${"));
assert!(result.contains("/projects/"));
}
#[test]
fn test_expand_pwd() {
let ctx = EnvContext::new();
let result = expand_value("$PWD/.env", &ctx);
assert!(result.ends_with("/.env"));
assert!(!result.contains("$PWD"));
}
#[test]
fn test_expand_custom_var() {
let ctx = EnvContext::new().with_var("MY_VAR", "my_value");
let result = expand_value("prefix_${MY_VAR}_suffix", &ctx);
assert_eq!(result, "prefix_my_value_suffix");
}
#[test]
fn test_expand_tool_path() {
let ctx = EnvContext::new().with_tool_path("node", PathBuf::from("/usr/local/bin/node"));
let result = expand_value("$JARVY_NODE_PATH", &ctx);
assert_eq!(result, "/usr/local/bin/node");
}
#[test]
fn test_expand_unknown_var() {
let ctx = EnvContext::new();
let result = expand_value("$NONEXISTENT_VAR_12345", &ctx);
assert_eq!(result, "$NONEXISTENT_VAR_12345");
}
#[test]
fn test_expand_path_tilde() {
let ctx = EnvContext::new();
let result = expand_path("~/.config/app", &ctx);
assert!(!result.to_string_lossy().contains('~'));
assert!(result.to_string_lossy().contains(".config/app"));
}
#[test]
fn test_expand_multiple_vars() {
let ctx = EnvContext::new()
.with_var("PROJECT", "myapp")
.with_var("VERSION", "1.0");
let result = expand_value("${PROJECT}-${VERSION}", &ctx);
assert_eq!(result, "myapp-1.0");
}
#[test]
fn test_env_context_default() {
let ctx = EnvContext::default();
assert!(!ctx.home_dir.as_os_str().is_empty());
assert!(!ctx.username.is_empty());
}
}