use std::ffi::OsString;
use std::path::{Path, PathBuf};
pub fn canonicalize_deep(p: &Path) -> PathBuf {
if let Ok(c) = std::fs::canonicalize(p) {
return c;
}
let mut rest: Vec<OsString> = Vec::new();
let mut cur = p;
while let Some(parent) = cur.parent() {
if let Some(name) = cur.file_name() {
rest.push(name.to_os_string());
}
if let Ok(c) = std::fs::canonicalize(parent) {
let mut out = c;
for seg in rest.iter().rev() {
out.push(seg);
}
return out;
}
cur = parent;
}
p.to_path_buf()
}
pub fn spellings(p: &Path) -> Vec<String> {
let normalized = normalize_lexically(p);
let mut out = vec![normalized.to_string_lossy().into_owned()];
for s in canonical_spellings(&normalized) {
if !out.contains(&s) {
out.push(s);
}
}
out
}
pub fn canonical_spellings(normalized: &Path) -> Vec<String> {
let mut out: Vec<String> = Vec::with_capacity(2);
let mut push = |candidate: PathBuf| {
let s = candidate.to_string_lossy().into_owned();
if !out.contains(&s) {
out.push(s);
}
};
if let (Some(parent), Some(name)) = (normalized.parent(), normalized.file_name()) {
push(canonicalize_deep(parent).join(name));
}
push(canonicalize_deep(normalized));
out
}
pub fn normalize_lexically(p: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
let popped =
matches!(out.components().next_back(), Some(Component::Normal(_))) && out.pop();
if !popped {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::{canonicalize_deep, spellings};
use std::path::Path;
#[test]
fn a_symlinked_file_keeps_its_place_in_the_tree_as_one_spelling() {
let tmp = std::env::temp_dir().join(format!("spell-{}", std::process::id()));
let dir = tmp.join("config");
let real = tmp.join("elsewhere").join("prod.toml");
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(real.parent().unwrap()).unwrap();
std::fs::write(&real, "x\n").unwrap();
let link = dir.join("prod.toml");
std::os::unix::fs::symlink(&real, &link).unwrap();
let got = spellings(&link);
let in_place = std::fs::canonicalize(&dir).unwrap().join("prod.toml");
let resolved = std::fs::canonicalize(&real).unwrap();
assert_eq!(got[0], link.to_string_lossy());
assert!(
got.contains(&in_place.to_string_lossy().into_owned()),
"{got:?}"
);
assert!(
got.contains(&resolved.to_string_lossy().into_owned()),
"{got:?}"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn dot_and_dotdot_segments_are_folded_without_touching_the_disk() {
use super::normalize_lexically;
let n = |s: &str| {
normalize_lexically(Path::new(s))
.to_string_lossy()
.into_owned()
};
assert_eq!(n("/repo/../sibling/x.rs"), "/sibling/x.rs");
assert_eq!(n("/repo/./src/./x.rs"), "/repo/src/x.rs");
assert_eq!(n("/repo/src/../src/x.rs"), "/repo/src/x.rs");
assert_eq!(n("/../x"), "/../x", "nothing to fold into is kept");
assert_eq!(n("../x"), "../x");
assert_eq!(
spellings(Path::new("/repo/../sibling/x.rs"))[0],
"/sibling/x.rs"
);
}
#[test]
fn a_plain_path_has_no_duplicate_spellings() {
let got = spellings(Path::new("/definitely-not-a-real-root-xyz/a/b"));
assert_eq!(got, vec!["/definitely-not-a-real-root-xyz/a/b".to_string()]);
}
#[test]
fn an_existing_path_is_canonicalized_as_usual() {
let tmp = std::env::temp_dir();
assert_eq!(
canonicalize_deep(&tmp),
std::fs::canonicalize(&tmp).unwrap()
);
}
#[test]
fn a_missing_child_takes_its_existing_ancestors_canonical_form() {
let tmp = std::env::temp_dir();
let missing = tmp.join("canon-deep-missing-dir").join("new.rs");
let want = std::fs::canonicalize(&tmp)
.unwrap()
.join("canon-deep-missing-dir")
.join("new.rs");
assert_eq!(canonicalize_deep(&missing), want);
}
#[test]
fn a_path_under_the_root_only_keeps_its_spelling() {
let p = Path::new("/definitely-not-a-real-root-xyz/a/b");
assert_eq!(canonicalize_deep(p), p.to_path_buf());
}
}