use crate::domain::model::body::Body;
use crate::domain::model::decision_record::{DecisionRecord, DrStatus, RecordLink};
use crate::domain::model::event::Event;
use crate::domain::model::record_ref::DecisionRecordRef;
use crate::domain::model::tag_list::TagList;
use crate::domain::model::title::Title;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecisionRecordEdit {
SetStatus {
record: DecisionRecordRef,
status: DrStatus,
},
SetTitle {
record: DecisionRecordRef,
title: Title,
},
SetBody {
record: DecisionRecordRef,
body: Body,
},
SetTags {
record: DecisionRecordRef,
tags: TagList,
},
AddLink {
record: DecisionRecordRef,
link: RecordLink,
},
RemoveLink {
record: DecisionRecordRef,
link: RecordLink,
},
AppendEvent {
record: DecisionRecordRef,
event: Event,
},
}
impl DecisionRecordEdit {
pub fn target_id(&self) -> &DecisionRecordRef {
match self {
Self::SetStatus { record, .. }
| Self::SetTitle { record, .. }
| Self::SetBody { record, .. }
| Self::SetTags { record, .. }
| Self::AddLink { record, .. }
| Self::RemoveLink { record, .. }
| Self::AppendEvent { record, .. } => record,
}
}
pub fn describe(&self) -> String {
match self {
Self::AddLink { record, link } => format!(
"added '{}' → {} on {}",
link.relationship.as_str(),
link.target,
record
),
Self::RemoveLink { record, link } => format!(
"removed '{}' → {} from {}",
link.relationship.as_str(),
link.target,
record
),
Self::SetStatus { record, status } => {
format!("set status of {record} to {}", status.as_str())
}
Self::SetTitle { record, .. } => format!("set title of {record}"),
Self::SetBody { record, .. } => format!("set body of {record}"),
Self::SetTags { record, .. } => format!("set tags of {record}"),
Self::AppendEvent { record, event } => {
format!("appended {} event to {record}", event.action.as_str())
}
}
}
}
pub fn apply_edit(record: DecisionRecord, edit: DecisionRecordEdit) -> DecisionRecord {
if edit.target_id() != &record.id {
return record;
}
match edit {
DecisionRecordEdit::SetStatus { status, .. } => record.with_status(status),
DecisionRecordEdit::SetTitle { title, .. } => record.with_title(title),
DecisionRecordEdit::SetBody { body, .. } => record.with_content(body),
DecisionRecordEdit::SetTags { tags, .. } => record.with_tags(tags),
DecisionRecordEdit::AddLink { link, .. } => {
let already = record
.links
.iter()
.any(|l| l.relationship == link.relationship && l.target == link.target);
if already {
record
} else {
let new_links = record.links.with_added(link);
record.with_links(new_links)
}
}
DecisionRecordEdit::RemoveLink { link, .. } => match record.links.with_removed(&link) {
Some(new_links) => record.with_links(new_links),
None => record,
},
DecisionRecordEdit::AppendEvent { event, .. } => {
let new_events = record.events.with_appended(event);
record.with_events(new_events)
}
}
}
pub fn apply_edits(record: DecisionRecord, edits: Vec<DecisionRecordEdit>) -> DecisionRecord {
edits.into_iter().fold(record, apply_edit)
}
#[cfg(test)]
pub mod strategy {
use super::DecisionRecordEdit;
use crate::domain::model::decision_record::dr_status::strategy::dr_status;
use crate::domain::model::record_ref::strategy::decision_record_ref;
use crate::domain::model::title::Title;
use proptest::prelude::*;
pub fn decision_record_edit() -> impl Strategy<Value = DecisionRecordEdit> {
prop_oneof![
(decision_record_ref(), dr_status())
.prop_map(|(record, status)| DecisionRecordEdit::SetStatus { record, status }),
(decision_record_ref(), "[A-Za-z][A-Za-z0-9 _-]{0,40}").prop_map(|(record, t)| {
DecisionRecordEdit::SetTitle {
record,
title: Title::new(&t).unwrap(),
}
}),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::decision_record::Relationship;
use proptest::prelude::*;
proptest! {
#[test]
fn describe_is_non_empty(edit in strategy::decision_record_edit()) {
prop_assert!(!edit.describe().is_empty());
}
}
fn dr_ref(s: &str) -> DecisionRecordRef {
DecisionRecordRef::new(s).unwrap()
}
fn record(id: &str, 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(id),
kind: RecordKind::new("adr").unwrap(),
title: Title::new("t").unwrap(),
description: None,
status,
date: IsoDate::new("2026-05-08").unwrap(),
tags: TagList::new(),
aliases: Vec::new(),
content: Body::default(),
events: crate::domain::model::event::EventLog::new(),
links: crate::domain::model::decision_record::RecordLinks::new(),
relates: crate::domain::model::relates::Relates::default(),
origin: EntryOrigin::Local,
location: EntryLocator::default(),
}
}
#[test]
fn add_link_appends_when_absent() {
let after = apply_edit(
record("ADR-0001", DrStatus::Proposed),
DecisionRecordEdit::AddLink {
record: dr_ref("ADR-0001"),
link: RecordLink {
target: dr_ref("ADR-0002"),
relationship: Relationship::Supersedes,
},
},
);
assert_eq!(after.links.iter().count(), 1);
}
#[test]
fn set_status_replaces_the_status() {
let after = apply_edit(
record("ADR-0001", DrStatus::Proposed),
DecisionRecordEdit::SetStatus {
record: dr_ref("ADR-0001"),
status: DrStatus::Accepted,
},
);
assert_eq!(after.status.as_str(), "accepted");
}
#[test]
fn mismatched_edit_is_silently_skipped() {
let before = record("ADR-0001", DrStatus::Proposed);
let after = apply_edit(
before.clone(),
DecisionRecordEdit::SetStatus {
record: dr_ref("ADR-0099"),
status: DrStatus::Accepted,
},
);
assert_eq!(after, before);
}
#[test]
fn apply_edits_filters_by_id() {
let after = apply_edits(
record("ADR-0001", DrStatus::Proposed),
vec![
DecisionRecordEdit::SetStatus {
record: dr_ref("ADR-0099"),
status: DrStatus::Rejected,
},
DecisionRecordEdit::SetStatus {
record: dr_ref("ADR-0001"),
status: DrStatus::Accepted,
},
],
);
assert_eq!(after.status.as_str(), "accepted");
}
}