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}