use std::path::{Component, Path, PathBuf};
pub fn normalize_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => out.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir | Component::Prefix(_)) => {}
_ => out.push(".."),
},
Component::Normal(part) => out.push(part),
}
}
out
}
pub fn absolute_normalized(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
normalize_lexical(path)
} else {
normalize_lexical(&base.join(path))
}
}
pub fn is_ancestor_of(ancestor: &Path, candidate: &Path) -> bool {
let ancestor_norm = normalize_lexical(ancestor);
let candidate_norm = normalize_lexical(candidate);
if lexically_contains(&ancestor_norm, &candidate_norm) {
return true;
}
match (ancestor_norm.canonicalize(), candidate_norm.canonicalize()) {
(Ok(a), Ok(c)) => c.starts_with(a),
_ => false,
}
}
fn lexically_contains(ancestor: &Path, candidate: &Path) -> bool {
if !candidate.starts_with(ancestor) {
return false;
}
if !ancestor.as_os_str().is_empty() {
return true;
}
candidate.is_relative() && matches!(candidate.components().next(), None | Some(Component::Normal(_)))
}
pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
let start = normalize_lexical(start);
start
.ancestors()
.find(|dir| dir.join(".git").exists())
.map(Path::to_path_buf)
}
pub fn repo_relative(root: &Path, path: &Path) -> Option<PathBuf> {
let root = normalize_lexical(root);
let path = normalize_lexical(path);
if !lexically_contains(&root, &path) {
return None;
}
path.strip_prefix(&root).ok().map(Path::to_path_buf)
}
pub fn to_slash(path: &Path) -> String {
let rendered = path.to_string_lossy();
if std::path::MAIN_SEPARATOR == '/' {
rendered.into_owned()
} else {
rendered.replace(std::path::MAIN_SEPARATOR, "/")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_resolves_dot_and_parent() {
let cases = [
("/repo/sub/../other/x.py", "/repo/other/x.py"),
("/repo/./src/main.rs", "/repo/src/main.rs"),
("/repo/a/b/../../c", "/repo/c"),
("/repo/..", "/"),
("/..", "/"),
("/../../etc", "/etc"),
("a/b/../c", "a/c"),
("./a", "a"),
];
for (input, expected) in cases {
assert_eq!(
normalize_lexical(Path::new(input)),
PathBuf::from(expected),
"normalizing {input}"
);
}
}
#[test]
fn normalize_keeps_leading_parent_on_relative_paths() {
assert_eq!(normalize_lexical(Path::new("../sibling")), PathBuf::from("../sibling"));
assert_eq!(normalize_lexical(Path::new("../../x")), PathBuf::from("../../x"));
assert_eq!(normalize_lexical(Path::new("a/../../x")), PathBuf::from("../x"));
}
#[test]
fn containment_is_not_fooled_by_a_parent_component() {
assert!(!is_ancestor_of(
Path::new("/repo/sub"),
Path::new("/repo/sub/../other/x.py")
));
assert!(is_ancestor_of(Path::new("/repo"), Path::new("/repo/sub/../other/x.py")));
assert!(is_ancestor_of(Path::new("/repo"), Path::new("/repo")));
assert!(!is_ancestor_of(Path::new("/repo"), Path::new("/repository/x")));
}
#[test]
fn a_root_that_normalizes_away_contains_only_relative_paths_below_it() {
assert!(normalize_lexical(Path::new(".")).as_os_str().is_empty());
for root in [".", "", "a/.."] {
assert!(
!is_ancestor_of(Path::new(root), Path::new("/etc/passwd")),
"root {root:?} must not contain an absolute path"
);
assert!(
!is_ancestor_of(Path::new(root), Path::new("../sibling/x.py")),
"root {root:?} must not contain a path above it"
);
assert!(
is_ancestor_of(Path::new(root), Path::new("sub/x.py")),
"root {root:?} must contain a path below it"
);
}
}
#[test]
fn repo_relative_rejects_what_is_not_below_the_root() {
assert_eq!(repo_relative(Path::new("."), Path::new("/elsewhere/x.py")), None);
assert_eq!(repo_relative(Path::new("."), Path::new("../x.py")), None);
assert_eq!(
repo_relative(Path::new("."), Path::new("./src/x.py")),
Some(PathBuf::from("src/x.py"))
);
}
#[test]
fn absolute_normalized_joins_then_normalizes() {
assert_eq!(
absolute_normalized(Path::new("/repo/sub"), Path::new("../other/x.py")),
PathBuf::from("/repo/other/x.py")
);
assert_eq!(
absolute_normalized(Path::new("/repo"), Path::new("/abs/./y.py")),
PathBuf::from("/abs/y.py")
);
}
#[test]
fn repo_relative_strips_the_root_and_rejects_outsiders() {
assert_eq!(
repo_relative(Path::new("/repo"), Path::new("/repo/./src/main.rs")),
Some(PathBuf::from("src/main.rs"))
);
assert_eq!(repo_relative(Path::new("/repo"), Path::new("/elsewhere/x")), None);
}
#[test]
fn find_repo_root_accepts_a_git_file_as_well_as_a_directory() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path();
let nested = root.join("a/b");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(find_repo_root(&nested), None);
std::fs::write(root.join(".git"), "gitdir: /elsewhere/.git/worktrees/wt\n").unwrap();
assert_eq!(
find_repo_root(&nested).as_deref(),
Some(normalize_lexical(root).as_path())
);
}
#[test]
fn to_slash_is_identity_on_unix_separators() {
assert_eq!(to_slash(Path::new("src/commands/scan.rs")), "src/commands/scan.rs");
}
}