use crate::domain::model::decision_record::DecisionRecordEdit;
use crate::domain::model::record_ref::DecisionRecordRef;
use crate::domain::usecases::decision_record::DecisionRecordRepository;
use crate::domain::usecases::edit::dr::commit_dr_edits;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkOutcome {
Linked,
}
pub fn link_decision_records(
repo: &dyn DecisionRecordRepository,
id: &DecisionRecordRef,
link: crate::domain::model::decision_record::RecordLink,
) -> anyhow::Result<LinkOutcome> {
if repo.find_by_id(id)?.is_none() {
anyhow::bail!("record {id} not found");
}
commit_dr_edits(
repo,
vec![DecisionRecordEdit::AddLink {
record: id.clone(),
link,
}],
)?;
Ok(LinkOutcome::Linked)
}
pub fn unlink_decision_records(
repo: &dyn DecisionRecordRepository,
id: &DecisionRecordRef,
link: crate::domain::model::decision_record::RecordLink,
) -> anyhow::Result<()> {
let record = repo
.find_by_id(id)?
.ok_or_else(|| anyhow::anyhow!("record {id} not found"))?;
if record.links.with_removed(&link).is_none() {
anyhow::bail!(
"no {} link from {} to {}",
link.relationship,
id,
link.target
);
}
commit_dr_edits(
repo,
vec![DecisionRecordEdit::RemoveLink {
record: id.clone(),
link,
}],
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::usecases::decision_record::tests::{
adr, record_link, FakeDecisionRecordRepository, RecordFixture,
};
fn scenario() -> Scenario {
Scenario {
repo: FakeDecisionRecordRepository::with_records(vec![]),
}
}
struct Scenario {
repo: FakeDecisionRecordRepository,
}
impl Scenario {
fn given(mut self, fixture: RecordFixture) -> Self {
let raw = fixture
.id
.as_deref()
.expect("given() requires an explicit id — use .with_id()")
.to_string();
let numeric = DecisionRecordRef::new(&raw)
.unwrap_or_else(|_| panic!("given(): invalid id {raw:?}"));
self.repo.push_record(fixture.build(numeric));
self
}
fn when_link(self, id: &str, target: &str, relationship: &str) -> LinkOutcomeWrapper {
let id_ref = DecisionRecordRef::new(id)
.unwrap_or_else(|_| panic!("when_link: invalid id {id:?}"));
let result =
link_decision_records(&self.repo, &id_ref, record_link(target, relationship));
LinkOutcomeWrapper {
repo: self.repo,
result,
}
}
}
struct LinkOutcomeWrapper {
repo: FakeDecisionRecordRepository,
result: anyhow::Result<LinkOutcome>,
}
impl LinkOutcomeWrapper {
fn then_linked(self) -> Self {
assert!(
matches!(
self.result.as_ref().expect("expected Ok, got Err"),
LinkOutcome::Linked
),
"expected Linked, got {:?}",
self.result
);
self
}
fn then_saved_status(self, expected: &str) -> Self {
let saved = self.repo.last_saved().expect("expected a save, got none");
assert_eq!(saved.status.as_str(), expected);
self
}
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} link(s), 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 supersedes_link_from_proposed_source_is_recorded_inertly() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("proposed"))
.when_link("ADR-0001", "ADR-0002", "supersedes")
.then_linked()
.then_saved_status("proposed")
.then_saved_link_count(1)
.then_saved_link_target(0, "ADR-0002");
}
#[test]
fn supersedes_link_from_accepted_source_is_recorded_inertly() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("accepted"))
.when_link("ADR-0001", "ADR-0002", "supersedes")
.then_linked()
.then_saved_status("accepted")
.then_saved_link_count(1);
}
#[test]
fn amends_link_on_proposed_source_is_recorded_inertly() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("proposed"))
.when_link("ADR-0001", "ADR-0002", "amends")
.then_linked()
.then_saved_status("proposed");
}
#[test]
fn adding_a_link_appends_it_to_the_record() {
scenario()
.given(adr("Use Rust").with_id("ADR-0001").status("accepted"))
.when_link("ADR-0001", "ADR-0001", "supersedes")
.then_saved_link_target(0, "ADR-0001");
}
#[test]
fn linking_an_unknown_record_returns_an_error() {
scenario()
.when_link("ADR-0099", "ADR-0001", "supersedes")
.then_err_contains("not found");
}
#[test]
fn adding_a_link_preserves_existing_links() {
scenario()
.given(
adr("Use Rust")
.with_id("ADR-0001")
.status("accepted")
.with_link("ADR-0001", "amends"),
)
.when_link("ADR-0001", "ADR-0002", "amends")
.then_saved_link_count(2);
}
#[test]
fn unlink_removes_the_matching_edge() {
let mut repo = FakeDecisionRecordRepository::new();
let raw = "ADR-0001";
let numeric = DecisionRecordRef::new(raw).unwrap();
repo.push_record(
adr("Use Rust")
.with_id(raw)
.status("accepted")
.with_link("ADR-0002", "amends")
.build(numeric),
);
let id = DecisionRecordRef::new(raw).unwrap();
unlink_decision_records(&repo, &id, record_link("ADR-0002", "amends"))
.expect("unlink should succeed");
let saved = repo.last_saved().expect("expected a save");
assert_eq!(saved.links.len(), 0);
}
#[test]
fn unlink_a_missing_edge_errors() {
let mut repo = FakeDecisionRecordRepository::new();
let raw = "ADR-0001";
let numeric = DecisionRecordRef::new(raw).unwrap();
repo.push_record(
adr("Use Rust")
.with_id(raw)
.status("accepted")
.build(numeric),
);
let id = DecisionRecordRef::new(raw).unwrap();
let err = unlink_decision_records(&repo, &id, record_link("ADR-0002", "amends"))
.expect_err("expected Err");
assert!(err.to_string().contains("no amends link"), "got: {err}");
}
}