cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Validate the cross-references inside every issue's content set.
//!
//! Composes [`IssueRepository::list`] (to discover well-formed issues
//! and anchor violations on each issue's `index.md` path) with
//! [`IssueContentReader::content_of`] (to obtain each issue's content
//! composition), then applies the pure
//! [`validate_content_references`] rule.

use crate::domain::model::check::{CheckViolation, CheckViolationKind, Severity, Violations};
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::usecases::issue::check_companions::{
    validate_content_references, ContentGraph, ContentViolation,
};
use crate::domain::usecases::issue::content_reader::IssueContentReader;
use crate::domain::usecases::issue::repository::IssueCheckResult;
use crate::domain::usecases::issue::IssueRepository;

const COMPANIONS_RULE_ID: &str = "issue/companions";

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

pub fn check_issues_content_references(
    repo: &dyn IssueRepository,
    content: &dyn IssueContentReader,
) -> Vec<IssueCheckResult> {
    let Ok(issues) = repo.list() else {
        return Vec::new();
    };
    let mut out: Vec<IssueCheckResult> = Vec::new();
    for issue in issues {
        let Ok(Some(set)) = content.content_of(&issue.id) else {
            continue;
        };
        let graph = ContentGraph {
            parts: &set.parts,
            references: &set.references,
        };
        let violations = validate_content_references(&graph);
        if violations.is_empty() {
            continue;
        }
        let path = locator_to_path(&issue.location);
        let mut bag = Violations::new();
        for v in violations {
            bag.push(to_check_violation(v, path.clone()));
        }
        out.push(IssueCheckResult {
            path,
            violations: bag,
        });
    }
    out
}

fn to_check_violation(v: ContentViolation, path: std::path::PathBuf) -> CheckViolation {
    let (severity, kind) = match v {
        ContentViolation::BrokenReference { from, to } => (
            Severity::Error,
            CheckViolationKind::BrokenCompanionLink { from, to },
        ),
    };
    CheckViolation {
        rule_id: COMPANIONS_RULE_ID,
        path,
        severity,
        kind,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::issue::test_fixtures::ir;
    use crate::domain::model::issue::Issue;
    use crate::domain::model::record_ref::IssueRef;
    use crate::domain::usecases::issue::content_reader::IssueContentSet;
    use crate::domain::usecases::issue::test_support::FakeIssueRepository;
    use std::collections::HashMap;
    use std::path::PathBuf;

    #[derive(Default)]
    struct StubContentReader {
        sets: HashMap<IssueRef, IssueContentSet>,
    }

    impl StubContentReader {
        fn with(mut self, id: IssueRef, parts: &[&str], references: &[(&str, &[&str])]) -> Self {
            self.sets.insert(
                id,
                IssueContentSet {
                    parts: parts.iter().map(|s| s.to_string()).collect(),
                    references: references
                        .iter()
                        .map(|(n, ts)| (n.to_string(), ts.iter().map(|t| t.to_string()).collect()))
                        .collect(),
                },
            );
            self
        }
    }

    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(n: u64) -> Issue {
        use crate::domain::model::issue::test_fixtures::feature;
        feature("any title")
            .with_id(&ir(n).to_string())
            .build(ir(n))
    }

    fn repo_with(issues: Vec<Issue>) -> FakeIssueRepository {
        let mut r = FakeIssueRepository::new();
        for i in issues {
            r.push_issue(i);
        }
        r
    }

    #[test]
    fn no_issues_yields_no_results() {
        let repo = FakeIssueRepository::new();
        let content = StubContentReader::default();
        let results = check_issues_content_references(&repo, &content);
        assert!(results.is_empty());
    }

    #[test]
    fn issue_with_clean_refs_produces_no_result() {
        let repo = repo_with(vec![issue(1)]);
        let content = StubContentReader::default().with(
            ir(1),
            &["index", "plan"],
            &[("index", &["plan"]), ("plan", &[])],
        );
        let results = check_issues_content_references(&repo, &content);
        assert!(results.is_empty());
    }

    #[test]
    fn broken_ref_yields_violation_anchored_on_issue_path() {
        let repo = repo_with(vec![issue(1)]);
        let content = StubContentReader::default().with(ir(1), &["index"], &[("index", &["plan"])]);
        let results = check_issues_content_references(&repo, &content);
        assert_eq!(results.len(), 1);
        let r = &results[0];
        assert_eq!(r.path, PathBuf::from("docs/issues/0001-issue/index.md"));
        let v = r.violations.iter().next().unwrap();
        assert_eq!(v.severity, Severity::Error);
        match &v.kind {
            CheckViolationKind::BrokenCompanionLink { from, to } => {
                assert_eq!(from, "index");
                assert_eq!(to, "plan");
            }
            other => panic!("unexpected violation kind: {other:?}"),
        }
    }

    #[test]
    fn issue_with_no_content_set_is_skipped_silently() {
        let repo = repo_with(vec![issue(1), issue(2)]);
        let content =
            StubContentReader::default().with(ir(2), &["index"], &[("index", &["missing"])]);
        let results = check_issues_content_references(&repo, &content);
        assert_eq!(results.len(), 1);
        assert_eq!(
            results[0].path,
            PathBuf::from("docs/issues/0002-issue/index.md")
        );
    }
}