use crate::domain::model::issue::{IssueEdit, IssueLink};
use crate::domain::model::record_ref::IssueRef;
use crate::domain::usecases::edit::issue::commit_issue_edits;
use crate::domain::usecases::issue::cycle_detection::{
detect_cycle, would_introduce_second_parent,
};
use crate::domain::usecases::issue::IssueRepository;
pub fn link_issue(
repo: &dyn IssueRepository,
id: &IssueRef,
link: IssueLink,
) -> anyhow::Result<()> {
let inverse = link.relationship.inverse();
if repo.find_by_id(id)?.is_none() {
anyhow::bail!("issue {id} not found");
}
if link.relationship.is_hierarchical() {
let all_issues = repo.list()?.into_vec();
if let Some(cycle_path) = detect_cycle(id, &link.target, &all_issues) {
anyhow::bail!("cycle detected: {cycle_path}");
}
if let Some(existing_parent) = would_introduce_second_parent(id, &link.target, &all_issues)
{
anyhow::bail!(
"{} already has a parent ({existing_parent}); a child has at most one parent",
link.target,
);
}
}
let target = link.target.clone();
commit_issue_edits(
repo,
vec![
IssueEdit::AddLink {
issue: target,
link: IssueLink {
target: id.clone(),
relationship: inverse,
},
},
IssueEdit::AddLink {
issue: id.clone(),
link,
},
],
)?;
Ok(())
}
pub fn unlink_issue(
repo: &dyn IssueRepository,
id: &IssueRef,
link: IssueLink,
) -> anyhow::Result<()> {
let inverse = link.relationship.inverse();
let issue = repo
.find_by_id(id)?
.ok_or_else(|| anyhow::anyhow!("issue {id} not found"))?;
if issue.links.with_removed(&link).is_none() {
anyhow::bail!(
"no {} link from {} to {}",
link.relationship,
id,
link.target
);
}
let target = link.target.clone();
commit_issue_edits(
repo,
vec![
IssueEdit::RemoveLink {
issue: target,
link: IssueLink {
target: id.clone(),
relationship: inverse,
},
},
IssueEdit::RemoveLink {
issue: id.clone(),
link,
},
],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::usecases::issue::tests::{
feature, issue_link, FakeIssueRepository, IssueFixture,
};
fn scenario() -> Scenario {
Scenario {
repo: FakeIssueRepository::new(),
}
}
struct Scenario {
repo: FakeIssueRepository,
}
impl Scenario {
fn given(mut self, fixture: IssueFixture) -> Self {
let raw = fixture
.id
.as_deref()
.expect("given() requires an explicit id — use .with_id()")
.to_string();
let numeric =
IssueRef::new(&raw).unwrap_or_else(|_| panic!("given(): invalid id {raw:?}"));
self.repo.push_issue(fixture.build(numeric));
self
}
fn when_link(self, id: &str, target: &str, relationship: &str) -> Outcome {
let id_ref = IssueRef::new(id).unwrap_or_else(|_| panic!("invalid issue ref {id:?}"));
let result = link_issue(&self.repo, &id_ref, issue_link(target, relationship));
Outcome {
repo: self.repo,
result,
}
}
fn when_unlink(self, id: &str, target: &str, relationship: &str) -> Outcome {
let id_ref = IssueRef::new(id).unwrap_or_else(|_| panic!("invalid issue ref {id:?}"));
let result = unlink_issue(&self.repo, &id_ref, issue_link(target, relationship));
Outcome {
repo: self.repo,
result,
}
}
}
struct Outcome {
repo: FakeIssueRepository,
result: anyhow::Result<()>,
}
impl Outcome {
fn then_saved_link_count(self, expected: usize) -> Self {
self.result.as_ref().expect("expected Ok, got Err");
let saved = self.repo.last_saved().expect("expected a save, got none");
assert_eq!(
saved.links.len(),
expected,
"expected {expected} links, got {}",
saved.links.len()
);
self
}
fn then_saved_link_target(self, index: usize, expected: &str) -> Self {
let saved = self.repo.last_saved().expect("expected a save, got none");
assert_eq!(
saved.links[index].target.as_str(),
expected,
"expected link[{index}].target = {expected:?}"
);
self
}
fn then_err_contains(self, substring: &str) {
let msg = self.result.expect_err("expected Err, got Ok").to_string();
assert!(
msg.contains(substring),
"expected error containing {substring:?}, got {msg:?}"
);
}
}
#[test]
fn adding_a_link_appends_it_to_the_issue() {
scenario()
.given(feature("Add login").with_id("ISSUE-0001"))
.when_link("ISSUE-0001", "ISSUE-0002", "blocked-by")
.then_saved_link_count(1)
.then_saved_link_target(0, "ISSUE-0002");
}
#[test]
fn linking_an_unknown_issue_returns_an_error() {
scenario()
.when_link("ISSUE-0099", "ISSUE-0001", "blocked-by")
.then_err_contains("not found");
}
#[test]
fn adding_a_link_preserves_existing_links() {
scenario()
.given(
feature("Add login")
.with_id("ISSUE-0001")
.with_link("ISSUE-0001", "parent-of"),
)
.when_link("ISSUE-0001", "ISSUE-0002", "blocked-by")
.then_saved_link_count(2);
}
#[test]
fn adding_a_parent_of_link_succeeds_when_target_is_orphan() {
scenario()
.given(feature("Epic").with_id("ISSUE-0001"))
.given(feature("Story").with_id("ISSUE-0002"))
.when_link("ISSUE-0001", "ISSUE-0002", "parent-of")
.then_saved_link_count(1)
.then_saved_link_target(0, "ISSUE-0002");
}
#[test]
fn adding_a_second_parent_to_a_child_is_rejected() {
scenario()
.given(
feature("Epic A")
.with_id("ISSUE-0001")
.with_link("ISSUE-0003", "parent-of"),
)
.given(feature("Epic B").with_id("ISSUE-0002"))
.given(
feature("Story")
.with_id("ISSUE-0003")
.with_link("ISSUE-0001", "child-of"),
)
.when_link("ISSUE-0002", "ISSUE-0003", "parent-of")
.then_err_contains("already has a parent");
}
#[test]
fn a_parent_of_link_that_would_create_a_cycle_is_rejected() {
scenario()
.given(
feature("A")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "parent-of"),
)
.given(feature("B").with_id("ISSUE-0002"))
.when_link("ISSUE-0002", "ISSUE-0001", "parent-of")
.then_err_contains("cycle detected");
}
#[test]
fn unlink_removes_the_matching_edge() {
scenario()
.given(
feature("Add login")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "blocked-by"),
)
.when_unlink("ISSUE-0001", "ISSUE-0002", "blocked-by")
.then_saved_link_count(0);
}
#[test]
fn unlink_keeps_other_edges() {
scenario()
.given(
feature("Add login")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "blocked-by")
.with_link("ISSUE-0003", "parent-of"),
)
.when_unlink("ISSUE-0001", "ISSUE-0002", "blocked-by")
.then_saved_link_count(1)
.then_saved_link_target(0, "ISSUE-0003");
}
#[test]
fn unlink_a_missing_edge_errors() {
scenario()
.given(feature("Add login").with_id("ISSUE-0001"))
.when_unlink("ISSUE-0001", "ISSUE-0002", "blocked-by")
.then_err_contains("no blocked-by link");
}
#[test]
fn unlink_an_unknown_issue_errors() {
scenario()
.when_unlink("ISSUE-0099", "ISSUE-0001", "blocked-by")
.then_err_contains("not found");
}
#[test]
fn a_self_parent_of_link_is_rejected() {
scenario()
.given(feature("Solo").with_id("ISSUE-0001"))
.when_link("ISSUE-0001", "ISSUE-0001", "parent-of")
.then_err_contains("cycle detected");
}
impl Outcome {
fn then_target_has_inverse(self, target_id: &str, verb: &str) -> Self {
let target_ref =
IssueRef::new(target_id).unwrap_or_else(|_| panic!("invalid id {target_id:?}"));
let saved = self
.repo
.saved_for(&target_ref)
.unwrap_or_else(|| panic!("expected a save for {target_id}, got none"));
assert!(
saved.links.iter().any(|l| l.relationship.as_str() == verb),
"target {target_id} missing inverse {verb}; saved links = {:?}",
saved
.links
.iter()
.map(|l| l.relationship.as_str())
.collect::<Vec<_>>()
);
self
}
fn then_no_save_for(self, target_id: &str) -> Self {
let target_ref =
IssueRef::new(target_id).unwrap_or_else(|_| panic!("invalid id {target_id:?}"));
assert!(
self.repo.saved_for(&target_ref).is_none(),
"expected no save for {target_id}, but one was recorded"
);
self
}
}
#[test]
fn depends_on_writes_blocked_by_on_target() {
scenario()
.given(feature("A").with_id("ISSUE-0001"))
.given(feature("B").with_id("ISSUE-0002"))
.when_link("ISSUE-0001", "ISSUE-0002", "blocked-by")
.then_target_has_inverse("ISSUE-0002", "blocks");
}
#[test]
fn parent_of_writes_child_of_on_target() {
scenario()
.given(feature("Epic").with_id("ISSUE-0001"))
.given(feature("Story").with_id("ISSUE-0002"))
.when_link("ISSUE-0001", "ISSUE-0002", "parent-of")
.then_target_has_inverse("ISSUE-0002", "child-of");
}
#[test]
fn cascade_fires_even_when_target_is_terminal_status() {
scenario()
.given(feature("A").with_id("ISSUE-0001"))
.given(feature("B").with_id("ISSUE-0002").status("closed"))
.when_link("ISSUE-0001", "ISSUE-0002", "blocked-by")
.then_target_has_inverse("ISSUE-0002", "blocks");
}
#[test]
fn cascade_is_idempotent_when_inverse_already_present() {
scenario()
.given(feature("A").with_id("ISSUE-0001"))
.given(
feature("B")
.with_id("ISSUE-0002")
.with_link("ISSUE-0001", "blocks"),
)
.when_link("ISSUE-0001", "ISSUE-0002", "blocked-by")
.then_no_save_for("ISSUE-0002");
}
#[test]
fn unlink_removes_inverse_on_target() {
let outcome = scenario()
.given(
feature("A")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "blocked-by"),
)
.given(
feature("B")
.with_id("ISSUE-0002")
.with_link("ISSUE-0001", "blocks"),
)
.when_unlink("ISSUE-0001", "ISSUE-0002", "blocked-by");
outcome.result.as_ref().expect("expected Ok, got Err");
let target_ref = IssueRef::new("ISSUE-0002").unwrap();
let target_saved = outcome
.repo
.saved_for(&target_ref)
.expect("cascade should have saved the target");
assert!(
target_saved.links.is_empty(),
"expected target's blocked-by removed; got: {:?}",
target_saved.links
);
}
}