cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Issue-side I/O wrapper and lowering helpers around the pure
//! [`IssueEdit`] vocabulary (defined in `domain/model/issue/edit.rs`).
//!
//! Two callers share the same primitive `IssueEdit::AddLink`:
//!
//! - [`add_symmetric_link`] — user-driven `cartu issue link add`. Pure
//!   function (no repo read): the symmetry is structural, so both
//!   sides can be emitted from scratch.
//! - The linter `SymmetricLinksRule::fix` (not migrated here, but
//!   exercised by `tests::rule_fix_shape_emits_a_single_add_link`)
//!   emits a single `AddLink` because `enumerate()` already observed
//!   the one-sided state and tells the rule which side is missing.
//!
//! Convergence point: [`commit_issue_edits`] applies both shapes
//! identically. The difference is only at the lowering layer.

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;

/// Commit a batch of edits to storage. Edits are grouped by target id;
/// each touched issue is loaded once, [`apply_edits`] applies whatever
/// concerns it (mismatched edits are silently skipped at the model
/// layer), and a save fires only when the record changed.
///
/// Groups are processed in first-seen-id order so the lowering
/// function controls relative save order (atomicity contract).
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}'");
        }
        // Hand the whole batch to the model; the per-edit id check
        // filters out edits that target other records (model-level
        // composability — see `domain/model/issue/edit.rs`).
        let after = apply_edits(loaded.clone(), edits.clone());
        if after != loaded {
            repo.save(&after)?;
        }
    }
    Ok(())
}

/// Lower a status transition into atomic edits. Idempotence is at the
/// lowering layer: when `new_status` equals the issue's current status,
/// returns an empty vector (no `SetStatus`, no `AppendEvent`).
///
/// Otherwise emits `SetStatus` + `AppendEvent(StatusChanged{from,to})`
/// on the same issue, in that order. The engine groups them, so only
/// one save fires.
///
/// Unlike the DR side, the issue model has no transition oracle — the
/// `Status` newtype is syntactic. Any non-equal target is accepted.
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 },
            },
        },
    ]
}

/// Lower `cartu issue link add A → B` (user-driven, both sides from
/// scratch) into two atomic `AddLink` edits — one per side. Pure: the
/// symmetry is structural, no repo read needed at this layer.
///
/// The linter's `SymmetricLinksRule::fix` does NOT call this — it has
/// already observed which side is missing and emits a single `AddLink`
/// directly (see `tests::rule_fix_shape_emits_a_single_add_link`).
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)
    }

    /// User-driven link add: emits 2 edits (one per side), engine
    /// applies, both issues are saved with symmetric back-links.
    #[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"));
    }

    /// One side already in place: the engine's per-record idempotence
    /// kicks in and no save fires for the already-symmetric side.
    #[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"));
    }

    /// Convergence: a rule's `fix` shape — a single `AddLink` on the
    /// observed-missing side — is applied by the same engine, with
    /// the same per-record idempotence. The rule does not call
    /// `add_symmetric_link`.
    #[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]);

        // Shape the rule would emit after observing the one-sided link.
        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()
    }

    /// Status transition: 2 edits on the same issue, one save with
    /// status flipped and event appended.
    #[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);
    }

    /// Same-status target is NoOp at the lowering layer — no edits,
    /// no save. Idempotence lives in the lowering function, not the
    /// engine.
    #[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);
    }

    /// Mixed batch: a status transition AND a link add on the same
    /// issue land in one save (engine coalesces per-id). Stresses the
    /// per-record coalescing across edit kinds.
    #[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();
        // One save carries both the new status, the new event, and the new link.
        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"));
        // ISSUE-0001 saved exactly once for the whole batch — even
        // though three edits targeted it.
        assert_eq!(
            repo.saves()
                .iter()
                .filter(|i| i.id.as_str() == "ISSUE-0001")
                .count(),
            1
        );
    }

    /// Fully symmetric workspace: the rule emits nothing (this is what
    /// the rule's `enumerate()` filter guarantees today). Just a
    /// belt-and-braces check that an empty edit list is a no-op.
    #[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());
    }

    /// Union-sourced records are read-only: any edit batch targeting
    /// them must be refused before any save fires.
    #[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);
    }
}