Skip to main content

tix/epics/
rollup.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// One member holding an open blocker, with the owner's reason text verbatim.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct BlockedMember {
7    /// The blocked member's number.
8    pub issue: IssueKey,
9    /// `#<number> <title>`, from the shared renderer in `hierarchy`.
10    pub display: String,
11    /// The reason text [`RelationshipGraph::blocked_state`] produced.
12    pub reason: String,
13}
14
15/// An epic's completion progress (data-model §6, FR-003).
16///
17/// Mirrors the hierarchy rollup ([`crate::hierarchy::ParentRollup`]): the counts
18/// spelled out for a listing, plus the typed [`Progress`] value so CLI, TUI and
19/// MCP results can be compared **by value** (NFR-003). [`Progress`] is the
20/// planner's, reused rather than reinvented (research R3).
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct EpicProgress {
23    /// Members in the board's **configured** completion column.
24    pub done: usize,
25    /// Members.
26    pub total: usize,
27    /// The typed completion value, reused from the planner.
28    pub progress: Progress,
29}
30
31/// The weight-aware figure of an epic (data-model §6).
32///
33/// **A member with no weight is excluded rather than counted as zero** — the
34/// shape WP01 settled and `data-model.md` §6 records. The weighted population is
35/// the epic's **child epics**, each contributing its own `weight`; a child counts
36/// as done when it has at least one member and every one of them is complete. A
37/// child with no weight, like a member issue (issues carry no weight — research
38/// R3), contributes nothing and is counted in `unweighted_children` so the
39/// exclusion is visible rather than silent. `own_weight` reports the epic's own
40/// weight, which is what a **parent** epic's figure consumes; this epic's own
41/// figure never includes it, so no number is counted twice.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct WeightedProgress {
44    /// This epic's own weight, `None` when unset.
45    pub own_weight: Option<i64>,
46    /// Children that carry a weight and therefore contribute.
47    pub weighted_children: usize,
48    /// Children excluded because they carry no weight.
49    pub unweighted_children: usize,
50    /// `Σ` weights of the weighted children that are complete.
51    pub done_weight: i64,
52    /// `Σ` weights of the weighted children — the weighted population.
53    pub total_weight: i64,
54    /// `done_weight` as a floored integer percentage of `total_weight`;
55    /// `None` when the weighted population is empty, so "no weight to report"
56    /// stays distinguishable from "0% done".
57    pub percent: Option<u32>,
58}
59
60/// The typed value the `rollup` operation returns (contract §1, data-model §6).
61///
62/// One value, carrying the epic's identity, its completion, its child count, its
63/// weight-aware figure, its blocked members, and its unresolvable references, so a
64/// CLI, TUI or MCP surface renders it rather than re-deriving any figure (C-008).
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct EpicRollup {
67    /// The epic the figures describe.
68    pub epic_id: EpicId,
69    /// The epic's display label, so a surface needs no second lookup.
70    pub name: String,
71    /// Completion figures, computed once here.
72    pub progress: EpicProgress,
73    /// Child epics, from the derived hierarchy. Never a stored count.
74    pub child_count: usize,
75    /// The weight-aware figure.
76    pub weighted_progress: WeightedProgress,
77    /// Members with an open blocker that are not done.
78    pub blocked: usize,
79    /// Those members, carrying the blocker owner's reason text verbatim.
80    pub blocked_members: Vec<BlockedMember>,
81    /// This epic's references that no longer resolve (C-005).
82    pub missing_references: Vec<EpicReferenceResolution>,
83}
84
85/// The two reference mutations, as the `## History` section records them.
86///
87/// Declared here rather than in `model::HistoryEventKind` because WP02 may not
88/// edit `src/model.rs`; WP03 maps these onto that enum, exactly as the project
89/// and hierarchy missions' kinds are mapped.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum EpicEventKind {
92    /// An issue gained an epic reference.
93    EpicReferenced,
94    /// An issue lost an epic reference.
95    EpicDereferenced,
96}
97
98impl EpicEventKind {
99    /// The canonical snake_case spelling the History section will carry.
100    #[must_use]
101    pub fn as_str(self) -> &'static str {
102        match self {
103            EpicEventKind::EpicReferenced => "epic_referenced",
104            EpicEventKind::EpicDereferenced => "epic_dereferenced",
105        }
106    }
107}
108
109/// One reference mutation, ready for storage to append to `## History`.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct EpicEvent {
112    /// Which of the two mutations this is.
113    pub kind: EpicEventKind,
114    /// The epic id exactly as recorded. A `String`, not an [`EpicId`], because a
115    /// dereference may remove a stored id that is not a valid shape.
116    pub epic_id: String,
117    /// The rendered detail line, from [`reference_detail`].
118    pub detail: String,
119}
120
121/// The outcome of a reference mutation: the resulting list plus the event.
122///
123/// The service produces the event and **does not write**; storage applies the list
124/// and appends the event, so the atomicity rule lives in one place (contract §4).
125/// It never returns a whole `Issue`, which is what keeps this module model-free.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct EpicChange {
128    /// The issue whose references changed.
129    pub issue: IssueKey,
130    /// The reference list after the change, first-seen order preserved.
131    pub epic_ids: Vec<String>,
132    /// The event to append, or `None` when the mutation was a no-op.
133    ///
134    /// `None` for referencing an already-present epic or dereferencing an absent
135    /// one: neither is a change, and recording one would put an entry in the audit
136    /// trail for nothing (contract §5).
137    pub event: Option<EpicEvent>,
138}
139
140/// The per-issue outcome of a bulk reference (contract §1).
141///
142/// One entry per selector, each carrying its own success or refusal, so one
143/// rejection is observable beside the rest rather than aborting them (contract
144/// §5).
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct BulkEpicOutcome {
147    /// The selector this entry answers.
148    pub selector: u32,
149    /// The change or the refusal for that selector.
150    pub outcome: Result<EpicChange, EpicError>,
151}
152
153/// The ordered rollup of an epic, by id or name (contract §1).
154pub fn rollup<N: EpicNode>(
155    config: &Config,
156    issues: &[N],
157    reference: &str,
158    targets: &ReferenceTargets,
159    graph: &RelationshipGraph<'_>,
160) -> Result<EpicRollup, EpicError> {
161    EpicIndex::build(config, issues).rollup(config, reference, targets, graph)
162}