cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use std::collections::BTreeMap;

use crate::domain::model::issue::{Issue, IssueRelationship};
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::status::RollupHistogram;
use crate::domain::model::tag_descriptor::TagDescriptors;
use crate::domain::usecases::issue::rollup_status::compute_status_rollup;
use crate::domain::usecases::issue::rollup_tags::{compute_tag_rollups, TagRollupValue};
use crate::domain::usecases::issue::IssueRepository;

/// Retrieve a single issue by ID.
///
/// Returns `Ok(Some(issue))` if found, `Ok(None)` if not found, or `Err` on
/// repository failure.
pub fn show_issue(repo: &dyn IssueRepository, id: &IssueRef) -> anyhow::Result<Option<Issue>> {
    repo.find_by_id(id)
}

/// Family of an issue: the parent (if any) and the children (in numeric-id
/// order). Used by `cartu issue show` to display the vertical breakdown.
///
/// Both sides are stored on the issue itself per DDR-018QWJVHRH35B: the
/// parent comes from the issue's own `child-of` link, the children from
/// its `parent-of` links. If multiple `child-of` links exist (invariant
/// violation), the smallest id wins; `cartu issue check` is the canonical
/// reporter for that situation.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IssueFamily {
    pub parent: Option<IssueRef>,
    pub children: Vec<IssueRef>,
    /// Status rollup over the direct children. `None` when there
    /// are no children.
    pub rollup: Option<RollupHistogram>,
    /// Per-tag rollups over the direct children, keyed by tag key.
    /// Empty when no descriptor declares an `aggregate` or no child
    /// carries a relevant key.
    pub tag_rollups: BTreeMap<String, TagRollupValue>,
}

/// Retrieve an issue with its family and status rollup. Equivalent
/// to [`show_issue_with_family_and_tags`] with no tag descriptors.
pub fn show_issue_with_family(
    repo: &dyn IssueRepository,
    id: &IssueRef,
) -> anyhow::Result<Option<(Issue, IssueFamily)>> {
    show_issue_with_family_and_tags(repo, id, &TagDescriptors::default())
}

/// Retrieve an issue with its family, status rollup, and per-tag
/// rollups computed against `descriptors`. Dangling child refs are
/// dropped silently.
pub fn show_issue_with_family_and_tags(
    repo: &dyn IssueRepository,
    id: &IssueRef,
    descriptors: &TagDescriptors,
) -> anyhow::Result<Option<(Issue, IssueFamily)>> {
    let Some(issue) = repo.find_by_id(id)? else {
        return Ok(None);
    };

    let parent = issue
        .links
        .iter()
        .filter(|l| l.relationship == IssueRelationship::ChildOf)
        .map(|l| l.target.clone())
        .min();

    let mut children: Vec<IssueRef> = issue
        .links
        .iter()
        .filter(|l| l.relationship == IssueRelationship::ParentOf)
        .map(|l| l.target.clone())
        .collect();
    children.sort();

    // Resolve each child to its enriched issue and compute the rollup.
    // Missing children (dangling refs) are dropped: `cartu issue check`
    // is the canonical reporter for that situation.
    let resolved_children: Vec<Issue> = children
        .iter()
        .filter_map(|r| repo.find_by_id(r).ok().flatten())
        .collect();
    let resolved_refs: Vec<&Issue> = resolved_children.iter().collect();
    let rollup = compute_status_rollup(&resolved_refs);
    let tag_rollups = compute_tag_rollups(&resolved_refs, descriptors);

    Ok(Some((
        issue,
        IssueFamily {
            parent,
            children,
            rollup,
            tag_rollups,
        },
    )))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::record_ref::IssueRef;
    use crate::domain::usecases::issue::tests::{feature, ir, FakeIssueRepository, IssueFixture};

    // ── Tests ─────────────────────────────────────────────────────────────────

    #[test]
    fn show_returns_the_issue_when_it_exists() {
        scenario()
            .given(feature("Add login").with_id("ISSUE-0001"))
            .when_show("ISSUE-0001")
            .then_title("Add login");
    }

    #[test]
    fn show_returns_none_for_an_unknown_id() {
        scenario().when_show("ISSUE-0099").then_not_found();
    }

    // ── Scenario ──────────────────────────────────────────────────────────────

    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_show(self, id: &str) -> ShowOutcome {
            let issue_ref =
                IssueRef::new(id).unwrap_or_else(|_| panic!("when_show: invalid id {id:?}"));
            ShowOutcome {
                result: show_issue(&self.repo, &issue_ref).expect("show_issue failed unexpectedly"),
            }
        }
    }

    struct ShowOutcome {
        result: Option<Issue>,
    }

    impl ShowOutcome {
        fn then_title(self, expected: &str) -> Self {
            let issue = self.result.as_ref().expect("expected Some, got None");
            assert_eq!(issue.title.as_str(), expected);
            self
        }

        fn then_not_found(self) -> Self {
            assert!(self.result.is_none(), "expected None, got Some");
            self
        }
    }

    // ── show_issue_with_family ────────────────────────────────────────────

    #[test]
    fn family_returns_parent_and_children() {
        let repo = FakeIssueRepository::with_issues(vec![
            feature("Epic")
                .with_id("ISSUE-0001")
                .with_link("ISSUE-0002", "parent-of")
                .build(ir(1)),
            feature("Story")
                .with_id("ISSUE-0002")
                .with_link("ISSUE-0001", "child-of")
                .build(ir(2)),
            feature("Standalone").with_id("ISSUE-0003").build(ir(3)),
        ]);
        let id = IssueRef::new("ISSUE-0002").unwrap();
        let (_, family) = show_issue_with_family(&repo, &id).unwrap().unwrap();
        assert_eq!(family.parent.unwrap().to_string(), "ISSUE-0001");
        assert!(family.children.is_empty());
    }

    #[test]
    fn family_returns_children_sorted_by_id() {
        let repo = FakeIssueRepository::with_issues(vec![
            feature("Epic")
                .with_id("ISSUE-0001")
                .with_link("ISSUE-0003", "parent-of")
                .with_link("ISSUE-0002", "parent-of")
                .build(ir(1)),
            feature("Story A").with_id("ISSUE-0002").build(ir(2)),
            feature("Story B").with_id("ISSUE-0003").build(ir(3)),
        ]);
        let id = IssueRef::new("ISSUE-0001").unwrap();
        let (_, family) = show_issue_with_family(&repo, &id).unwrap().unwrap();
        let ids: Vec<String> = family.children.iter().map(|r| r.to_string()).collect();
        assert_eq!(ids, vec!["ISSUE-0002", "ISSUE-0003"]);
        assert!(family.parent.is_none());
    }

    #[test]
    fn family_is_empty_for_orphan_with_no_children() {
        let repo = FakeIssueRepository::with_issues(vec![feature("Standalone")
            .with_id("ISSUE-0001")
            .build(ir(1))]);
        let id = IssueRef::new("ISSUE-0001").unwrap();
        let (_, family) = show_issue_with_family(&repo, &id).unwrap().unwrap();
        assert!(family.parent.is_none());
        assert!(family.children.is_empty());
    }
}