use std::env;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::OnceLock;
static CURRENT_DIR_CACHE: OnceLock<Result<PathBuf, String>> = OnceLock::new();
pub fn resolve_path(path: impl AsRef<Path>) -> Result<PathBuf, String> {
let expanded = expand_home(path.as_ref());
let expanded = expand_posix_drive(&expanded).unwrap_or(expanded);
let absolute = if expanded.is_absolute() { expanded } else { current_dir_cached()?.join(expanded) };
Ok(normalize_lexical(&absolute))
}
fn current_dir_cached() -> Result<&'static PathBuf, String> {
CURRENT_DIR_CACHE.get_or_init(|| env::current_dir().map_err(|error| format!("Failed to read current directory: {error}"))).as_ref().map_err(Clone::clone)
}
pub fn ensure_path_allowed(path: impl AsRef<Path>) -> Result<PathBuf, String> {
resolve_path(path)
}
pub fn existing_path(path: impl AsRef<Path>) -> Result<PathBuf, String> {
let resolved = ensure_path_allowed(path)?;
if !resolved.exists() {
return Err(format!("Path does not exist: {}", resolved.display()));
}
Ok(resolved)
}
pub fn target_path(path: impl AsRef<Path>) -> Result<PathBuf, String> {
resolve_path(path)
}
pub fn canonical_key(path: &str) -> String {
match resolve_path(path) {
Ok(resolved) => comparable_path(&resolved),
Err(_) => {
let mut fallback = path.replace('\\', "/");
if cfg!(windows) {
fallback = fallback.to_ascii_lowercase();
}
fallback.trim_end_matches('/').to_string()
}
}
}
fn comparable_path(path: &Path) -> String {
let path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let normalized = normalize_lexical(&path);
let mut value = normalized.to_string_lossy().replace('\\', "/");
if let Some(stripped) = value.strip_prefix("//?/") {
value = stripped.to_string();
}
if cfg!(windows) {
value = value.to_ascii_lowercase();
}
value.trim_end_matches('/').to_string()
}
fn expand_home(path: &Path) -> PathBuf {
let text = path.to_string_lossy();
let is_home = text == "~" || text.starts_with("~/") || text.starts_with("~\\");
if !is_home {
return path.to_path_buf();
}
let Some(home) = home_dir() else {
return path.to_path_buf();
};
let rest = text.strip_prefix("~/").or_else(|| text.strip_prefix("~\\")).unwrap_or("");
home.join(rest)
}
fn expand_posix_drive(path: &Path) -> Option<PathBuf> {
if !cfg!(windows) {
return None;
}
let text = path.to_string_lossy();
let bytes = text.as_bytes();
let is_sep = |byte: u8| byte == b'/' || byte == b'\\';
if bytes.len() < 2 || !is_sep(bytes[0]) || !bytes[1].is_ascii_alphabetic() {
return None;
}
let drive = bytes[1].to_ascii_uppercase() as char;
if bytes.len() == 2 {
return Some(PathBuf::from(format!("{drive}:\\")));
}
if !is_sep(bytes[2]) {
return None;
}
Some(PathBuf::from(format!("{drive}:\\{}", &text[3..])))
}
fn home_dir() -> Option<PathBuf> {
env::var_os("USERPROFILE").or_else(|| env::var_os("HOME")).map(PathBuf::from)
}
fn normalize_lexical(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
item => normalized.push(item.as_os_str()),
}
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_relative_path() {
let path = resolve_path(".").unwrap();
assert!(path.is_absolute());
}
#[test]
fn translates_posix_drive_path_on_windows() {
if !cfg!(windows) {
return;
}
let path = resolve_path("/c/git/repo").unwrap();
assert_eq!(path.to_string_lossy().to_ascii_lowercase(), "c:\\git\\repo");
let root = resolve_path("/d").unwrap();
assert_eq!(root.to_string_lossy().to_ascii_lowercase(), "d:\\");
let other = resolve_path("/dev/null").unwrap();
assert!(other.to_string_lossy().to_ascii_lowercase().ends_with("dev\\null"));
}
}