use crate::domain::model::event::{Event, EventAction, State};
use crate::domain::model::issue::{apply_edits, Issue, IssueEdit, IssueLink, IssueRelationship};
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::status::Status;
use crate::domain::model::temporal::timestamp::Timestamp;
use crate::domain::usecases::issue::IssueRepository;
pub fn commit_issue_edits(repo: &dyn IssueRepository, edits: Vec<IssueEdit>) -> anyhow::Result<()> {
let mut order: Vec<IssueRef> = Vec::new();
let mut seen: std::collections::HashSet<IssueRef> = std::collections::HashSet::new();
for edit in &edits {
let id = edit.target_id().clone();
if seen.insert(id.clone()) {
order.push(id);
}
}
for id in order {
let Some(loaded) = repo.find_by_id(&id)? else {
continue;
};
if let crate::domain::model::entry_origin::EntryOrigin::Union { name } = &loaded.origin {
anyhow::bail!("cannot modify '{id}' — it is sourced from the read-only union '{name}'");
}
let after = apply_edits(loaded.clone(), edits.clone());
if after != loaded {
repo.save(&after)?;
}
}
Ok(())
}
pub fn lower_status_transition(
issue: &Issue,
new_status: Status,
timestamp: Timestamp,
) -> Vec<IssueEdit> {
if issue.status.as_str() == new_status.as_str() {
return Vec::new();
}
let from = State::new(issue.status.as_str()).expect("status name is a valid State");
let to = State::new(new_status.as_str()).expect("status name is a valid State");
vec![
IssueEdit::SetStatus {
issue: issue.id.clone(),
status: new_status,
},
IssueEdit::AppendEvent {
issue: issue.id.clone(),
event: Event {
timestamp,
action: EventAction::StatusChanged { from, to },
},
},
]
}
pub fn add_symmetric_link(
source: IssueRef,
relationship: IssueRelationship,
target: IssueRef,
) -> Vec<IssueEdit> {
let inverse = relationship.inverse();
vec![
IssueEdit::AddLink {
issue: source.clone(),
link: IssueLink {
target: target.clone(),
relationship,
},
},
IssueEdit::AddLink {
issue: target,
link: IssueLink {
target: source,
relationship: inverse,
},
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::usecases::issue::tests::{feature, FakeIssueRepository, IssueFixture};
fn ir(s: &str) -> IssueRef {
IssueRef::new(s).unwrap()
}
fn build(fixture: IssueFixture) -> crate::domain::model::issue::Issue {
let raw = fixture.id.as_deref().expect("with_id required").to_string();
let numeric = IssueRef::new(&raw).unwrap();
fixture.build(numeric)
}
#[test]
fn user_link_add_yields_two_edits_and_two_symmetric_saves() {
let a = build(feature("A").with_id("ISSUE-0001"));
let b = build(feature("B").with_id("ISSUE-0002"));
let repo = FakeIssueRepository::with_issues(vec![a, b]);
let edits = add_symmetric_link(
ir("ISSUE-0001"),
IssueRelationship::Blocks,
ir("ISSUE-0002"),
);
assert_eq!(edits.len(), 2);
commit_issue_edits(&repo, edits).unwrap();
let saved_a = repo.saved_for(&ir("ISSUE-0001")).unwrap();
assert!(saved_a
.links
.iter()
.any(|l| l.relationship == IssueRelationship::Blocks
&& l.target.as_str() == "ISSUE-0002"));
let saved_b = repo.saved_for(&ir("ISSUE-0002")).unwrap();
assert!(saved_b
.links
.iter()
.any(|l| l.relationship == IssueRelationship::BlockedBy
&& l.target.as_str() == "ISSUE-0001"));
}
#[test]
fn user_link_add_skips_save_when_side_already_present() {
let a = build(
feature("A")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "blocks"),
);
let b = build(feature("B").with_id("ISSUE-0002"));
let repo = FakeIssueRepository::with_issues(vec![a, b]);
let edits = add_symmetric_link(
ir("ISSUE-0001"),
IssueRelationship::Blocks,
ir("ISSUE-0002"),
);
commit_issue_edits(&repo, edits).unwrap();
assert!(
repo.saved_for(&ir("ISSUE-0001")).is_none(),
"ISSUE-0001 already had the forward link — no save expected"
);
let saved_b = repo.saved_for(&ir("ISSUE-0002")).unwrap();
assert!(saved_b
.links
.iter()
.any(|l| l.relationship == IssueRelationship::BlockedBy
&& l.target.as_str() == "ISSUE-0001"));
}
#[test]
fn rule_fix_shape_emits_a_single_add_link() {
let a = build(
feature("A")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "blocks"),
);
let b = build(feature("B").with_id("ISSUE-0002"));
let repo = FakeIssueRepository::with_issues(vec![a, b]);
let edits = vec![IssueEdit::AddLink {
issue: ir("ISSUE-0002"),
link: IssueLink {
target: ir("ISSUE-0001"),
relationship: IssueRelationship::BlockedBy,
},
}];
commit_issue_edits(&repo, edits).unwrap();
assert!(repo.saved_for(&ir("ISSUE-0001")).is_none());
let saved_b = repo.saved_for(&ir("ISSUE-0002")).unwrap();
assert!(saved_b
.links
.iter()
.any(|l| l.relationship == IssueRelationship::BlockedBy
&& l.target.as_str() == "ISSUE-0001"));
}
fn ts() -> Timestamp {
Timestamp::new("2026-05-08T00:00:00Z").unwrap()
}
#[test]
fn status_transition_emits_set_then_append_and_saves_once() {
let issue = build(feature("A").with_id("ISSUE-0001").status("open"));
let repo = FakeIssueRepository::with_issues(vec![issue.clone()]);
let edits = lower_status_transition(&issue, Status::unresolved("in-progress"), ts());
assert_eq!(edits.len(), 2);
assert!(matches!(
edits[0],
IssueEdit::SetStatus { ref status, .. } if status.as_str() == "in-progress"
));
assert!(matches!(edits[1], IssueEdit::AppendEvent { .. }));
commit_issue_edits(&repo, edits).unwrap();
assert_eq!(repo.save_count(), 1);
let saved = repo.saved_for(&ir("ISSUE-0001")).unwrap();
assert_eq!(saved.status.as_str(), "in-progress");
assert_eq!(saved.events.len(), 1);
}
#[test]
fn same_status_lowering_emits_empty_vector() {
let issue = build(feature("A").with_id("ISSUE-0001").status("open"));
let repo = FakeIssueRepository::with_issues(vec![issue.clone()]);
let edits = lower_status_transition(&issue, Status::unresolved("open"), ts());
assert!(edits.is_empty());
commit_issue_edits(&repo, edits).unwrap();
assert_eq!(repo.save_count(), 0);
}
#[test]
fn status_and_link_on_same_issue_coalesce_into_one_save() {
let a = build(feature("A").with_id("ISSUE-0001").status("open"));
let b = build(feature("B").with_id("ISSUE-0002"));
let repo = FakeIssueRepository::with_issues(vec![a.clone(), b]);
let mut edits = lower_status_transition(&a, Status::unresolved("closed"), ts());
edits.push(IssueEdit::AddLink {
issue: ir("ISSUE-0001"),
link: IssueLink {
target: ir("ISSUE-0002"),
relationship: IssueRelationship::Blocks,
},
});
commit_issue_edits(&repo, edits).unwrap();
let saved = repo.saved_for(&ir("ISSUE-0001")).unwrap();
assert_eq!(saved.status.as_str(), "closed");
assert_eq!(saved.events.len(), 1);
assert!(saved
.links
.iter()
.any(|l| l.relationship == IssueRelationship::Blocks
&& l.target.as_str() == "ISSUE-0002"));
assert_eq!(
repo.saves()
.iter()
.filter(|i| i.id.as_str() == "ISSUE-0001")
.count(),
1
);
}
#[test]
fn empty_edits_save_nothing() {
let a = build(
feature("A")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "blocks"),
);
let b = build(
feature("B")
.with_id("ISSUE-0002")
.with_link("ISSUE-0001", "blocked-by"),
);
let repo = FakeIssueRepository::with_issues(vec![a, b]);
commit_issue_edits(&repo, vec![]).unwrap();
assert!(repo.saved_for(&ir("ISSUE-0001")).is_none());
assert!(repo.saved_for(&ir("ISSUE-0002")).is_none());
}
#[test]
fn commit_refuses_to_modify_a_union_issue() {
let target = build(feature("From shared").with_id("ISSUE-0042"));
let mut repo = FakeIssueRepository::new();
repo.push_union_issue(target, "../shared/issues");
let edits = vec![IssueEdit::SetStatus {
issue: ir("ISSUE-0042"),
status: Status::new("closed").unwrap(),
}];
let err = commit_issue_edits(&repo, edits).unwrap_err().to_string();
assert!(err.contains("ISSUE-0042"), "got: {err}");
assert!(err.contains("../shared/issues"), "got: {err}");
assert_eq!(repo.save_count(), 0);
}
}