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;
#[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 {
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,
}
}
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())
}
}
}
}
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)
}
}
}
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::*;
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);
}
#[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);
}
#[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");
}
}