use crate::finding::{Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{CorpusCheck, CorpusView};
use headwater_graph::links::{Binding, Link};
pub const RULE: &str = "link.path.unresolved";
const NO_LINKS: &str = "the view carries no bound prose links for this corpus";
pub struct Paths;
impl CorpusCheck for Paths {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 1;
const NEEDS_LINKS: bool = true;
fn evaluate(&self, view: &CorpusView<'_>) -> Outcome {
let Some(links) = view.links() else {
return Outcome::Skipped(NO_LINKS.to_string());
};
Outcome::failed(links.iter().filter_map(finding).collect())
}
}
fn finding(link: &Link) -> Option<Finding> {
let (message, remediation) = match &link.binding {
Binding::Missing { path } => (
format!(
"`{}` names no file of this repository: nothing stands at `{path}`",
link.destination
),
format!(
"point it at a file that exists, or restore `{path}`. A document that moved keeps \
its identifier, so `headwater explain` finds where it went"
),
),
Binding::Unnormalizable { why } => (
format!(
"`{}` does not name a path of this repository at all: {why}",
link.destination
),
"rewrite the destination as a path relative to the document that writes it, or as an \
absolute address with a scheme"
.to_string(),
),
_ => return None,
};
Some(Finding {
rule: self::RULE,
severity: Severity::Error,
obligation: None,
path: link.source_path.clone(),
line: link.span.start.line,
column: link.span.start.col,
message,
remediation,
patch: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use headwater_doc::LinkForm;
use headwater_yaml::{Position, Span};
fn span(line: usize, col: usize) -> Span {
Span {
start: Position {
line,
col,
offset: 0,
},
end: Position {
line,
col: col + 1,
offset: 1,
},
}
}
fn link(destination: &str, binding: Binding) -> Link {
Link {
source_path: "docs/spec/01-conceptual-model.md".to_string(),
destination: destination.to_string(),
fragment: None,
form: LinkForm::Inline,
span: span(30, 5),
binding,
}
}
#[test]
fn a_missing_target_is_an_error_at_the_citing_line() {
let found = finding(&link(
"glossary-moved.md",
Binding::Missing {
path: "docs/spec/glossary-moved.md".to_string(),
},
))
.expect("a finding");
assert_eq!(found.rule, self::RULE);
assert_eq!(found.severity, Severity::Error);
assert_eq!(found.path, "docs/spec/01-conceptual-model.md");
assert_eq!(found.line, 30);
assert_eq!(found.column, 5);
assert!(found.message.contains("glossary-moved.md"), "{found:#?}");
assert!(
found.message.contains("docs/spec/glossary-moved.md"),
"{found:#?}"
);
assert!(!found.fixable(), "{found:#?}");
}
#[test]
fn an_unnormalizable_destination_says_something_else() {
let found = finding(&link(
"../../../../outside.md",
Binding::Unnormalizable {
why: "climbs above the repository root".to_string(),
},
))
.expect("a finding");
assert_eq!(found.severity, Severity::Error);
assert!(
found.message.contains("climbs above the repository root"),
"{found:#?}"
);
assert!(!found.remediation.contains("restore"), "{found:#?}");
}
#[test]
fn a_link_that_resolved_is_not_this_rule() {
for binding in [
Binding::Corpus {
path: "docs/spec/glossary.md".to_string(),
class: "typed",
id: None,
},
Binding::Repository {
path: "CLAUDE.md".to_string(),
},
Binding::SameDocument,
Binding::External,
] {
assert!(finding(&link("x", binding)).is_none());
}
}
#[test]
fn a_view_with_no_links_skips_rather_than_passes() {
let view = CorpusView::only_links(None);
assert!(matches!(Paths.evaluate(&view), Outcome::Skipped(why) if why == NO_LINKS));
}
#[test]
fn the_broken_links_of_the_view_become_the_findings() {
let links = vec![
link(
"glossary-moved.md",
Binding::Missing {
path: "docs/spec/glossary-moved.md".to_string(),
},
),
link("https://example.com", Binding::External),
link(
"../../../../outside.md",
Binding::Unnormalizable {
why: "climbs above the repository root".to_string(),
},
),
];
let view = CorpusView::only_links(Some(&links));
let Outcome::Failed(found) = Paths.evaluate(&view) else {
panic!("two broken links are two findings");
};
assert_eq!(found.len(), 2, "{found:#?}");
}
#[test]
fn a_corpus_with_no_broken_link_passes() {
let links = vec![link("https://example.com", Binding::External)];
let view = CorpusView::only_links(Some(&links));
assert!(matches!(Paths.evaluate(&view), Outcome::Passed));
}
}