Skip to main content

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
80pub use crate::projects::{IssueKey, MemberRef, Patch};
81
82/// The frontmatter key carrying an issue's epic references.
83///
84/// WP03's field is `epics` (contracts/epic-registry.md §3); named once here so a
85/// second spelling has no home.
86pub const EPICS_KEY: &str = "epics";
87
88/// The minimum an issue must expose for epic derivation.
89///
90/// WP03 implements this for [`crate::model::Issue`]. Keeping it a trait is what
91/// lets this module be written and tested without `src/model.rs`, which WP03
92/// owns. There is deliberately **no** label accessor: C-001 requires a
93/// `task`-labelled issue to be an ordinary member, and a trait that cannot see
94/// labels cannot make it anything else.
95pub trait EpicNode {
96    /// The issue's number, which is its identity within one snapshot.
97    fn number(&self) -> u32;
98    /// The issue's title, used only for the derived display label.
99    fn title(&self) -> &str;
100    /// The issue's raw status, compared against the board's completion column.
101    fn status(&self) -> &str;
102    /// The stored epic ids, in the order the file carries them.
103    fn epic_ids(&self) -> &[String];
104}
105
106// ---------------------------------------------------------------------------
107// T006 — the Epic view, resolution, and lifecycle
108// ---------------------------------------------------------------------------
109
110/// A derived reference to another epic, with its display label.
111///
112/// The label is computed on read and **never stored**: a stored name goes stale
113/// the moment the epic is renamed (data-model §2).
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct EpicRef {
116    /// The referenced epic's stable id.
117    pub id: EpicId,
118    /// The epic's display label at read time.
119    pub name: String,
120    /// `Name (e-id)`, from [`epic_display`].
121    pub display: String,
122}
123
124impl EpicRef {
125    /// Build a reference, deriving the label from the name.
126    #[must_use]
127    pub fn new(id: &EpicId, name: &str) -> Self {
128        Self {
129            id: id.clone(),
130            name: name.to_string(),
131            display: epic_display(id, name),
132        }
133    }
134}
135
136/// The service-side view of an epic (data-model §2).
137///
138/// Combines the registry record with the derived members and children.
139/// Registry-only operations ([`create_epic`], [`edit_epic`]) return it with an
140/// **empty** member list — there is no issue set to derive from — while
141/// index-backed operations ([`EpicIndex::resolve`], [`list_epics`]) fill it.
142/// Nothing here is stored.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Epic {
145    /// The stable registry key; the identity that survives a rename (C-003).
146    pub id: EpicId,
147    /// The mutable display label; never the identity.
148    pub name: String,
149    /// Optional long-form context.
150    pub description: Option<String>,
151    /// Optional epic status (free-form; not a board column, not a category).
152    pub status: Option<String>,
153    /// Optional owner identity string.
154    pub owner: Option<String>,
155    /// Optional start date (`YYYY-MM-DD`).
156    pub start_date: Option<String>,
157    /// Optional due date (`YYYY-MM-DD`).
158    pub due_date: Option<String>,
159    /// Optional positive relative size, used by the epic rollup.
160    pub weight: Option<i64>,
161    /// The recorded parent epic **id**, exactly as stored. Retained even when it
162    /// no longer resolves, so the reference can be repaired (contract §3.4).
163    pub parent: Option<String>,
164    /// Project **ids** this epic concerns, verbatim (FR-002).
165    pub projects: Vec<String>,
166    /// The recorded milestone **name**, verbatim (FR-002).
167    pub milestone: Option<String>,
168    /// The recorded cycle **name**, verbatim (FR-002).
169    pub cycle: Option<String>,
170    /// Derived members, ordered by issue number. Never stored.
171    pub members: Vec<MemberRef>,
172    /// `members.len()`, carried because listings ask for it constantly.
173    pub member_count: usize,
174    /// Derived child epics, in registry declaration order. Never stored.
175    pub children: Vec<EpicRef>,
176    /// `children.len()`.
177    pub child_count: usize,
178}
179
180impl Epic {
181    /// Project a registry record into the service view.
182    fn from_configuration(
183        id: EpicId,
184        record: &EpicConfiguration,
185        members: Vec<MemberRef>,
186        children: Vec<EpicRef>,
187    ) -> Self {
188        Self {
189            id,
190            name: record.name.clone(),
191            description: record.description.clone(),
192            status: record.status.clone(),
193            owner: record.owner.clone(),
194            start_date: record.start_date.clone(),
195            due_date: record.due_date.clone(),
196            weight: record.weight,
197            parent: record.parent.clone(),
198            projects: record.projects.clone(),
199            milestone: record.milestone.clone(),
200            cycle: record.cycle.clone(),
201            member_count: members.len(),
202            members,
203            child_count: children.len(),
204            children,
205        }
206    }
207}
208
209/// The shared display form for an epic: `Harden the sync path (e-7c4a91)`.
210///
211/// One function, because every listing must show the id beside the name and a
212/// second formatting site is how two surfaces end up disagreeing about what an
213/// epic is called.
214#[must_use]
215pub fn epic_display(id: &EpicId, name: &str) -> String {
216    format!("{name} ({id})")
217}
218
219/// The diagnostic kinds the epic service names, spelled exactly.
220///
221/// The variants map one-to-one onto [`EpicError`], so a surface prints the named
222/// kind without a second classification step (contract `cli-surface.md` §4).
223#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
224pub enum EpicDiagnosticKind {
225    /// A name is blank, padded, or already in use.
226    DuplicateName,
227    /// An epic reference names no stored epic.
228    UnknownEpic,
229    /// An issue selector names no issue in the snapshot.
230    UnknownIssue,
231    /// The start/due dates are malformed or inverted.
232    InvalidDates,
233    /// The weight is not a positive relative size.
234    InvalidWeight,
235    /// A reference field's value is not a well-formed id.
236    InvalidReference,
237    /// The proposed parent is the epic itself.
238    SelfParent,
239    /// The proposed parent chain would close a cycle.
240    Cycle,
241}
242
243impl EpicDiagnosticKind {
244    /// The canonical snake_case spelling a surface prints.
245    #[must_use]
246    pub fn as_str(self) -> &'static str {
247        match self {
248            EpicDiagnosticKind::DuplicateName => "duplicate_name",
249            EpicDiagnosticKind::UnknownEpic => "unknown_epic",
250            EpicDiagnosticKind::UnknownIssue => "unknown_issue",
251            EpicDiagnosticKind::InvalidDates => "invalid_dates",
252            EpicDiagnosticKind::InvalidWeight => "invalid_weight",
253            EpicDiagnosticKind::InvalidReference => "invalid_reference",
254            EpicDiagnosticKind::SelfParent => "self_parent",
255            EpicDiagnosticKind::Cycle => "cycle",
256        }
257    }
258}
259
260impl fmt::Display for EpicDiagnosticKind {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        f.write_str(self.as_str())
263    }
264}
265
266/// The single wording for an epic reference that names no stored epic.
267#[must_use]
268pub fn unknown_epic_reason(reference: &str) -> String {
269    format!("no epic matches `{reference}`; an epic is selected by its stable id or by name")
270}
271
272/// The single wording for an issue selector that names no issue.
273#[must_use]
274pub fn unknown_issue_reason(selector: impl fmt::Display) -> String {
275    format!("issue `{selector}` is not in this store")
276}
277
278/// The single wording for an epic date refusal.
279///
280/// Wraps the shared [`crate::model::validate_issue_dates`] message so the date
281/// *rule* keeps its one owner and only the wording lives here.
282#[must_use]
283pub fn invalid_dates_reason(detail: &str) -> String {
284    format!("epic dates are invalid: {detail}")
285}
286
287/// The single wording for an epic weight refusal.
288#[must_use]
289pub fn invalid_weight_reason(value: i64) -> String {
290    format!("epic weight must be a positive integer; got {value}")
291}
292
293/// The single wording for a reference field whose value is not a well-formed id.
294///
295/// The write-path half of WP01's load-time shape check: an epic references a
296/// project **by id**, so a value that is not one has to be refused rather than
297/// stored, or the registry would no longer load. A *dangling* but well-formed id
298/// is a different thing and is stored, then diagnosed (C-005).
299#[must_use]
300pub fn invalid_reference_reason(field: &str, value: &str) -> String {
301    format!(
302        "`epics.<id>.{field}` entry `{value}` is not a well-formed id; \
303         an epic references the target by its stable id"
304    )
305}
306
307/// The single wording for an epic name refusal.
308#[must_use]
309pub fn duplicate_name_reason(name: &str) -> String {
310    if name.trim().is_empty() {
311        "an epic name must not be blank".to_string()
312    } else if name.trim() != name {
313        format!("epic name `{name}` has leading or trailing whitespace")
314    } else {
315        format!("epic name `{name}` is already in use; epic names must be unique")
316    }
317}
318
319/// The single wording for a self-parent refusal.
320#[must_use]
321pub fn self_parent_reason(epic: &EpicId) -> String {
322    format!("epic `{epic}` cannot be its own parent")
323}
324
325/// The single wording for a cycle refusal, naming the offending path.
326#[must_use]
327pub fn cycle_reason(epic: &EpicId, path: &[EpicId]) -> String {
328    let rendered: Vec<&str> = path.iter().map(EpicId::as_str).collect();
329    format!(
330        "parent would close an epic cycle: `{epic}` -> {}",
331        rendered.join(" -> ")
332    )
333}
334
335/// The single wording for an unresolved parent epic (contract §3.4).
336#[must_use]
337pub fn unresolved_parent_reason(epic: &EpicId, recorded_parent: &str) -> String {
338    format!(
339        "epic `{epic}` records parent `{recorded_parent}`, which no longer exists \
340         (the reference is kept so it can be repaired)"
341    )
342}
343
344/// The single wording for a reference change's `## History` detail line.
345///
346/// One renderer, in the service, for every writer, so the CLI, the TUI and MCP
347/// cannot word the same change differently.
348#[must_use]
349pub fn reference_detail(epic_id: &str, referenced: bool) -> String {
350    if referenced {
351        format!("epic `{epic_id}` referenced")
352    } else {
353        format!("epic `{epic_id}` dereferenced")
354    }
355}
356
357/// Why an epic operation was refused.
358///
359/// The variants map one-to-one onto [`EpicDiagnosticKind`] and carry the
360/// contract's names (`contracts/epic-service.md` §1).
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum EpicError {
363    /// An epic reference did not resolve.
364    UnknownEpic {
365        /// The reference exactly as the caller wrote it.
366        reference: String,
367    },
368    /// An issue selector named no issue in the snapshot.
369    UnknownIssue {
370        /// The selector exactly as the caller wrote it.
371        selector: u32,
372    },
373    /// The name is blank, padded, or already in use.
374    DuplicateName {
375        /// The name as supplied.
376        name: String,
377    },
378    /// The start/due dates are malformed or inverted.
379    InvalidDates {
380        /// The shared date rule's message.
381        detail: String,
382    },
383    /// The weight is not a positive relative size.
384    InvalidWeight {
385        /// The value as supplied.
386        value: i64,
387    },
388    /// A reference field's value is not a well-formed id.
389    InvalidReference {
390        /// The record field the value belongs to, e.g. `projects`.
391        field: &'static str,
392        /// The value as supplied.
393        value: String,
394    },
395    /// The proposed parent is the epic itself.
396    SelfParent {
397        /// The epic that would parent itself.
398        epic: EpicId,
399    },
400    /// The proposed parent chain would close a cycle.
401    Cycle {
402        /// The epic whose proposed parent closes the loop.
403        epic: EpicId,
404        /// The offending path, from the epic back to itself.
405        path: Vec<EpicId>,
406    },
407}
408
409impl EpicError {
410    /// The named diagnostic kind this refusal reports.
411    #[must_use]
412    pub fn kind(&self) -> EpicDiagnosticKind {
413        match self {
414            EpicError::UnknownEpic { .. } => EpicDiagnosticKind::UnknownEpic,
415            EpicError::UnknownIssue { .. } => EpicDiagnosticKind::UnknownIssue,
416            EpicError::DuplicateName { .. } => EpicDiagnosticKind::DuplicateName,
417            EpicError::InvalidDates { .. } => EpicDiagnosticKind::InvalidDates,
418            EpicError::InvalidWeight { .. } => EpicDiagnosticKind::InvalidWeight,
419            EpicError::InvalidReference { .. } => EpicDiagnosticKind::InvalidReference,
420            EpicError::SelfParent { .. } => EpicDiagnosticKind::SelfParent,
421            EpicError::Cycle { .. } => EpicDiagnosticKind::Cycle,
422        }
423    }
424
425    /// The single wording for this refusal, via the kind's helper.
426    #[must_use]
427    pub fn message(&self) -> String {
428        match self {
429            EpicError::UnknownEpic { reference } => unknown_epic_reason(reference),
430            EpicError::UnknownIssue { selector } => unknown_issue_reason(selector),
431            EpicError::DuplicateName { name } => duplicate_name_reason(name),
432            EpicError::InvalidDates { detail } => invalid_dates_reason(detail),
433            EpicError::InvalidWeight { value } => invalid_weight_reason(*value),
434            EpicError::InvalidReference { field, value } => invalid_reference_reason(field, value),
435            EpicError::SelfParent { epic } => self_parent_reason(epic),
436            EpicError::Cycle { epic, path } => cycle_reason(epic, path),
437        }
438    }
439}
440
441impl fmt::Display for EpicError {
442    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443        write!(f, "{}: {}", self.kind(), self.message())
444    }
445}
446
447impl std::error::Error for EpicError {}
448
449/// An epic refusal becomes user input error, so `?` works at the call site.
450///
451/// The payload is a single-line diagnostic (`<kind>: <message>`), so it takes the
452/// crate's existing `invalid input:` prefix without doubling it — the same
453/// boundary every other selector/validation refusal crosses (cli-surface §6).
454impl From<EpicError> for TixError {
455    fn from(err: EpicError) -> TixError {
456        TixError::InvalidInput(err.to_string())
457    }
458}
459
460/// The spec for a new epic (contract §1: name plus optional metadata).
461#[derive(Debug, Clone, Default, PartialEq, Eq)]
462pub struct NewEpic {
463    /// Required display label.
464    pub name: String,
465    /// Optional long-form context.
466    pub description: Option<String>,
467    /// Optional epic status.
468    pub status: Option<String>,
469    /// Optional owner identity string.
470    pub owner: Option<String>,
471    /// Optional start date.
472    pub start_date: Option<String>,
473    /// Optional due date.
474    pub due_date: Option<String>,
475    /// Optional positive relative size.
476    pub weight: Option<i64>,
477    /// Project **ids** this epic concerns (FR-002). Shape is checked; existence is
478    /// diagnosed rather than refused, so a dangling reference is repairable.
479    pub projects: Vec<String>,
480    /// Optional milestone name (the existing name-keyed reference).
481    pub milestone: Option<String>,
482    /// Optional cycle name (the existing name-keyed reference).
483    pub cycle: Option<String>,
484}
485
486/// The fields of an epic edit.
487#[derive(Debug, Clone, Default, PartialEq, Eq)]
488pub struct EpicEdit {
489    /// The display label. A rename is a label change only (C-003, NFR-002).
490    pub name: Patch<String>,
491    /// Long-form context.
492    pub description: Patch<String>,
493    /// Epic status.
494    pub status: Patch<String>,
495    /// Owner identity string.
496    pub owner: Patch<String>,
497    /// Start date.
498    pub start_date: Patch<String>,
499    /// Due date.
500    pub due_date: Patch<String>,
501    /// Weight.
502    pub weight: Patch<i64>,
503    /// The parent epic reference: `Set` resolves to the parent's id and is
504    /// validated against the epic chain, `Clear` detaches the epic.
505    pub parent: Patch<String>,
506    /// Project **ids** this epic concerns. Each entry is shape-validated and never
507    /// resolved, so a dangling-but-well-formed id is stored for repair (C-005).
508    pub projects: Patch<Vec<String>>,
509    /// Milestone name; `Clear` removes the reference.
510    pub milestone: Patch<String>,
511    /// Cycle name; `Clear` removes the reference.
512    pub cycle: Patch<String>,
513}
514
515/// The impact report of an epic deletion (contract §1).
516///
517/// It **reports**; it does not delete. The affected issues keep their references
518/// (which then read as missing references, C-005) and the child epics keep their
519/// recorded parent (which then reads as unresolved, contract §3.4), so this type
520/// carries them for the caller's notice rather than a cascade (C-006).
521#[derive(Debug, Clone, PartialEq, Eq)]
522pub struct EpicDeletion {
523    /// The id that was removed from the registry. Never handed out again.
524    pub id: EpicId,
525    /// The name the record carried, for the notice.
526    pub name: String,
527    /// The issues that still reference the deleted epic, ordered by number.
528    pub affected: Vec<MemberRef>,
529    /// The epics that recorded it as their parent, in declaration order.
530    pub child_epics: Vec<EpicRef>,
531}
532
533/// Normalize an optional text field: a written-but-empty value is absent.
534///
535/// WP01 refuses a present-but-blank key, so collapsing it here is what keeps a
536/// record this service writes loadable. Non-blank values are kept verbatim.
537fn normalize_optional(value: Option<String>) -> Option<String> {
538    value.filter(|v| !v.trim().is_empty())
539}
540
541/// Validate a requested display name against the registry.
542///
543/// Mirrors [`crate::config`]'s rule (`validate_epics`): non-blank, no padding,
544/// and unique case-folded, since a user does not tell `Platform` from `platform`.
545/// WP01 owns the loader's copy; this is the write-path half, pinned to the same
546/// rule by `t006_create_epic_refuses_a_duplicate_name`.
547fn validate_name(
548    registry: &IndexMap<String, EpicConfiguration>,
549    name: &str,
550    exclude: Option<&EpicId>,
551) -> Result<(), EpicError> {
552    if name.trim().is_empty() || name.trim() != name {
553        return Err(EpicError::DuplicateName {
554            name: name.to_string(),
555        });
556    }
557    let normalized = name.to_lowercase();
558    let clash = registry.iter().any(|(raw, record)| {
559        let is_self = exclude.is_some_and(|id| raw.as_str() == id.as_str());
560        !is_self && record.name.to_lowercase() == normalized
561    });
562    if clash {
563        return Err(EpicError::DuplicateName {
564            name: name.to_string(),
565        });
566    }
567    Ok(())
568}
569
570/// Validate the pair through the shared date rule (WP01 uses the same helper).
571fn validate_dates(start: Option<&str>, due: Option<&str>) -> Result<(), EpicError> {
572    crate::model::validate_issue_dates(start, due).map_err(|err| EpicError::InvalidDates {
573        detail: err.to_string(),
574    })
575}
576
577/// Validate a project-id list: shape only, never resolution.
578///
579/// Each entry must be a well-formed [`ProjectId`], because that is what a
580/// registry must hold to load. A well-formed id the project registry does not
581/// hold is **kept** — it is the diagnosable dangling reference C-005 promises —
582/// and an exact duplicate collapses to one reference, as a repeated issue-side id
583/// does. The caller's order is preserved.
584fn validate_project_ids(list: &[String]) -> Result<Vec<String>, EpicError> {
585    let mut out: Vec<String> = Vec::new();
586    for entry in list {
587        let id = ProjectId::new(entry).map_err(|_| EpicError::InvalidReference {
588            field: "projects",
589            value: entry.clone(),
590        })?;
591        if !out.iter().any(|stored| stored == id.as_str()) {
592            out.push(id.to_string());
593        }
594    }
595    Ok(out)
596}
597
598/// Validate a weight: a positive relative size, or nothing.
599fn validate_weight(weight: i64) -> Result<(), EpicError> {
600    if weight > 0 {
601        return Ok(());
602    }
603    Err(EpicError::InvalidWeight { value: weight })
604}
605
606/// Resolve an epic id from a reference: **id first**, then a name convenience.
607///
608/// Resolution is by the stable id (C-003). A name is accepted because a human
609/// selects epics by name; it resolves to the same epic, and WP01's uniqueness
610/// rule guarantees it is unambiguous. An id that also happens to look like
611/// another epic's name wins, which is what "id first" means.
612pub fn resolve_epic_id(
613    registry: &IndexMap<String, EpicConfiguration>,
614    reference: &str,
615) -> Result<EpicId, EpicError> {
616    if let Ok(id) = EpicId::new(reference) {
617        if registry.contains_key(id.as_str()) {
618            return Ok(id);
619        }
620    }
621    let normalized = reference.trim().to_lowercase();
622    if let Some((raw, _)) = registry
623        .iter()
624        .find(|(_, record)| record.name.to_lowercase() == normalized)
625    {
626        return EpicId::new(raw).map_err(|_| EpicError::UnknownEpic {
627            reference: reference.to_string(),
628        });
629    }
630    Err(EpicError::UnknownEpic {
631        reference: reference.to_string(),
632    })
633}
634
635/// The id a **dereference** should match for `reference` (WP09 item B).
636///
637/// One owner for the resolve-or-keep-verbatim classification the repair path
638/// needs: a reference that names a live epic resolves to its stable id, and
639/// anything else — a stale id, a malformed one, or a name-shaped string the
640/// registry no longer holds — is passed through **verbatim** so
641/// `tix epic remove` can still drop it (C-005). A second spelling of this rule
642/// would let one surface refuse a removal another surface performs.
643///
644/// The callers are `commands::epic::dereference_from_epic`,
645/// `commands::epic::plan_edit_epics`, and MCP's `epic_dissociate_issues`, so the
646/// three cannot drift.
647#[must_use]
648pub(crate) fn dereference_needle(
649    registry: &IndexMap<String, EpicConfiguration>,
650    reference: &str,
651) -> String {
652    resolve_epic_id(registry, reference).map_or_else(|_| reference.to_string(), |id| id.to_string())
653}
654
655/// The epics that record `id` as their parent, in declaration order.
656fn recorded_children(registry: &IndexMap<String, EpicConfiguration>, id: &EpicId) -> Vec<EpicRef> {
657    registry
658        .iter()
659        .filter(|(_, record)| record.parent.as_deref() == Some(id.as_str()))
660        .filter_map(|(raw, record)| {
661            EpicId::new(raw)
662                .ok()
663                .map(|child| EpicRef::new(&child, &record.name))
664        })
665        .collect()
666}
667
668/// Create an epic, allocating a fresh stable id (FR-001).
669///
670/// Validates the name, the dates, and the weight first, so a refusal yields no
671/// registry change (contract §4). The record is inserted into the in-memory
672/// [`Config`]; the caller persists it with `Config::save`, so the atomic write
673/// path has exactly one owner. The returned view carries no members or children —
674/// a create has no issue set to derive from.
675pub fn create_epic(config: &mut Config, new: &NewEpic) -> Result<Epic, EpicError> {
676    validate_name(&config.epics, &new.name, None)?;
677    validate_dates(new.start_date.as_deref(), new.due_date.as_deref())?;
678    if let Some(weight) = new.weight {
679        validate_weight(weight)?;
680    }
681    let projects = validate_project_ids(&new.projects)?;
682    let id = config.allocate_epic_id();
683    let record = EpicConfiguration {
684        name: new.name.clone(),
685        description: normalize_optional(new.description.clone()),
686        status: normalize_optional(new.status.clone()),
687        owner: normalize_optional(new.owner.clone()),
688        start_date: new.start_date.clone(),
689        due_date: new.due_date.clone(),
690        weight: new.weight,
691        projects,
692        milestone: normalize_optional(new.milestone.clone()),
693        cycle: normalize_optional(new.cycle.clone()),
694        ..EpicConfiguration::default()
695    };
696    config.epics.insert(id.to_string(), record.clone());
697    Ok(Epic::from_configuration(
698        id,
699        &record,
700        Vec::new(),
701        Vec::new(),
702    ))
703}
704
705/// Edit an epic's fields by reference.
706///
707/// A name change is a **label change only**: the registry key is untouched, so
708/// every member reference and derived figure stays intact (C-003, NFR-002). A
709/// proposed parent is validated — self-parent and cycle — **before** any write,
710/// so a rejection leaves the registry byte-unchanged (contract §3, §4). The
711/// returned view carries no members — an edit has no issue set to derive from.
712pub fn edit_epic(config: &mut Config, reference: &str, edit: &EpicEdit) -> Result<Epic, EpicError> {
713    let id = resolve_epic_id(&config.epics, reference)?;
714    let mut updated =
715        config
716            .epics
717            .get(id.as_str())
718            .cloned()
719            .ok_or_else(|| EpicError::UnknownEpic {
720                reference: reference.to_string(),
721            })?;
722
723    // --- validation first: nothing below this block runs on a refusal --------
724    match &edit.name {
725        Patch::Unchanged => {}
726        Patch::Clear => {
727            return Err(EpicError::DuplicateName {
728                name: String::new(),
729            });
730        }
731        Patch::Set(name) => {
732            validate_name(&config.epics, name, Some(&id))?;
733        }
734    }
735    if let Patch::Set(weight) = &edit.weight {
736        validate_weight(*weight)?;
737    }
738    let proposed_parent = match &edit.parent {
739        Patch::Unchanged => None,
740        Patch::Clear => Some(None),
741        Patch::Set(parent) => Some(Some(validate_epic_parent(&config.epics, &id, parent)?)),
742    };
743    let proposed_projects = match &edit.projects {
744        Patch::Unchanged => None,
745        Patch::Clear => Some(Vec::new()),
746        Patch::Set(list) => Some(validate_project_ids(list)?),
747    };
748
749    // --- apply --------------------------------------------------------------
750    if let Patch::Set(name) = &edit.name {
751        updated.name.clone_from(name);
752    }
753    apply_optional(&edit.description, &mut updated.description);
754    apply_optional(&edit.status, &mut updated.status);
755    apply_optional(&edit.owner, &mut updated.owner);
756    apply_date(&edit.start_date, &mut updated.start_date);
757    apply_date(&edit.due_date, &mut updated.due_date);
758    if let Patch::Set(weight) = &edit.weight {
759        updated.weight = Some(*weight);
760    }
761    if let Patch::Clear = &edit.weight {
762        updated.weight = None;
763    }
764    if let Some(parent) = proposed_parent {
765        updated.parent = parent.map(|id| id.to_string());
766    }
767    if let Some(projects) = proposed_projects {
768        updated.projects = projects;
769    }
770    apply_optional(&edit.milestone, &mut updated.milestone);
771    apply_optional(&edit.cycle, &mut updated.cycle);
772
773    validate_dates(updated.start_date.as_deref(), updated.due_date.as_deref())?;
774
775    if let Some(record) = config.epics.get_mut(id.as_str()) {
776        *record = updated.clone();
777    }
778    Ok(Epic::from_configuration(
779        id,
780        &updated,
781        Vec::new(),
782        Vec::new(),
783    ))
784}
785
786/// Apply an optional-text patch.
787fn apply_optional(patch: &Patch<String>, slot: &mut Option<String>) {
788    match patch {
789        Patch::Unchanged => {}
790        Patch::Clear => *slot = None,
791        Patch::Set(value) => *slot = normalize_optional(Some(value.clone())),
792    }
793}
794
795/// Apply a date patch.
796fn apply_date(patch: &Patch<String>, slot: &mut Option<String>) {
797    match patch {
798        Patch::Unchanged => {}
799        Patch::Clear => *slot = None,
800        Patch::Set(value) => *slot = Some(value.clone()),
801    }
802}
803
804/// Remove an epic from the registry, reporting what referenced it.
805///
806/// Removes the registry entry **only**. Every member issue keeps its `epics`
807/// entry (which then reads as a missing reference) and every child epic keeps its
808/// recorded parent (which then reads as unresolved), so both can be repaired
809/// rather than being silently rebound (C-005, C-006). The id is never reused
810/// (WP01's allocation carries that guarantee).
811pub fn delete_epic<N: EpicNode>(
812    config: &mut Config,
813    reference: &str,
814    issues: &[N],
815) -> Result<EpicDeletion, EpicError> {
816    let id = resolve_epic_id(&config.epics, reference)?;
817    let record = config
818        .epics
819        .get(id.as_str())
820        .cloned()
821        .ok_or_else(|| EpicError::UnknownEpic {
822            reference: reference.to_string(),
823        })?;
824    let affected = EpicIndex::build(config, issues)
825        .epic(&id)
826        .map(|epic| epic.members.clone())
827        .unwrap_or_default();
828    let child_epics = recorded_children(&config.epics, &id);
829    config.epics.shift_remove(id.as_str());
830    Ok(EpicDeletion {
831        id,
832        name: record.name,
833        affected,
834        child_epics,
835    })
836}
837
838// ---------------------------------------------------------------------------
839// T007 — the derived index and membership
840// ---------------------------------------------------------------------------
841
842/// The derived members of an epic (data-model §4).
843#[derive(Debug, Clone, PartialEq, Eq)]
844pub struct EpicMembership {
845    /// The epic the members belong to.
846    pub epic_id: EpicId,
847    /// Members ordered by issue number.
848    pub members: Vec<MemberRef>,
849    /// `members.len()`.
850    pub member_count: usize,
851}
852
853/// The derived epic view over one snapshot of issues and the registry.
854///
855/// Built once and read many times. The bucket map is the NFR-001 index; the epic
856/// map is an `IndexMap` so declaration order is the display order; the label and
857/// status maps carry what a member listing and the completion read need. Nothing
858/// here is stored.
859#[derive(Debug, Clone, PartialEq, Eq)]
860pub struct EpicIndex {
861    /// Epics in declaration order, each carrying its derived members and children.
862    epics: IndexMap<EpicId, Epic>,
863    /// The reverse index: epic -> member issue keys, ascending.
864    buckets: HashMap<EpicId, Vec<IssueKey>>,
865    /// Recorded parent per epic, id-typed and only for resolvable ids.
866    parents: HashMap<EpicId, EpicId>,
867    /// Child epics per epic, in declaration order.
868    children: HashMap<EpicId, Vec<EpicId>>,
869    /// References whose epic does not resolve, deterministically ordered.
870    missing: Vec<EpicReferenceResolution>,
871    /// Issue number -> display label, for member listings.
872    labels: HashMap<IssueKey, String>,
873    /// Issue number -> status, for the completion read.
874    statuses: HashMap<IssueKey, String>,
875}
876
877impl EpicIndex {
878    /// Build the index in **one pass** over the issue set.
879    ///
880    /// Membership comes from each issue's `epics` list, never from the registry.
881    /// A reference whose epic does not resolve is kept in [`Self::missing`] rather
882    /// than dropped: a silently absent reference is the failure C-005 forbids, and
883    /// [`resolve_references`] reports exactly this set.
884    pub fn build<N: EpicNode>(config: &Config, issues: &[N]) -> Self {
885        Self::build_from_registry(&config.epics, issues)
886    }
887
888    /// [`Self::build`] against a bare registry, for callers that hold only the
889    /// `[epics.<id>]` table (the contract's `registry + issue set` operations).
890    pub fn build_from_registry<N: EpicNode>(
891        registry: &IndexMap<String, EpicConfiguration>,
892        issues: &[N],
893    ) -> Self {
894        let known: HashSet<String> = registry.keys().cloned().collect();
895
896        let mut labels: HashMap<IssueKey, String> = HashMap::new();
897        let mut statuses: HashMap<IssueKey, String> = HashMap::new();
898        let mut buckets: HashMap<EpicId, Vec<IssueKey>> = HashMap::new();
899        let mut missing: Vec<EpicReferenceResolution> = Vec::new();
900
901        for issue in issues {
902            let key = IssueKey::new(issue.number());
903            labels.insert(
904                key,
905                crate::hierarchy::display_label(issue.number(), issue.title()),
906            );
907            statuses.insert(key, issue.status().to_string());
908
909            // A duplicated id on one issue is one reference: reported once,
910            // bucketed once (T007 step 7).
911            let mut seen: HashSet<&str> = HashSet::new();
912            for raw in issue.epic_ids() {
913                if !seen.insert(raw.as_str()) {
914                    continue;
915                }
916                let holder = ReferenceHolder::Issue(key);
917                match EpicId::new(raw) {
918                    Ok(id) if known.contains(id.as_str()) => {
919                        buckets.entry(id).or_default().push(key);
920                    }
921                    Ok(_) => missing.push(EpicReferenceResolution {
922                        holder: holder.clone(),
923                        target_kind: EpicReferenceKind::Epic,
924                        target: raw.clone(),
925                        reason: missing_epic_reason(&holder, raw),
926                    }),
927                    Err(_) => missing.push(EpicReferenceResolution {
928                        holder,
929                        target_kind: EpicReferenceKind::Issue,
930                        target: raw.clone(),
931                        reason: malformed_epic_reason(issue.number(), raw),
932                    }),
933                }
934            }
935        }
936
937        for members in buckets.values_mut() {
938            members.sort_unstable();
939        }
940
941        // The hierarchy, derived from the registry's `parent` fields. A parent id
942        // that is not a valid [`EpicId`] shape is retained in the view's `parent`
943        // (for repair) but cannot enter the typed parent map.
944        let mut children: HashMap<EpicId, Vec<EpicId>> = HashMap::new();
945        let mut ordered: Vec<(EpicId, EpicConfiguration)> = Vec::new();
946        let parents: HashMap<EpicId, EpicId> = resolved_parents(registry);
947        for (raw, record) in registry {
948            let Ok(id) = EpicId::new(raw) else {
949                continue;
950            };
951            if let Some(parent) = parents.get(&id) {
952                children.entry(parent.clone()).or_default().push(id.clone());
953            }
954            ordered.push((id, record.clone()));
955        }
956
957        let mut epics: IndexMap<EpicId, Epic> = IndexMap::new();
958        for (id, record) in ordered {
959            let members: Vec<MemberRef> = buckets
960                .get(&id)
961                .map(|keys| {
962                    keys.iter()
963                        .map(|key| MemberRef {
964                            issue: *key,
965                            display: labels.get(key).cloned().unwrap_or_else(|| key.to_string()),
966                        })
967                        .collect()
968                })
969                .unwrap_or_default();
970            let kid_refs: Vec<EpicRef> = children
971                .get(&id)
972                .map(|ids| {
973                    ids.iter()
974                        .filter_map(|child| {
975                            registry
976                                .get(child.as_str())
977                                .map(|record| EpicRef::new(child, &record.name))
978                        })
979                        .collect()
980                })
981                .unwrap_or_default();
982            epics.insert(
983                id.clone(),
984                Epic::from_configuration(id, &record, members, kid_refs),
985            );
986        }
987
988        missing.sort_by(reference_order);
989
990        Self {
991            epics,
992            buckets,
993            parents,
994            children,
995            missing,
996            labels,
997            statuses,
998        }
999    }
1000
1001    /// The reverse index, as the contract spells it.
1002    #[must_use]
1003    pub fn buckets(&self) -> &HashMap<EpicId, Vec<IssueKey>> {
1004        &self.buckets
1005    }
1006
1007    /// Issue number -> its resolved epic ids, in registry declaration order (WP09
1008    /// item C).
1009    ///
1010    /// The **inverse** of [`Self::buckets`], and the one owner of it: the board's
1011    /// epic partition, `tix list --group epic`/`--sort epic`, and
1012    /// `tix status --group epic` all read this rather than re-reading the issues'
1013    /// `epics` lists, so a filter and a partition cannot disagree about who is a
1014    /// member. A stored reference the registry does not hold is **not** a member and
1015    /// therefore contributes no id (the repair path is the filter's raw-string
1016    /// match, deliberately — see [`crate::search::StructuredFilter::epic_matches`]).
1017    #[must_use]
1018    pub fn memberships_by_issue(&self) -> HashMap<u32, Vec<String>> {
1019        let order: HashMap<&str, usize> = self
1020            .epics
1021            .keys()
1022            .enumerate()
1023            .map(|(position, id)| (id.as_str(), position))
1024            .collect();
1025        let mut memberships: HashMap<u32, Vec<String>> = HashMap::new();
1026        for (id, keys) in &self.buckets {
1027            for key in keys {
1028                memberships
1029                    .entry(key.number())
1030                    .or_default()
1031                    .push(id.to_string());
1032            }
1033        }
1034        // Declaration order within each issue, so the rank and the bucket label are
1035        // deterministic whatever order the bucket map iterated in.
1036        for ids in memberships.values_mut() {
1037            ids.sort_by_key(|id| order.get(id.as_str()).copied().unwrap_or(usize::MAX));
1038        }
1039        memberships
1040    }
1041
1042    /// The member keys of one epic.
1043    ///
1044    /// `None` **only** for an epic the registry does not hold. A known epic with
1045    /// no members returns an empty slice, so "not an epic" and "an epic with no
1046    /// members yet" stay distinguishable to a caller.
1047    #[must_use]
1048    pub fn bucket(&self, id: &EpicId) -> Option<&[IssueKey]> {
1049        if !self.epics.contains_key(id) {
1050            return None;
1051        }
1052        Some(self.buckets.get(id).map_or(&[][..], Vec::as_slice))
1053    }
1054
1055    /// Epics in declaration order.
1056    #[must_use]
1057    pub fn epics(&self) -> Vec<&Epic> {
1058        self.epics.values().collect()
1059    }
1060
1061    /// One epic by id.
1062    #[must_use]
1063    pub fn epic(&self, id: &EpicId) -> Option<&Epic> {
1064        self.epics.get(id)
1065    }
1066
1067    /// The issue display label the index recorded.
1068    #[must_use]
1069    pub fn label(&self, key: IssueKey) -> Option<&str> {
1070        self.labels.get(&key).map(String::as_str)
1071    }
1072
1073    /// An issue's status, as the index recorded it.
1074    #[must_use]
1075    pub fn status_of(&self, key: IssueKey) -> Option<&str> {
1076        self.statuses.get(&key).map(String::as_str)
1077    }
1078
1079    /// The diagnosed **issue-held** references of this snapshot.
1080    ///
1081    /// Epic-held references are diagnosed per epic by [`Self::missing_for`],
1082    /// because they live on the record rather than in the issue set.
1083    #[must_use]
1084    pub fn missing(&self) -> &[EpicReferenceResolution] {
1085        &self.missing
1086    }
1087
1088    /// Resolve an epic by id (first) or by name.
1089    pub fn resolve(&self, reference: &str) -> Result<&Epic, EpicError> {
1090        if let Ok(id) = EpicId::new(reference) {
1091            if let Some(epic) = self.epics.get(&id) {
1092                return Ok(epic);
1093            }
1094        }
1095        let normalized = reference.trim().to_lowercase();
1096        self.epics
1097            .values()
1098            .find(|epic| epic.name.to_lowercase() == normalized)
1099            .ok_or_else(|| EpicError::UnknownEpic {
1100                reference: reference.to_string(),
1101            })
1102    }
1103
1104    /// The ordered membership of an epic.
1105    pub fn membership(&self, reference: &str) -> Result<EpicMembership, EpicError> {
1106        let epic = self.resolve(reference)?;
1107        Ok(EpicMembership {
1108            epic_id: epic.id.clone(),
1109            members: epic.members.clone(),
1110            member_count: epic.member_count,
1111        })
1112    }
1113
1114    /// Consume the index into its reverse map (the contract's return shape).
1115    #[must_use]
1116    pub fn into_buckets(self) -> HashMap<EpicId, Vec<IssueKey>> {
1117        self.buckets
1118    }
1119
1120    /// Consume the index into its missing-reference list.
1121    #[must_use]
1122    pub fn into_missing(self) -> Vec<EpicReferenceResolution> {
1123        self.missing
1124    }
1125
1126    /// Consume the index into its declaration-ordered epic list.
1127    #[must_use]
1128    pub fn into_epics(self) -> Vec<Epic> {
1129        self.epics.into_values().collect()
1130    }
1131}
1132
1133/// Build the reverse index once for one read (`HashMap<EpicId, Vec<IssueKey>>`).
1134///
1135/// An epic filter is then a bucket lookup rather than a scan (NFR-001).
1136#[must_use]
1137pub fn membership_index<N: EpicNode>(
1138    config: &Config,
1139    issues: &[N],
1140) -> HashMap<EpicId, Vec<IssueKey>> {
1141    EpicIndex::build(config, issues).into_buckets()
1142}
1143
1144/// Issue number -> its resolved epic ids, in declaration order (WP09 item C).
1145///
1146/// [`EpicIndex::memberships_by_issue`] over one snapshot, for the callers — the
1147/// board's partition, `tix list --group epic`/`--sort epic`, and
1148/// `tix status --group epic` — that need the inverse membership without holding
1149/// the index.
1150#[must_use]
1151pub fn memberships_by_issue<N: EpicNode>(
1152    config: &Config,
1153    issues: &[N],
1154) -> HashMap<u32, Vec<String>> {
1155    EpicIndex::build(config, issues).memberships_by_issue()
1156}
1157
1158/// The ordered membership of an epic, by id or name.
1159pub fn membership<N: EpicNode>(
1160    config: &Config,
1161    issues: &[N],
1162    reference: &str,
1163) -> Result<EpicMembership, EpicError> {
1164    EpicIndex::build(config, issues).membership(reference)
1165}
1166
1167/// The epics in declaration order, with derived member and child counts.
1168///
1169/// Never errors: an empty registry is an empty list, and a registry with no
1170/// issues is a list with zero members.
1171#[must_use]
1172pub fn list_epics<N: EpicNode>(config: &Config, issues: &[N]) -> Vec<Epic> {
1173    EpicIndex::build(config, issues).into_epics()
1174}
1175
1176/// Resolve an epic by id (first) or by name.
1177pub fn resolve<N: EpicNode>(
1178    config: &Config,
1179    issues: &[N],
1180    reference: &str,
1181) -> Result<Epic, EpicError> {
1182    EpicIndex::build(config, issues).resolve(reference).cloned()
1183}
1184
1185// ---------------------------------------------------------------------------
1186// T008 — the dedicated iterative epic-chain walk and hierarchy navigation
1187// ---------------------------------------------------------------------------
1188
1189/// The diagnosed state of an epic whose recorded parent no longer exists
1190/// (data-model §5, contract §3.4).
1191///
1192/// The epic's record is **not** modified when its parent is deleted: the
1193/// reference survives so it can be repaired. Any cascade would be an escalation
1194/// trigger.
1195#[derive(Debug, Clone, PartialEq, Eq)]
1196pub struct UnresolvedEpicParent {
1197    /// The epic holding the reference.
1198    pub epic: EpicId,
1199    /// The reference exactly as stored, retained for repair.
1200    pub recorded_parent: String,
1201    /// Human-readable text, reused verbatim by lint, the CLI, the TUI and MCP.
1202    pub reason: String,
1203}
1204
1205/// The derived hierarchy view for one epic (data-model §5).
1206#[derive(Debug, Clone, PartialEq, Eq)]
1207pub struct EpicHierarchy {
1208    /// The epic the view is about.
1209    pub epic: EpicId,
1210    /// The recorded parent id, exactly as stored; retained even when unresolved.
1211    pub recorded_parent: Option<String>,
1212    /// The resolved parent, `None` when there is none or it does not resolve.
1213    pub parent: Option<EpicRef>,
1214    /// Direct children, in registry declaration order.
1215    pub children: Vec<EpicRef>,
1216    /// `children.len()`.
1217    pub child_count: usize,
1218    /// Ancestors, root-first.
1219    pub ancestors: Vec<EpicRef>,
1220    /// Distance from the root: the number of resolved ancestors walked.
1221    pub depth: usize,
1222    /// Present when the recorded parent no longer exists.
1223    pub unresolved: Option<UnresolvedEpicParent>,
1224}
1225
1226/// The chain that closes a cycle if `epic` were pointed at `parent`, or `None`.
1227///
1228/// **Iterative**, from `parent` upward through the recorded parents. Because each
1229/// epic has at most one parent the hierarchy is a *functional* graph, so this is
1230/// the only walk needed — there is no branching to explore. Recursion would
1231/// overflow the stack on a 10,000-deep chain (NFR-001), so the loop carries its
1232/// own `visited` set and terminates on the first repeat or the root.
1233///
1234/// Only a repeat of **the proposed epic** closes a cycle. A walk that merely
1235/// *enters* a stored cycle the epic is not part of must not be reported as one:
1236/// attaching an unrelated epic under a cyclic subtree is a legitimate edit, and
1237/// the pre-existing loop is [`validate_epic_chains`]'s to report. Reporting it
1238/// here would refuse valid edits and emit a spurious `cycle` for every acyclic
1239/// epic whose chain happens to run into the loop.
1240fn epic_cycle_path(
1241    parents: &HashMap<EpicId, EpicId>,
1242    epic: &EpicId,
1243    parent: &EpicId,
1244) -> Option<Vec<EpicId>> {
1245    let mut path = vec![epic.clone()];
1246    let mut visited: HashSet<EpicId> = HashSet::new();
1247    visited.insert(epic.clone());
1248    let mut current = parent.clone();
1249    loop {
1250        if current == *epic {
1251            // The proposed edge closes the loop, and `path` is the cycle itself:
1252            // the epic, then the chain from the proposed parent back to it.
1253            return Some(path);
1254        }
1255        if !visited.insert(current.clone()) {
1256            // A pre-existing loop that does not contain `epic`, so the proposed
1257            // edge closes nothing. `validate_epic_chains` reports that loop.
1258            return None;
1259        }
1260        path.push(current.clone());
1261        {
1262            let next = parents.get(&current)?;
1263            current = next.clone()
1264        }
1265    }
1266}
1267
1268/// The resolvable parent edges of a registry: epic id -> recorded parent id.
1269///
1270/// The one home of "which edges exist", shared by the derived index and by the
1271/// chain validation so the two cannot drift. A parent that is not a well-formed
1272/// id, or that the registry does not hold, is **not** an edge: it is the
1273/// unresolved state [`EpicHierarchy::unresolved`] surfaces and
1274/// [`EpicIndex::missing_for`] diagnoses.
1275fn resolved_parents(registry: &IndexMap<String, EpicConfiguration>) -> HashMap<EpicId, EpicId> {
1276    let known: HashSet<String> = registry.keys().cloned().collect();
1277    let mut parents: HashMap<EpicId, EpicId> = HashMap::new();
1278    for (raw, record) in registry {
1279        let Ok(id) = EpicId::new(raw) else {
1280            continue;
1281        };
1282        if let Some(parent) = record
1283            .parent
1284            .as_deref()
1285            .and_then(|p| EpicId::new(p).ok())
1286            .filter(|p| known.contains(p.as_str()))
1287        {
1288            parents.insert(id, parent);
1289        }
1290    }
1291    parents
1292}
1293
1294/// Validate a proposed parent for `epic`, returning the resolved parent id.
1295///
1296/// The two rules of contract §3 apply, and both run **before any write**:
1297///
1298/// 1. `epic == parent` is always a self-parent;
1299/// 2. a proposed parent chain that would close a cycle is refused, with the
1300///    offending path in the diagnostic.
1301///
1302/// The walk is the dedicated **epic chain** walk, not the issue-parent walk from
1303/// source issue 170's Mission (C-002); it follows the same shape and the same
1304/// single-owner rule, and it is iterative so a 10,000-node chain cannot overflow
1305/// the stack (NFR-001).
1306pub fn validate_epic_parent(
1307    registry: &IndexMap<String, EpicConfiguration>,
1308    epic: &EpicId,
1309    parent_reference: &str,
1310) -> Result<EpicId, EpicError> {
1311    let parent = resolve_epic_id(registry, parent_reference)?;
1312    if &parent == epic {
1313        return Err(EpicError::SelfParent { epic: epic.clone() });
1314    }
1315    if let Some(path) = epic_cycle_path(&resolved_parents(registry), epic, &parent) {
1316        return Err(EpicError::Cycle {
1317            epic: epic.clone(),
1318            path,
1319        });
1320    }
1321    Ok(parent)
1322}
1323
1324/// Every stored epic-chain defect in the registry, deterministically ordered.
1325///
1326/// **Never** returns an error — lint consumes the list directly — and performs one
1327/// pass over the registry. Reports each self-parent and each stored cycle once,
1328/// named by the epic whose parent edge closes it.
1329#[must_use]
1330pub fn validate_epic_chains(registry: &IndexMap<String, EpicConfiguration>) -> Vec<EpicError> {
1331    let parents = resolved_parents(registry);
1332    let mut out: Vec<EpicError> = Vec::new();
1333    let mut ids: Vec<EpicId> = registry
1334        .keys()
1335        .filter_map(|raw| EpicId::new(raw).ok())
1336        .collect();
1337    ids.sort();
1338
1339    // A self-parent is one defect, not two: its chain is trivially cyclic, so it
1340    // is reported here and seeded into `seen` to keep the cycle sweep off it.
1341    let mut seen: HashSet<EpicId> = HashSet::new();
1342    for id in &ids {
1343        if parents.get(id) == Some(id) {
1344            seen.insert(id.clone());
1345            out.push(EpicError::SelfParent { epic: id.clone() });
1346        }
1347    }
1348    for id in &ids {
1349        if seen.contains(id) {
1350            continue;
1351        }
1352        let Some(parent) = parents.get(id) else {
1353            continue;
1354        };
1355        if let Some(path) = epic_cycle_path(&parents, id, parent) {
1356            for node in &path {
1357                seen.insert(node.clone());
1358            }
1359            out.push(EpicError::Cycle {
1360                epic: id.clone(),
1361                path,
1362            });
1363        }
1364    }
1365    out.sort_by(|a, b| {
1366        a.kind()
1367            .cmp(&b.kind())
1368            .then_with(|| chain_epic(a).cmp(&chain_epic(b)))
1369    });
1370    out
1371}
1372
1373/// The epic a chain diagnostic is about, for deterministic ordering.
1374fn chain_epic(err: &EpicError) -> Option<&EpicId> {
1375    match err {
1376        EpicError::SelfParent { epic } | EpicError::Cycle { epic, .. } => Some(epic),
1377        _ => None,
1378    }
1379}
1380
1381impl EpicIndex {
1382    /// The derived hierarchy of one epic (contract §1, data-model §5).
1383    ///
1384    /// Reads the pre-built parent and child maps, so navigation is a lookup
1385    /// rather than a scan. `ancestors` is root-first and `depth` is the number of
1386    /// resolved ancestors walked; a **recorded parent that no longer exists** is
1387    /// surfaced as [`EpicHierarchy::unresolved`] rather than dropped (C-005,
1388    /// contract §3.4).
1389    pub fn hierarchy(&self, reference: &str) -> Result<EpicHierarchy, EpicError> {
1390        let epic = self.resolve(reference)?;
1391        let id = epic.id.clone();
1392
1393        let parent = self.parents.get(&id).and_then(|parent| self.ref_of(parent));
1394        let mut ancestors: Vec<EpicRef> = Vec::new();
1395        let mut visited: HashSet<EpicId> = HashSet::new();
1396        visited.insert(id.clone());
1397        let mut current = self.parents.get(&id).cloned();
1398        while let Some(node) = current {
1399            if !visited.insert(node.clone()) {
1400                // A stored cycle: stop rather than loop. `validate_epic_chains`
1401                // reports it; navigation stays total.
1402                break;
1403            }
1404            if let Some(reference) = self.ref_of(&node) {
1405                ancestors.push(reference);
1406            }
1407            current = self.parents.get(&node).cloned();
1408        }
1409        ancestors.reverse();
1410
1411        let children: Vec<EpicRef> = self
1412            .children
1413            .get(&id)
1414            .map(|ids| ids.iter().filter_map(|child| self.ref_of(child)).collect())
1415            .unwrap_or_default();
1416
1417        let unresolved = epic.parent.as_deref().and_then(|recorded| {
1418            let resolves = self
1419                .parents
1420                .get(&id)
1421                .is_some_and(|p| p.as_str() == recorded);
1422            if resolves {
1423                return None;
1424            }
1425            Some(UnresolvedEpicParent {
1426                epic: id.clone(),
1427                recorded_parent: recorded.to_string(),
1428                reason: unresolved_parent_reason(&id, recorded),
1429            })
1430        });
1431
1432        Ok(EpicHierarchy {
1433            epic: id,
1434            recorded_parent: epic.parent.clone(),
1435            parent,
1436            child_count: children.len(),
1437            children,
1438            depth: ancestors.len(),
1439            ancestors,
1440            unresolved,
1441        })
1442    }
1443
1444    /// A derived reference to an epic the index knows, by id.
1445    fn ref_of(&self, id: &EpicId) -> Option<EpicRef> {
1446        self.epics.get(id).map(|epic| EpicRef::new(id, &epic.name))
1447    }
1448}
1449
1450/// The ordered hierarchy of an epic, by id or name.
1451pub fn hierarchy<N: EpicNode>(
1452    config: &Config,
1453    issues: &[N],
1454    reference: &str,
1455) -> Result<EpicHierarchy, EpicError> {
1456    EpicIndex::build(config, issues).hierarchy(reference)
1457}
1458
1459// ---------------------------------------------------------------------------
1460// T009 — reference diagnosis across all five kinds
1461// ---------------------------------------------------------------------------
1462
1463/// Which kind of reference failed (contract §1, data-model §7).
1464///
1465/// The variants are the five kinds `resolve_references` diagnoses. `Epic` is a
1466/// recorded **epic id** that the registry does not hold — from an epic's `parent`
1467/// or from an issue's `epics` entry. `Issue` is an issue's `epics` entry that is
1468/// not a well-formed epic id at all (a blank, padded, or name-shaped entry): the
1469/// entry names no epic, so the `Epic` kind — which names a missing id — cannot
1470/// express it. `Project`, `Milestone` and `Cycle` are an epic record's own
1471/// reference fields.
1472#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1473pub enum EpicReferenceKind {
1474    /// A recorded epic id the registry does not hold.
1475    Epic,
1476    /// An epic's project id the project registry does not hold.
1477    Project,
1478    /// An issue-side `epics` entry that is not a well-formed epic id.
1479    Issue,
1480    /// An epic's milestone name the milestone registry does not hold.
1481    Milestone,
1482    /// An epic's cycle name the cycle registry does not hold.
1483    Cycle,
1484}
1485
1486impl EpicReferenceKind {
1487    /// The canonical snake_case spelling a surface prints.
1488    #[must_use]
1489    pub fn as_str(self) -> &'static str {
1490        match self {
1491            EpicReferenceKind::Epic => "epic",
1492            EpicReferenceKind::Project => "project",
1493            EpicReferenceKind::Issue => "issue",
1494            EpicReferenceKind::Milestone => "milestone",
1495            EpicReferenceKind::Cycle => "cycle",
1496        }
1497    }
1498
1499    /// The `tix lint` diagnostic kind for a missing reference of this kind
1500    /// (contract `cli-surface.md` §4).
1501    #[must_use]
1502    pub fn diagnostic_kind(self) -> &'static str {
1503        match self {
1504            EpicReferenceKind::Epic => "missing_epic_reference",
1505            EpicReferenceKind::Project => "missing_project_reference",
1506            EpicReferenceKind::Issue => "missing_issue_reference",
1507            EpicReferenceKind::Milestone => "missing_milestone_reference",
1508            EpicReferenceKind::Cycle => "missing_cycle_reference",
1509        }
1510    }
1511}
1512
1513impl fmt::Display for EpicReferenceKind {
1514    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1515        f.write_str(self.as_str())
1516    }
1517}
1518
1519/// The entity that holds a failing reference (data-model §7's holder).
1520///
1521/// A reference lives either on an epic record or in an issue's `epics` list, and
1522/// a diagnostic that did not say which would make the user edit the wrong file.
1523/// One enum rather than a second diagnostic type, because a second type for one
1524/// direction is the second resolver C-002 forbids.
1525#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1526pub enum ReferenceHolder {
1527    /// An epic-held reference, by epic id.
1528    Epic(EpicId),
1529    /// An issue-held reference, by issue number.
1530    Issue(IssueKey),
1531}
1532
1533impl fmt::Display for ReferenceHolder {
1534    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1535        match self {
1536            ReferenceHolder::Epic(id) => write!(f, "epic `{id}`"),
1537            ReferenceHolder::Issue(key) => write!(f, "issue {key}"),
1538        }
1539    }
1540}
1541
1542/// The diagnosed outcome for a reference that does not resolve (data-model §7).
1543///
1544/// The diagnostic names the **failing reference**, never merely that something is
1545/// missing, so `tix lint` can point at the field to repair.
1546#[derive(Debug, Clone, PartialEq, Eq)]
1547pub struct EpicReferenceResolution {
1548    /// The entity holding the reference field.
1549    pub holder: ReferenceHolder,
1550    /// Which kind of reference failed.
1551    pub target_kind: EpicReferenceKind,
1552    /// The reference exactly as recorded, retained for repair.
1553    pub target: String,
1554    /// Human-readable text, reused verbatim by lint, the CLI, the TUI and MCP.
1555    pub reason: String,
1556}
1557
1558/// The single wording for a recorded epic id that does not resolve.
1559#[must_use]
1560pub fn missing_epic_reason(holder: &ReferenceHolder, target: &str) -> String {
1561    format!(
1562        "{holder} records epic reference `{target}`, which is not in the registry \
1563         (the reference is kept so it can be repaired)"
1564    )
1565}
1566
1567/// The single wording for an issue-side entry that names no epic at all.
1568#[must_use]
1569pub fn malformed_epic_reason(issue: u32, raw: &str) -> String {
1570    format!(
1571        "issue #{issue} records epic reference `{raw}`, which is not a well-formed epic id \
1572         (ids are recorded, never names; the entry is kept so it can be repaired)"
1573    )
1574}
1575
1576/// The single wording for an epic's project reference that does not resolve.
1577#[must_use]
1578pub fn missing_project_reason(epic: &EpicId, target: &str) -> String {
1579    format!(
1580        "epic `{epic}` references project `{target}`, which is not in the project registry \
1581         (the reference is kept so it can be repaired)"
1582    )
1583}
1584
1585/// The single wording for an epic's milestone reference that does not resolve.
1586#[must_use]
1587pub fn missing_milestone_reason(epic: &EpicId, target: &str) -> String {
1588    format!(
1589        "epic `{epic}` references milestone `{target}`, which is not in the milestone registry \
1590         (the reference is kept so it can be repaired)"
1591    )
1592}
1593
1594/// The single wording for an epic's cycle reference that does not resolve.
1595#[must_use]
1596pub fn missing_cycle_reason(epic: &EpicId, target: &str) -> String {
1597    format!(
1598        "epic `{epic}` references cycle `{target}`, which is not in the cycle registry \
1599         (the reference is kept so it can be repaired)"
1600    )
1601}
1602
1603/// The ordering every diagnostic list uses: holder, then kind, then target.
1604fn reference_order(a: &EpicReferenceResolution, b: &EpicReferenceResolution) -> std::cmp::Ordering {
1605    a.holder
1606        .cmp(&b.holder)
1607        .then_with(|| a.target_kind.cmp(&b.target_kind))
1608        .then_with(|| a.target.cmp(&b.target))
1609}
1610
1611/// The resolvable targets of one snapshot, consumed from their owners.
1612///
1613/// This module performs no file I/O: the sets are plain in-memory collections, so
1614/// a caller with a fixture — a unit test, or a surface that already resolved them
1615/// — builds one directly. [`ReferenceTargets::from_planner`] is the optional
1616/// production adapter: it consumes the **existing** lookups, including
1617/// `Planner::milestone_names()`, which is filesystem-backed, plus
1618/// `Planner::cycle_names()` and the implicit `Default` cycle `Planner::cycle()`
1619/// answers for, and the project registry for project ids. No second milestone or
1620/// cycle store exists (research R2).
1621///
1622/// Each set is a `HashSet`, so a lookup is a hash probe rather than a scan: with
1623/// `E` epics and `p` references each, `resolve_references` stays one pass over the
1624/// issues plus one pass over the registry (contract §7, NFR-001).
1625#[derive(Debug, Clone, Default, PartialEq, Eq)]
1626pub struct ReferenceTargets {
1627    /// Project ids the project registry holds, as the registry keys them.
1628    pub projects: HashSet<String>,
1629    /// Milestone names the milestone registry holds.
1630    pub milestones: HashSet<String>,
1631    /// Cycle names the cycle registry answers for.
1632    pub cycles: HashSet<String>,
1633}
1634
1635impl ReferenceTargets {
1636    /// Consume the existing planning lookups for one [`Planner`].
1637    ///
1638    /// This is the production adapter: milestones come from
1639    /// [`Planner::milestone_names`] and cycles from [`Planner::cycle_names`] plus
1640    /// the implicit `Default` cycle [`Planner::cycle`] answers for even when no
1641    /// file holds it. Both sets are built once here, so the diagnosis below is a
1642    /// probe per reference rather than a scan per reference.
1643    pub fn from_planner(planner: &Planner) -> Result<Self> {
1644        let mut cycles: HashSet<String> = planner.cycle_names().into_iter().collect();
1645        if planner.cycle(DEFAULT_CYCLE_NAME).is_some() {
1646            cycles.insert(DEFAULT_CYCLE_NAME.to_string());
1647        }
1648        Ok(Self {
1649            projects: planner.config().projects.keys().cloned().collect(),
1650            milestones: planner.milestone_names()?.into_iter().collect(),
1651            cycles,
1652        })
1653    }
1654
1655    /// Whether the project registry holds `id`.
1656    #[must_use]
1657    pub fn project_known(&self, id: &str) -> bool {
1658        self.projects.contains(id)
1659    }
1660
1661    /// Whether the milestone registry holds `name`.
1662    #[must_use]
1663    pub fn milestone_known(&self, name: &str) -> bool {
1664        self.milestones.contains(name)
1665    }
1666
1667    /// Whether the cycle registry answers for `name`.
1668    #[must_use]
1669    pub fn cycle_known(&self, name: &str) -> bool {
1670        self.cycles.contains(name)
1671    }
1672}
1673
1674/// Every reference in the snapshot that does not resolve (contract §1, §5).
1675///
1676/// **Never** returns an error — lint consumes the list directly — and performs one
1677/// pass over the issue set (the index build) plus one pass over the registry
1678/// rather than one lookup per reference (contract §7). Each failing reference is
1679/// reported once, naming the holder, the kind, and the recorded target, ordered by
1680/// `(holder, kind, target)` so lint output is stable (C-005, contract §8).
1681///
1682/// `targets` carries what the owners resolved: the project registry, and the
1683/// milestone and cycle names from the `Planner` lookups (contract §2). The service
1684/// stores none of them.
1685#[must_use]
1686pub fn resolve_references<N: EpicNode>(
1687    registry: &IndexMap<String, EpicConfiguration>,
1688    issues: &[N],
1689    targets: &ReferenceTargets,
1690) -> Vec<EpicReferenceResolution> {
1691    let index = EpicIndex::build_from_registry(registry, issues);
1692    let mut out: Vec<EpicReferenceResolution> = index.missing().to_vec();
1693    for epic in index.epics() {
1694        out.extend(index.missing_for(&epic.id, targets));
1695    }
1696    out.sort_by(reference_order);
1697    out
1698}
1699
1700impl EpicIndex {
1701    /// This epic's **unresolvable** references, deterministically ordered.
1702    ///
1703    /// The epic-held half of [`resolve_references`]: the recorded parent (when it
1704    /// no longer resolves), the project ids absent from the project registry, and
1705    /// the milestone and cycle names absent from the planner's lookups. Turns the
1706    /// epic's own fields, not the issue set, so it is a constant-time read.
1707    #[must_use]
1708    pub fn missing_for(
1709        &self,
1710        id: &EpicId,
1711        targets: &ReferenceTargets,
1712    ) -> Vec<EpicReferenceResolution> {
1713        let Some(epic) = self.epics.get(id) else {
1714            return Vec::new();
1715        };
1716        let holder = ReferenceHolder::Epic(id.clone());
1717        let mut out: Vec<EpicReferenceResolution> = Vec::new();
1718
1719        if let Some(recorded) = epic.parent.as_deref() {
1720            let resolves = self.parents.get(id).is_some_and(|p| p.as_str() == recorded);
1721            if !resolves {
1722                out.push(EpicReferenceResolution {
1723                    holder: holder.clone(),
1724                    target_kind: EpicReferenceKind::Epic,
1725                    target: recorded.to_string(),
1726                    reason: missing_epic_reason(&holder, recorded),
1727                });
1728            }
1729        }
1730        for project in &epic.projects {
1731            if !targets.project_known(project) {
1732                out.push(EpicReferenceResolution {
1733                    holder: holder.clone(),
1734                    target_kind: EpicReferenceKind::Project,
1735                    target: project.clone(),
1736                    reason: missing_project_reason(id, project),
1737                });
1738            }
1739        }
1740        if let Some(milestone) = epic.milestone.as_deref() {
1741            if !targets.milestone_known(milestone) {
1742                out.push(EpicReferenceResolution {
1743                    holder: holder.clone(),
1744                    target_kind: EpicReferenceKind::Milestone,
1745                    target: milestone.to_string(),
1746                    reason: missing_milestone_reason(id, milestone),
1747                });
1748            }
1749        }
1750        if let Some(cycle) = epic.cycle.as_deref() {
1751            if !targets.cycle_known(cycle) {
1752                out.push(EpicReferenceResolution {
1753                    holder: holder.clone(),
1754                    target_kind: EpicReferenceKind::Cycle,
1755                    target: cycle.to_string(),
1756                    reason: missing_cycle_reason(id, cycle),
1757                });
1758            }
1759        }
1760        out.sort_by(reference_order);
1761        out
1762    }
1763}
1764
1765// ---------------------------------------------------------------------------
1766// T010/T011 — the typed rollup: the one progress computation (FR-003, C-008)
1767// ---------------------------------------------------------------------------
1768
1769/// One member holding an open blocker, with the owner's reason text verbatim.
1770#[derive(Debug, Clone, PartialEq, Eq)]
1771pub struct BlockedMember {
1772    /// The blocked member's number.
1773    pub issue: IssueKey,
1774    /// `#<number> <title>`, from the shared renderer in `hierarchy`.
1775    pub display: String,
1776    /// The reason text [`RelationshipGraph::blocked_state`] produced.
1777    pub reason: String,
1778}
1779
1780/// An epic's completion progress (data-model §6, FR-003).
1781///
1782/// Mirrors the hierarchy rollup ([`crate::hierarchy::ParentRollup`]): the counts
1783/// spelled out for a listing, plus the typed [`Progress`] value so CLI, TUI and
1784/// MCP results can be compared **by value** (NFR-003). [`Progress`] is the
1785/// planner's, reused rather than reinvented (research R3).
1786#[derive(Debug, Clone, PartialEq, Eq)]
1787pub struct EpicProgress {
1788    /// Members in the board's **configured** completion column.
1789    pub done: usize,
1790    /// Members.
1791    pub total: usize,
1792    /// The typed completion value, reused from the planner.
1793    pub progress: Progress,
1794}
1795
1796/// The weight-aware figure of an epic (data-model §6).
1797///
1798/// **A member with no weight is excluded rather than counted as zero** — the
1799/// shape WP01 settled and `data-model.md` §6 records. The weighted population is
1800/// the epic's **child epics**, each contributing its own `weight`; a child counts
1801/// as done when it has at least one member and every one of them is complete. A
1802/// child with no weight, like a member issue (issues carry no weight — research
1803/// R3), contributes nothing and is counted in `unweighted_children` so the
1804/// exclusion is visible rather than silent. `own_weight` reports the epic's own
1805/// weight, which is what a **parent** epic's figure consumes; this epic's own
1806/// figure never includes it, so no number is counted twice.
1807#[derive(Debug, Clone, PartialEq, Eq)]
1808pub struct WeightedProgress {
1809    /// This epic's own weight, `None` when unset.
1810    pub own_weight: Option<i64>,
1811    /// Children that carry a weight and therefore contribute.
1812    pub weighted_children: usize,
1813    /// Children excluded because they carry no weight.
1814    pub unweighted_children: usize,
1815    /// `Σ` weights of the weighted children that are complete.
1816    pub done_weight: i64,
1817    /// `Σ` weights of the weighted children — the weighted population.
1818    pub total_weight: i64,
1819    /// `done_weight` as a floored integer percentage of `total_weight`;
1820    /// `None` when the weighted population is empty, so "no weight to report"
1821    /// stays distinguishable from "0% done".
1822    pub percent: Option<u32>,
1823}
1824
1825/// The typed value the `rollup` operation returns (contract §1, data-model §6).
1826///
1827/// One value, carrying the epic's identity, its completion, its child count, its
1828/// weight-aware figure, its blocked members, and its unresolvable references, so a
1829/// CLI, TUI or MCP surface renders it rather than re-deriving any figure (C-008).
1830#[derive(Debug, Clone, PartialEq, Eq)]
1831pub struct EpicRollup {
1832    /// The epic the figures describe.
1833    pub epic_id: EpicId,
1834    /// The epic's display label, so a surface needs no second lookup.
1835    pub name: String,
1836    /// Completion figures, computed once here.
1837    pub progress: EpicProgress,
1838    /// Child epics, from the derived hierarchy. Never a stored count.
1839    pub child_count: usize,
1840    /// The weight-aware figure.
1841    pub weighted_progress: WeightedProgress,
1842    /// Members with an open blocker that are not done.
1843    pub blocked: usize,
1844    /// Those members, carrying the blocker owner's reason text verbatim.
1845    pub blocked_members: Vec<BlockedMember>,
1846    /// This epic's references that no longer resolve (C-005).
1847    pub missing_references: Vec<EpicReferenceResolution>,
1848}
1849
1850impl EpicIndex {
1851    /// Progress, child count, weight, and blocked work for one epic
1852    /// (contract §1, FR-003, C-008).
1853    ///
1854    /// This is the **only** place an epic's figures are computed: every surface
1855    /// renders this value rather than formatting its own figure.
1856    ///
1857    /// - Completion reads the board's **configured** completion column through
1858    ///   the workflow service ([`crate::workflow::active_resolution`]), never a
1859    ///   literal `"done"`.
1860    /// - Membership is the pre-built bucket, so the figures are a bucket lookup
1861    ///   rather than a scan (contract §7).
1862    /// - `blocked` is **consumed** from source issue 169's derived read: the
1863    ///   graph's [`RelationshipGraph::blocked_state`] decides, and its `reason`
1864    ///   text is carried through verbatim. Nothing here re-derives blocked work
1865    ///   (research R4), and a **done** member is never counted as blocked.
1866    /// - `missing_references` is this epic's own unresolvable references, so an
1867    ///   unresolved reference is visible wherever progress is shown (C-005).
1868    pub fn rollup(
1869        &self,
1870        config: &Config,
1871        reference: &str,
1872        targets: &ReferenceTargets,
1873        graph: &RelationshipGraph<'_>,
1874    ) -> Result<EpicRollup, EpicError> {
1875        let epic = self.resolve(reference)?;
1876        let id = epic.id.clone();
1877        let (_, resolution) = crate::workflow::active_resolution(config);
1878
1879        let members = self.bucket(&id).unwrap_or(&[]);
1880        let done_board: HashSet<IssueKey> = members
1881            .iter()
1882            .filter(|key| {
1883                self.status_of(**key)
1884                    .and_then(|status| resolution.resolve(status))
1885                    .is_some_and(crate::workflow::WorkflowCategory::is_complete)
1886            })
1887            .copied()
1888            .collect();
1889        let done = done_board.len();
1890        let total = members.len();
1891
1892        let blocked_members: Vec<BlockedMember> = self
1893            .blocked_members(&id, graph)
1894            .into_iter()
1895            .filter(|member| !done_board.contains(&member.issue))
1896            .collect();
1897
1898        Ok(EpicRollup {
1899            name: epic.name.clone(),
1900            progress: EpicProgress {
1901                done,
1902                total,
1903                progress: Progress { done, total },
1904            },
1905            child_count: epic.child_count,
1906            weighted_progress: self.weighted_progress(config, &id, &resolution),
1907            blocked: blocked_members.len(),
1908            blocked_members,
1909            missing_references: self.missing_for(&id, targets),
1910            epic_id: id,
1911        })
1912    }
1913
1914    /// The epic's members with an open blocker, consuming the owner's derived read.
1915    ///
1916    /// The predicate is [`RelationshipGraph::blocked_state`]'s and so is the
1917    /// `reason` text: this function only shapes the owner's answer, it never
1918    /// re-derives it (research R4). Ordered by issue number.
1919    #[must_use]
1920    pub fn blocked_members(
1921        &self,
1922        id: &EpicId,
1923        graph: &RelationshipGraph<'_>,
1924    ) -> Vec<BlockedMember> {
1925        let Some(members) = self.buckets.get(id) else {
1926            return Vec::new();
1927        };
1928        let mut out: Vec<BlockedMember> = members
1929            .iter()
1930            .filter_map(|key| {
1931                let state = graph.blocked_state(key.number());
1932                if !state.is_blocked {
1933                    return None;
1934                }
1935                Some(BlockedMember {
1936                    issue: *key,
1937                    display: self
1938                        .label(*key)
1939                        .map(str::to_string)
1940                        .unwrap_or_else(|| key.to_string()),
1941                    reason: state.reason,
1942                })
1943            })
1944            .collect();
1945        out.sort_by_key(|member| member.issue);
1946        out
1947    }
1948
1949    /// The weight-aware figure for one epic (data-model §6).
1950    ///
1951    /// `resolution` is the active board's category seam, resolved once by the
1952    /// caller, so a child's completion is judged by the same rule as the epic's
1953    /// own completion rather than by a second lookup per child.
1954    fn weighted_progress(
1955        &self,
1956        config: &Config,
1957        id: &EpicId,
1958        resolution: &crate::workflow::CategoryResolution,
1959    ) -> WeightedProgress {
1960        let own_weight = config.epics.get(id.as_str()).and_then(|r| r.weight);
1961        let Some(children) = self.children.get(id) else {
1962            return WeightedProgress {
1963                own_weight,
1964                weighted_children: 0,
1965                unweighted_children: 0,
1966                done_weight: 0,
1967                total_weight: 0,
1968                percent: None,
1969            };
1970        };
1971        let mut weighted = 0usize;
1972        let mut unweighted = 0usize;
1973        let mut done_weight = 0i64;
1974        let mut total_weight = 0i64;
1975        for child in children {
1976            let Some(weight) = config.epics.get(child.as_str()).and_then(|r| r.weight) else {
1977                unweighted += 1;
1978                continue;
1979            };
1980            weighted += 1;
1981            total_weight += weight;
1982            if self.child_is_complete(child, resolution) {
1983                done_weight += weight;
1984            }
1985        }
1986        let percent = if total_weight > 0 {
1987            let done = u32::try_from(done_weight.max(0)).unwrap_or(u32::MAX);
1988            let total = u32::try_from(total_weight.max(0)).unwrap_or(u32::MAX);
1989            Some(done.saturating_mul(100) / total.max(1))
1990        } else {
1991            None
1992        };
1993        WeightedProgress {
1994            own_weight,
1995            weighted_children: weighted,
1996            unweighted_children: unweighted,
1997            done_weight,
1998            total_weight,
1999            percent,
2000        }
2001    }
2002
2003    /// Whether a child epic has members and all of them are complete.
2004    fn child_is_complete(
2005        &self,
2006        child: &EpicId,
2007        resolution: &crate::workflow::CategoryResolution,
2008    ) -> bool {
2009        let Some(members) = self.buckets.get(child) else {
2010            return false;
2011        };
2012        if members.is_empty() {
2013            return false;
2014        }
2015        members.iter().all(|key| {
2016            self.status_of(*key)
2017                .and_then(|status| resolution.resolve(status))
2018                .is_some_and(crate::workflow::WorkflowCategory::is_complete)
2019        })
2020    }
2021}
2022
2023// ---------------------------------------------------------------------------
2024// T008 — association mutations, with History events
2025// ---------------------------------------------------------------------------
2026
2027/// The two reference mutations, as the `## History` section records them.
2028///
2029/// Declared here rather than in `model::HistoryEventKind` because WP02 may not
2030/// edit `src/model.rs`; WP03 maps these onto that enum, exactly as the project
2031/// and hierarchy missions' kinds are mapped.
2032#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2033pub enum EpicEventKind {
2034    /// An issue gained an epic reference.
2035    EpicReferenced,
2036    /// An issue lost an epic reference.
2037    EpicDereferenced,
2038}
2039
2040impl EpicEventKind {
2041    /// The canonical snake_case spelling the History section will carry.
2042    #[must_use]
2043    pub fn as_str(self) -> &'static str {
2044        match self {
2045            EpicEventKind::EpicReferenced => "epic_referenced",
2046            EpicEventKind::EpicDereferenced => "epic_dereferenced",
2047        }
2048    }
2049}
2050
2051/// One reference mutation, ready for storage to append to `## History`.
2052#[derive(Debug, Clone, PartialEq, Eq)]
2053pub struct EpicEvent {
2054    /// Which of the two mutations this is.
2055    pub kind: EpicEventKind,
2056    /// The epic id exactly as recorded. A `String`, not an [`EpicId`], because a
2057    /// dereference may remove a stored id that is not a valid shape.
2058    pub epic_id: String,
2059    /// The rendered detail line, from [`reference_detail`].
2060    pub detail: String,
2061}
2062
2063/// The outcome of a reference mutation: the resulting list plus the event.
2064///
2065/// The service produces the event and **does not write**; storage applies the list
2066/// and appends the event, so the atomicity rule lives in one place (contract §4).
2067/// It never returns a whole `Issue`, which is what keeps this module model-free.
2068#[derive(Debug, Clone, PartialEq, Eq)]
2069pub struct EpicChange {
2070    /// The issue whose references changed.
2071    pub issue: IssueKey,
2072    /// The reference list after the change, first-seen order preserved.
2073    pub epic_ids: Vec<String>,
2074    /// The event to append, or `None` when the mutation was a no-op.
2075    ///
2076    /// `None` for referencing an already-present epic or dereferencing an absent
2077    /// one: neither is a change, and recording one would put an entry in the audit
2078    /// trail for nothing (contract §5).
2079    pub event: Option<EpicEvent>,
2080}
2081
2082/// The per-issue outcome of a bulk reference (contract §1).
2083///
2084/// One entry per selector, each carrying its own success or refusal, so one
2085/// rejection is observable beside the rest rather than aborting them (contract
2086/// §5).
2087#[derive(Debug, Clone, PartialEq, Eq)]
2088pub struct BulkEpicOutcome {
2089    /// The selector this entry answers.
2090    pub selector: u32,
2091    /// The change or the refusal for that selector.
2092    pub outcome: Result<EpicChange, EpicError>,
2093}
2094
2095/// Find an issue in the snapshot by its number.
2096fn find_issue<N: EpicNode>(issues: &[N], selector: u32) -> Result<&N, EpicError> {
2097    issues
2098        .iter()
2099        .find(|issue| issue.number() == selector)
2100        .ok_or(EpicError::UnknownIssue { selector })
2101}
2102
2103/// Produce the reference change for a **resolved** epic id.
2104fn reference_resolved<N: EpicNode>(
2105    issues: &[N],
2106    selector: u32,
2107    id: &EpicId,
2108) -> Result<EpicChange, EpicError> {
2109    let issue = find_issue(issues, selector)?;
2110    let current = issue.epic_ids();
2111    let key = IssueKey::new(selector);
2112    if current.iter().any(|stored| stored == id.as_str()) {
2113        return Ok(EpicChange {
2114            issue: key,
2115            epic_ids: current.to_vec(),
2116            event: None,
2117        });
2118    }
2119    let mut next = current.to_vec();
2120    next.push(id.to_string());
2121    Ok(EpicChange {
2122        issue: key,
2123        epic_ids: next,
2124        event: Some(EpicEvent {
2125            kind: EpicEventKind::EpicReferenced,
2126            epic_id: id.to_string(),
2127            detail: reference_detail(id.as_str(), true),
2128        }),
2129    })
2130}
2131
2132/// Reference an epic from an issue, producing the change and its History event.
2133///
2134/// Idempotent: an already-present reference changes nothing and yields **no**
2135/// event (contract §5). Refuses an unknown epic or an unknown issue; a refusal
2136/// produces no change and no event.
2137pub fn reference_epic<N: EpicNode>(
2138    config: &Config,
2139    issues: &[N],
2140    issue_selector: u32,
2141    epic_reference: &str,
2142) -> Result<EpicChange, EpicError> {
2143    let id = resolve_epic_id(&config.epics, epic_reference)?;
2144    reference_resolved(issues, issue_selector, &id)
2145}
2146
2147/// Remove an issue's reference to an epic.
2148///
2149/// Drops only the reference: it never deletes or modifies the issue (C-006).
2150/// Dereferencing an absent reference is an idempotent no-op with no event. The
2151/// epic is **not** required to exist — removing a stale reference is the repair
2152/// path C-005 promises — so the only refusal is an unknown issue.
2153///
2154/// `epic_id` is compared against the stored list **verbatim**: unlike
2155/// [`reference_epic`], an epic *name* is not resolved here. That is deliberate —
2156/// the stored entries are ids, and a caller must resolve a name to an id first
2157/// (via [`resolve_epic_id`]). A name therefore matches nothing and is an inert
2158/// no-op, which keeps the repair path working for an id that is no longer a valid
2159/// [`EpicId`] shape or no longer in the registry.
2160pub fn dereference_epic<N: EpicNode>(
2161    issues: &[N],
2162    issue_selector: u32,
2163    epic_id: &str,
2164) -> Result<EpicChange, EpicError> {
2165    let issue = find_issue(issues, issue_selector)?;
2166    let current = issue.epic_ids();
2167    let key = IssueKey::new(issue_selector);
2168    if !current.iter().any(|stored| stored == epic_id) {
2169        return Ok(EpicChange {
2170            issue: key,
2171            epic_ids: current.to_vec(),
2172            event: None,
2173        });
2174    }
2175    let next: Vec<String> = current
2176        .iter()
2177        .filter(|stored| stored.as_str() != epic_id)
2178        .cloned()
2179        .collect();
2180    Ok(EpicChange {
2181        issue: key,
2182        epic_ids: next,
2183        event: Some(EpicEvent {
2184            kind: EpicEventKind::EpicDereferenced,
2185            epic_id: epic_id.to_string(),
2186            detail: reference_detail(epic_id, false),
2187        }),
2188    })
2189}
2190
2191/// Reference each selector independently, reporting every outcome.
2192///
2193/// One rejection must not abort the rest (contract §5): the returned vector has
2194/// one entry per selector, in selector order, and an unknown epic refuses each
2195/// entry rather than the whole call.
2196#[must_use]
2197pub fn bulk_reference<N: EpicNode>(
2198    config: &Config,
2199    issues: &[N],
2200    epic_reference: &str,
2201    issue_selectors: &[u32],
2202) -> Vec<BulkEpicOutcome> {
2203    let resolved = resolve_epic_id(&config.epics, epic_reference);
2204    issue_selectors
2205        .iter()
2206        .map(|&selector| {
2207            let outcome = match &resolved {
2208                Ok(id) => reference_resolved(issues, selector, id),
2209                Err(err) => Err(err.clone()),
2210            };
2211            BulkEpicOutcome { selector, outcome }
2212        })
2213        .collect()
2214}
2215
2216/// The ordered rollup of an epic, by id or name (contract §1).
2217pub fn rollup<N: EpicNode>(
2218    config: &Config,
2219    issues: &[N],
2220    reference: &str,
2221    targets: &ReferenceTargets,
2222    graph: &RelationshipGraph<'_>,
2223) -> Result<EpicRollup, EpicError> {
2224    EpicIndex::build(config, issues).rollup(config, reference, targets, graph)
2225}
2226
2227#[cfg(test)]
2228mod tests {
2229    use super::*;
2230    use crate::relationships::{IssueView, RelationKind, Relationship};
2231    use std::path::PathBuf;
2232
2233    /// An in-memory stand-in for the shape WP03 exposes on `Issue`.
2234    ///
2235    /// The inline suite is deliberately filesystem-free (T012 step 4): every test
2236    /// below builds a [`Config`] and a `Vec<Node>` by hand, with no `Store` and no
2237    /// `tempdir`. `labels` is carried so a `task`-labelled fixture is a real
2238    /// fixture, but it is **not** part of [`EpicNode`] — see
2239    /// `t007_a_task_labelled_issue_is_a_member_like_any_other`.
2240    #[derive(Debug, Clone, PartialEq, Eq)]
2241    struct Node {
2242        number: u32,
2243        title: String,
2244        status: String,
2245        epics: Vec<String>,
2246        labels: Vec<String>,
2247    }
2248
2249    impl Node {
2250        fn new(number: u32, title: &str) -> Self {
2251            Self {
2252                number,
2253                title: title.to_string(),
2254                status: "todo".to_string(),
2255                epics: Vec::new(),
2256                labels: Vec::new(),
2257            }
2258        }
2259
2260        fn with(mut self, ids: &[&str]) -> Self {
2261            self.epics = ids.iter().map(|id| (*id).to_string()).collect();
2262            self
2263        }
2264
2265        fn in_column(mut self, status: &str) -> Self {
2266            self.status = status.to_string();
2267            self
2268        }
2269
2270        fn labelled(mut self, label: &str) -> Self {
2271            self.labels.push(label.to_string());
2272            self
2273        }
2274    }
2275
2276    impl EpicNode for Node {
2277        fn number(&self) -> u32 {
2278            self.number
2279        }
2280        fn title(&self) -> &str {
2281            &self.title
2282        }
2283        fn status(&self) -> &str {
2284            &self.status
2285        }
2286        fn epic_ids(&self) -> &[String] {
2287            &self.epics
2288        }
2289    }
2290
2291    /// A registry record holding `(id, name)` pairs in declaration order.
2292    fn registry(entries: &[(&str, &str)]) -> Config {
2293        let mut config = Config::default();
2294        for (id, name) in entries {
2295            config.epics.insert(
2296                (*id).to_string(),
2297                EpicConfiguration {
2298                    name: (*name).to_string(),
2299                    ..EpicConfiguration::default()
2300                },
2301            );
2302        }
2303        config
2304    }
2305
2306    /// A registry record with a parent and/or a weight.
2307    fn record(name: &str, parent: Option<&str>, weight: Option<i64>) -> EpicConfiguration {
2308        EpicConfiguration {
2309            name: name.to_string(),
2310            parent: parent.map(str::to_string),
2311            weight,
2312            ..EpicConfiguration::default()
2313        }
2314    }
2315
2316    fn ids(change: &EpicChange) -> Vec<&str> {
2317        change.epic_ids.iter().map(String::as_str).collect()
2318    }
2319
2320    /// The bare registry a contract operation takes (`registry + issue set`).
2321    fn epics(config: &Config) -> &IndexMap<String, EpicConfiguration> {
2322        &config.epics
2323    }
2324
2325    /// Targets resolved from an in-memory fixture: the accepted names of each kind.
2326    fn targets(projects: &[&str], milestones: &[&str], cycles: &[&str]) -> ReferenceTargets {
2327        ReferenceTargets {
2328            projects: projects.iter().map(|id| (*id).to_string()).collect(),
2329            milestones: milestones.iter().map(|m| (*m).to_string()).collect(),
2330            cycles: cycles.iter().map(|c| (*c).to_string()).collect(),
2331        }
2332    }
2333
2334    /// An empty issue set, typed so a call can infer `EpicNode`.
2335    fn no_issues() -> Vec<Node> {
2336        Vec::new()
2337    }
2338
2339    /// A snapshot owning everything a [`RelationshipGraph`] borrows.
2340    ///
2341    /// A graph borrows its issue views, which borrow the titles and the typed
2342    /// edges, so the fixture owns all three and the caller keeps `views` beside
2343    /// the graph. Edges are the source issue 169's shape, so `blocked` is the
2344    /// owner's derived read and never a set this module invented (research R4).
2345    struct Blocking {
2346        issues: Vec<Node>,
2347        edges: Vec<Vec<Relationship>>,
2348    }
2349
2350    impl Blocking {
2351        /// A snapshot with `blocks` edges `(blocker, blocked)`.
2352        fn new(issues: Vec<Node>, blocks: &[(u32, u32)]) -> Self {
2353            let edges = issues
2354                .iter()
2355                .map(|issue| {
2356                    blocks
2357                        .iter()
2358                        .filter(|(blocker, _)| *blocker == issue.number)
2359                        .map(|(blocker, blocked)| {
2360                            Relationship::new(*blocker, *blocked, RelationKind::Blocks, "blocked")
2361                        })
2362                        .collect()
2363                })
2364                .collect();
2365            Self { issues, edges }
2366        }
2367
2368        /// Issue views borrowing this fixture; keep them alive beside the graph.
2369        fn views(&self) -> Vec<IssueView<'_>> {
2370            self.issues
2371                .iter()
2372                .zip(self.edges.iter())
2373                .map(|(issue, edges)| {
2374                    IssueView::new(issue.number, &issue.title, &issue.status, edges.as_slice())
2375                })
2376                .collect()
2377        }
2378    }
2379
2380    /// A board whose completion column is a **custom** name, so a literal `"done"`
2381    /// comparison cannot pass the tests that use it.
2382    fn custom_completion(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> Config {
2383        let mut config = Config::default();
2384        config.kanban.configurations.insert(
2385            "default".to_string(),
2386            crate::config::BoardConfiguration {
2387                columns: columns.iter().map(|c| (*c).to_string()).collect(),
2388                done_column: Some(done_column.to_string()),
2389                category_map: map
2390                    .iter()
2391                    .map(|(column, key)| ((*column).to_string(), (*key).to_string()))
2392                    .collect(),
2393                ..Default::default()
2394            },
2395        );
2396        config
2397    }
2398
2399    // --- T006: create / edit / delete / list / resolve -----------------------
2400
2401    #[test]
2402    fn t006_create_epic_allocates_a_stable_id_and_the_metadata() {
2403        let mut config = Config::default();
2404        let new = NewEpic {
2405            name: "Harden the sync path".to_string(),
2406            description: Some("Reduce operational risk.".to_string()),
2407            status: Some("active".to_string()),
2408            owner: Some("Alice <alice@example.com>".to_string()),
2409            start_date: Some("2026-09-01".to_string()),
2410            due_date: Some("2026-12-31".to_string()),
2411            weight: Some(5),
2412            ..NewEpic::default()
2413        };
2414        let epic = create_epic(&mut config, &new).unwrap();
2415        assert!(epic.id.as_str().starts_with("e-"), "{}", epic.id);
2416        assert_eq!(epic.name, "Harden the sync path");
2417        assert_eq!(epic.weight, Some(5));
2418        assert_eq!(epic.member_count, 0);
2419        assert_eq!(epic.members, [] as [crate::projects::MemberRef; 0]);
2420        assert_eq!(epic.children, [] as [crate::epics::EpicRef; 0]);
2421        // The record is the registry's, keyed by the stable id.
2422        assert!(config.epics.contains_key(epic.id.as_str()));
2423        // A second create never hands out the same id.
2424        let second = create_epic(&mut config, &new).unwrap_err();
2425        assert_eq!(second.kind(), EpicDiagnosticKind::DuplicateName);
2426        let other = NewEpic {
2427            name: "Other".to_string(),
2428            ..NewEpic::default()
2429        };
2430        assert_ne!(create_epic(&mut config, &other).unwrap().id, epic.id);
2431    }
2432
2433    #[test]
2434    fn t006_create_epic_refuses_a_duplicate_name_and_bad_metadata() {
2435        let mut config = registry(&[("e-aaaa", "Platform")]);
2436        let cases = [
2437            (
2438                NewEpic {
2439                    name: "platform".to_string(),
2440                    ..NewEpic::default()
2441                },
2442                "duplicate_name",
2443            ),
2444            (
2445                NewEpic {
2446                    name: " padded".to_string(),
2447                    ..NewEpic::default()
2448                },
2449                "duplicate_name",
2450            ),
2451            (
2452                NewEpic {
2453                    name: String::new(),
2454                    ..NewEpic::default()
2455                },
2456                "duplicate_name",
2457            ),
2458            (
2459                NewEpic {
2460                    name: "Dates".to_string(),
2461                    start_date: Some("2026-12-31".to_string()),
2462                    due_date: Some("2026-01-01".to_string()),
2463                    ..NewEpic::default()
2464                },
2465                "invalid_dates",
2466            ),
2467            (
2468                NewEpic {
2469                    name: "Heavy".to_string(),
2470                    weight: Some(0),
2471                    ..NewEpic::default()
2472                },
2473                "invalid_weight",
2474            ),
2475        ];
2476        for (new, kind) in cases {
2477            let err = create_epic(&mut config, &new).unwrap_err();
2478            assert_eq!(err.kind().as_str(), kind, "{err}");
2479        }
2480        // Nothing was inserted by any refusal: only the seeded epic remains.
2481        assert_eq!(config.epics.len(), 1);
2482    }
2483
2484    #[test]
2485    fn t006_a_rename_changes_the_label_only() {
2486        let mut config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Child")]);
2487        config
2488            .epics
2489            .insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
2490        let issues = vec![Node::new(1, "Member").with(&["e-aaaa"])];
2491        let before = config.epics["e-aaaa"].clone();
2492
2493        let edit = EpicEdit {
2494            name: Patch::Set("Platform hardening".to_string()),
2495            ..EpicEdit::default()
2496        };
2497        let edited = edit_epic(&mut config, "e-aaaa", &edit).unwrap();
2498        assert_eq!(edited.name, "Platform hardening");
2499        assert_eq!(edited.id.as_str(), "e-aaaa", "a rename never re-keys");
2500
2501        // Every reference and derived figure is intact under the new label.
2502        assert_eq!(config.epics["e-aaaa"].name, "Platform hardening");
2503        assert_eq!(config.epics["e-aaaa"].description, before.description);
2504        let index = EpicIndex::build(&config, &issues);
2505        assert_eq!(index.membership("e-aaaa").unwrap().member_count, 1);
2506        assert_eq!(
2507            index.hierarchy("e-bbbb").unwrap().parent.unwrap().name,
2508            "Platform hardening"
2509        );
2510        assert_eq!(
2511            index.resolve("Platform hardening").unwrap().id.as_str(),
2512            "e-aaaa"
2513        );
2514    }
2515
2516    #[test]
2517    fn t006_delete_reports_affected_issues_and_child_epics_and_keeps_them() {
2518        let mut config = registry(&[("e-aaaa", "Platform")]);
2519        config
2520            .epics
2521            .insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
2522        let issues = vec![
2523            Node::new(1, "Member one").with(&["e-aaaa"]),
2524            Node::new(2, "Member two").with(&["e-aaaa", "e-bbbb"]),
2525        ];
2526
2527        let deletion = delete_epic(&mut config, "e-aaaa", &issues).unwrap();
2528        assert_eq!(deletion.id.as_str(), "e-aaaa");
2529        assert_eq!(deletion.name, "Platform");
2530        assert_eq!(
2531            deletion
2532                .affected
2533                .iter()
2534                .map(|m| m.issue.number())
2535                .collect::<Vec<_>>(),
2536            vec![1, 2]
2537        );
2538        assert_eq!(deletion.affected[0].display, "#1 Member one");
2539        assert_eq!(
2540            deletion
2541                .child_epics
2542                .iter()
2543                .map(|e| e.id.as_str())
2544                .collect::<Vec<_>>(),
2545            vec!["e-bbbb"]
2546        );
2547        // The registry entry went; nothing else did (C-006).
2548        assert!(!config.epics.contains_key("e-aaaa"));
2549        assert!(config.epics.contains_key("e-bbbb"));
2550        assert_eq!(config.epics["e-bbbb"].parent.as_deref(), Some("e-aaaa"));
2551        assert_eq!(issues.len(), 2, "no issue is deleted");
2552        // The retained references are diagnosable, not silently rebound.
2553        let index = EpicIndex::build(&config, &issues);
2554        assert!(
2555            index
2556                .missing()
2557                .iter()
2558                .all(|m| m.target_kind == EpicReferenceKind::Epic)
2559        );
2560        assert_eq!(index.missing().len(), 2);
2561        assert_eq!(
2562            index
2563                .hierarchy("e-bbbb")
2564                .unwrap()
2565                .unresolved
2566                .unwrap()
2567                .recorded_parent,
2568            "e-aaaa"
2569        );
2570        // The id is never handed out again.
2571        let fresh = create_epic(
2572            &mut config,
2573            &NewEpic {
2574                name: "Fresh".to_string(),
2575                ..NewEpic::default()
2576            },
2577        )
2578        .unwrap();
2579        assert_ne!(fresh.id.as_str(), "e-aaaa");
2580    }
2581
2582    #[test]
2583    fn t006_resolution_by_id_and_by_name_agree() {
2584        let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
2585        let index = EpicIndex::build(&config, &no_issues());
2586        assert_eq!(index.resolve("e-aaaa").unwrap().name, "Platform");
2587        assert_eq!(index.resolve("Platform").unwrap().id.as_str(), "e-aaaa");
2588        assert_eq!(
2589            index.resolve("Platform").unwrap(),
2590            index.resolve("e-aaaa").unwrap()
2591        );
2592        // The name convenience is case-folded and trimmed, like the project one.
2593        assert_eq!(index.resolve("  sync ").unwrap().id.as_str(), "e-bbbb");
2594    }
2595
2596    #[test]
2597    fn t006_an_unknown_id_and_an_unknown_name_agree_on_unknown_epic() {
2598        let config = registry(&[("e-aaaa", "Platform")]);
2599        let index = EpicIndex::build(&config, &no_issues());
2600        for reference in ["e-zzzz", "Nowhere"] {
2601            let err = index.resolve(reference).unwrap_err();
2602            assert_eq!(err.kind(), EpicDiagnosticKind::UnknownEpic, "{err}");
2603            assert_eq!(err.kind().as_str(), "unknown_epic");
2604            assert!(err.message().contains(reference));
2605        }
2606    }
2607
2608    #[test]
2609    fn t006_list_epics_is_declaration_ordered_with_derived_counts() {
2610        let mut config = registry(&[("e-bbbb", "Second"), ("e-aaaa", "First")]);
2611        config
2612            .epics
2613            .insert("e-cccc".to_string(), record("Child", Some("e-aaaa"), None));
2614        // Declaration order, not sorted: moving `e-aaaa` after `e-cccc` proves it.
2615        config.epics.shift_remove("e-aaaa");
2616        config.epics.insert(
2617            "e-aaaa".to_string(),
2618            EpicConfiguration {
2619                name: "First".to_string(),
2620                ..Default::default()
2621            },
2622        );
2623        let issues = vec![
2624            Node::new(1, "One").with(&["e-aaaa"]),
2625            Node::new(2, "Two").with(&["e-aaaa"]),
2626        ];
2627        let listed = list_epics(&config, &issues);
2628        assert_eq!(
2629            listed.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(),
2630            vec!["e-bbbb", "e-cccc", "e-aaaa"]
2631        );
2632        let first = &listed[2];
2633        assert_eq!(first.member_count, 2);
2634        assert_eq!(first.child_count, 1);
2635        assert_eq!(first.children[0].id.as_str(), "e-cccc");
2636        assert_eq!(first.children[0].display, "Child (e-cccc)");
2637        // An empty registry is an empty list, never an error.
2638        assert_eq!(
2639            list_epics(&Config::default(), &no_issues()),
2640            [] as [crate::epics::Epic; 0]
2641        );
2642    }
2643
2644    #[test]
2645    fn t006_an_invalid_weight_is_refused_on_edit_and_a_valid_one_is_kept() {
2646        let mut config = registry(&[("e-aaaa", "Platform")]);
2647        let bad = EpicEdit {
2648            weight: Patch::Set(0),
2649            ..EpicEdit::default()
2650        };
2651        let err = edit_epic(&mut config, "e-aaaa", &bad).unwrap_err();
2652        assert_eq!(err.kind().as_str(), "invalid_weight");
2653        assert_eq!(
2654            config.epics["e-aaaa"].weight, None,
2655            "a refusal writes nothing"
2656        );
2657        let good = EpicEdit {
2658            weight: Patch::Set(8),
2659            ..EpicEdit::default()
2660        };
2661        assert_eq!(
2662            edit_epic(&mut config, "e-aaaa", &good).unwrap().weight,
2663            Some(8)
2664        );
2665        let cleared = EpicEdit {
2666            weight: Patch::Clear,
2667            ..EpicEdit::default()
2668        };
2669        assert_eq!(
2670            edit_epic(&mut config, "e-aaaa", &cleared).unwrap().weight,
2671            None
2672        );
2673    }
2674
2675    // --- T007: membership and the index --------------------------------------
2676
2677    #[test]
2678    fn t007_membership_is_ordered_by_issue_number_and_stable_across_rebuilds() {
2679        let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
2680        let issues = vec![
2681            Node::new(9, "Nine").with(&["e-aaaa"]),
2682            Node::new(3, "Three").with(&["e-aaaa", "e-bbbb"]),
2683            Node::new(7, "Seven").with(&["e-aaaa", "e-aaaa"]),
2684        ];
2685        let index = EpicIndex::build(&config, &issues);
2686        let membership = index.membership("e-aaaa").unwrap();
2687        assert_eq!(
2688            membership
2689                .members
2690                .iter()
2691                .map(|m| m.issue.number())
2692                .collect::<Vec<_>>(),
2693            vec![3, 7, 9]
2694        );
2695        assert_eq!(membership.member_count, 3);
2696        assert_eq!(membership.members[0].display, "#3 Three");
2697        assert_eq!(index.membership("e-bbbb").unwrap().member_count, 1);
2698        // A duplicate id on one issue is one reference, not two.
2699        assert_eq!(
2700            index.bucket(&EpicId::new("e-aaaa").unwrap()).unwrap().len(),
2701            3
2702        );
2703        // Two builds of the same snapshot are equal, so nothing is order-dependent.
2704        assert_eq!(index, EpicIndex::build(&config, &issues));
2705    }
2706
2707    #[test]
2708    fn t007_two_epics_sharing_an_issue_both_hold_it_and_an_unreferenced_issue_is_in_none() {
2709        let config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Sync")]);
2710        let issues = vec![
2711            Node::new(1, "Shared").with(&["e-aaaa", "e-bbbb"]),
2712            Node::new(2, "Lonely"),
2713        ];
2714        let buckets = membership_index(&config, &issues);
2715        assert_eq!(
2716            buckets[&EpicId::new("e-aaaa").unwrap()],
2717            vec![IssueKey::new(1)]
2718        );
2719        assert_eq!(
2720            buckets[&EpicId::new("e-bbbb").unwrap()],
2721            vec![IssueKey::new(1)]
2722        );
2723        assert!(!buckets.values().flatten().any(|key| key.number() == 2));
2724        // A known epic with no members is an empty bucket, not a missing one.
2725        let config = registry(&[("e-cccc", "Empty")]);
2726        let buckets = membership_index(&config, &no_issues());
2727        assert_eq!(buckets.get(&EpicId::new("e-cccc").unwrap()), None);
2728        let index = EpicIndex::build(&config, &no_issues());
2729        assert_eq!(index.bucket(&EpicId::new("e-cccc").unwrap()), Some(&[][..]));
2730        assert!(index.bucket(&EpicId::new("e-zzzz").unwrap()).is_none());
2731    }
2732
2733    #[test]
2734    fn t007_a_missing_epic_reference_is_accounted_for_rather_than_dropped() {
2735        let config = registry(&[("e-aaaa", "Platform")]);
2736        let issues = vec![Node::new(4, "Dangling").with(&["e-gone", "e-aaaa"])];
2737        let index = EpicIndex::build(&config, &issues);
2738        // The resolvable reference still buckets the issue.
2739        assert_eq!(
2740            index.bucket(&EpicId::new("e-aaaa").unwrap()).unwrap().len(),
2741            1
2742        );
2743        // The unresolvable one is reported, never silently absent.
2744        assert_eq!(index.missing().len(), 1);
2745        let missing = &index.missing()[0];
2746        assert_eq!(missing.target_kind, EpicReferenceKind::Epic);
2747        assert_eq!(missing.target, "e-gone");
2748        assert_eq!(missing.holder, ReferenceHolder::Issue(IssueKey::new(4)));
2749        assert_eq!(missing.holder.to_string(), "issue #4");
2750        assert!(missing.reason.contains("e-gone"));
2751    }
2752
2753    #[test]
2754    fn t007_no_member_list_is_stored_and_a_reference_change_leaves_the_record_alone() {
2755        let config = registry(&[("e-aaaa", "Platform")]);
2756        let issues = vec![
2757            Node::new(1, "Member").with(&["e-aaaa"]),
2758            Node::new(2, "Second"),
2759        ];
2760        let record_before = config.epics["e-aaaa"].clone();
2761        let change = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
2762        assert_eq!(ids(&change), vec!["e-aaaa"]);
2763        // The change is on the **issue**, and the registry record is untouched:
2764        // an epic record has no member list to carry (data-model §4, C-002).
2765        assert_eq!(config.epics["e-aaaa"], record_before);
2766        let serialized = toml::to_string(&config.epics["e-aaaa"]).unwrap();
2767        assert!(!serialized.contains("members"), "{serialized}");
2768        assert!(!serialized.contains("issues"), "{serialized}");
2769    }
2770
2771    #[test]
2772    fn t007_a_task_labelled_issue_is_a_member_like_any_other() {
2773        // The fixture really carries a `task` label …
2774        let task = Node::new(1, "A task").with(&["e-aaaa"]).labelled("task");
2775        let ordinary = Node::new(2, "An issue").with(&["e-aaaa"]);
2776        assert_eq!(task.labels, vec!["task".to_string()]);
2777
2778        let config = registry(&[("e-aaaa", "Platform")]);
2779        let issues = vec![task, ordinary];
2780        let index = EpicIndex::build(&config, &issues);
2781        let members = index.membership("e-aaaa").unwrap();
2782        // … and membership is exactly the same shape for it (C-001).
2783        assert_eq!(
2784            members
2785                .members
2786                .iter()
2787                .map(|m| m.issue.number())
2788                .collect::<Vec<_>>(),
2789            vec![1, 2]
2790        );
2791        // `EpicNode` has no label accessor, so no task structure can exist: the
2792        // only bucket map is the epic one (one entry, one epic).
2793        assert_eq!(index.buckets().len(), 1);
2794        // And a task-labelled issue rides the ordinary reference path: referencing
2795        // the plain issue and dereferencing the task are the same operations with
2796        // the same shapes.
2797        let added = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
2798        assert!(added.event.is_none(), "it already carries the reference");
2799        assert_eq!(ids(&added), vec!["e-aaaa"]);
2800        let changed = dereference_epic(&issues, 1, "e-aaaa").unwrap();
2801        assert_eq!(ids(&changed), [] as [&str; 0]);
2802        assert_eq!(changed.event.unwrap().kind, EpicEventKind::EpicDereferenced);
2803    }
2804
2805    // --- T008: the dedicated iterative walk and hierarchy --------------------
2806
2807    #[test]
2808    fn t008_a_three_deep_chain_reports_ancestors_children_and_depth() {
2809        let mut config = registry(&[("e-root", "Root")]);
2810        config
2811            .epics
2812            .insert("e-mid".to_string(), record("Mid", Some("e-root"), None));
2813        config
2814            .epics
2815            .insert("e-leaf".to_string(), record("Leaf", Some("e-mid"), None));
2816
2817        let index = EpicIndex::build(&config, &no_issues());
2818        let leaf = index.hierarchy("e-leaf").unwrap();
2819        assert_eq!(leaf.parent.unwrap().id.as_str(), "e-mid");
2820        assert_eq!(leaf.recorded_parent.as_deref(), Some("e-mid"));
2821        assert_eq!(leaf.depth, 2);
2822        assert_eq!(
2823            leaf.ancestors
2824                .iter()
2825                .map(|a| a.id.as_str())
2826                .collect::<Vec<_>>(),
2827            vec!["e-root", "e-mid"],
2828            "ancestors are root-first"
2829        );
2830        assert!(leaf.children.is_empty() && leaf.child_count == 0);
2831        assert!(leaf.unresolved.is_none());
2832
2833        let root = index.hierarchy("e-root").unwrap();
2834        assert!(root.parent.is_none() && root.ancestors.is_empty() && root.depth == 0);
2835        assert_eq!(
2836            root.children
2837                .iter()
2838                .map(|c| c.id.as_str())
2839                .collect::<Vec<_>>(),
2840            vec!["e-mid"]
2841        );
2842        assert_eq!(root.children[0].display, "Mid (e-mid)");
2843    }
2844
2845    #[test]
2846    fn t008_a_self_parent_is_rejected_before_any_write() {
2847        let mut config = registry(&[("e-aaaa", "Platform"), ("e-bbbb", "Other")]);
2848        let before = config.clone();
2849        let edit = EpicEdit {
2850            parent: Patch::Set("e-aaaa".to_string()),
2851            ..EpicEdit::default()
2852        };
2853        let err = edit_epic(&mut config, "e-aaaa", &edit).unwrap_err();
2854        assert_eq!(err.kind(), EpicDiagnosticKind::SelfParent);
2855        assert_eq!(err.kind().as_str(), "self_parent");
2856        assert_eq!(
2857            config, before,
2858            "a refusal leaves the registry byte-unchanged"
2859        );
2860        // By name too: the name resolves to the same epic, so it is the same refusal.
2861        let by_name = EpicEdit {
2862            parent: Patch::Set("Platform".to_string()),
2863            ..EpicEdit::default()
2864        };
2865        assert_eq!(
2866            edit_epic(&mut config, "e-aaaa", &by_name)
2867                .unwrap_err()
2868                .kind(),
2869            EpicDiagnosticKind::SelfParent
2870        );
2871        assert_eq!(config, before);
2872    }
2873
2874    #[test]
2875    fn t008_a_cycle_closing_parent_is_rejected_with_the_path() {
2876        let mut config = registry(&[("e-aaaa", "A")]);
2877        config
2878            .epics
2879            .insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
2880        config
2881            .epics
2882            .insert("e-cccc".to_string(), record("C", Some("e-bbbb"), None));
2883        let before = config.epics.clone();
2884
2885        // `e-aaaa -> e-cccc` closes a-aaaa -> c-cccc -> b-bbbb -> a-aaaa.
2886        let edit = EpicEdit {
2887            parent: Patch::Set("e-cccc".to_string()),
2888            ..EpicEdit::default()
2889        };
2890        let err = edit_epic(&mut config, "e-aaaa", &edit).unwrap_err();
2891        let EpicError::Cycle { path, .. } = &err else {
2892            panic!("expected a cycle refusal, got {err}");
2893        };
2894        assert_eq!(
2895            path.iter().map(EpicId::as_str).collect::<Vec<_>>(),
2896            vec!["e-aaaa", "e-cccc", "e-bbbb"]
2897        );
2898        assert!(err.message().contains("e-cccc"));
2899        assert_eq!(config.epics, before, "SC-005: nothing was written");
2900        assert!(config.epics["e-aaaa"].parent.is_none());
2901    }
2902
2903    #[test]
2904    fn t008_a_ten_thousand_node_chain_walks_without_overflow() {
2905        // NFR-001's scale: a recursive walk overflows the stack here, an
2906        // iterative one is 10,000 loop turns.
2907        const N: usize = 10_000;
2908        let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
2909        registry.insert("e-00000".to_string(), record("Root", None, None));
2910        for n in 1..N {
2911            let parent = format!("e-{:05}", n - 1);
2912            registry.insert(format!("e-{n:05}"), record("Node", Some(&parent), None));
2913        }
2914
2915        // Closing the chain is refused, with the whole path reported.
2916        let root = EpicId::new("e-00000").unwrap();
2917        let deepest = format!("e-{:05}", N - 1);
2918        let err = validate_epic_parent(&registry, &root, &deepest).unwrap_err();
2919        let EpicError::Cycle { path, .. } = &err else {
2920            panic!("expected a cycle refusal, got {err}");
2921        };
2922        assert_eq!(path.len(), N);
2923        assert_eq!(path[0].as_str(), "e-00000");
2924        assert_eq!(path[1].as_str(), deepest);
2925
2926        // And a walk that does **not** close stays accepted at the same depth.
2927        let fresh = EpicId::new("e-new").unwrap();
2928        assert_eq!(
2929            validate_epic_parent(&registry, &fresh, &deepest)
2930                .unwrap()
2931                .as_str(),
2932            deepest
2933        );
2934
2935        // The navigation walk is iterative too: the leaf's depth is N - 1.
2936        let config = Config {
2937            epics: registry,
2938            ..Config::default()
2939        };
2940        let index = EpicIndex::build(&config, &no_issues());
2941        assert_eq!(index.hierarchy(&deepest).unwrap().depth, N - 1);
2942    }
2943
2944    #[test]
2945    fn t008_a_parent_naming_a_deleted_epic_is_unresolved_not_dropped() {
2946        let mut config = registry(&[("e-aaaa", "Gone")]);
2947        config
2948            .epics
2949            .insert("e-bbbb".to_string(), record("Child", Some("e-aaaa"), None));
2950        config.epics.shift_remove("e-aaaa");
2951
2952        let index = EpicIndex::build(&config, &no_issues());
2953        let hierarchy = index.hierarchy("e-bbbb").unwrap();
2954        assert!(hierarchy.parent.is_none(), "it does not resolve");
2955        assert_eq!(hierarchy.recorded_parent.as_deref(), Some("e-aaaa"));
2956        let unresolved = hierarchy.unresolved.unwrap();
2957        assert_eq!(unresolved.epic.as_str(), "e-bbbb");
2958        assert_eq!(unresolved.recorded_parent, "e-aaaa");
2959        assert!(unresolved.reason.contains("e-aaaa"));
2960        assert!(unresolved.reason.contains("e-bbbb"));
2961        // The reference is retained on the record so it can be repaired.
2962        assert_eq!(config.epics["e-bbbb"].parent.as_deref(), Some("e-aaaa"));
2963        // A repair clears it.
2964        let edit = EpicEdit {
2965            parent: Patch::Clear,
2966            ..EpicEdit::default()
2967        };
2968        let mut config = config;
2969        edit_epic(&mut config, "e-bbbb", &edit).unwrap();
2970        assert!(config.epics["e-bbbb"].parent.is_none());
2971    }
2972
2973    #[test]
2974    fn t008_entering_a_stored_cycle_is_not_closing_one() {
2975        // A stored cycle `e-aaaa -> e-bbbb -> e-aaaa` that survives load, plus two
2976        // acyclic epics whose chains merely run into it.
2977        let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
2978        registry.insert("e-aaaa".to_string(), record("A", Some("e-bbbb"), None));
2979        registry.insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
2980        registry.insert("e-cccc".to_string(), record("C", Some("e-aaaa"), None));
2981        registry.insert("e-dddd".to_string(), record("D", None, None));
2982
2983        // Attaching an unrelated epic under the cyclic subtree closes nothing:
2984        // neither the epic being edited nor its chain is part of the loop.
2985        let c = EpicId::new("e-cccc").unwrap();
2986        assert_eq!(
2987            validate_epic_parent(&registry, &c, "e-aaaa")
2988                .unwrap()
2989                .as_str(),
2990            "e-aaaa"
2991        );
2992        let fresh = EpicId::new("e-new1").unwrap();
2993        assert_eq!(
2994            validate_epic_parent(&registry, &fresh, "e-bbbb")
2995                .unwrap()
2996                .as_str(),
2997            "e-bbbb"
2998        );
2999
3000        // … and the chain sweep reports the pre-existing loop exactly once, not
3001        // once per epic that runs into it.
3002        let defects = validate_epic_chains(&registry);
3003        assert_eq!(defects.len(), 1, "{defects:#?}");
3004        assert_eq!(defects[0].kind(), EpicDiagnosticKind::Cycle);
3005        assert_eq!(chain_epic(&defects[0]).unwrap().as_str(), "e-aaaa");
3006
3007        // A proposal that *does* close a loop still refuses, with the path.
3008        let err = validate_epic_parent(&registry, &c, "e-cccc").unwrap_err();
3009        assert_eq!(err.kind(), EpicDiagnosticKind::SelfParent);
3010        let closes = validate_epic_parent(&registry, &EpicId::new("e-dddd").unwrap(), "e-aaaa");
3011        assert_eq!(closes.unwrap().as_str(), "e-aaaa");
3012    }
3013
3014    #[test]
3015    fn t008_a_stored_self_parent_is_reported_once() {
3016        let mut registry: IndexMap<String, EpicConfiguration> = IndexMap::new();
3017        registry.insert("e-aaaa".to_string(), record("A", Some("e-aaaa"), None));
3018        registry.insert("e-bbbb".to_string(), record("B", None, None));
3019        let defects = validate_epic_chains(&registry);
3020        assert_eq!(defects.len(), 1, "one defect, not two: {defects:#?}");
3021        assert_eq!(defects[0].kind(), EpicDiagnosticKind::SelfParent);
3022        assert_eq!(chain_epic(&defects[0]).unwrap().as_str(), "e-aaaa");
3023    }
3024
3025    #[test]
3026    fn t006_the_reference_fields_are_writable_through_the_service() {
3027        let mut config = registry(&[("e-aaaa", "Platform")]);
3028        let created = create_epic(
3029            &mut config,
3030            &NewEpic {
3031                name: "Full".to_string(),
3032                projects: vec!["p-ok1".to_string()],
3033                milestone: Some("2026-Q4".to_string()),
3034                cycle: Some("October 2026".to_string()),
3035                ..NewEpic::default()
3036            },
3037        )
3038        .unwrap();
3039        assert_eq!(created.parent, None);
3040        assert_eq!(config.epics[created.id.as_str()].projects, vec!["p-ok1"]);
3041        assert_eq!(
3042            config.epics[created.id.as_str()].milestone.as_deref(),
3043            Some("2026-Q4")
3044        );
3045
3046        // `Set` writes, a dangling-but-well-formed id is kept (not refused) and is
3047        // then diagnosed, and `Clear` removes the reference.
3048        let edit = EpicEdit {
3049            projects: Patch::Set(vec!["p-gone".to_string(), "p-gone".to_string()]),
3050            milestone: Patch::Set("Nowhere".to_string()),
3051            cycle: Patch::Clear,
3052            ..EpicEdit::default()
3053        };
3054        let edited = edit_epic(&mut config, "e-aaaa", &edit).unwrap();
3055        assert_eq!(edited.projects, vec!["p-gone"]);
3056        assert_eq!(edited.milestone.as_deref(), Some("Nowhere"));
3057        assert_eq!(edited.cycle, None);
3058        let record = &config.epics["e-aaaa"];
3059        assert_eq!(
3060            record.projects,
3061            vec!["p-gone"],
3062            "a duplicate collapses to one"
3063        );
3064        assert_eq!(record.milestone.as_deref(), Some("Nowhere"));
3065        assert_eq!(record.cycle, None);
3066
3067        let found = resolve_references(epics(&config), &no_issues(), &ReferenceTargets::default());
3068        let kinds_of = |epic: &EpicId| -> Vec<EpicReferenceKind> {
3069            found
3070                .iter()
3071                .filter(|f| f.holder == ReferenceHolder::Epic(epic.clone()))
3072                .map(|f| f.target_kind)
3073                .collect()
3074        };
3075        // The epic the service just edited carries the dangling project and
3076        // milestone, and no cycle (it was cleared).
3077        assert_eq!(
3078            kinds_of(&EpicId::new("e-aaaa").unwrap()),
3079            vec![EpicReferenceKind::Project, EpicReferenceKind::Milestone]
3080        );
3081        // The created epic carries all three reference kinds it was given.
3082        assert_eq!(
3083            kinds_of(&created.id),
3084            vec![
3085                EpicReferenceKind::Project,
3086                EpicReferenceKind::Milestone,
3087                EpicReferenceKind::Cycle
3088            ]
3089        );
3090    }
3091
3092    #[test]
3093    fn t006_a_malformed_project_id_is_refused_before_any_write() {
3094        let mut config = registry(&[("e-aaaa", "Platform")]);
3095        let before = config.clone();
3096        let bad_edit = EpicEdit {
3097            projects: Patch::Set(vec!["Not A Project".to_string()]),
3098            ..EpicEdit::default()
3099        };
3100        let err = edit_epic(&mut config, "e-aaaa", &bad_edit).unwrap_err();
3101        assert_eq!(err.kind(), EpicDiagnosticKind::InvalidReference);
3102        assert_eq!(err.kind().as_str(), "invalid_reference");
3103        assert!(err.message().contains("Not A Project"));
3104        assert_eq!(config, before, "a refusal writes nothing");
3105
3106        let bad_create = create_epic(
3107            &mut config,
3108            &NewEpic {
3109                name: "Bad refs".to_string(),
3110                projects: vec!["  p-ok1".to_string()],
3111                ..NewEpic::default()
3112            },
3113        );
3114        assert_eq!(
3115            bad_create.unwrap_err().kind(),
3116            EpicDiagnosticKind::InvalidReference
3117        );
3118        assert_eq!(config, before);
3119    }
3120
3121    #[test]
3122    fn t008_a_stored_cycle_is_reported_by_the_chain_validation() {
3123        let mut config = registry(&[("e-aaaa", "A")]);
3124        config
3125            .epics
3126            .insert("e-bbbb".to_string(), record("B", Some("e-aaaa"), None));
3127        // Force a stored cycle behind the write path's back.
3128        config
3129            .epics
3130            .insert("e-aaaa".to_string(), record("A", Some("e-bbbb"), None));
3131        let defects = validate_epic_chains(epics(&config));
3132        assert_eq!(defects.len(), 1);
3133        assert_eq!(defects[0].kind(), EpicDiagnosticKind::Cycle);
3134        // Navigation stays total on a stored cycle rather than looping.
3135        let index = EpicIndex::build(&config, &no_issues());
3136        assert_eq!(index.hierarchy("e-aaaa").unwrap().depth, 1);
3137    }
3138
3139    // --- T009: reference diagnosis -------------------------------------------
3140
3141    #[test]
3142    fn t009_each_missing_reference_kind_reports_exactly_one_named_entry() {
3143        let mut config = registry(&[("e-aaaa", "Platform")]);
3144        config.epics.insert(
3145            "e-bbbb".to_string(),
3146            EpicConfiguration {
3147                name: "Broken".to_string(),
3148                parent: Some("e-aaaa".to_string()),
3149                projects: vec!["p-gone".to_string()],
3150                milestone: Some("2026-Q4".to_string()),
3151                cycle: Some("October 2026".to_string()),
3152                ..EpicConfiguration::default()
3153            },
3154        );
3155        let issues = vec![
3156            Node::new(1, "Dangling").with(&["e-missing"]),
3157            Node::new(2, "Malformed").with(&["Not An Id"]),
3158        ];
3159        let targets = targets(&[], &[], &[]);
3160        let found = resolve_references(epics(&config), &issues, &targets);
3161
3162        assert_eq!(found.len(), 5, "{found:#?}");
3163        for kind in [
3164            EpicReferenceKind::Epic,
3165            EpicReferenceKind::Project,
3166            EpicReferenceKind::Issue,
3167            EpicReferenceKind::Milestone,
3168            EpicReferenceKind::Cycle,
3169        ] {
3170            let matching: Vec<&EpicReferenceResolution> =
3171                found.iter().filter(|f| f.target_kind == kind).collect();
3172            assert_eq!(matching.len(), 1, "{kind} must be diagnosed once");
3173            let record = matching[0];
3174            assert!(!record.target.is_empty() || kind == EpicReferenceKind::Issue);
3175            assert_ne!(record.reason, "");
3176            assert!(record.reason.contains(&record.target) || record.target.is_empty());
3177            assert_eq!(
3178                kind.diagnostic_kind(),
3179                format!("missing_{}_reference", kind.as_str())
3180            );
3181        }
3182        // The recorded target is what the user must repair, verbatim.
3183        let by_kind = |kind: EpicReferenceKind| {
3184            found
3185                .iter()
3186                .find(|f| f.target_kind == kind)
3187                .unwrap()
3188                .clone()
3189        };
3190        let epic_ref = by_kind(EpicReferenceKind::Epic);
3191        assert_eq!(epic_ref.target, "e-missing");
3192        assert_eq!(epic_ref.holder, ReferenceHolder::Issue(IssueKey::new(1)));
3193        assert_eq!(epic_ref.holder.to_string(), "issue #1");
3194        assert_eq!(by_kind(EpicReferenceKind::Project).target, "p-gone");
3195        assert_eq!(by_kind(EpicReferenceKind::Milestone).target, "2026-Q4");
3196        assert_eq!(by_kind(EpicReferenceKind::Cycle).target, "October 2026");
3197        assert_eq!(by_kind(EpicReferenceKind::Issue).target, "Not An Id");
3198        assert_eq!(
3199            by_kind(EpicReferenceKind::Issue).holder,
3200            ReferenceHolder::Issue(IssueKey::new(2))
3201        );
3202    }
3203
3204    #[test]
3205    fn t009_a_clean_snapshot_reports_nothing_and_a_resolvable_one_does_too() {
3206        let mut config = registry(&[("e-aaaa", "Platform")]);
3207        config.epics.insert(
3208            "e-bbbb".to_string(),
3209            EpicConfiguration {
3210                name: "Complete".to_string(),
3211                parent: Some("e-aaaa".to_string()),
3212                projects: vec!["p-aaaa".to_string()],
3213                milestone: Some("2026-Q4".to_string()),
3214                cycle: Some("October 2026".to_string()),
3215                ..EpicConfiguration::default()
3216            },
3217        );
3218        let issues = vec![Node::new(1, "Member").with(&["e-aaaa"])];
3219        let targets = targets(&["p-aaaa"], &["2026-Q4"], &["October 2026"]);
3220        assert_eq!(
3221            resolve_references(epics(&config), &issues, &targets),
3222            [] as [crate::epics::EpicReferenceResolution; 0]
3223        );
3224        // … and an epic with no reference fields is clean too.
3225        assert_eq!(
3226            resolve_references(
3227                epics(&registry(&[("e-aaaa", "Bare")])),
3228                &no_issues(),
3229                &targets
3230            ),
3231            [] as [crate::epics::EpicReferenceResolution; 0]
3232        );
3233    }
3234
3235    #[test]
3236    fn t009_two_runs_over_the_same_input_are_equal_and_ordered() {
3237        let mut config = registry(&[("e-bbbb", "B")]);
3238        config
3239            .epics
3240            .insert("e-aaaa".to_string(), record("A", Some("e-gone"), None));
3241        config
3242            .epics
3243            .insert("e-cccc".to_string(), record("C", Some("e-gone"), None));
3244        let issues = vec![
3245            Node::new(5, "Five").with(&["e-x1"]),
3246            Node::new(2, "Two").with(&["e-x2"]),
3247        ];
3248        let first = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
3249        let second = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
3250        assert_eq!(first, second);
3251        // (holder, kind, target): the two issues first, then the two epics in order.
3252        assert_eq!(
3253            first
3254                .iter()
3255                .map(|f| (f.holder.to_string(), f.target.clone()))
3256                .collect::<Vec<_>>(),
3257            vec![
3258                ("epic `e-aaaa`".to_string(), "e-gone".to_string()),
3259                ("epic `e-cccc`".to_string(), "e-gone".to_string()),
3260                ("issue #2".to_string(), "e-x2".to_string()),
3261                ("issue #5".to_string(), "e-x1".to_string()),
3262            ]
3263        );
3264        assert!(
3265            first
3266                .windows(2)
3267                .all(|w| reference_order(&w[0], &w[1]) != std::cmp::Ordering::Greater)
3268        );
3269    }
3270
3271    #[test]
3272    fn t009_targets_come_from_the_planner_lookups_not_a_second_store() {
3273        // No filesystem: a `Planner` built from an in-memory config answers the
3274        // cycle half of the targets from the same `[cycles]` registry the config
3275        // holds, including the implicit `Default`, and the milestone half from the
3276        // milestone registry (empty here because the tempdir's dir does not exist).
3277        let mut config = Config::default();
3278        config.cycles.insert(
3279            "October 2026".to_string(),
3280            crate::config::PlanningCycle::default(),
3281        );
3282        let planner = Planner::from_config(config, PathBuf::from("."));
3283        let resolved = ReferenceTargets::from_planner(&planner).unwrap();
3284        assert!(resolved.cycle_known("October 2026"));
3285        assert!(resolved.cycle_known(DEFAULT_CYCLE_NAME));
3286        assert!(!resolved.cycle_known("Nowhere"));
3287        assert!(!resolved.milestone_known("2026-Q4"));
3288    }
3289
3290    // --- T010/T011: the rollup ------------------------------------------------
3291
3292    #[test]
3293    fn t010_three_members_one_complete_report_done_one_of_three() {
3294        let config = registry(&[("e-aaaa", "Platform")]);
3295        let fixture = Blocking::new(
3296            vec![
3297                Node::new(1, "Complete").in_column("done").with(&["e-aaaa"]),
3298                Node::new(2, "Open").with(&["e-aaaa"]),
3299                Node::new(3, "Also open").with(&["e-aaaa"]),
3300            ],
3301            &[],
3302        );
3303        let views = fixture.views();
3304        let graph = RelationshipGraph::new(&views, "done");
3305        let figures = rollup(
3306            &config,
3307            &fixture.issues,
3308            "e-aaaa",
3309            &ReferenceTargets::default(),
3310            &graph,
3311        )
3312        .unwrap();
3313        assert_eq!(figures.progress.done, 1);
3314        assert_eq!(figures.progress.total, 3);
3315        assert_eq!(figures.progress.progress, Progress { done: 1, total: 3 });
3316        assert_eq!(figures.child_count, 0);
3317        assert_eq!(figures.blocked, 0);
3318        assert_eq!(figures.weighted_progress.total_weight, 0);
3319        assert_eq!(figures.weighted_progress.percent, None);
3320    }
3321
3322    #[test]
3323    fn t010_an_epic_with_no_members_reports_zeros() {
3324        let config = registry(&[("e-aaaa", "Platform")]);
3325        let fixture = Blocking::new(no_issues(), &[]);
3326        let views = fixture.views();
3327        let graph = RelationshipGraph::new(&views, "done");
3328        let figures = rollup(
3329            &config,
3330            &fixture.issues,
3331            "e-aaaa",
3332            &ReferenceTargets::default(),
3333            &graph,
3334        )
3335        .unwrap();
3336        assert_eq!(figures.progress.progress, Progress { done: 0, total: 0 });
3337        assert_eq!(figures.child_count, 0);
3338        assert_eq!(figures.blocked, 0);
3339        assert_eq!(
3340            figures.blocked_members,
3341            [] as [crate::epics::BlockedMember; 0]
3342        );
3343        assert_eq!(
3344            figures.missing_references,
3345            [] as [crate::epics::EpicReferenceResolution; 0]
3346        );
3347    }
3348
3349    #[test]
3350    fn t010_completion_resolves_from_the_configured_column_not_a_literal() {
3351        // The board completes on `shipped` and has no `done` column at all.
3352        let mut config = custom_completion(
3353            &["triage", "wip", "shipped"],
3354            "shipped",
3355            &[
3356                ("triage", "unstarted"),
3357                ("wip", "active"),
3358                ("shipped", "completed"),
3359            ],
3360        );
3361        config.epics.insert(
3362            "e-aaaa".to_string(),
3363            EpicConfiguration {
3364                name: "Platform".to_string(),
3365                ..EpicConfiguration::default()
3366            },
3367        );
3368        let issues = vec![
3369            Node::new(1, "Shipped")
3370                .in_column("shipped")
3371                .with(&["e-aaaa"]),
3372            Node::new(2, "Literal done")
3373                .in_column("done")
3374                .with(&["e-aaaa"]),
3375        ];
3376        let fixture = Blocking::new(issues, &[]);
3377        let views = fixture.views();
3378        let graph = RelationshipGraph::new(&views, "done");
3379        let figures = rollup(
3380            &config,
3381            &fixture.issues,
3382            "e-aaaa",
3383            &ReferenceTargets::default(),
3384            &graph,
3385        )
3386        .unwrap();
3387        assert_eq!(
3388            figures.progress.done, 1,
3389            "only the configured column counts"
3390        );
3391        assert_eq!(figures.progress.total, 2);
3392    }
3393
3394    #[test]
3395    fn t011_the_known_fixture_counts_every_figure() {
3396        // Three members (one complete, one blocked), one child epic with a weight.
3397        let mut config = registry(&[("e-parent", "Parent"), ("e-child", "Child")]);
3398        config.epics.insert(
3399            "e-parent".to_string(),
3400            EpicConfiguration {
3401                name: "Parent".to_string(),
3402                weight: Some(5),
3403                ..EpicConfiguration::default()
3404            },
3405        );
3406        config.epics.insert(
3407            "e-child".to_string(),
3408            EpicConfiguration {
3409                name: "Child".to_string(),
3410                parent: Some("e-parent".to_string()),
3411                weight: Some(3),
3412                projects: vec!["p-gone".to_string()],
3413                ..EpicConfiguration::default()
3414            },
3415        );
3416        let issues = vec![
3417            Node::new(1, "Complete")
3418                .in_column("done")
3419                .with(&["e-parent"]),
3420            Node::new(2, "Blocked").with(&["e-parent"]),
3421            Node::new(3, "Open").with(&["e-parent"]),
3422            // The child's only member is complete, so the child is complete.
3423            Node::new(4, "Child member")
3424                .in_column("done")
3425                .with(&["e-child"]),
3426        ];
3427        let fixture = Blocking::new(issues, &[(3, 2)]);
3428        let views = fixture.views();
3429        let graph = RelationshipGraph::new(&views, "done");
3430        let figures = rollup(
3431            &config,
3432            &fixture.issues,
3433            "e-parent",
3434            &ReferenceTargets::default(),
3435            &graph,
3436        )
3437        .unwrap();
3438
3439        assert_eq!(figures.epic_id.as_str(), "e-parent");
3440        assert_eq!(figures.name, "Parent");
3441        assert_eq!(figures.progress.done, 1);
3442        assert_eq!(figures.progress.total, 3);
3443        assert_eq!(figures.child_count, 1);
3444        assert_eq!(figures.blocked, 1);
3445        assert_eq!(figures.blocked_members.len(), 1);
3446        assert_eq!(figures.blocked_members[0].issue.number(), 2);
3447        assert_ne!(figures.blocked_members[0].reason, "");
3448        let weighted = &figures.weighted_progress;
3449        assert_eq!(weighted.own_weight, Some(5));
3450        assert_eq!(weighted.weighted_children, 1);
3451        assert_eq!(weighted.unweighted_children, 0);
3452        assert_eq!(weighted.total_weight, 3);
3453        assert_eq!(weighted.done_weight, 3);
3454        assert_eq!(weighted.percent, Some(100));
3455        // The parent has no dangling reference of its own …
3456        assert_eq!(
3457            figures.missing_references,
3458            [] as [crate::epics::EpicReferenceResolution; 0]
3459        );
3460        // … and the child's dangling project reference is visible on the child's
3461        // own rollup, which reads the same single computation.
3462        let child = rollup(
3463            &config,
3464            &fixture.issues,
3465            "e-child",
3466            &ReferenceTargets::default(),
3467            &graph,
3468        )
3469        .unwrap();
3470        assert_eq!(child.progress.progress, Progress { done: 1, total: 1 });
3471        assert_eq!(child.child_count, 0);
3472        assert_eq!(
3473            child
3474                .missing_references
3475                .iter()
3476                .map(|f| f.target_kind)
3477                .collect::<Vec<_>>(),
3478            vec![EpicReferenceKind::Project]
3479        );
3480        // The parent's own weight is what a parent consumes; the child reports its
3481        // own, and neither counts the other's twice.
3482        assert_eq!(child.weighted_progress.own_weight, Some(3));
3483        // Repeated reads of the same snapshot are equal (NFR-003).
3484        assert_eq!(
3485            figures,
3486            rollup(
3487                &config,
3488                &fixture.issues,
3489                "e-parent",
3490                &ReferenceTargets::default(),
3491                &graph
3492            )
3493            .unwrap()
3494        );
3495    }
3496
3497    #[test]
3498    fn t011_weighted_progress_excludes_an_unweighted_child() {
3499        let mut config = registry(&[("e-parent", "Parent")]);
3500        config.epics.insert(
3501            "e-light".to_string(),
3502            record("Light", Some("e-parent"), None),
3503        );
3504        config.epics.insert(
3505            "e-heavy".to_string(),
3506            record("Heavy", Some("e-parent"), Some(10)),
3507        );
3508        let fixture = Blocking::new(vec![], &[]);
3509        let views = fixture.views();
3510        let graph = RelationshipGraph::new(&views, "done");
3511        let figures = rollup(
3512            &config,
3513            &fixture.issues,
3514            "e-parent",
3515            &ReferenceTargets::default(),
3516            &graph,
3517        )
3518        .unwrap();
3519        let weighted = &figures.weighted_progress;
3520        assert_eq!(
3521            weighted.weighted_children, 1,
3522            "the unweighted child is excluded"
3523        );
3524        assert_eq!(weighted.unweighted_children, 1);
3525        assert_eq!(weighted.total_weight, 10, "not 10 + 0");
3526        assert_eq!(
3527            weighted.done_weight, 0,
3528            "a child with no members is not complete"
3529        );
3530        assert_eq!(weighted.percent, Some(0));
3531    }
3532
3533    #[test]
3534    fn t011_a_done_member_is_never_counted_as_blocked() {
3535        let config = registry(&[("e-aaaa", "Platform")]);
3536        let issues = vec![
3537            Node::new(1, "Done but claimed blocked")
3538                .in_column("done")
3539                .with(&["e-aaaa"]),
3540            Node::new(2, "Genuinely blocked").with(&["e-aaaa"]),
3541            Node::new(3, "An open blocker"),
3542        ];
3543        // The owner's read says #1 is blocked (its blocker is open) — the rollup
3544        // still must not count it, because it is complete.
3545        let fixture = Blocking::new(issues, &[(1, 1), (3, 1), (3, 2)]);
3546        let views = fixture.views();
3547        let graph = RelationshipGraph::new(&views, "done");
3548        let figures = rollup(
3549            &config,
3550            &fixture.issues,
3551            "e-aaaa",
3552            &ReferenceTargets::default(),
3553            &graph,
3554        )
3555        .unwrap();
3556        assert_eq!(figures.progress.done, 1);
3557        assert_eq!(figures.blocked, 1);
3558        assert_eq!(figures.blocked_members[0].issue.number(), 2);
3559    }
3560
3561    #[test]
3562    fn t011_the_blocked_read_is_consumed_from_the_relationship_graph() {
3563        let config = registry(&[("e-aaaa", "Platform")]);
3564        let fixture = Blocking::new(
3565            vec![
3566                Node::new(1, "Blocked").with(&["e-aaaa"]),
3567                Node::new(2, "Blocker"),
3568            ],
3569            &[(2, 1)],
3570        );
3571        let views = fixture.views();
3572        let graph = RelationshipGraph::new(&views, "done");
3573        let index = EpicIndex::build(&config, &fixture.issues);
3574        let blocked = index.blocked_members(&EpicId::new("e-aaaa").unwrap(), &graph);
3575        assert_eq!(blocked.len(), 1);
3576        // The reason is the relationship service's own text, not a second wording.
3577        let owner = graph.blocked_state(1);
3578        assert!(owner.is_blocked);
3579        assert_eq!(blocked[0].reason, owner.reason);
3580    }
3581
3582    #[test]
3583    fn t011_missing_references_are_visible_on_the_rollup() {
3584        let mut config = registry(&[("e-aaaa", "Platform")]);
3585        config.epics.insert(
3586            "e-aaaa".to_string(),
3587            EpicConfiguration {
3588                name: "Platform".to_string(),
3589                parent: Some("e-gone".to_string()),
3590                milestone: Some("Nowhere".to_string()),
3591                ..EpicConfiguration::default()
3592            },
3593        );
3594        let fixture = Blocking::new(vec![], &[]);
3595        let views = fixture.views();
3596        let graph = RelationshipGraph::new(&views, "done");
3597        let figures = rollup(
3598            &config,
3599            &fixture.issues,
3600            "e-aaaa",
3601            &ReferenceTargets::default(),
3602            &graph,
3603        )
3604        .unwrap();
3605        assert_eq!(
3606            figures
3607                .missing_references
3608                .iter()
3609                .map(|f| f.target_kind)
3610                .collect::<Vec<_>>(),
3611            vec![EpicReferenceKind::Epic, EpicReferenceKind::Milestone]
3612        );
3613    }
3614
3615    // --- T009/T012: bulk reference and the deterministic event ----------------
3616
3617    #[test]
3618    fn t012_reference_is_idempotent_and_each_accepted_change_has_one_event() {
3619        let config = registry(&[("e-aaaa", "Platform")]);
3620        let issues = vec![
3621            Node::new(1, "Member").with(&["e-aaaa"]),
3622            Node::new(2, "Second"),
3623        ];
3624
3625        let noop = reference_epic(&config, &issues, 1, "e-aaaa").unwrap();
3626        assert!(
3627            noop.event.is_none(),
3628            "an already-present reference changes nothing"
3629        );
3630        assert_eq!(ids(&noop), vec!["e-aaaa"]);
3631
3632        let added = reference_epic(&config, &issues, 2, "e-aaaa").unwrap();
3633        assert_eq!(ids(&added), vec!["e-aaaa"]);
3634        let event = added.event.unwrap();
3635        assert_eq!(event.kind, EpicEventKind::EpicReferenced);
3636        assert_eq!(event.kind.as_str(), "epic_referenced");
3637        assert_eq!(event.detail, "epic `e-aaaa` referenced");
3638
3639        let removed = dereference_epic(&issues, 1, "e-aaaa").unwrap();
3640        assert_eq!(ids(&removed), [] as [&str; 0]);
3641        let event = removed.event.clone().unwrap();
3642        assert_eq!(event.kind.as_str(), "epic_dereferenced");
3643        assert_eq!(event.detail, "epic `e-aaaa` dereferenced");
3644
3645        // Dereferencing an absent reference is an inert no-op with no event, and
3646        // it need not resolve: removing a stale reference is the repair path.
3647        let absent = dereference_epic(&issues, 2, "e-aaaa").unwrap();
3648        assert!(absent.event.is_none());
3649        assert_eq!(ids(&absent), [] as [&str; 0]);
3650        let stale = dereference_epic(&issues, 1, "e-gone").unwrap();
3651        assert!(stale.event.is_none());
3652        assert_eq!(ids(&stale), vec!["e-aaaa"]);
3653        // A name is not resolved here — stored entries are ids.
3654        assert!(
3655            dereference_epic(&issues, 1, "Platform")
3656                .unwrap()
3657                .event
3658                .is_none()
3659        );
3660    }
3661
3662    #[test]
3663    fn t012_bulk_reference_reports_each_selector_independently() {
3664        let config = registry(&[("e-aaaa", "Platform")]);
3665        let issues = vec![
3666            Node::new(1, "One").with(&["e-aaaa"]),
3667            Node::new(2, "Two"),
3668            Node::new(3, "Three"),
3669        ];
3670        let outcomes = bulk_reference(&config, &issues, "e-aaaa", &[1, 2, 99, 3]);
3671        assert_eq!(outcomes.len(), 4);
3672        assert_eq!(
3673            outcomes.iter().map(|o| o.selector).collect::<Vec<_>>(),
3674            vec![1, 2, 99, 3]
3675        );
3676        assert!(outcomes[0].outcome.as_ref().unwrap().event.is_none());
3677        assert!(outcomes[1].outcome.as_ref().unwrap().event.is_some());
3678        // The unknown issue refuses **its** entry only; the rest still succeed.
3679        assert_eq!(
3680            outcomes[2].outcome.as_ref().unwrap_err().kind(),
3681            EpicDiagnosticKind::UnknownIssue
3682        );
3683        assert!(outcomes[3].outcome.as_ref().unwrap().event.is_some());
3684
3685        // An unknown epic refuses every entry rather than aborting the call.
3686        let all = bulk_reference(&config, &issues, "e-zzzz", &[1, 2]);
3687        assert_eq!(all.len(), 2);
3688        assert!(
3689            all.iter()
3690                .all(|o| o.outcome.as_ref().unwrap_err().kind() == EpicDiagnosticKind::UnknownEpic)
3691        );
3692    }
3693
3694    #[test]
3695    fn t012_reference_epic_refuses_an_unknown_issue() {
3696        let config = registry(&[("e-aaaa", "Platform")]);
3697        let err = reference_epic(&config, &no_issues(), 7, "e-aaaa").unwrap_err();
3698        assert_eq!(err.kind(), EpicDiagnosticKind::UnknownIssue);
3699        assert!(err.message().contains('7'));
3700    }
3701
3702    #[test]
3703    fn t012_every_operation_in_the_contract_is_reachable() {
3704        // A compile-time inventory of contract §1: each name below is called, so a
3705        // rename or a removal fails this test rather than drifting silently.
3706        let mut config = registry(&[("e-aaaa", "Platform")]);
3707        config.epics.insert(
3708            "e-bbbb".to_string(),
3709            record("Child", Some("e-aaaa"), Some(2)),
3710        );
3711        let issues = vec![Node::new(1, "Member").in_column("done").with(&["e-aaaa"])];
3712        let fixture = Blocking::new(issues.clone(), &[]);
3713        let views = fixture.views();
3714        let graph = RelationshipGraph::new(&views, "done");
3715
3716        let _ = create_epic(
3717            &mut config,
3718            &NewEpic {
3719                name: "Created".to_string(),
3720                ..NewEpic::default()
3721            },
3722        )
3723        .unwrap();
3724        let _ = edit_epic(
3725            &mut config,
3726            "e-bbbb",
3727            &EpicEdit {
3728                status: Patch::Set("active".to_string()),
3729                projects: Patch::Set(vec!["p-ok1".to_string()]),
3730                milestone: Patch::Set("2026-Q4".to_string()),
3731                cycle: Patch::Set("October 2026".to_string()),
3732                ..EpicEdit::default()
3733            },
3734        )
3735        .unwrap();
3736        let _ = list_epics(&config, &issues);
3737        let _ = resolve(&config, &issues, "e-aaaa").unwrap();
3738        let _ = membership_index(&config, &issues);
3739        let _ = membership(&config, &issues, "e-aaaa").unwrap();
3740        let _ = hierarchy(&config, &issues, "e-aaaa").unwrap();
3741        let _ = reference_epic(&config, &issues, 1, "e-bbbb").unwrap();
3742        let _ = dereference_epic(&issues, 1, "e-aaaa").unwrap();
3743        let _ = bulk_reference(&config, &issues, "e-aaaa", &[1]);
3744        let _ = resolve_references(epics(&config), &issues, &ReferenceTargets::default());
3745        let _ = rollup(
3746            &config,
3747            &fixture.issues,
3748            "e-aaaa",
3749            &ReferenceTargets::default(),
3750            &graph,
3751        )
3752        .unwrap();
3753        let deletion = delete_epic(&mut config, "e-bbbb", &issues).unwrap();
3754        assert_eq!(deletion.name, "Child");
3755
3756        // The refusal wording and kinds are the contract's, verbatim.
3757        assert_eq!(EpicDiagnosticKind::DuplicateName.as_str(), "duplicate_name");
3758        assert_eq!(EpicDiagnosticKind::UnknownEpic.as_str(), "unknown_epic");
3759        assert_eq!(EpicDiagnosticKind::UnknownIssue.as_str(), "unknown_issue");
3760        assert_eq!(EpicDiagnosticKind::InvalidDates.as_str(), "invalid_dates");
3761        assert_eq!(EpicDiagnosticKind::InvalidWeight.as_str(), "invalid_weight");
3762        assert_eq!(
3763            EpicDiagnosticKind::InvalidReference.as_str(),
3764            "invalid_reference"
3765        );
3766        assert_eq!(EpicDiagnosticKind::SelfParent.as_str(), "self_parent");
3767        assert_eq!(EpicDiagnosticKind::Cycle.as_str(), "cycle");
3768        assert_eq!(EPICS_KEY, "epics");
3769        assert_eq!(
3770            epic_display(&EpicId::new("e-aaaa").unwrap(), "Platform"),
3771            "Platform (e-aaaa)"
3772        );
3773    }
3774
3775    #[test]
3776    fn t012_the_module_is_filesystem_free() {
3777        // One test that proves the whole surface needs no `Store` and no
3778        // `tempfile`: everything below is built from `Config::default()` and
3779        // `Vec<Node>` and no path is ever touched. It mirrors the contract's
3780        // "registry + issue set" operations exactly.
3781        let mut config = Config::default();
3782        let created = create_epic(
3783            &mut config,
3784            &NewEpic {
3785                name: "An epic".to_string(),
3786                weight: Some(1),
3787                ..NewEpic::default()
3788            },
3789        )
3790        .unwrap();
3791        let fixture = Blocking::new(
3792            vec![Node::new(1, "Member").with(&[created.id.as_str()])],
3793            &[],
3794        );
3795        let views = fixture.views();
3796        let graph = RelationshipGraph::new(&views, "done");
3797        let figures = rollup(
3798            &config,
3799            &fixture.issues,
3800            created.id.as_str(),
3801            &ReferenceTargets::default(),
3802            &graph,
3803        )
3804        .unwrap();
3805        assert_eq!(figures.progress.total, 1);
3806        assert_eq!(
3807            resolve_references(
3808                epics(&config),
3809                &fixture.issues,
3810                &ReferenceTargets::default()
3811            ),
3812            [] as [crate::epics::EpicReferenceResolution; 0]
3813        );
3814    }
3815}