use crate::domain::model::event::{Event, EventAction, State};
use crate::domain::model::record_ref::DecisionRecordRef;
use crate::domain::model::temporal::timestamp::Timestamp;
use super::{DecisionRecord, DrStatus, Relationship};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CascadeAction {
Supersede { source: DecisionRecordRef },
AmendedBy { source: DecisionRecordRef },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CascadeRequest {
pub target: DecisionRecordRef,
pub action: CascadeAction,
pub timestamp: Timestamp,
}
#[derive(Debug, Clone)]
pub struct TransitionOutcome {
pub from: DrStatus,
pub to: DrStatus,
pub updated: DecisionRecord,
pub cascades: Vec<CascadeRequest>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransitionError {
FromTerminal { from: DrStatus },
Illegal {
from: DrStatus,
to: DrStatus,
allowed: &'static [DrStatus],
},
}
impl std::fmt::Display for TransitionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransitionError::FromTerminal { from } => write!(
f,
"illegal transition from '{from}': (terminal — no further transitions)"
),
TransitionError::Illegal { from, to, allowed } => {
let allowed_list = allowed
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ");
write!(
f,
"illegal transition '{from}' → '{to}': allowed from '{from}' is {allowed_list}"
)
}
}
}
}
impl std::error::Error for TransitionError {}
impl DecisionRecord {
pub fn transition_to(
&self,
target: DrStatus,
timestamp: Timestamp,
) -> Result<TransitionOutcome, TransitionError> {
let current = self.status;
if current == target {
return Err(TransitionError::Illegal {
from: current,
to: target,
allowed: current.allowed_next(),
});
}
if current.is_terminal() {
return Err(TransitionError::FromTerminal { from: current });
}
if !current.allows(target) {
return Err(TransitionError::Illegal {
from: current,
to: target,
allowed: current.allowed_next(),
});
}
let event = Event {
timestamp: timestamp.clone(),
action: EventAction::StatusChanged {
from: State::new(current.as_str()).expect("DrStatus names are valid State"),
to: State::new(target.as_str()).expect("DrStatus names are valid State"),
},
};
let new_events = self.events.with_appended(event);
let updated = self.clone().with_status(target).with_events(new_events);
let cascades = if current == DrStatus::Proposed && target == DrStatus::Accepted {
self.links
.iter()
.filter_map(|l| {
let action = match l.relationship {
Relationship::Supersedes => CascadeAction::Supersede {
source: self.id.clone(),
},
Relationship::Amends => CascadeAction::AmendedBy {
source: self.id.clone(),
},
_ => return None,
};
Some(CascadeRequest {
target: l.target.clone(),
action,
timestamp: timestamp.clone(),
})
})
.collect()
} else {
Vec::new()
};
Ok(TransitionOutcome {
from: current,
to: target,
updated,
cascades,
})
}
}
#[cfg(test)]
pub mod strategy {
use super::{CascadeAction, CascadeRequest};
use crate::domain::model::record_ref::strategy::decision_record_ref;
use crate::domain::model::temporal::timestamp::Timestamp;
use proptest::prelude::*;
pub fn cascade_action() -> impl Strategy<Value = CascadeAction> {
prop_oneof![
decision_record_ref().prop_map(|source| CascadeAction::Supersede { source }),
decision_record_ref().prop_map(|source| CascadeAction::AmendedBy { source }),
]
}
prop_compose! {
pub fn cascade_request()(
target in decision_record_ref(),
action in cascade_action(),
) -> CascadeRequest {
CascadeRequest {
target,
action,
timestamp: Timestamp::new("2026-01-01T00:00:00Z").unwrap(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::decision_record::{RecordLink, RecordLinks};
use crate::domain::model::record_ref::DecisionRecordRef;
use proptest::prelude::*;
proptest! {
#[test]
fn cascade_request_target_matches_action_source_when_supersede(r in strategy::cascade_request()) {
let ok = match r.action {
CascadeAction::Supersede { .. } | CascadeAction::AmendedBy { .. } => true,
};
prop_assert!(ok);
}
}
fn ts() -> Timestamp {
Timestamp::new("2026-05-08T00:00:00Z").unwrap()
}
fn dr_ref(s: &str) -> DecisionRecordRef {
DecisionRecordRef::new(s).unwrap()
}
fn record(status: DrStatus) -> DecisionRecord {
use crate::domain::model::body::Body;
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::entry_origin::EntryOrigin;
use crate::domain::model::record_kind::RecordKind;
use crate::domain::model::tag_list::TagList;
use crate::domain::model::temporal::iso_date::IsoDate;
use crate::domain::model::title::Title;
DecisionRecord {
id: dr_ref("ADR-0001"),
kind: RecordKind::new("adr").unwrap(),
title: Title::new("Use Rust").unwrap(),
description: None,
status,
date: IsoDate::new("2026-05-01").unwrap(),
tags: TagList::new(),
aliases: Vec::new(),
content: Body::default(),
events: crate::domain::model::event::EventLog::new(),
links: RecordLinks::new(),
relates: crate::domain::model::relates::Relates::default(),
origin: EntryOrigin::Local,
location: EntryLocator::default(),
}
}
#[test]
fn legal_transition_returns_updated_record_and_event() {
let r = record(DrStatus::Proposed);
let outcome = r.transition_to(DrStatus::Accepted, ts()).unwrap();
assert_eq!(outcome.from, DrStatus::Proposed);
assert_eq!(outcome.to, DrStatus::Accepted);
assert_eq!(outcome.updated.status.as_str(), "accepted");
assert_eq!(outcome.updated.events.len(), 1);
assert!(outcome.cascades.is_empty());
}
#[test]
fn illegal_transition_is_rejected() {
let r = record(DrStatus::Proposed);
let err = r.transition_to(DrStatus::Deprecated, ts()).unwrap_err();
assert!(matches!(err, TransitionError::Illegal { .. }));
}
#[test]
fn transition_from_terminal_is_rejected() {
let r = record(DrStatus::Rejected);
let err = r.transition_to(DrStatus::Accepted, ts()).unwrap_err();
assert!(matches!(err, TransitionError::FromTerminal { .. }));
}
#[test]
fn proposed_to_accepted_emits_supersede_cascade_for_supersedes_links() {
let r = record(DrStatus::Proposed).with_links({
let mut l = RecordLinks::new();
l.push(RecordLink {
target: dr_ref("ADR-0099"),
relationship: Relationship::Supersedes,
});
l
});
let outcome = r.transition_to(DrStatus::Accepted, ts()).unwrap();
assert_eq!(outcome.cascades.len(), 1);
assert!(matches!(
outcome.cascades[0].action,
CascadeAction::Supersede { .. }
));
assert_eq!(outcome.cascades[0].target.as_str(), "ADR-0099");
}
#[test]
fn proposed_to_accepted_emits_amended_by_for_amends_links() {
let r = record(DrStatus::Proposed).with_links({
let mut l = RecordLinks::new();
l.push(RecordLink {
target: dr_ref("ADR-0099"),
relationship: Relationship::Amends,
});
l
});
let outcome = r.transition_to(DrStatus::Accepted, ts()).unwrap();
assert_eq!(outcome.cascades.len(), 1);
assert!(matches!(
outcome.cascades[0].action,
CascadeAction::AmendedBy { .. }
));
}
#[test]
fn cascade_does_not_fire_outside_proposed_to_accepted() {
let r = record(DrStatus::Accepted).with_links({
let mut l = RecordLinks::new();
l.push(RecordLink {
target: dr_ref("ADR-0099"),
relationship: Relationship::Supersedes,
});
l
});
let outcome = r.transition_to(DrStatus::Deprecated, ts()).unwrap();
assert!(outcome.cascades.is_empty());
}
}