cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Pure mutation vocabulary for [`DecisionRecord`]. Mirror of
//! `domain::model::issue::edit` — see that module's docs for the
//! reasoning. Each [`DecisionRecordEdit`] is targeted at a specific
//! record id; the pure transforms refuse to apply when the id does
//! not match.

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 {
    /// The id of the decision record this edit targets.
    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,
        }
    }

    /// Short, log-friendly description used by the `--fix` and
    /// `--fix --dry-run` CLI output.
    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())
            }
        }
    }
}

/// Apply one edit to a record. Silently skips when the edit targets a
/// different id (composability — see issue edit module docs).
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::*;

    /// Sampled coverage of [`DecisionRecordEdit`].
    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");
    }
}