use std::path::Path;
fn candidates(value: &str) -> Vec<&str> {
let mut out = vec![value];
let anchorless = strip_anchor(value);
let lineless = strip_line_ref(value);
for c in [anchorless, lineless, strip_line_ref(anchorless)] {
if !c.is_empty() && !out.contains(&c) {
out.push(c);
}
}
out
}
pub fn strip_anchor(value: &str) -> &str {
value.split('#').next().unwrap_or(value)
}
pub fn strip_line_ref(value: &str) -> &str {
let mut out = value;
for _ in 0..2 {
match out.rsplit_once(':') {
Some((head, tail)) if !head.is_empty() && is_line_segment(tail) => out = head,
_ => break,
}
}
out
}
fn is_line_segment(s: &str) -> bool {
let (start, end) = match s.split_once('-') {
Some((a, b)) => (a, Some(b)),
None => (s, None),
};
let digits = |x: &str| !x.is_empty() && x.bytes().all(|b| b.is_ascii_digit());
digits(start) && end.is_none_or(digits)
}
pub fn normalize(value: &str) -> String {
let value = value.trim();
if value.is_empty() {
return String::new();
}
let absolute = value.starts_with('/');
let mut out: Vec<&str> = Vec::new();
for segment in value.split('/') {
match segment {
"." | "" => {}
".." => match out.last() {
None | Some(&"..") => out.push(segment),
Some(_) => {
out.pop();
}
},
_ => out.push(segment),
}
}
let joined = out.join("/");
match (absolute, joined.is_empty()) {
(true, _) => format!("/{joined}"),
(false, true) => ".".to_string(),
(false, false) => joined,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathRejection {
Absolute,
Escapes,
Ignored,
}
impl PathRejection {
pub fn reason(self) -> &'static str {
match self {
PathRejection::Absolute => {
"is absolute — a ref is relative to the project root, so this one \
means nothing on another machine"
}
PathRejection::Escapes => {
"leaves the project root — nothing outside it travels with the project"
}
PathRejection::Ignored => {
"is ignored by git — it is in this working copy and will not be in \
anyone else's"
}
}
}
}
pub fn containment(value: &str) -> Option<PathRejection> {
let normalized = normalize(value);
if normalized.is_empty() {
return None; }
if Path::new(&normalized).is_absolute() {
return Some(PathRejection::Absolute);
}
if normalized == ".." || normalized.starts_with("../") {
return Some(PathRejection::Escapes);
}
None
}
pub fn same_path(a: &str, b: &str) -> bool {
normalize(a) == normalize(b)
}
pub fn resolved(project_root: &Path, value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
candidates(value)
.into_iter()
.find(|c| project_root.join(c).exists())
.map(|c| c.to_string())
}
pub fn exists(project_root: &Path, value: &str) -> bool {
resolved(project_root, value).is_some()
}
pub fn ignored(project_root: &Path, values: &[String]) -> Vec<String> {
let (probes, owners): (Vec<String>, Vec<usize>) = values
.iter()
.enumerate()
.filter_map(|(i, v)| resolved(project_root, v).map(|p| (p, i)))
.unzip();
if probes.is_empty() {
return Vec::new();
}
let Some(matched) = crate::io::git::ignored_paths(project_root, &probes) else {
return Vec::new();
};
probes
.iter()
.zip(owners)
.filter(|(probe, _)| matched.contains(probe))
.map(|(_, i)| values[i].clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn project() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("doc")).unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("doc/design.md"), "x").unwrap();
fs::write(dir.path().join("doc/issue#3.md"), "x").unwrap();
fs::write(dir.path().join("src/parser.rs"), "x").unwrap();
fs::write(dir.path().join("src/odd:9.rs"), "x").unwrap();
dir
}
#[test]
fn a_plain_path_resolves() {
let dir = project();
assert!(exists(dir.path(), "doc/design.md"));
assert!(!exists(dir.path(), "doc/missing.md"));
}
#[test]
fn an_anchor_is_ignored() {
let dir = project();
assert!(exists(dir.path(), "doc/design.md#rationale"));
assert!(!exists(dir.path(), "doc/missing.md#rationale"));
}
#[test]
fn a_line_reference_is_ignored() {
let dir = project();
assert!(exists(dir.path(), "src/parser.rs:807"));
assert!(exists(dir.path(), "src/parser.rs:807-820"));
assert!(exists(dir.path(), "src/parser.rs:807:12"));
assert!(!exists(dir.path(), "src/missing.rs:807"));
assert!(!exists(dir.path(), "src/missing.rs:807-820"));
}
#[test]
fn a_hash_or_colon_in_the_filename_still_resolves() {
let dir = project();
assert!(exists(dir.path(), "doc/issue#3.md"));
assert!(exists(dir.path(), "src/odd:9.rs"));
}
#[test]
fn an_empty_or_suffix_only_value_resolves_to_nothing() {
let dir = project();
assert!(!exists(dir.path(), ""));
assert!(!exists(dir.path(), " "));
assert!(!exists(dir.path(), "#anchor"));
assert!(!exists(dir.path(), ":807"));
}
#[test]
fn a_colon_run_is_not_eaten_past_a_column() {
assert_eq!(strip_line_ref("a:1:2:3"), "a:1");
assert_eq!(strip_line_ref("src/parser.rs"), "src/parser.rs");
assert_eq!(strip_line_ref("src/parser.rs:807"), "src/parser.rs");
assert_eq!(strip_line_ref("src/parser.rs:807-820"), "src/parser.rs");
}
#[test]
fn normalize_folds_dot_and_dotdot() {
assert_eq!(normalize("./sub/../real.md"), "real.md");
assert_eq!(normalize("doc/../src/parser.rs"), "src/parser.rs");
assert_eq!(normalize("./real.md"), "real.md");
assert_eq!(normalize("a//b"), "a/b");
assert_eq!(normalize("doc/"), "doc");
assert_eq!(normalize(" real.md "), "real.md");
assert_eq!(normalize("src/parser.rs"), "src/parser.rs");
assert_eq!(normalize(normalize("./sub/../real.md").as_str()), "real.md");
}
#[test]
fn normalize_leaves_the_suffix_alone() {
assert_eq!(normalize("./sub/../real.md:807"), "real.md:807");
assert_eq!(normalize("./doc/../design.md#why"), "design.md#why");
assert_eq!(normalize("doc/issue#3.md"), "doc/issue#3.md");
assert_eq!(normalize("src/odd:9.rs"), "src/odd:9.rs");
assert_eq!(normalize("src/parser.rs:807-820"), "src/parser.rs:807-820");
}
#[test]
fn normalize_keeps_what_makes_a_path_escape() {
assert_eq!(normalize("../outside.md"), "../outside.md");
assert_eq!(normalize("a/../../b.md"), "../b.md");
assert_eq!(normalize("../../b.md"), "../../b.md");
assert_eq!(normalize("/etc/hosts"), "/etc/hosts");
assert_eq!(normalize("/etc/../etc/hosts"), "/etc/hosts");
assert_eq!(normalize("/../etc/hosts"), "/../etc/hosts");
}
#[test]
fn normalize_handles_values_that_fold_to_nothing() {
assert_eq!(normalize("."), ".");
assert_eq!(normalize("./"), ".");
assert_eq!(normalize("sub/.."), ".");
assert_eq!(normalize(""), "");
assert_eq!(normalize(" "), "");
assert!(!exists(project().path(), &normalize("")));
}
#[test]
fn normalize_does_not_fold_inside_a_segment() {
assert_eq!(normalize("doc/..hidden.md"), "doc/..hidden.md");
assert_eq!(normalize("doc/a..b.md"), "doc/a..b.md");
assert_eq!(normalize("...md"), "...md");
}
fn ignoring_project(dir: &Path) -> Option<()> {
let git = |args: &[&str]| {
std::process::Command::new("git")
.current_dir(dir)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
};
fs::create_dir_all(dir.join("scratch")).ok()?;
fs::create_dir_all(dir.join("doc")).ok()?;
fs::write(dir.join(".gitignore"), "scratch/\n*.tmp\n").ok()?;
fs::write(dir.join("scratch/notes.md"), "x").ok()?;
fs::write(dir.join("doc/design.md"), "x").ok()?;
fs::write(dir.join("doc/draft.tmp"), "x").ok()?;
git(&["init", "-q"]).then_some(())
}
#[test]
fn ignored_reports_what_git_covers_and_nothing_else() {
let dir = tempfile::tempdir().unwrap();
if ignoring_project(dir.path()).is_none() {
return; }
let values: Vec<String> = ["scratch/notes.md", "doc/design.md", "doc/draft.tmp"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
ignored(dir.path(), &values),
vec!["scratch/notes.md".to_string(), "doc/draft.tmp".to_string()]
);
}
#[test]
fn ignored_asks_about_the_resolved_path_not_the_raw_value() {
let dir = tempfile::tempdir().unwrap();
if ignoring_project(dir.path()).is_none() {
return;
}
let values = vec!["doc/draft.tmp:12".to_string()];
assert_eq!(ignored(dir.path(), &values), values);
}
#[test]
fn ignored_says_nothing_about_a_path_that_does_not_resolve() {
let dir = tempfile::tempdir().unwrap();
if ignoring_project(dir.path()).is_none() {
return;
}
let values = vec!["scratch/gone.md".to_string()];
assert!(ignored(dir.path(), &values).is_empty());
}
#[test]
fn ignored_is_silent_outside_a_repository() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("scratch")).unwrap();
fs::write(dir.path().join("scratch/notes.md"), "x").unwrap();
if crate::io::git::ignored_paths(dir.path(), &["scratch/notes.md".to_string()]).is_some() {
return;
}
let values = vec!["scratch/notes.md".to_string()];
assert!(ignored(dir.path(), &values).is_empty());
}
#[test]
fn resolved_returns_the_path_without_its_suffix() {
let dir = project();
assert_eq!(
resolved(dir.path(), "src/parser.rs:807").as_deref(),
Some("src/parser.rs")
);
assert_eq!(
resolved(dir.path(), "doc/design.md#why").as_deref(),
Some("doc/design.md")
);
assert_eq!(
resolved(dir.path(), "doc/issue#3.md").as_deref(),
Some("doc/issue#3.md")
);
assert_eq!(resolved(dir.path(), "doc/missing.md"), None);
}
#[test]
fn containment_refuses_what_will_not_travel() {
assert_eq!(containment("../outside.md"), Some(PathRejection::Escapes));
assert_eq!(containment("a/../../b.md"), Some(PathRejection::Escapes));
assert_eq!(containment(".."), Some(PathRejection::Escapes));
assert_eq!(containment("/etc/hosts"), Some(PathRejection::Absolute));
assert_eq!(
containment("/etc/../etc/hosts"),
Some(PathRejection::Absolute)
);
assert_eq!(
containment("/Users/x/proj/doc/design.md"),
Some(PathRejection::Absolute)
);
}
#[test]
fn containment_allows_everything_that_stays_inside() {
assert_eq!(containment("doc/design.md"), None);
assert_eq!(containment("./sub/../real.md"), None);
assert_eq!(containment("doc/../src/parser.rs:807"), None);
assert_eq!(containment("."), None);
assert_eq!(containment("doc/..hidden.md"), None);
assert_eq!(containment(""), None);
}
#[test]
fn same_path_sees_through_spelling_but_not_through_the_suffix() {
assert!(same_path("real.md", "./sub/../real.md"));
assert!(same_path("./sub/../real.md", "real.md"));
assert!(same_path("./sub/../real.md", "./sub/../real.md"));
assert!(same_path("doc/design.md#why", "./doc/design.md#why"));
assert!(!same_path("real.md", "real.md:807"));
assert!(!same_path("doc/design.md", "doc/design.md#why"));
assert!(!same_path("real.md", "other.md"));
}
#[test]
fn a_non_numeric_suffix_is_part_of_the_path() {
assert_eq!(strip_line_ref("src/parser.rs:main"), "src/parser.rs:main");
assert_eq!(strip_line_ref("src/parser.rs:8a"), "src/parser.rs:8a");
assert_eq!(strip_line_ref("src/parser.rs:-8"), "src/parser.rs:-8");
assert_eq!(strip_line_ref("src/parser.rs:8-"), "src/parser.rs:8-");
}
}