cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Drive a workspace-wide check pass.
//!
//! [`run_check`] walks every configured decision-record kind and the
//! issue corpus, runs the per-scope check pipelines plus the issue
//! companion-link audit, and returns a typed [`CheckReport`] the CLI
//! translates into either a human stream or a structured payload.
//!
//! The use case returns **all** scanned entries — clean ones with an
//! empty [`Violations`] bag, violating ones with the full bag. Callers
//! decide how to render; the loops, merges, and per-file path
//! reconciliation live here.

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)
}

/// One file's check outcome, with its discovered kind so the CLI can
/// label it (`[adr]`, `[issue]`, …) without re-deriving from the path.
#[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())
    }
}

/// One decision-record source the check should walk. `kind` is what
/// the CLI displays and what `CheckedEntry::kind` carries.
pub struct DrSource<'a> {
    pub kind: &'a str,
    pub repo: &'a dyn DecisionRecordRepository,
    pub defect_scanner: &'a dyn EntryDefectScanner,
    pub tag_descriptors: &'a TagDescriptors,
}

/// Issue source bundle: the repository for rule evaluation and
/// well-formed entry enumeration, the defect scanner for load
/// failures, the content reader for companion-link audit, and the
/// per-corpus configuration (`statuses`, `tag_descriptors`).
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());
    }
}