use std::str::FromStr;
use crate::domain::model::decision_record::{DecisionRecordEdit, DrStatus, TransitionError};
use crate::domain::model::event::{Event, EventAction};
use crate::domain::model::record_ref::DecisionRecordRef;
use crate::domain::model::status::Status;
use crate::domain::usecases::decision_record::DecisionRecordRepository;
use crate::domain::usecases::edit::dr::{commit_dr_edits, lower_transition};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateDecisionRecordOutcome {
StatusChanged { from: Status, to: Status },
NoOp,
EventAppended,
}
pub fn update_decision_record(
repo: &dyn DecisionRecordRepository,
id: &DecisionRecordRef,
event: Event,
) -> anyhow::Result<UpdateDecisionRecordOutcome> {
let record = repo
.find_by_id(id)?
.ok_or_else(|| anyhow::anyhow!("record {id} not found"))?;
if let EventAction::StatusChanged { to, .. } = &event.action {
if record.status.as_str() == to.as_str() {
return Ok(UpdateDecisionRecordOutcome::NoOp);
}
let target = DrStatus::from_str(to.as_str())
.map_err(|_| anyhow::anyhow!("'{to}' is not a known decision-record status"))?;
let edits = lower_transition(repo, &record, target, event.timestamp.clone())
.map_err(|e: TransitionError| anyhow::anyhow!("{e}"))?;
commit_dr_edits(repo, edits)?;
return Ok(UpdateDecisionRecordOutcome::StatusChanged {
from: Status::unresolved(record.status.as_str()),
to: Status::unresolved(target.as_str()),
});
}
commit_dr_edits(
repo,
vec![DecisionRecordEdit::AppendEvent {
record: record.id.clone(),
event,
}],
)?;
Ok(UpdateDecisionRecordOutcome::EventAppended)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::event::Event;
use crate::domain::usecases::decision_record::tests::{
adr, other_event, status_changed_event, FakeDecisionRecordRepository, RecordFixture,
};
#[test]
fn status_change_transitions_the_record_status() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("proposed"))
.when_status_changed("ADR-0001", "proposed", "accepted")
.then_saved_status("accepted");
}
#[test]
fn status_change_to_current_status_is_a_noop() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("proposed"))
.when_status_changed("ADR-0001", "proposed", "proposed")
.then_noop();
}
#[test]
fn updating_an_unknown_record_returns_an_error() {
scenario()
.when_status_changed("ADR-0099", "proposed", "accepted")
.then_err_contains("not found");
}
#[test]
fn illegal_transition_from_proposed_to_deprecated_is_rejected() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("proposed"))
.when_status_changed("ADR-0001", "proposed", "deprecated")
.then_err_contains("illegal transition");
}
#[test]
fn illegal_transition_from_proposed_to_superseded_is_rejected() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("proposed"))
.when_status_changed("ADR-0001", "proposed", "superseded")
.then_err_contains("illegal transition");
}
#[test]
fn illegal_transition_from_accepted_to_rejected_is_rejected() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("accepted"))
.when_status_changed("ADR-0001", "accepted", "rejected")
.then_err_contains("illegal transition");
}
#[test]
fn transition_from_terminal_status_is_rejected() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("rejected"))
.when_status_changed("ADR-0001", "rejected", "accepted")
.then_err_contains("terminal");
}
#[test]
fn legal_transition_from_accepted_to_deprecated_succeeds() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("accepted"))
.when_status_changed("ADR-0001", "accepted", "deprecated")
.then_saved_status("deprecated");
}
#[test]
fn accepting_a_record_supersedes_its_supersedes_targets() {
let scenario = scenario()
.given(adr("Old design").with_id("ADR-0001").status("accepted"))
.given(
adr("New design")
.with_id("ADR-0002")
.status("proposed")
.with_link("ADR-0001", "supersedes"),
);
let outcome = scenario.when_status_changed("ADR-0002", "proposed", "accepted");
outcome.then_target_status("ADR-0001", "superseded");
}
#[test]
fn cascade_is_idempotent_when_target_is_already_superseded_and_back_linked() {
let scenario = scenario()
.given(
adr("Old design")
.with_id("ADR-0001")
.status("superseded")
.with_link("ADR-0002", "superseded-by"),
)
.given(
adr("New design")
.with_id("ADR-0002")
.status("proposed")
.with_link("ADR-0001", "supersedes"),
);
let outcome = scenario.when_status_changed("ADR-0002", "proposed", "accepted");
outcome.then_no_save_for("ADR-0001");
}
#[test]
fn amends_link_writes_back_pointer_without_status_change() {
let scenario = scenario()
.given(adr("Old design").with_id("ADR-0001").status("accepted"))
.given(
adr("Amendment")
.with_id("ADR-0002")
.status("proposed")
.with_link("ADR-0001", "amends"),
);
let outcome = scenario.when_status_changed("ADR-0002", "proposed", "accepted");
outcome.then_target_status("ADR-0001", "accepted");
outcome.then_target_has_back_link("ADR-0001", "ADR-0002", "amended-by");
}
#[test]
fn supersedes_cascade_writes_back_pointer_into_target() {
let scenario = scenario()
.given(adr("Old design").with_id("ADR-0001").status("accepted"))
.given(
adr("New design")
.with_id("ADR-0002")
.status("proposed")
.with_link("ADR-0001", "supersedes"),
);
let outcome = scenario.when_status_changed("ADR-0002", "proposed", "accepted");
outcome.then_target_has_back_link("ADR-0001", "ADR-0002", "superseded-by");
}
#[test]
fn cascade_does_not_duplicate_an_existing_back_pointer() {
let scenario = scenario()
.given(
adr("Old design")
.with_id("ADR-0001")
.status("superseded")
.with_link("ADR-0002", "superseded-by"),
)
.given(
adr("New design")
.with_id("ADR-0002")
.status("proposed")
.with_link("ADR-0001", "supersedes"),
);
let outcome = scenario.when_status_changed("ADR-0002", "proposed", "accepted");
outcome.then_no_save_for("ADR-0001");
}
#[test]
fn cascade_does_not_fire_outside_proposed_to_accepted() {
let scenario = scenario()
.given(adr("Old design").with_id("ADR-0001").status("accepted"))
.given(
adr("New design")
.with_id("ADR-0002")
.status("accepted")
.with_link("ADR-0001", "supersedes"),
);
let outcome = scenario.when_status_changed("ADR-0002", "accepted", "deprecated");
outcome.then_no_save_for("ADR-0001");
}
#[test]
fn dangling_supersedes_target_is_silently_skipped() {
let scenario = scenario().given(
adr("New design")
.with_id("ADR-0001")
.status("proposed")
.with_link("ADR-0099", "supersedes"),
);
let outcome = scenario.when_status_changed("ADR-0001", "proposed", "accepted");
outcome.then_saved_status("accepted");
}
#[test]
fn legal_transition_from_deprecated_to_superseded_succeeds() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("deprecated"))
.when_status_changed("ADR-0001", "deprecated", "superseded")
.then_saved_status("superseded");
}
#[test]
fn created_event_is_appended_without_changing_status() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("proposed"))
.when_event("ADR-0001", other_event())
.then_saved_has_event("created")
.then_status_unchanged("proposed");
}
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 an explicit id — use .with_id()")
.to_string();
let numeric = DecisionRecordRef::new(&raw)
.unwrap_or_else(|_| panic!("given(): invalid id {raw:?}"));
self.repo.push_record(fixture.build(numeric));
self
}
fn when_status_changed(self, id: &str, from: &str, to: &str) -> UpdateOutcome {
let record_ref = DecisionRecordRef::new(id)
.unwrap_or_else(|_| panic!("when_status_changed: invalid id {id:?}"));
let result =
update_decision_record(&self.repo, &record_ref, status_changed_event(from, to));
UpdateOutcome {
repo: self.repo,
result,
}
}
fn when_event(self, id: &str, event: Event) -> UpdateOutcome {
let record_ref = DecisionRecordRef::new(id)
.unwrap_or_else(|_| panic!("when_event: invalid id {id:?}"));
let result = update_decision_record(&self.repo, &record_ref, event);
UpdateOutcome {
repo: self.repo,
result,
}
}
}
struct UpdateOutcome {
repo: FakeDecisionRecordRepository,
result: anyhow::Result<UpdateDecisionRecordOutcome>,
}
impl UpdateOutcome {
fn then_saved_status(self, expected: &str) -> Self {
let outcome = self.result.as_ref().expect("expected Ok, got Err");
assert!(
matches!(outcome, UpdateDecisionRecordOutcome::StatusChanged { to, .. } if to.as_str() == expected),
"expected StatusChanged to {expected:?}, got {outcome:?}"
);
let saved = self.repo.last_saved().expect("expected a save, got none");
assert_eq!(
saved.status.as_str(),
expected,
"expected saved status {expected:?}, got {:?}",
saved.status.as_str()
);
self
}
fn then_saved_has_event(self, action: &str) -> Self {
assert_eq!(
self.result.as_ref().expect("expected Ok, got Err"),
&UpdateDecisionRecordOutcome::EventAppended
);
let saved = self.repo.last_saved().expect("expected a save, got none");
assert!(
saved.events.iter().any(|e| e.action.as_str() == action),
"expected event {action:?} in {:?}",
saved
.events
.iter()
.map(|e| e.action.as_str())
.collect::<Vec<_>>()
);
self
}
fn then_status_unchanged(self, expected: &str) -> Self {
let saved = self.repo.last_saved().expect("expected a save, got none");
assert_eq!(
saved.status.as_str(),
expected,
"expected status to remain {expected:?}, got {:?}",
saved.status.as_str()
);
self
}
fn then_target_status(&self, id: &str, expected: &str) {
let id_ref = DecisionRecordRef::new(id).unwrap();
let saved = self
.repo
.saved_with_id(&id_ref)
.unwrap_or_else(|| panic!("expected a save for {id}, none found"));
assert_eq!(
saved.status.as_str(),
expected,
"expected target {id} status {expected:?}, got {:?}",
saved.status.as_str()
);
}
fn then_target_has_back_link(&self, target_id: &str, source_id: &str, relationship: &str) {
let target_ref = DecisionRecordRef::new(target_id).unwrap();
let saved = self
.repo
.saved_with_id(&target_ref)
.unwrap_or_else(|| panic!("expected a save for {target_id}, none found"));
let found = saved
.links
.iter()
.any(|l| l.relationship.as_str() == relationship && l.target.as_str() == source_id);
assert!(
found,
"expected {target_id} to carry back-link {relationship} → {source_id}, links: {:?}",
saved
.links
.iter()
.map(|l| (l.relationship.as_str(), l.target.as_str()))
.collect::<Vec<_>>()
);
}
fn then_no_save_for(&self, id: &str) {
let id_ref = DecisionRecordRef::new(id).unwrap();
assert!(
self.repo.saved_with_id(&id_ref).is_none(),
"expected no save for {id}, found one"
);
}
fn then_noop(self) -> Self {
assert_eq!(
self.result.as_ref().expect("expected Ok, got Err"),
&UpdateDecisionRecordOutcome::NoOp
);
assert!(self.repo.last_saved().is_none(), "expected nothing saved");
self
}
fn then_err_contains(self, substring: &str) {
let msg = self.result.expect_err("expected Err, got Ok").to_string();
assert!(
msg.contains(substring),
"expected error containing {substring:?}, got {msg:?}"
);
}
}
}