use std::collections::{BTreeMap, HashMap, HashSet};
use thiserror::Error;
use super::platform::Platform;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ResolveError {
#[error("malformed path expression `{input}`: {reason}")]
Malformed {
input: String,
reason: String,
},
#[error("unknown path variable `${{{name}}}`")]
UnknownVar {
name: String,
},
#[error("cycle resolving `${{{name}}}`: {chain}")]
Cycle {
name: String,
chain: String,
},
#[error("`${{{name}}}` is only available on {required} (current platform: {current})")]
WrongPlatform {
name: String,
required: Platform,
current: Platform,
},
}
#[derive(Debug, Clone)]
pub struct Resolver {
platform: Platform,
overrides: BTreeMap<String, String>,
env: HashMap<String, String>,
}
impl Resolver {
pub fn new() -> Self {
Self {
platform: Platform::current(),
overrides: BTreeMap::new(),
env: std::env::vars().collect(),
}
}
pub fn for_platform(platform: Platform) -> Self {
Self {
platform,
overrides: BTreeMap::new(),
env: std::env::vars().collect(),
}
}
pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
self.env = env;
self
}
pub fn with_overrides(mut self, overrides: BTreeMap<String, String>) -> Self {
self.overrides = overrides;
self
}
pub fn resolve(&self, input: &str) -> Result<String, ResolveError> {
self.resolve_with_stack(input, &mut Vec::new())
}
pub fn resolve_var(&self, name: &str) -> Result<String, ResolveError> {
self.resolve_var_with_stack(name, &mut Vec::new())
}
pub fn known_vars(&self) -> Vec<String> {
let mut names: HashSet<String> = self
.builtin_names()
.iter()
.map(|s| (*s).to_string())
.collect();
names.extend(self.overrides.keys().cloned());
let mut out: Vec<String> = names.into_iter().collect();
out.sort();
out
}
fn builtin_names(&self) -> &'static [&'static str] {
match self.platform {
Platform::Windows => &[
"HOME",
"XDG_CONFIG",
"XDG_DATA",
"XDG_STATE",
"XDG_CACHE",
"XDG_RUNTIME",
"LOCAL_BIN",
"DOCUMENTS",
"WIN_LOCALAPPDATA",
"WIN_APPDATA",
],
Platform::Macos => &[
"HOME",
"XDG_CONFIG",
"XDG_DATA",
"XDG_STATE",
"XDG_CACHE",
"XDG_RUNTIME",
"LOCAL_BIN",
"DOCUMENTS",
"MAC_LIBRARY",
],
Platform::Linux => &[
"HOME",
"XDG_CONFIG",
"XDG_DATA",
"XDG_STATE",
"XDG_CACHE",
"XDG_RUNTIME",
"LOCAL_BIN",
"DOCUMENTS",
],
}
}
fn resolve_with_stack(
&self,
input: &str,
stack: &mut Vec<String>,
) -> Result<String, ResolveError> {
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(idx) = rest.find("${") {
out.push_str(&rest[..idx]);
rest = &rest[idx + 2..]; let end = find_matching_brace(rest).ok_or_else(|| ResolveError::Malformed {
input: input.into(),
reason: "unclosed `${`".into(),
})?;
let expr = &rest[..end];
if expr.is_empty() {
return Err(ResolveError::Malformed {
input: input.into(),
reason: "empty `${}`".into(),
});
}
let resolved = self.resolve_expr(expr, stack)?;
out.push_str(&resolved);
rest = &rest[end + 1..]; }
out.push_str(rest);
Ok(out)
}
fn resolve_expr(&self, expr: &str, stack: &mut Vec<String>) -> Result<String, ResolveError> {
if let Some(env_expr) = expr.strip_prefix("env:") {
let (name, fallback) = match env_expr.split_once(":-") {
Some((n, fb)) => (n, Some(fb)),
None => (env_expr, None),
};
if name.is_empty() {
return Err(ResolveError::Malformed {
input: format!("${{{expr}}}"),
reason: "empty env var name".into(),
});
}
return match self.env.get(name) {
Some(v) if !v.is_empty() => Ok(v.clone()),
_ => match fallback {
Some(fb) => self.resolve_with_stack(fb, stack),
None => Ok(String::new()),
},
};
}
self.resolve_var_with_stack(expr, stack)
}
fn resolve_var_with_stack(
&self,
name: &str,
stack: &mut Vec<String>,
) -> Result<String, ResolveError> {
if stack.iter().any(|n| n == name) {
let chain = stack
.iter()
.chain(std::iter::once(&name.to_string()))
.cloned()
.collect::<Vec<_>>()
.join(" -> ");
return Err(ResolveError::Cycle {
name: name.into(),
chain,
});
}
stack.push(name.into());
let result = self.lookup_var(name, stack);
stack.pop();
result
}
fn lookup_var(&self, name: &str, stack: &mut Vec<String>) -> Result<String, ResolveError> {
if let Some(template) = self.overrides.get(name) {
return self.resolve_with_stack(template, stack);
}
self.builtin_var(name, stack)
}
fn builtin_var(&self, name: &str, stack: &mut Vec<String>) -> Result<String, ResolveError> {
match name {
"WIN_LOCALAPPDATA" | "WIN_APPDATA" if self.platform != Platform::Windows => {
return Err(ResolveError::WrongPlatform {
name: name.into(),
required: Platform::Windows,
current: self.platform,
});
}
"MAC_LIBRARY" if self.platform != Platform::Macos => {
return Err(ResolveError::WrongPlatform {
name: name.into(),
required: Platform::Macos,
current: self.platform,
});
}
_ => {}
}
let home_env_key = match self.platform {
Platform::Windows => "USERPROFILE",
Platform::Linux | Platform::Macos => "HOME",
};
let home_from_env = || -> Result<String, ResolveError> {
self.env
.get(home_env_key)
.filter(|v| !v.is_empty())
.cloned()
.ok_or_else(|| ResolveError::UnknownVar { name: name.into() })
};
let derived_from_home =
|suffix: &str, stack: &mut Vec<String>| -> Result<String, ResolveError> {
let h = self.resolve_var_with_stack("HOME", stack)?;
Ok(format!("{h}{suffix}"))
};
let xdg = |env_key: &str,
fallback_suffix: &str,
stack: &mut Vec<String>|
-> Result<String, ResolveError> {
if let Some(v) = self.env.get(env_key)
&& !v.is_empty()
{
return Ok(v.clone());
}
derived_from_home(fallback_suffix, stack)
};
match name {
"HOME" => home_from_env(),
"XDG_CONFIG" => xdg("XDG_CONFIG_HOME", "/.config", stack),
"XDG_DATA" => xdg("XDG_DATA_HOME", "/.local/share", stack),
"XDG_STATE" => xdg("XDG_STATE_HOME", "/.local/state", stack),
"XDG_CACHE" => xdg("XDG_CACHE_HOME", "/.cache", stack),
"XDG_RUNTIME" => {
if let Some(v) = self.env.get("XDG_RUNTIME_DIR")
&& !v.is_empty()
{
return Ok(v.clone());
}
let fallback_keys = match self.platform {
Platform::Windows => ["TEMP", "TMP"].as_slice(),
Platform::Linux | Platform::Macos => ["TMPDIR"].as_slice(),
};
for k in fallback_keys {
if let Some(v) = self.env.get(*k)
&& !v.is_empty()
{
return Ok(v.clone());
}
}
Ok(match self.platform {
Platform::Windows => "C:/Windows/Temp".into(),
_ => "/tmp".into(),
})
}
"LOCAL_BIN" => derived_from_home("/.local/bin", stack),
"DOCUMENTS" => derived_from_home("/Documents", stack),
"WIN_LOCALAPPDATA" => self
.env
.get("LOCALAPPDATA")
.filter(|v| !v.is_empty())
.cloned()
.ok_or_else(|| ResolveError::UnknownVar { name: name.into() }),
"WIN_APPDATA" => self
.env
.get("APPDATA")
.filter(|v| !v.is_empty())
.cloned()
.ok_or_else(|| ResolveError::UnknownVar { name: name.into() }),
"MAC_LIBRARY" => derived_from_home("/Library", stack),
_ => Err(ResolveError::UnknownVar { name: name.into() }),
}
}
}
impl Default for Resolver {
fn default() -> Self {
Self::new()
}
}
fn find_matching_brace(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut depth: usize = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
depth += 1;
i += 2;
continue;
}
if bytes[i] == b'}' {
if depth == 0 {
return Some(i);
}
depth -= 1;
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn linux(home: &str) -> Resolver {
let mut env = HashMap::new();
env.insert("HOME".into(), home.into());
Resolver::for_platform(Platform::Linux).with_env(env)
}
fn windows(profile: &str) -> Resolver {
let mut env = HashMap::new();
env.insert("USERPROFILE".into(), profile.into());
env.insert("LOCALAPPDATA".into(), format!("{profile}/AppData/Local"));
env.insert("APPDATA".into(), format!("{profile}/AppData/Roaming"));
Resolver::for_platform(Platform::Windows).with_env(env)
}
fn macos(home: &str) -> Resolver {
let mut env = HashMap::new();
env.insert("HOME".into(), home.into());
Resolver::for_platform(Platform::Macos).with_env(env)
}
#[test]
fn home_resolves() {
let r = linux("/home/user");
assert_eq!(r.resolve("${HOME}/x").unwrap(), "/home/user/x");
}
#[test]
fn xdg_falls_back_to_default() {
let r = linux("/home/user");
assert_eq!(r.resolve("${XDG_CONFIG}").unwrap(), "/home/user/.config");
assert_eq!(r.resolve("${XDG_DATA}").unwrap(), "/home/user/.local/share");
}
#[test]
fn xdg_env_override_wins() {
let mut env = HashMap::new();
env.insert("HOME".into(), "/home/user".into());
env.insert("XDG_CONFIG_HOME".into(), "/custom/cfg".into());
let r = Resolver::for_platform(Platform::Linux).with_env(env);
assert_eq!(r.resolve("${XDG_CONFIG}").unwrap(), "/custom/cfg");
}
#[test]
fn env_passthrough() {
let mut env = HashMap::new();
env.insert("HOME".into(), "/h".into());
env.insert("EDITOR".into(), "nvim".into());
let r = Resolver::for_platform(Platform::Linux).with_env(env);
assert_eq!(r.resolve("editor=${env:EDITOR}").unwrap(), "editor=nvim");
}
#[test]
fn env_fallback_used_when_unset() {
let r = linux("/h");
assert_eq!(r.resolve("${env:NOPE:-default}").unwrap(), "default");
}
#[test]
fn env_fallback_can_reference_other_vars() {
let r = linux("/h");
assert_eq!(r.resolve("${env:NOPE:-${HOME}/x}").unwrap(), "/h/x");
}
#[test]
fn user_override_shadows_builtin() {
let mut overrides = BTreeMap::new();
overrides.insert("HOME".into(), "/custom".into());
let r = linux("/orig").with_overrides(overrides);
assert_eq!(r.resolve("${HOME}/x").unwrap(), "/custom/x");
}
#[test]
fn home_override_cascades_into_derived_vars() {
let mut overrides = BTreeMap::new();
overrides.insert("HOME".into(), "/custom".into());
let r = linux("/orig").with_overrides(overrides);
assert_eq!(r.resolve("${XDG_CONFIG}").unwrap(), "/custom/.config");
assert_eq!(r.resolve("${LOCAL_BIN}").unwrap(), "/custom/.local/bin");
assert_eq!(r.resolve("${DOCUMENTS}").unwrap(), "/custom/Documents");
}
#[test]
fn override_loop_through_derived_vars_is_caught() {
let mut overrides = BTreeMap::new();
overrides.insert("HOME".into(), "${XDG_CONFIG}/parent".into());
let r = linux("/orig").with_overrides(overrides);
assert!(matches!(
r.resolve("${HOME}").unwrap_err(),
ResolveError::Cycle { .. }
));
}
#[test]
fn override_can_reference_other_vars() {
let mut overrides = BTreeMap::new();
overrides.insert("MYBIN".into(), "${HOME}/bin".into());
let r = linux("/h").with_overrides(overrides);
assert_eq!(r.resolve("${MYBIN}/x").unwrap(), "/h/bin/x");
}
#[test]
fn cycle_is_detected() {
let mut overrides = BTreeMap::new();
overrides.insert("A".into(), "${B}".into());
overrides.insert("B".into(), "${A}".into());
let r = linux("/h").with_overrides(overrides);
match r.resolve("${A}").unwrap_err() {
ResolveError::Cycle { chain, .. } => assert!(chain.contains("A -> B -> A")),
other => panic!("expected Cycle, got {other:?}"),
}
}
#[test]
fn unknown_var_errors() {
let r = linux("/h");
match r.resolve("${NOPE}").unwrap_err() {
ResolveError::UnknownVar { name } => assert_eq!(name, "NOPE"),
other => panic!("expected UnknownVar, got {other:?}"),
}
}
#[test]
fn win_var_errors_on_linux() {
let r = linux("/h");
let err = r.resolve("${WIN_LOCALAPPDATA}").unwrap_err();
assert!(matches!(err, ResolveError::WrongPlatform { .. }));
}
#[test]
fn win_var_resolves_on_windows() {
let r = windows("C:/Users/u");
assert_eq!(
r.resolve("${WIN_LOCALAPPDATA}/x").unwrap(),
"C:/Users/u/AppData/Local/x"
);
assert_eq!(
r.resolve("${WIN_APPDATA}/x").unwrap(),
"C:/Users/u/AppData/Roaming/x"
);
}
#[test]
fn mac_var_errors_on_linux() {
let r = linux("/h");
assert!(matches!(
r.resolve("${MAC_LIBRARY}").unwrap_err(),
ResolveError::WrongPlatform { .. }
));
}
#[test]
fn mac_var_resolves_on_macos() {
let r = macos("/Users/u");
assert_eq!(r.resolve("${MAC_LIBRARY}/x").unwrap(), "/Users/u/Library/x");
}
#[test]
fn unclosed_brace_errors() {
let r = linux("/h");
let err = r.resolve("${HOME").unwrap_err();
assert!(matches!(err, ResolveError::Malformed { .. }));
}
#[test]
fn empty_braces_errors() {
let r = linux("/h");
let err = r.resolve("${}").unwrap_err();
assert!(matches!(err, ResolveError::Malformed { .. }));
}
#[test]
fn no_vars_passthrough() {
let r = linux("/h");
assert_eq!(r.resolve("/etc/hosts").unwrap(), "/etc/hosts");
}
#[test]
fn known_vars_lists_platform_appropriate() {
let linux_r = linux("/h");
let names = linux_r.known_vars();
assert!(names.contains(&"HOME".to_string()));
assert!(names.contains(&"XDG_CONFIG".to_string()));
assert!(names.contains(&"DOCUMENTS".to_string()));
assert!(!names.iter().any(|n| n.starts_with("WIN_")));
assert!(!names.iter().any(|n| n.starts_with("MAC_")));
let win_r = windows("C:/U");
let win_names = win_r.known_vars();
assert!(win_names.contains(&"WIN_LOCALAPPDATA".to_string()));
assert!(win_names.contains(&"WIN_APPDATA".to_string()));
assert!(!win_names.iter().any(|n| n.starts_with("MAC_")));
}
#[test]
fn xdg_runtime_uses_env_then_tmpdir() {
let mut env = HashMap::new();
env.insert("HOME".into(), "/h".into());
env.insert("XDG_RUNTIME_DIR".into(), "/run/user/1000".into());
let r = Resolver::for_platform(Platform::Linux).with_env(env);
assert_eq!(r.resolve("${XDG_RUNTIME}").unwrap(), "/run/user/1000");
let mut env2 = HashMap::new();
env2.insert("HOME".into(), "/h".into());
env2.insert("TMPDIR".into(), "/var/tmp".into());
let r2 = Resolver::for_platform(Platform::Linux).with_env(env2);
assert_eq!(r2.resolve("${XDG_RUNTIME}").unwrap(), "/var/tmp");
}
}