cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::check::{CheckViolation, CheckViolationKind, Severity, Violations};
use crate::domain::model::decision_record::DecisionRecord;
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::load_defect::LoadDefect;
use crate::domain::model::malformed_entry::MalformedEntry;
use crate::domain::usecases::check::{dr_rules, DrCheckCtx};
use crate::domain::usecases::decision_record::{
    DecisionRecordCheckResult, DecisionRecordRepository,
};
use crate::domain::usecases::entry_defect_scanner::EntryDefectScanner;

const SCAN_RULE_ID: &str = "decision-record/scan";

fn locator_to_path(loc: &EntryLocator) -> std::path::PathBuf {
    let s = loc.as_str();
    let bare = s.strip_prefix("file://").unwrap_or(s);
    std::path::PathBuf::from(bare)
}

fn render_defect(defect: &LoadDefect) -> String {
    match defect {
        LoadDefect::SourceUnreadable { reason } => reason.clone(),
        LoadDefect::InvalidFrontmatter { reason } => reason.clone(),
        LoadDefect::MissingId => "missing 'id'".to_string(),
        LoadDefect::IdPrefixMismatch { expected, found } => {
            format!("id '{found}' does not start with prefix '{expected}'")
        }
        LoadDefect::InvalidStatus { value } => format!("invalid status '{value}'"),
        LoadDefect::MissingEventsLog => "missing 'events.jsonl' sibling".to_string(),
        LoadDefect::StatusJournalMismatch {
            frontmatter,
            terminal,
        } => format!(
            "frontmatter status '{frontmatter}' diverges from event-log terminal '{terminal}'"
        ),
    }
}

/// Check all decision records and return results for files with violations.
///
/// `known_refs` is an optional set of all known entity ref strings across the
/// entire workspace (e.g. `"ISSUE-0001"`, `"ADR-0002"`).  When provided, any
/// link whose target is not in the set is reported as a warning.  Pass an
/// empty set to skip link-target resolution.
pub fn check_decision_records(
    repo: &dyn DecisionRecordRepository,
    defect_scanner: &dyn EntryDefectScanner,
    known_refs: &crate::domain::model::entity_ref::KnownRefs,
    tag_descriptors: &crate::domain::model::tag_descriptor::TagDescriptors,
) -> anyhow::Result<Vec<DecisionRecordCheckResult>> {
    let mut per_file: Vec<(std::path::PathBuf, Violations)> = Vec::new();
    let mut pairs: Vec<(std::path::PathBuf, DecisionRecord)> = Vec::new();

    for MalformedEntry {
        location, defect, ..
    } in defect_scanner.scan()?
    {
        let path = locator_to_path(&location);
        let mut bag = Violations::new();
        bag.push(scan_violation(
            path.clone(),
            Severity::Error,
            render_defect(&defect),
        ));
        per_file.push((path, bag));
    }

    for record in repo.list()? {
        let path = locator_to_path(&record.location);
        pairs.push((path, record));
    }

    // Rule-based pass.
    let dr_ctx = DrCheckCtx {
        repo,
        records: &pairs,
        known_refs,
        tag_descriptors,
    };
    for rule in dr_rules() {
        for finding in rule.find(&dr_ctx)? {
            let v = finding.violation;
            if let Some(entry) = per_file.iter_mut().find(|(p, _)| p == &v.path) {
                entry.1.push(v);
            } else {
                let mut bag = Violations::new();
                let path = v.path.clone();
                bag.push(v);
                per_file.push((path, bag));
            }
        }
    }

    Ok(per_file
        .into_iter()
        .map(|(path, violations)| DecisionRecordCheckResult { path, violations })
        .collect())
}

