cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Brief the *decision* of every decision record matching a filter.
//! Each record yields one block, extracted from the `> [!DECISION]`
//! marker when present (DDR-01861F1CBBFDD).

use crate::domain::model::alert::extract_alerts;
use crate::domain::model::decision_record::{DecisionRecord, Relationship};
use crate::domain::model::entity_ref::EntityRef;
use crate::domain::model::tag_filter::TagFilter;
use crate::domain::usecases::decision_record::list::list_decision_records;
use crate::domain::usecases::decision_record::DecisionRecordRepository;

const DECISION_MARKER: &str = "DECISION";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecisionBrief {
    pub record: DecisionRecord,
    pub source: BriefSource,
    /// Decision text extracted from the `[!DECISION]` alert. Empty
    /// when `source == Absent`.
    pub content: String,
    /// Targets of every `Amends` link on the record, in declaration order.
    pub amends: Vec<EntityRef>,
    /// Sources of every `AmendedBy` inverse link, written by the cascade.
    pub amended_by: Vec<EntityRef>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BriefSource {
    /// Extracted from a `> [!DECISION]` alert in the body.
    Marker,
    /// No `[!DECISION]` alert on the record.
    Absent,
}

pub fn brief_decisions(
    repo: &dyn DecisionRecordRepository,
    status_filter: Option<crate::domain::model::decision_record::DrStatus>,
    tag_filters: &[TagFilter],
    applicable_only: bool,
) -> anyhow::Result<Vec<DecisionBrief>> {
    let records = list_decision_records(repo, status_filter, tag_filters)?;
    let kept = records
        .into_iter()
        .filter(|r| !applicable_only || is_applicable(&r.status));
    Ok(kept.map(brief_one).collect())
}

/// A decision is applicable when its status is `accepted`
/// (DDR-018QWJVHRH35B).
fn is_applicable(status: &crate::domain::model::decision_record::DrStatus) -> bool {
    *status == crate::domain::model::decision_record::DrStatus::Accepted
}

fn brief_one(record: DecisionRecord) -> DecisionBrief {
    let alerts = extract_alerts(&record.content);
    let decision = alerts.into_iter().find(|a| a.marker == DECISION_MARKER);
    let targets_of = |rel: Relationship| -> Vec<EntityRef> {
        record
            .links
            .iter()
            .filter(|l| l.relationship == rel)
            .map(|l| l.target.as_entity_ref().clone())
            .collect()
    };
    let amends = targets_of(Relationship::Amends);
    let amended_by = targets_of(Relationship::AmendedBy);
    let (source, content) = match decision {
        Some(alert) => (BriefSource::Marker, alert.content),
        None => (BriefSource::Absent, String::new()),
    };
    DecisionBrief {
        record,
        source,
        content,
        amends,
        amended_by,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::usecases::decision_record::tests::{
        adr, ddr, FakeDecisionRecordRepository, RecordFixture,
    };

    #[test]
    fn brief_returns_empty_when_no_records_match() {
        scenario().when_brief().then_empty();
    }

    #[test]
    fn brief_with_marker_extracts_decision_content() {
        scenario()
            .given(adr("Use Rust").with_id("ADR-0001").with_body(
                "## Context\n\nWe needed a language.\n\n> [!DECISION]\n> We adopt Rust.",
            ))
            .when_brief()
            .then_count(1)
            .then_marker_at(0)
            .then_content_at(0, "We adopt Rust.");
    }

    #[test]
    fn brief_without_marker_leaves_content_empty() {
        scenario()
            .given(
                adr("Untouched")
                    .with_id("ADR-0001")
                    .with_body("## Decision\n\nLegacy form, no marker."),
            )
            .when_brief()
            .then_count(1)
            .then_absent_at(0)
            .then_content_at(0, "");
    }

    #[test]
    fn brief_picks_first_decision_when_body_has_multiple() {
        // `cartu check` warns on duplicate `[!DECISION]` markers;
        // the brief stays deterministic by taking the first.
        scenario()
            .given(
                adr("Two decisions")
                    .with_id("ADR-0001")
                    .with_body("> [!DECISION]\n> first\n\n> [!DECISION]\n> second"),
            )
            .when_brief()
            .then_marker_at(0)
            .then_content_at(0, "first");
    }

    #[test]
    fn brief_ignores_non_decision_markers() {
        scenario()
            .given(
                adr("Context only")
                    .with_id("ADR-0001")
                    .with_body("> [!CONTEXT]\n> background\n\nplain prose"),
            )
            .when_brief()
            .then_absent_at(0);
    }

    #[test]
    fn brief_applicable_only_keeps_records_in_accepted_status() {
        scenario()
            .given(
                adr("Accepted")
                    .with_id("ADR-0001")
                    .status("accepted")
                    .with_body("> [!DECISION]\n> applies"),
            )
            .given(
                adr("Proposed")
                    .with_id("ADR-0002")
                    .status("proposed")
                    .with_body("> [!DECISION]\n> not yet decided"),
            )
            .given(
                adr("Rejected")
                    .with_id("ADR-0003")
                    .status("rejected")
                    .with_body("> [!DECISION]\n> turned down"),
            )
            .given(
                adr("Superseded")
                    .with_id("ADR-0004")
                    .status("superseded")
                    .with_body("> [!DECISION]\n> replaced"),
            )
            .given(
                adr("Deprecated")
                    .with_id("ADR-0005")
                    .status("deprecated")
                    .with_body("> [!DECISION]\n> retired"),
            )
            .when_brief_applicable_only()
            .then_count(1)
            .then_ids(&["ADR-0001"]);
    }

    #[test]
    fn brief_includes_every_status_when_filter_disabled() {
        scenario()
            .given(adr("Accepted").with_id("ADR-0001").status("accepted"))
            .given(adr("Proposed").with_id("ADR-0002").status("proposed"))
            .given(adr("Rejected").with_id("ADR-0003").status("rejected"))
            .when_brief()
            .then_count(3);
    }

    #[test]
    fn brief_surfaces_amends_link_targets() {
        scenario()
            .given(
                adr("Narrowed")
                    .with_id("ADR-0001")
                    .with_body("> [!DECISION]\n> revised")
                    .with_link("ADR-0099", "amends")
                    .with_link("ADR-0050", "amends")
                    .with_relates("ADR-0098"),
            )
            .when_brief()
            .then_count(1)
            .then_amends_at(0, &["ADR-0099", "ADR-0050"]);
    }

    #[test]
    fn brief_surfaces_amended_by_from_local_inverse_links() {
        scenario()
            .given(
                adr("Base")
                    .with_id("ADR-0001")
                    .with_link("ADR-0002", "amended-by")
                    .with_link("ADR-0003", "amended-by"),
            )
            .when_brief()
            .then_count(1)
            .then_amended_by_at(0, &["ADR-0002", "ADR-0003"]);
    }

    #[test]
    fn brief_amended_by_empty_without_inbound_links() {
        scenario()
            .given(adr("Standalone").with_id("ADR-0001"))
            .when_brief()
            .then_amended_by_at(0, &[]);
    }

    #[test]
    fn brief_amends_is_empty_when_no_amends_link() {
        scenario()
            .given(
                adr("Standalone")
                    .with_id("ADR-0001")
                    .with_relates("ADR-0050"),
            )
            .when_brief()
            .then_count(1)
            .then_amends_at(0, &[]);
    }

    #[test]
    fn brief_spans_kinds_in_id_order() {
        scenario()
            .given(
                adr("ADR one")
                    .with_id("ADR-0001")
                    .with_body("> [!DECISION]\n> adr says yes"),
            )
            .given(
                ddr("DDR two")
                    .with_id("DDR-0002")
                    .with_body("> [!DECISION]\n> ddr says no"),
            )
            .when_brief()
            .then_count(2)
            .then_content_at(0, "adr says yes")
            .then_content_at(1, "ddr says no");
    }

    // ── Scenario ──────────────────────────────────────────────────────────────

    fn scenario() -> Scenario {
        Scenario {
            repo: FakeDecisionRecordRepository::with_records(vec![]),
        }
    }

    struct Scenario {
        repo: FakeDecisionRecordRepository,
    }

    impl Scenario {
        fn given(mut self, fixture: RecordFixture) -> Self {
            let raw = fixture
                .id
                .as_deref()
                .expect("given() requires .with_id()")
                .to_string();
            let numeric = crate::domain::model::record_ref::DecisionRecordRef::new(&raw)
                .unwrap_or_else(|_| panic!("given(): invalid id {raw:?}"));
            self.repo.push_record(fixture.build(numeric));
            self
        }

        fn when_brief(self) -> BriefOutcome {
            let result = brief_decisions(&self.repo, None, &[], false)
                .expect("brief_decisions failed unexpectedly");
            BriefOutcome { result }
        }

        fn when_brief_applicable_only(self) -> BriefOutcome {
            let result = brief_decisions(&self.repo, None, &[], true)
                .expect("brief_decisions failed unexpectedly");
            BriefOutcome { result }
        }
    }

    struct BriefOutcome {
        result: Vec<DecisionBrief>,
    }

    impl BriefOutcome {
        fn then_empty(self) -> Self {
            assert!(
                self.result.is_empty(),
                "expected empty, got {} briefs",
                self.result.len()
            );
            self
        }

        fn then_count(self, n: usize) -> Self {
            assert_eq!(
                self.result.len(),
                n,
                "expected {n} briefs, got {}",
                self.result.len()
            );
            self
        }

        fn then_marker_at(self, idx: usize) -> Self {
            assert_eq!(
                self.result[idx].source,
                BriefSource::Marker,
                "expected Marker source at {idx}"
            );
            self
        }

        fn then_absent_at(self, idx: usize) -> Self {
            assert_eq!(
                self.result[idx].source,
                BriefSource::Absent,
                "expected Absent source at {idx}"
            );
            self
        }

        fn then_content_at(self, idx: usize, expected: &str) -> Self {
            assert_eq!(self.result[idx].content, expected);
            self
        }

        fn then_amends_at(self, idx: usize, expected: &[&str]) -> Self {
            let actual: Vec<String> = self.result[idx]
                .amends
                .iter()
                .map(|r| r.as_str().to_string())
                .collect();
            assert_eq!(actual, expected);
            self
        }

        fn then_amended_by_at(self, idx: usize, expected: &[&str]) -> Self {
            let actual: Vec<String> = self.result[idx]
                .amended_by
                .iter()
                .map(|r| r.as_str().to_string())
                .collect();
            assert_eq!(actual, expected);
            self
        }

        fn then_ids(self, expected: &[&str]) -> Self {
            let actual: Vec<String> = self
                .result
                .iter()
                .map(|b| b.record.id.as_str().to_string())
                .collect();
            assert_eq!(actual, expected);
            self
        }
    }
}