cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Pure mutation vocabulary for [`Issue`]. Each [`IssueEdit`] variant
//! describes one atomic change targeted at a specific issue id. The
//! pure transforms [`apply_edit`] / [`apply_edits`] only apply when the
//! edit's id matches the input issue — mismatching edits are silently
//! ignored. That makes a `Vec<IssueEdit>` safe to throw at any issue:
//! only the relevant edits land, no cross-record accidents possible.
//!
//! The I/O wrapper that loads/saves around these pure transforms lives
//! in `domain/usecases/edit/issue.rs`.

use crate::domain::model::body::Body;
use crate::domain::model::event::Event;
use crate::domain::model::issue::{Issue, IssueLink};
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::status::Status;
use crate::domain::model::tag_list::TagList;
use crate::domain::model::title::Title;

/// One atomic mutation on one issue. Each variant carries the target
/// `issue` id so a `Vec<IssueEdit>` is self-contained: the engine can
/// group by id, and the pure apply functions can refuse mismatched
/// edits without a wrapper type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueEdit {
    AddLink { issue: IssueRef, link: IssueLink },
    RemoveLink { issue: IssueRef, link: IssueLink },
    SetStatus { issue: IssueRef, status: Status },
    SetTitle { issue: IssueRef, title: Title },
    SetBody { issue: IssueRef, body: Body },
    SetTags { issue: IssueRef, tags: TagList },
    AppendEvent { issue: IssueRef, event: Event },
}

impl IssueEdit {
    /// The id of the issue this edit targets.
    pub fn target_id(&self) -> &IssueRef {
        match self {
            Self::AddLink { issue, .. }
            | Self::RemoveLink { issue, .. }
            | Self::SetStatus { issue, .. }
            | Self::SetTitle { issue, .. }
            | Self::SetBody { issue, .. }
            | Self::SetTags { issue, .. }
            | Self::AppendEvent { issue, .. } => issue,
        }
    }

    /// Short, log-friendly description used by the `--fix` and
    /// `--fix --dry-run` CLI output.
    pub fn describe(&self) -> String {
        match self {
            Self::AddLink { issue, link } => format!(
                "added '{}' → {} on {}",
                link.relationship.as_str(),
                link.target,
                issue
            ),
            Self::RemoveLink { issue, link } => format!(
                "removed '{}' → {} from {}",
                link.relationship.as_str(),
                link.target,
                issue
            ),
            Self::SetStatus { issue, status } => {
                format!("set status of {issue} to {}", status.as_str())
            }
            Self::SetTitle { issue, .. } => format!("set title of {issue}"),
            Self::SetBody { issue, .. } => format!("set body of {issue}"),
            Self::SetTags { issue, .. } => format!("set tags of {issue}"),
            Self::AppendEvent { issue, event } => {
                format!("appended {} event to {issue}", event.action.as_str())
            }
        }
    }
}

/// Apply one edit to an issue. The edit is applied only when its
/// `issue` id matches `issue.id`; otherwise `issue` is returned
/// unchanged (silent skip — see module docs). `AddLink` is idempotent
/// at this layer too: a link already present is not appended again.
pub fn apply_edit(issue: Issue, edit: IssueEdit) -> Issue {
    if edit.target_id() != &issue.id {
        return issue;
    }
    match edit {
        IssueEdit::AddLink { link, .. } => {
            let already = issue
                .links
                .iter()
                .any(|l| l.relationship == link.relationship && l.target == link.target);
            if already {
                issue
            } else {
                let new_links = issue.links.with_added(link);
                issue.with_links(new_links)
            }
        }
        IssueEdit::RemoveLink { link, .. } => match issue.links.with_removed(&link) {
            Some(new_links) => issue.with_links(new_links),
            None => issue,
        },
        IssueEdit::SetStatus { status, .. } => issue.with_status(status),
        IssueEdit::SetTitle { title, .. } => issue.with_title(title),
        IssueEdit::SetBody { body, .. } => issue.with_content(body),
        IssueEdit::SetTags { tags, .. } => issue.with_tags(tags),
        IssueEdit::AppendEvent { event, .. } => {
            let new_events = issue.events.with_appended(event);
            issue.with_events(new_events)
        }
    }
}

/// Fold an edit batch onto one issue. Mismatched edits are skipped
/// (see [`apply_edit`]), so a heterogeneous batch can be applied to
/// each touched record in turn and each will only absorb its own
/// edits.
pub fn apply_edits(issue: Issue, edits: Vec<IssueEdit>) -> Issue {
    edits.into_iter().fold(issue, apply_edit)
}

#[cfg(test)]
pub mod strategy {
    use super::IssueEdit;
    use crate::domain::model::issue::issue_link::strategy::issue_link;
    use crate::domain::model::record_ref::strategy::issue_ref;
    use crate::domain::model::status::Status;
    use crate::domain::model::title::Title;
    use proptest::prelude::*;

