use crate::finding::{at, Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{CorpusCheck, CorpusView};
use headwater_graph::index::{Defect, Reported};
pub const RULE: &str = "identifier.claimed_twice";
const NO_REPORT: &str = "the view carries no phase-A report for this corpus";
pub struct Duplicate {
facet: String,
}
impl Duplicate {
pub fn over(facet: &str) -> Self {
Duplicate {
facet: facet.to_string(),
}
}
}
impl CorpusCheck for Duplicate {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 1;
const NEEDS_PHASE_A: bool = true;
fn evaluate(&self, view: &CorpusView<'_>) -> Outcome {
let Some(identity) = view.identity() else {
return Outcome::Skipped(NO_REPORT.to_string());
};
Outcome::failed(
identity
.iter()
.flat_map(|reported| self.findings(reported))
.collect(),
)
}
}
impl Duplicate {
fn findings(&self, reported: &Reported) -> Vec<Finding> {
let Defect::Duplicate {
id,
other,
other_span,
} = &reported.defect
else {
return Vec::new();
};
let message = format!(
"`{id}` is claimed by {other} and by {}, and an identifier names one document. A \
target that names it resolves to two, so neither document is reachable by it",
reported.path
);
let remediation = format!(
"change the `{}` of one of the two documents, and repoint every relation that names \
`{id}` at the document it meant",
self.facet
);
[(&reported.path, reported.span), (other, *other_span)]
.into_iter()
.map(|(path, span)| {
let (line, column) = at(span);
Finding {
rule: self::RULE,
severity: Severity::Error,
obligation: None,
path: path.clone(),
line,
column,
message: message.clone(),
remediation: remediation.clone(),
patch: None,
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use headwater_yaml::{Position, Span};
fn span(line: usize) -> Option<Span> {
Some(Span {
start: Position {
line,
col: 1,
offset: 0,
},
end: Position {
line,
col: 3,
offset: 2,
},
})
}
fn collision() -> Reported {
Reported {
path: "docs/spec/04-twin.md".to_string(),
span: span(2),
defect: Defect::Duplicate {
id: "SPEC-FIX-first".to_string(),
other: "docs/spec/00-first.md".to_string(),
other_span: span(3),
},
}
}
#[test]
fn one_collision_reports_in_both_files_and_names_both() {
let found = Duplicate::over("id").findings(&collision());
assert_eq!(found.len(), 2, "{found:#?}");
let paths: Vec<&str> = found.iter().map(|f| f.path.as_str()).collect();
assert_eq!(paths, vec!["docs/spec/04-twin.md", "docs/spec/00-first.md"]);
assert_eq!(found[0].line, 2, "{found:#?}");
assert_eq!(found[1].line, 3, "{found:#?}");
for finding in &found {
assert_eq!(finding.severity, Severity::Error, "{finding:#?}");
assert!(!finding.fixable(), "{finding:#?}");
assert!(finding.message.contains("docs/spec/00-first.md"));
assert!(finding.message.contains("docs/spec/04-twin.md"));
assert!(finding.message.contains("`SPEC-FIX-first`"));
}
assert_eq!(found[0].message, found[1].message);
}
#[test]
fn the_repair_names_the_configured_identifier_key() {
let found = Duplicate::over("identifier").findings(&collision());
assert!(
found[0].remediation.contains("change the `identifier` of"),
"{:#?}",
found[0]
);
}
#[test]
fn a_defect_of_one_document_is_not_this_rule() {
for defect in [
Defect::NoIdentifier {
kind: "design_spec".to_string(),
},
Defect::NotAScalar,
] {
let reported = Reported {
path: "docs/spec/03-anonymous.md".to_string(),
span: span(2),
defect,
};
assert!(Duplicate::over("id").findings(&reported).is_empty());
}
}
}