use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::domain::model::check::Violations;
use crate::domain::model::entity_ref::KnownRefs;
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::status::StatusesConfig;
use crate::domain::model::tag_descriptor::TagDescriptors;
use crate::domain::usecases::decision_record::{
check_decision_records, DecisionRecordCheckResult, DecisionRecordRepository,
};
use crate::domain::usecases::entry_defect_scanner::EntryDefectScanner;
use crate::domain::usecases::issue::content_reader::IssueContentReader;
use crate::domain::usecases::issue::{
check_issues, check_issues_content_references, IssueCheckResult, IssueRepository,
};
fn locator_to_path(loc: &EntryLocator) -> PathBuf {
let s = loc.as_str();
let bare = s.strip_prefix("file://").unwrap_or(s);
PathBuf::from(bare)
}
#[derive(Debug)]
pub struct CheckedEntry {
pub kind: String,
pub path: PathBuf,
pub violations: Violations,
}
#[derive(Debug, Default)]
pub struct CheckReport {
pub entries: Vec<CheckedEntry>,
}
impl CheckReport {
pub fn has_errors(&self) -> bool {
self.entries.iter().any(|e| e.violations.has_errors())
}
}
pub struct DrSource<'a> {
pub kind: &'a str,
pub repo: &'a dyn DecisionRecordRepository,
pub defect_scanner: &'a dyn EntryDefectScanner,
pub tag_descriptors: &'a TagDescriptors,
}
pub struct IssueSource<'a> {
pub repo: &'a dyn IssueRepository,
pub defect_scanner: &'a dyn EntryDefectScanner,
pub content_reader: &'a dyn IssueContentReader,
pub statuses: &'a StatusesConfig,
pub tag_descriptors: &'a TagDescriptors,
}
pub fn run_check(
issue: Option<IssueSource<'_>>,
dr_sources: &[DrSource<'_>],
known_refs: &KnownRefs,
) -> anyhow::Result<CheckReport> {
let mut entries: Vec<CheckedEntry> = Vec::new();
for src in dr_sources {
entries.extend(check_dr_kind(src, known_refs)?);
}
if let Some(issue) = issue {
entries.extend(check_issue_corpus(&issue, known_refs)?);
}
Ok(CheckReport { entries })
}
pub fn check_dr_kind(
src: &DrSource<'_>,
known_refs: &KnownRefs,
) -> anyhow::Result<Vec<CheckedEntry>> {
let scan_paths = ok_paths_for_dr(src.repo)?;
let results: Vec<(PathBuf, Violations)> = check_decision_records(
src.repo,
src.defect_scanner,
known_refs,
src.tag_descriptors,
)?
.into_iter()
.map(|DecisionRecordCheckResult { path, violations }| (path, violations))
.collect();
Ok(merge_kind(src.kind, &scan_paths, results))
}
pub fn check_issue_corpus(
issue: &IssueSource<'_>,
known_refs: &KnownRefs,
) -> anyhow::Result<Vec<CheckedEntry>> {
let scan_paths = ok_paths_for_issue(issue.repo)?;
let mut results: Vec<(PathBuf, Violations)> = check_issues(
issue.repo,
issue.defect_scanner,
known_refs,
issue.statuses,
issue.tag_descriptors,
)?
.into_iter()
.map(|IssueCheckResult { path, violations }| (path, violations))
.collect();
let dir_results = check_issues_content_references(issue.repo, issue.content_reader);
results = merge_by_path(results, dir_results);
Ok(merge_kind("issue", &scan_paths, results))
}
fn ok_paths_for_dr(repo: &dyn DecisionRecordRepository) -> anyhow::Result<Vec<PathBuf>> {
Ok(repo
.list()?
.into_iter()
.map(|r| locator_to_path(&r.location))
.collect())
}
fn ok_paths_for_issue(repo: &dyn IssueRepository) -> anyhow::Result<Vec<PathBuf>> {
Ok(repo
.list()?
.into_iter()
.map(|i| locator_to_path(&i.location))
.collect())
}
fn merge_kind(
kind: &str,
all_paths: &[PathBuf],
results: Vec<(PathBuf, Violations)>,
) -> Vec<CheckedEntry> {
let dirty: HashSet<&Path> = results.iter().map(|(p, _)| p.as_path()).collect();
let mut entries: Vec<CheckedEntry> = all_paths
.iter()
.filter(|p| !dirty.contains(p.as_path()))
.map(|p| CheckedEntry {
kind: kind.to_string(),
path: p.clone(),
violations: Violations::new(),
})
.collect();
for (path, violations) in results {
entries.push(CheckedEntry {
kind: kind.to_string(),
path,
violations,
});
}
entries
}
fn merge_by_path(
a: Vec<(PathBuf, Violations)>,
b: Vec<IssueCheckResult>,
) -> Vec<(PathBuf, Violations)> {
let mut merged: HashMap<PathBuf, Violations> = a.into_iter().collect();
for IssueCheckResult { path, violations } in b {
merged.entry(path).or_default().extend(violations);
}
merged.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::entity_ref::KnownRefs;
use crate::domain::model::issue::companion::{
CompanionContent, CompanionIdentifier, IssueCompanions,
};
use crate::domain::model::issue::test_fixtures::{feature, ir};
use crate::domain::model::issue::Issue;
use crate::domain::model::load_defect::LoadDefect;
use crate::domain::model::malformed_entry::MalformedEntry;
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::status::StatusesConfig;
use crate::domain::model::tag_descriptor::TagDescriptors;
use crate::domain::usecases::issue::content_reader::IssueContentSet;
fn enriched_feature(id: u64, title: &str) -> Issue {
use crate::domain::usecases::issue::tests::enrich_issue;
let mut issue = feature(title).with_id(&ir(id).to_string()).build(ir(id));
enrich_issue(&mut issue, &StatusesConfig::default_issue());
issue
}
#[derive(Default)]
struct StubIssueRepo {
entries: Vec<(PathBuf, Result<Issue, String>)>,
}
impl IssueRepository for StubIssueRepo {
fn save(&self, _i: &Issue) -> anyhow::Result<()> {
Ok(())
}
fn list(&self) -> anyhow::Result<crate::domain::model::issue::IssueCollection> {
Ok(self
.entries
.iter()
.filter_map(|(p, r)| {
r.as_ref().ok().cloned().map(|mut i| {
i.location = EntryLocator::new(p.display().to_string());
i
})
})
.collect())
}
fn find_by_id(&self, id: &IssueRef) -> anyhow::Result<Option<Issue>> {
Ok(self
.entries
.iter()
.filter_map(|(_, r)| r.as_ref().ok())
.find(|i| &i.id == id)
.cloned())
}
fn issue_companions(&self, _id: &IssueRef) -> anyhow::Result<IssueCompanions> {
Ok(IssueCompanions::new())
}
fn read_companion(
&self,
_id: &IssueRef,
_identifier: &CompanionIdentifier,
) -> anyhow::Result<Option<CompanionContent>> {
Ok(None)
}
}
impl EntryDefectScanner for StubIssueRepo {
fn scan(&self) -> anyhow::Result<Vec<MalformedEntry>> {
use crate::domain::model::entry_origin::EntryOrigin;
Ok(self
.entries
.iter()
.filter_map(|(p, r)| {
r.as_ref().err().map(|reason| MalformedEntry {
location: EntryLocator::new(p.display().to_string()),
origin: EntryOrigin::Local,
defect: LoadDefect::InvalidFrontmatter {
reason: reason.clone(),
},
})
})
.collect())
}
}
#[derive(Default)]
struct StubContentReader {
sets: HashMap<IssueRef, IssueContentSet>,
}
impl IssueContentReader for StubContentReader {
fn content_of(&self, id: &IssueRef) -> anyhow::Result<Option<IssueContentSet>> {
Ok(self.sets.get(id).map(|s| IssueContentSet {
parts: s.parts.clone(),
references: s.references.clone(),
}))
}
}
fn issue_src<'a, R: IssueRepository + EntryDefectScanner>(
repo: &'a R,
reader: &'a dyn IssueContentReader,
statuses: &'a StatusesConfig,
td: &'a TagDescriptors,
) -> IssueSource<'a> {
IssueSource {
repo,
defect_scanner: repo,
content_reader: reader,
statuses,
tag_descriptors: td,
}
}
#[test]
fn empty_workspace_yields_an_empty_report() {
let repo = StubIssueRepo::default();
let reader = StubContentReader::default();
let statuses = StatusesConfig::default_issue();
let td = TagDescriptors::default();
let known = KnownRefs::new();
let report =
run_check(Some(issue_src(&repo, &reader, &statuses, &td)), &[], &known).unwrap();
assert!(report.entries.is_empty());
assert!(!report.has_errors());
}
#[test]
fn clean_issue_appears_as_clean_entry() {
let repo = StubIssueRepo {
entries: vec![(
PathBuf::from("docs/issues/0001-a/index.md"),
Ok(enriched_feature(1, "A")),
)],
};
let reader = StubContentReader::default();
let statuses = StatusesConfig::default_issue();
let td = TagDescriptors::default();
let known = KnownRefs::new();
let report =
run_check(Some(issue_src(&repo, &reader, &statuses, &td)), &[], &known).unwrap();
assert_eq!(report.entries.len(), 1);
let e = &report.entries[0];
assert_eq!(e.kind, "issue");
assert_eq!(e.path, PathBuf::from("docs/issues/0001-a/index.md"));
assert!(e.violations.is_empty());
assert!(!report.has_errors());
}
#[test]
fn scan_parse_error_surfaces_as_a_violating_entry() {
let repo = StubIssueRepo {
entries: vec![(
PathBuf::from("docs/issues/0001-broken/index.md"),
Err("yaml gibberish".to_string()),
)],
};
let reader = StubContentReader::default();
let statuses = StatusesConfig::default_issue();
let td = TagDescriptors::default();
let report = run_check(
Some(issue_src(&repo, &reader, &statuses, &td)),
&[],
&KnownRefs::new(),
)
.unwrap();
assert_eq!(report.entries.len(), 1);
assert!(report.has_errors());
assert!(!report.entries[0].violations.is_empty());
}
#[test]
fn dir_reference_violations_fold_into_the_issue_entry() {
let issue_path = PathBuf::from("docs/issues/0001-a/index.md");
let repo = StubIssueRepo {
entries: vec![(issue_path.clone(), Ok(enriched_feature(1, "A")))],
};
let mut reader = StubContentReader::default();
reader.sets.insert(
ir(1),
IssueContentSet {
parts: vec!["index".to_string()],
references: vec![("index".to_string(), vec!["plan".to_string()])],
},
);
let statuses = StatusesConfig::default_issue();
let td = TagDescriptors::default();
let report = run_check(
Some(issue_src(&repo, &reader, &statuses, &td)),
&[],
&KnownRefs::new(),
)
.unwrap();
let entry = report
.entries
.iter()
.find(|e| e.path == issue_path)
.expect("issue entry present");
assert!(
!entry.violations.is_empty(),
"dir reference violation should be folded into the issue entry"
);
assert!(report.has_errors());
}
}