fn scan_violation(path: std::path::PathBuf, severity: Severity, detail: String) -> CheckViolation {
    CheckViolation {
        rule_id: SCAN_RULE_ID,
        path,
        severity,
        kind: CheckViolationKind::ScanIssue { detail },
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::check::render;
    use crate::domain::model::decision_record::DecisionRecord;
    use crate::domain::model::entity_ref::KnownRefs;
    use crate::domain::model::tag_descriptor::TagDescriptors;
    use crate::domain::usecases::decision_record::tests::{
        adr, FakeDecisionRecordRepository, RecordFixture,
    };
    use std::path::PathBuf;

    fn p(s: &str) -> PathBuf {
        PathBuf::from(s)
    }

    fn build(fix: RecordFixture, id: u32) -> DecisionRecord {
        use crate::domain::model::record_ref::DecisionRecordRef;
        fix.build(DecisionRecordRef::new(format!("ADR-{id:04}")).unwrap())
    }

    fn run(entries: Vec<(PathBuf, DecisionRecord)>) -> String {
        let repo = FakeDecisionRecordRepository::with_pairs(entries);
        let results =
            check_decision_records(&repo, &repo, &KnownRefs::new(), &TagDescriptors::default())
                .expect("check_decision_records failed unexpectedly");
        results
            .iter()
            .flat_map(|r| {
                r.violations.iter().map(|v| {
                    let severity = if v.severity == Severity::Error {
                        "error"
                    } else {
                        "warning"
                    };
                    format!("{}: {severity}: {}", r.path.display(), render(&v.kind))
                })
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn all_valid_records_produce_no_output() {
        let v = run(vec![
            (
                p("docs/adr/0001-use-rust/index.md"),
                build(adr("Use Rust").with_id("ADR-0001").status("accepted"), 1),
            ),
            (
                p("docs/adr/0002-use-postgres/index.md"),
                build(
                    adr("Use Postgres").with_id("ADR-0002").status("proposed"),
                    2,
                ),
            ),
        ]);
        assert!(v.is_empty(), "expected no violations, got:\n{v}");
    }

    #[test]
    fn no_files_produce_no_output() {
        let v = run(vec![]);
        assert!(v.is_empty(), "expected no violations, got:\n{v}");
    }

    #[test]
    fn duplicate_record_ids_are_flagged_on_both_files() {
        let v = run(vec![
            (
                p("docs/adr/0001-use-rust/index.md"),
                build(adr("Use Rust").with_id("ADR-0001").status("accepted"), 1),
            ),
            (
                p("docs/adr/0001-use-rust-copy/index.md"),
                build(
                    adr("Use Rust copy").with_id("ADR-0001").status("accepted"),
                    1,
                ),
            ),
        ]);
        assert!(v.contains("DuplicateId"), "got:\n{v}");
        assert_eq!(
            v.lines().filter(|l| l.contains("DuplicateId")).count(),
            2,
            "expected both files flagged, got:\n{v}"
        );
    }

    #[test]
    fn supersedes_without_back_link_is_flagged_on_the_target() {
        let v = run(vec![
            (
                p("docs/adr/0001-old/index.md"),
                build(adr("Old design").with_id("ADR-0001").status("accepted"), 1),
            ),
            (
                p("docs/adr/0002-new/index.md"),
                build(
                    adr("New design")
                        .with_id("ADR-0002")
                        .status("accepted")
                        .with_link("ADR-0001", "supersedes"),
                    2,
                ),
            ),
        ]);
        assert!(
            v.contains("0001-old")
                && v.contains("MissingBackPointer")
                && v.contains("back=superseded-by"),
            "got:\n{v}"
        );
    }

    #[test]
    fn amends_without_back_link_is_flagged_on_the_target() {
        let v = run(vec![
            (
                p("docs/adr/0001-base/index.md"),
                build(adr("Base").with_id("ADR-0001").status("accepted"), 1),
            ),
            (
                p("docs/adr/0002-amend/index.md"),
                build(
                    adr("Amendment")
                        .with_id("ADR-0002")
                        .status("accepted")
                        .with_link("ADR-0001", "amends"),
                    2,
                ),
            ),
        ]);
        assert!(
            v.contains("0001-base")
                && v.contains("MissingBackPointer")
                && v.contains("back=amended-by"),
            "got:\n{v}"
        );
    }

    #[test]
    fn superseded_by_without_forward_link_is_flagged_on_the_source() {
        let v = run(vec![
            (
                p("docs/adr/0001-old/index.md"),
                build(
                    adr("Old design")
                        .with_id("ADR-0001")
                        .status("superseded")
                        .with_link("ADR-0002", "superseded-by"),
                    1,
                ),
            ),
            (
                p("docs/adr/0002-new/index.md"),
                build(adr("New design").with_id("ADR-0002").status("accepted"), 2),
            ),
        ]);
        assert!(
            v.contains("0002-new")
                && v.contains("MissingForwardLink")
                && v.contains("forward=supersedes"),
            "got:\n{v}"
        );
    }

    #[test]
    fn fully_paired_supersedes_link_is_silent() {
        let v = run(vec![
            (
                p("docs/adr/0001-old/index.md"),
                build(
                    adr("Old")
                        .with_id("ADR-0001")
                        .status("superseded")
                        .with_link("ADR-0002", "superseded-by"),
                    1,
                ),
            ),
            (
                p("docs/adr/0002-new/index.md"),
                build(
                    adr("New")
                        .with_id("ADR-0002")
                        .status("accepted")
                        .with_link("ADR-0001", "supersedes"),
                    2,
                ),
            ),
        ]);
        assert!(v.is_empty(), "expected silent, got:\n{v}");
    }
}