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,
pub content: String,
pub amends: Vec<EntityRef>,
pub amended_by: Vec<EntityRef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BriefSource {
Marker,
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())
}
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() {
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");
}
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
}
}
}