use regex::Regex;
use std::borrow::Cow;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Platform {
Windows,
Unix,
}
impl Platform {
pub fn current() -> Self {
if cfg!(windows) {
Platform::Windows
} else {
Platform::Unix
}
}
}
pub struct PathResolver {
platform: Platform,
}
impl PathResolver {
pub fn new() -> Self {
Self {
platform: Platform::current(),
}
}
pub fn with_platform(platform: Platform) -> Self {
Self { platform }
}
pub fn resolve(&self, path: &str) -> PathBuf {
let expanded = self.expand_variables(path);
let normalized = self.normalize_separators(&expanded);
PathBuf::from(normalized.into_owned())
}
fn expand_variables<'a>(&self, path: &'a str) -> Cow<'a, str> {
let mut result = Cow::Borrowed(path);
result = self.expand_home_dir(result);
result = self.expand_env_vars(result);
result
}
fn expand_home_dir<'a>(&self, path: Cow<'a, str>) -> Cow<'a, str> {
if path.starts_with("~/") || &*path == "~" {
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
if &*path == "~" {
return Cow::Owned(home);
}
return Cow::Owned(path.replacen("~", &home, 1));
}
}
path
}
fn expand_env_vars<'a>(&self, path: Cow<'a, str>) -> Cow<'a, str> {
let env_var_re = Regex::new(r"\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)").unwrap();
let mut has_vars = false;
for cap in env_var_re.captures_iter(&path) {
if cap.get(1).or_else(|| cap.get(2)).is_some() {
has_vars = true;
break;
}
}
if !has_vars {
return path;
}
let mut result = path.into_owned();
for cap in env_var_re.captures_iter(&result.clone()) {
let var_name = cap.get(1).or_else(|| cap.get(2)).unwrap().as_str();
if let Ok(value) = std::env::var(var_name) {
result = result.replace(cap.get(0).unwrap().as_str(), &value);
}
}
Cow::Owned(result)
}
fn normalize_separators<'a>(&self, path: &'a str) -> Cow<'a, str> {
match self.platform {
Platform::Windows => {
if path.contains('/') {
if path.starts_with("\\\\") || path.starts_with("//") {
Cow::Owned(path.replace('/', "\\"))
} else {
Cow::Owned(path.replace('/', "\\"))
}
} else {
Cow::Borrowed(path)
}
}
Platform::Unix => {
if path.contains('\\') {
Cow::Owned(path.replace('\\', "/"))
} else {
Cow::Borrowed(path)
}
}
}
}
pub fn is_absolute(&self, path: &str) -> bool {
match self.platform {
Platform::Windows => {
path.chars().nth(1) == Some(':')
|| path.starts_with("\\\\")
|| path.starts_with("//")
}
Platform::Unix => {
path.starts_with('/')
}
}
}
pub fn join(&self, base: &str, path: &str) -> String {
if self.is_absolute(path) {
return path.to_string();
}
let separator = match self.platform {
Platform::Windows => "\\",
Platform::Unix => "/",
};
let base = base.trim_end_matches(&['/', '\\'][..]);
format!("{}{}{}", base, separator, path)
}
}
impl Default for PathResolver {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[serial_test::serial] fn test_expand_home_dir() {
let resolver = PathResolver::new();
std::env::set_var("HOME", "/home/user");
let expanded = resolver.expand_home_dir("~/Documents".into());
assert_eq!(expanded, "/home/user/Documents");
let expanded = resolver.expand_home_dir("~".into());
assert_eq!(expanded, "/home/user");
let expanded = resolver.expand_home_dir("/absolute/path".into());
assert_eq!(expanded, "/absolute/path");
}
#[test]
#[serial_test::serial] fn test_expand_env_vars() {
let resolver = PathResolver::new();
std::env::set_var("TEST_VAR", "value");
std::env::set_var("TEST_PATH", "/test/path");
let expanded = resolver.expand_env_vars("${TEST_VAR}/file".into());
assert_eq!(expanded, "value/file");
let expanded = resolver.expand_env_vars("$TEST_PATH/file".into());
assert_eq!(expanded, "/test/path/file");
let expanded = resolver.expand_env_vars("${TEST_VAR}/${TEST_PATH}".into());
assert_eq!(expanded, "value//test/path");
}
#[test]
fn test_normalize_separators_unix() {
let resolver = PathResolver::with_platform(Platform::Unix);
assert_eq!(
resolver.normalize_separators("path\\to\\file"),
"path/to/file"
);
assert_eq!(
resolver.normalize_separators("path/to/file"),
"path/to/file"
);
}
#[test]
fn test_normalize_separators_windows() {
let resolver = PathResolver::with_platform(Platform::Windows);
assert_eq!(
resolver.normalize_separators("path/to/file"),
"path\\to\\file"
);
assert_eq!(
resolver.normalize_separators("path\\to\\file"),
"path\\to\\file"
);
assert_eq!(
resolver.normalize_separators("\\\\server\\share"),
"\\\\server\\share"
);
}
#[test]
fn test_is_absolute() {
let unix_resolver = PathResolver::with_platform(Platform::Unix);
assert!(unix_resolver.is_absolute("/path/to/file"));
assert!(!unix_resolver.is_absolute("relative/path"));
let win_resolver = PathResolver::with_platform(Platform::Windows);
assert!(win_resolver.is_absolute("C:\\path\\to\\file"));
assert!(win_resolver.is_absolute("\\\\server\\share"));
assert!(!win_resolver.is_absolute("relative\\path"));
}
#[test]
fn test_join() {
let unix_resolver = PathResolver::with_platform(Platform::Unix);
assert_eq!(
unix_resolver.join("/base/path", "file.txt"),
"/base/path/file.txt"
);
assert_eq!(
unix_resolver.join("/base/path/", "file.txt"),
"/base/path/file.txt"
);
assert_eq!(unix_resolver.join("/base", "/absolute"), "/absolute");
let win_resolver = PathResolver::with_platform(Platform::Windows);
assert_eq!(
win_resolver.join("C:\\base", "file.txt"),
"C:\\base\\file.txt"
);
}
#[test]
#[serial_test::serial] fn test_resolve_full_path() {
let resolver = PathResolver::new();
std::env::set_var("HOME", "/home/user");
std::env::set_var("PROJECT", "myproject");
let resolved = resolver.resolve("~/${PROJECT}/src");
let expected = if cfg!(windows) {
PathBuf::from("/home/user/myproject/src".replace('/', "\\"))
} else {
PathBuf::from("/home/user/myproject/src")
};
assert_eq!(resolved, expected);
}
}