cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
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;

/// Outcome of a `link_decision_records` call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkOutcome {
    /// Link was added; no side effects.
    Linked,
}

/// Add a link to an existing decision record.
///
/// A `proposed` record has no authority per DDR-018QWJVHRH35B: outgoing
/// links — `supersedes` and `amends` — are recorded but inert. The
/// cascade on the target fires when the source transitions
/// `proposed → accepted` via [`update_decision_record`].
///
/// Returns `Err` if the record is not found.
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)
}

/// Remove the matching `(target, relationship)` link from an existing
/// decision record. Errors if the record is not found or no such link
/// exists.
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() {
        // Per DDR-018QWJVHRH35B: a `proposed` record has no authority, so
        // its outgoing supersedes link is stored but no cascade fires
        // until the source transitions to `accepted`.
        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() {
        // The cascade now fires on the source's `proposed → accepted`
        // transition, never at link-write time. Linking from an
        // already-accepted source records the link with no side-effect
        // here; users wanting the cascade must re-accept (or use a
        // manual update flow).
        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}");
    }
}