tix/epics.rs
1//! The single owner of epic behaviour (mission `epics-work-items-01M3BYW4`,
2//! WP02 — `contracts/epic-service.md`).
3//!
4//! Everything about an epic lives here: identity and resolution, the registry
5//! lifecycle (create / edit / delete / list), the derived hierarchy with its
6//! dedicated iterative cycle walk, reference diagnosis across all five kinds,
7//! derived membership and the index that makes an epic filter a bucket lookup,
8//! and — in exactly one place — the epic rollup (completion, child counts,
9//! weight, blocked work). A second identity resolver, a second membership index,
10//! a second progress implementation, a second relationship mechanism, or a
11//! second milestone/cycle store is the divergence the mission's charter
12//! escalates (C-002, C-008).
13//!
14//! ## What this module deliberately does not do
15//!
16//! - **It performs no file I/O** — no `Store`, no `std::fs`, no `tempfile`. It
17//! takes an in-memory [`Config`] and an in-memory issue set and returns values;
18//! the write path is storage's (WP03) and this module owns the rule. Every
19//! operation is therefore testable with no repository at all (T012 step 4).
20//! [`ReferenceTargets::from_planner`] is the one optional adapter that consumes
21//! another owner's filesystem-backed lookup; the sets it produces are plain
22//! in-memory values, and a caller can supply them directly instead.
23//! - **It does not know about the issue model.** [`EpicNode`] is the minimum an
24//! issue must expose — its number, title, status, and its `epics` id list — and
25//! WP03 implements it for [`crate::model::Issue`], exactly as the project
26//! service's `ProjectNode` and the hierarchy service's `HierarchyNode` are
27//! implemented there. WP02 is forbidden from touching `src/model.rs`, so the
28//! service is defined against the shape WP03 provides. Because the trait has
29//! **no label accessor**, membership is structurally incapable of telling a
30//! `task`-labelled issue from any other — that is the C-001 guard, not a
31//! convention.
32//! - **It never mutates or returns a whole `Issue`.** [`reference_epic`] and
33//! [`dereference_epic`] return an [`EpicChange`]: the resulting reference list
34//! plus the History event to append. Storage applies it. That boundary is what
35//! keeps this module model-free.
36//! - **It stores no membership.** An epic's members are derived by inverting the
37//! issues' reference lists and are never persisted (data-model §4, C-002). The
38//! registry record carries no member list at all.
39//! - **It does not recompute blocked state.** [`EpicIndex::rollup`] consumes
40//! source issue 169's derived [`RelationshipGraph::blocked_state`] and reuses
41//! its `reason` text verbatim (research R4). Re-deriving blocked work here
42//! would create a second owner for state another Mission owns.
43//! - **It does not reimplement milestone or cycle lookup.** [`ReferenceTargets`]
44//! consumes `Planner::milestone_names()`, `Planner::cycle_names()` and
45//! `Planner::cycle()` (`src/planning.rs`) through
46//! [`ReferenceTargets::from_planner`]; no second store is introduced (research
47//! R2). Project references are checked against the project registry itself,
48//! never by reversing this module's index (research R6).
49//! - **It does not share the issue-parent cycle walk.** An epic chain and an
50//! issue-parent chain are different semantics (C-002); the walk below *follows
51//! the same shape* as `crate::hierarchy`'s and is dedicated to epics.
52//!
53//! ## The index is built once per read
54//!
55//! [`EpicIndex`] is the derived view over one snapshot. It is built once
56//! ([`EpicIndex::build`]) and every read — [`EpicIndex::membership`],
57//! [`EpicIndex::resolve`], [`EpicIndex::hierarchy`], [`EpicIndex::rollup`],
58//! [`EpicIndex::missing`] — reads that one value rather than rebuilding. The
59//! contract's free operations each build exactly one index per call, so a filter
60//! is a bucket lookup and a rollup never re-scans the issue set (NFR-001).
61//!
62//! ## Ordering is deterministic
63//!
64//! Members are ordered by issue number, epics by declaration order, child lists
65//! by declaration order, ancestors root-first, and diagnostics by
66//! `(holder, kind, target)`, so repeated reads of the same snapshot are
67//! identical (contract §8).
68
69use std::collections::{HashMap, HashSet};
70use std::fmt;
71
72use indexmap::IndexMap;
73
74use crate::config::{Config, EpicConfiguration, EpicId, ProjectId};
75use crate::cycle_store::DEFAULT_CYCLE_NAME;
76use crate::error::{Result, TixError};
77use crate::planning::{Planner, Progress};
78use crate::relationships::RelationshipGraph;
79
80mod index;
81mod references;
82mod registry;
83mod rollup;
84pub use index::*;
85pub use references::*;
86pub use registry::*;
87pub use rollup::*;
88
89pub use crate::projects::{IssueKey, MemberRef, Patch};
90
91/// The minimum an issue must expose for epic derivation.
92///
93/// WP03 implements this for [`crate::model::Issue`]. Keeping it a trait is what
94/// lets this module be written and tested without `src/model.rs`, which WP03
95/// owns. There is deliberately **no** label accessor: C-001 requires a
96/// `task`-labelled issue to be an ordinary member, and a trait that cannot see
97/// labels cannot make it anything else.
98pub trait EpicNode {
99 /// The issue's number, which is its identity within one snapshot.
100 fn number(&self) -> u32;
101 /// The issue's title, used only for the derived display label.
102 fn title(&self) -> &str;
103 /// The issue's raw status, compared against the board's completion column.
104 fn status(&self) -> &str;
105 /// The stored epic ids, in the order the file carries them.
106 fn epic_ids(&self) -> &[String];
107}
108
109// ---------------------------------------------------------------------------
110// T006 — the Epic view, resolution, and lifecycle
111// ---------------------------------------------------------------------------
112
113/// The id a **dereference** should match for `reference` (WP09 item B).
114///
115/// One owner for the resolve-or-keep-verbatim classification the repair path
116/// needs: a reference that names a live epic resolves to its stable id, and
117/// anything else — a stale id, a malformed one, or a name-shaped string the
118/// registry no longer holds — is passed through **verbatim** so
119/// `tix epic remove` can still drop it (C-005). A second spelling of this rule
120/// would let one surface refuse a removal another surface performs.
121///
122/// The callers are `commands::epic::dereference_from_epic`,
123/// `commands::epic::plan_edit_epics`, and MCP's `epic_dissociate_issues`, so the
124/// three cannot drift.
125#[must_use]
126pub(crate) fn dereference_needle(
127 registry: &IndexMap<String, EpicConfiguration>,
128 reference: &str,
129) -> String {
130 resolve_epic_id(registry, reference).map_or_else(|_| reference.to_string(), |id| id.to_string())
131}
132
133// ---------------------------------------------------------------------------
134// T007 — the derived index and membership
135// ---------------------------------------------------------------------------
136
137// ---------------------------------------------------------------------------
138// T008 — the dedicated iterative epic-chain walk and hierarchy navigation
139// ---------------------------------------------------------------------------
140
141// ---------------------------------------------------------------------------
142// T009 — reference diagnosis across all five kinds
143// ---------------------------------------------------------------------------
144
145// ---------------------------------------------------------------------------
146// T010/T011 — the typed rollup: the one progress computation (FR-003, C-008)
147// ---------------------------------------------------------------------------
148
149// ---------------------------------------------------------------------------
150// T008 — association mutations, with History events
151// ---------------------------------------------------------------------------
152
153#[cfg(test)]
154mod tests;