    /// Sampled coverage of [`IssueEdit`]: representative variants
    /// across the structural / link / status axes.
    pub fn issue_edit() -> impl Strategy<Value = IssueEdit> {
        prop_oneof![
            (issue_ref(), issue_link())
                .prop_map(|(issue, link)| IssueEdit::AddLink { issue, link }),
            (issue_ref(), "[a-z]{1,12}").prop_map(|(issue, s)| IssueEdit::SetStatus {
                issue,
                status: Status::unresolved(&s),
            }),
            (issue_ref(), "[A-Za-z][A-Za-z0-9 _-]{0,40}").prop_map(|(issue, t)| {
                IssueEdit::SetTitle {
                    issue,
                    title: Title::new(&t).unwrap(),
                }
            }),
        ]
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::entry_locator::EntryLocator;
    use crate::domain::model::entry_origin::EntryOrigin;
    use crate::domain::model::event::{EventAction, State};
    use crate::domain::model::issue::IssueRelationship;
    use crate::domain::model::temporal::timestamp::Timestamp;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn describe_is_non_empty(edit in strategy::issue_edit()) {
            prop_assert!(!edit.describe().is_empty());
        }
    }

    fn ir(s: &str) -> IssueRef {
        IssueRef::new(s).unwrap()
    }

    fn issue(id: &str, status: &str) -> Issue {
        use crate::domain::model::body::Body;
        use crate::domain::model::issue::{IssueLinks, Tracker};
        use crate::domain::model::relates::Relates;
        use crate::domain::model::tag_list::TagList;
        use crate::domain::model::temporal::iso_date::IsoDate;
        use crate::domain::model::title::Title;
        Issue {
            id: ir(id),
            title: Title::new("t").unwrap(),
            description: None,
            status: Status::unresolved(status),
            assignee: None,
            tracker: Tracker::local("seed"),
            origin: EntryOrigin::Local,
            date: IsoDate::new("2026-05-08").unwrap(),
            due_date: None,
            tags: TagList::new(),
            aliases: Vec::new(),
            content: Body::default(),
            events: crate::domain::model::event::EventLog::new(),
            links: IssueLinks::new(),
            relates: Relates::default(),
            location: EntryLocator::default(),
        }
    }

    fn ts() -> Timestamp {
        Timestamp::new("2026-05-08T00:00:00Z").unwrap()
    }

    #[test]
    fn add_link_appends_when_absent() {
        let before = issue("ISSUE-0001", "open");
        let edit = IssueEdit::AddLink {
            issue: ir("ISSUE-0001"),
            link: IssueLink {
                target: ir("ISSUE-0002"),
                relationship: IssueRelationship::Blocks,
            },
        };
        let after = apply_edit(before, edit);
        assert_eq!(after.links.len(), 1);
    }

    #[test]
    fn add_link_is_idempotent_on_duplicate() {
        let mut issue = issue("ISSUE-0001", "open");
        issue.links = issue.links.with_added(IssueLink {
            target: ir("ISSUE-0002"),
            relationship: IssueRelationship::Blocks,
        });
        let edit = IssueEdit::AddLink {
            issue: ir("ISSUE-0001"),
            link: IssueLink {
                target: ir("ISSUE-0002"),
                relationship: IssueRelationship::Blocks,
            },
        };
        let after = apply_edit(issue.clone(), edit);
        assert_eq!(after.links.len(), 1);
        assert_eq!(after, issue);
    }

    #[test]
    fn set_status_replaces_the_status() {
        let before = issue("ISSUE-0001", "open");
        let after = apply_edit(
            before,
            IssueEdit::SetStatus {
                issue: ir("ISSUE-0001"),
                status: Status::unresolved("closed"),
            },
        );
        assert_eq!(after.status.as_str(), "closed");
    }

    #[test]
    fn append_event_grows_the_log() {
        let before = issue("ISSUE-0001", "open");
        let event = Event {
            timestamp: ts(),
            action: EventAction::StatusChanged {
                from: State::new("open").unwrap(),
                to: State::new("closed").unwrap(),
            },
        };
        let after = apply_edit(
            before,
            IssueEdit::AppendEvent {
                issue: ir("ISSUE-0001"),
                event,
            },
        );
        assert_eq!(after.events.len(), 1);
    }

    /// The key safety property: an edit targeting a different id is
    /// silently ignored.
    #[test]
    fn mismatched_edit_is_silently_skipped() {
        let before = issue("ISSUE-0001", "open");
        let foreign = IssueEdit::SetStatus {
            issue: ir("ISSUE-0099"),
            status: Status::unresolved("closed"),
        };
        let after = apply_edit(before.clone(), foreign);
        assert_eq!(after, before);
    }

    /// Composability: a batch targeting several issues can be safely
    /// folded onto each one — only the matching edits apply.
    #[test]
    fn apply_edits_filters_by_id() {
        let target = issue("ISSUE-0001", "open");
        let batch = vec![
            IssueEdit::SetStatus {
                issue: ir("ISSUE-0099"),
                status: Status::unresolved("closed"),
            },
            IssueEdit::SetStatus {
                issue: ir("ISSUE-0001"),
                status: Status::unresolved("in-progress"),
            },
            IssueEdit::SetStatus {
                issue: ir("ISSUE-0042"),
                status: Status::unresolved("closed"),
            },
        ];
        let after = apply_edits(target, batch);
        assert_eq!(after.status.as_str(), "in-progress");
    }
}