Skip to main content

tix/
hierarchy.rs

1//! The single owner of hierarchy behaviour (mission
2//! `parent-issues-subissues-01M3BXX3`, WP01).
3//!
4//! Everything about an issue's parent lives here: child derivation, parent
5//! set/change/clear, validation, the rollup figures, and unresolved-parent
6//! detection. Surfaces call this rather than re-deriving, because a second
7//! implementation is exactly the divergence the mission's intake escalates.
8//!
9//! ## What this module deliberately does not do
10//!
11//! - **It touches no files.** It takes an in-memory issue set and a
12//!   `done_column` the caller has already resolved, so the whole surface is
13//!   testable with no `Store` and no `tempfile` (T005 step 4). Storage owns the
14//!   write; this module owns the rule.
15//! - **It does not know about the issue model.** [`HierarchyNode`] is the
16//!   minimum an issue must expose, and WP02 implements it for `Issue`. WP01 is
17//!   forbidden from touching `src/model.rs`, so the service is defined against
18//!   the shape WP02 will provide rather than against `Issue` directly.
19//! - **It stores no child list.** Child membership is derived by inverting the
20//!   child's parent pointer, never persisted (C-003).
21//!
22//! ## Decisions taken here, and why
23//!
24//! **The frontmatter key is `parent`.** It has to be unambiguous against the
25//! existing Spec Kitty pointer fields `mission` and `task`, which are
26//! governance metadata and not a hierarchy (research R2, C-007). `parent` is
27//! distinct from all three, and it is reused verbatim by `FilterField::key()` in
28//! WP06 (research R4).
29//!
30//! **The stored reference is an issue number, not a selector.** A selector can be
31//! a title fragment, and a title is not stable: retitling a parent would silently
32//! break every child pointing at it. Selectors are the *input* to
33//! [`set_parent`] and friends; the number is what lands in the file.
34//!
35//! **The completion basis is direct children only.** Both figure fields in
36//! `data-model.md` §3 — `direct_children` and `completed_children` — are
37//! direct-child counts, and a subtree rollup is `O(V)` per parent, which turns a
38//! board render into `O(V²)`. The basis is carried on the value anyway
39//! ([`CompletionBasis`]) so a consumer can never guess which figure it is looking
40//! at, and so changing the basis later is a visible change rather than a silent
41//! one.
42//!
43//! **History events are declared here, not in `model.rs`.** The mission requires
44//! the `## History` section to record parent set/changed/cleared, but
45//! `HistoryEventKind` is `model.rs`'s and WP01 may not edit it. The three kinds
46//! are therefore declared as [`ParentEventKind`] and WP02 maps them onto
47//! `HistoryEventKind` when it adds the field. Declaring them here keeps the
48//! service complete and the boundary honest.
49
50use std::collections::{BTreeMap, BTreeSet};
51
52use crate::error::{TixError, render_diagnostics};
53use crate::id::{AliasIndex, IdentityNode, IssueRef};
54use crate::planning::Progress;
55use crate::relationships::RelationshipGraph;
56
57/// The frontmatter key carrying an issue's parent reference.
58///
59/// Chosen once here and reused verbatim by the search layer in WP06.
60pub const PARENT_KEY: &str = "parent";
61
62/// The minimum an issue must expose for hierarchy derivation.
63///
64/// WP02 implements this for [`crate::model::Issue`]. Keeping it a trait is what
65/// lets this module be written and tested without `src/model.rs`, which WP02
66/// owns.
67pub trait HierarchyNode {
68    /// The issue's number, which is its stable identity.
69    fn number(&self) -> u32;
70    /// The issue's title, used only for the derived display label.
71    fn title(&self) -> &str;
72    /// The issue's raw status, compared against the caller's completion column.
73    fn status(&self) -> &str;
74    /// The canonical issue id, when the record has one.
75    fn canonical_id(&self) -> Option<&str> {
76        None
77    }
78    /// The store-relative filename used by the shared alias index.
79    fn filename(&self) -> Option<&str> {
80        None
81    }
82    /// The stored canonical parent reference. `None` means no parent.
83    fn parent_ref(&self) -> Option<&IssueRef> {
84        None
85    }
86    /// Legacy numeric parent alias used by in-memory test nodes.
87    fn parent_number(&self) -> Option<u32>;
88}
89
90// ---------------------------------------------------------------------------
91// T001 — ParentReference and HierarchyIndex
92// ---------------------------------------------------------------------------
93
94/// A resolved reference to another issue, with its derived display label.
95///
96/// `data-model.md` §1. The label is computed on read and **never stored**: a
97/// stored title goes stale the moment the referenced issue is retitled.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ParentReference {
100    /// The referenced issue's canonical graph identity.
101    pub id: IssueRef,
102    /// The referenced issue's number.
103    pub number: u32,
104    /// `#<number> <title>`, or just `#<number>` when the title is blank.
105    pub display: String,
106}
107
108impl ParentReference {
109    /// Build a reference, deriving the label from the title.
110    pub fn new<N: HierarchyNode>(issue: &N) -> Self {
111        Self {
112            id: hierarchy_ref(issue),
113            number: issue.number(),
114            display: display_label(issue.number(), issue.title()),
115        }
116    }
117}
118
119fn hierarchy_ref<N: HierarchyNode>(issue: &N) -> IssueRef {
120    issue
121        .canonical_id()
122        .and_then(IssueRef::id)
123        .map(|reference| reference.with_number(issue.number()))
124        .unwrap_or_else(|| IssueRef::Number(issue.number()))
125}
126
127/// The shared display form for a referenced issue: `#12 Fix the thing`.
128///
129/// One function, because a second formatting site is how two surfaces end up
130/// disagreeing about what an issue is called.
131pub fn display_label(number: u32, title: &str) -> String {
132    let title = title.trim();
133    if title.is_empty() {
134        format!("#{number}")
135    } else {
136        format!("#{number} {title}")
137    }
138}
139
140/// A child, as `data-model.md` §2's ordered list element.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ChildRef {
143    /// The child's canonical graph identity.
144    pub id: IssueRef,
145    /// The child's number.
146    pub number: u32,
147    /// The child's derived display label.
148    pub display: String,
149}
150
151/// The derived set of an issue's direct children (`data-model.md` §2).
152///
153/// Derived, never stored: this is the mechanical guard for C-003, and
154/// `no_child_list_is_stored_in_the_index` pins it.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ChildMembership {
157    /// The issue the children belong to.
158    pub parent: u32,
159    /// Direct children, ordered by issue number.
160    pub children: Vec<ChildRef>,
161    /// `children.len()`, carried because callers ask for it constantly.
162    pub child_count: usize,
163}
164
165impl ChildMembership {
166    fn empty(parent: u32) -> Self {
167        Self {
168            parent,
169            children: Vec::new(),
170            child_count: 0,
171        }
172    }
173}
174
175/// The derived parent/child view over one snapshot of issues.
176///
177/// Built once and read many times. Both maps are `BTreeMap`, so every iteration
178/// is in issue-number order and repeated reads of the same snapshot are
179/// byte-identical (NFR-003).
180#[derive(Debug, Clone, Default, PartialEq, Eq)]
181pub struct HierarchyIndex {
182    /// child identity -> parent identity. Only issues that *have* a parent appear.
183    parent_by_child: BTreeMap<IssueRef, IssueRef>,
184    /// parent identity -> child identities, ascending.
185    children_by_parent: BTreeMap<IssueRef, Vec<IssueRef>>,
186    /// identity -> issue number alias.
187    numbers: BTreeMap<IssueRef, u32>,
188    /// number alias -> every identity carrying it.
189    refs_by_number: BTreeMap<u32, Vec<IssueRef>>,
190    /// identity -> display label, for the issues this index knows about.
191    labels: BTreeMap<IssueRef, String>,
192    /// identity -> status, for completion resolution.
193    statuses: BTreeMap<IssueRef, String>,
194    /// identity -> source filename, for exact lint attribution.
195    filenames: BTreeMap<IssueRef, String>,
196    /// The one project-wide alias index this snapshot was built from.
197    aliases: AliasIndex,
198    /// Alias failures retained in addition to unresolved-parent states.
199    alias_diagnostics: Vec<HierarchyDiagnostic>,
200}
201
202struct AliasNode<'a, N>(&'a N);
203
204impl<N: HierarchyNode> IdentityNode for AliasNode<'_, N> {
205    fn canonical_id(&self) -> Option<&str> {
206        self.0.canonical_id()
207    }
208    fn issue_number(&self) -> u32 {
209        self.0.number()
210    }
211    fn issue_filename(&self) -> &str {
212        self.0.filename().unwrap_or("")
213    }
214    fn issue_title(&self) -> &str {
215        self.0.title()
216    }
217}
218
219impl HierarchyIndex {
220    /// Build the index from an in-memory issue set.
221    ///
222    /// A child whose recorded parent is not in the set is **kept** in
223    /// `parent_by_child`: dropping it would turn a diagnosable dangling reference
224    /// into a silently absent one, which is the failure `UnresolvedParentState`
225    /// exists to prevent. [`Self::unresolved`] reports those.
226    pub fn build<N: HierarchyNode>(issues: &[N]) -> Self {
227        let mut index = Self::default();
228        for issue in issues {
229            let number = issue.number();
230            let identity = hierarchy_ref(issue);
231            index.numbers.insert(identity, number);
232            index
233                .refs_by_number
234                .entry(number)
235                .or_default()
236                .push(identity);
237            index
238                .labels
239                .insert(identity, display_label(number, issue.title()));
240            index
241                .filenames
242                .insert(identity, issue.filename().unwrap_or("").to_string());
243            index.statuses.insert(identity, issue.status().to_string());
244        }
245        let alias_nodes: Vec<AliasNode<'_, N>> = issues.iter().map(AliasNode).collect();
246        index.aliases = AliasIndex::build(&alias_nodes);
247        for refs in index.refs_by_number.values_mut() {
248            refs.sort();
249            refs.dedup();
250        }
251        for issue in issues {
252            let child_identity = hierarchy_ref(issue);
253            let parent = issue
254                .parent_ref()
255                .copied()
256                .or_else(|| issue.parent_number().map(IssueRef::Number));
257            if let Some(parent) = parent {
258                let parent = match index.aliases.resolve(&parent) {
259                    Ok(resolved) => resolved,
260                    Err(error) => {
261                        let kind = match error {
262                            crate::id::AliasError::Ambiguous { .. } => {
263                                HierarchyDiagnosticKind::AmbiguousTarget
264                            }
265                            crate::id::AliasError::DuplicateId { .. } => {
266                                HierarchyDiagnosticKind::DuplicateIdentity
267                            }
268                            crate::id::AliasError::Missing(_) => {
269                                HierarchyDiagnosticKind::UnresolvedParent
270                            }
271                        };
272                        if kind != HierarchyDiagnosticKind::UnresolvedParent {
273                            index.alias_diagnostics.push(
274                                HierarchyDiagnostic::new(
275                                    kind,
276                                    issue.number(),
277                                    format!(
278                                        "{PARENT_KEY}: {} cannot be resolved: {}",
279                                        parent.human(),
280                                        alias_error_text(&error)
281                                    ),
282                                )
283                                .with_source_file(issue.filename().unwrap_or("")),
284                            );
285                        }
286                        parent
287                    }
288                };
289                index.parent_by_child.insert(child_identity, parent);
290                index
291                    .children_by_parent
292                    .entry(parent)
293                    .or_default()
294                    .push(child_identity);
295            }
296        }
297        // Child order is the human-facing issue-number order. Canonical ids are
298        // the map identity, but random id order is not a useful report order.
299        for children in index.children_by_parent.values_mut() {
300            children.sort_by_key(|child| index.numbers.get(child).copied().unwrap_or_default());
301        }
302        index
303    }
304
305    fn identity_for_number(&self, issue: u32) -> Option<IssueRef> {
306        match self.refs_by_number.get(&issue)?.as_slice() {
307            [one] => Some(*one),
308            _ => None,
309        }
310    }
311
312    fn number_for_identity(&self, identity: &IssueRef) -> Option<u32> {
313        self.numbers.get(identity).copied()
314    }
315
316    /// Alias failures discovered while resolving stored parent references.
317    pub fn alias_diagnostics(&self) -> &[HierarchyDiagnostic] {
318        &self.alias_diagnostics
319    }
320
321    /// The canonical parent reference stored on `issue`.
322    pub fn parent_ref_of(&self, issue: u32) -> Option<&IssueRef> {
323        self.identity_for_number(issue)
324            .and_then(|identity| self.parent_by_child.get(&identity))
325            .or_else(|| self.parent_by_child.get(&IssueRef::Number(issue)))
326    }
327
328    /// The canonical recorded parent of one canonical issue reference.
329    pub fn parent_ref_for(&self, issue: &IssueRef) -> Option<&IssueRef> {
330        self.parent_by_child.get(issue)
331    }
332
333    /// The canonical identity of `issue` itself.
334    pub fn identity_ref_of(&self, issue: u32) -> Option<IssueRef> {
335        self.identity_for_number(issue)
336    }
337
338    /// The ordered direct children of `issue`.
339    ///
340    /// An issue with no children returns an empty membership, **not** an error:
341    /// "has no children" is the ordinary state of most issues.
342    pub fn children_of(&self, issue: u32) -> ChildMembership {
343        let identity = self
344            .identity_for_number(issue)
345            .unwrap_or(IssueRef::Number(issue));
346        let Some(children) = self.children_by_parent.get(&identity) else {
347            return ChildMembership::empty(issue);
348        };
349        let children: Vec<ChildRef> = children
350            .iter()
351            .map(|child| ChildRef {
352                id: *child,
353                number: self.number_for_identity(child).unwrap_or_default(),
354                display: self.label_ref(child),
355            })
356            .collect();
357        ChildMembership {
358            parent: issue,
359            child_count: children.len(),
360            children,
361        }
362    }
363
364    /// The stored parent number of `issue`, if it has one.
365    ///
366    /// Returns the number without resolving it, so a dangling reference is still
367    /// visible here. Use [`Self::unresolved`] to separate the two cases.
368    pub fn parent_of(&self, issue: u32) -> Option<u32> {
369        self.parent_ref_of(issue).and_then(|parent| {
370            parent
371                .as_number()
372                .or_else(|| self.number_for_identity(parent))
373        })
374    }
375
376    /// The derived display label for an issue this index knows.
377    pub fn label(&self, issue: u32) -> String {
378        self.identity_for_number(issue)
379            .map_or_else(|| format!("#{issue}"), |identity| self.label_ref(&identity))
380    }
381
382    fn label_ref(&self, identity: &IssueRef) -> String {
383        self.labels
384            .get(identity)
385            .cloned()
386            .unwrap_or_else(|| identity.to_string())
387    }
388
389    /// An issue's raw status, as the index recorded it.
390    pub fn status_of(&self, issue: u32) -> Option<&str> {
391        self.identity_for_number(issue)
392            .and_then(|identity| self.statuses.get(&identity))
393            .map(String::as_str)
394    }
395
396    /// Whether `issue` exists in the indexed set.
397    pub fn contains(&self, issue: u32) -> bool {
398        self.identity_for_number(issue).is_some()
399    }
400
401    /// Whether a canonical reference resolves in the indexed set.
402    pub fn contains_ref(&self, reference: &IssueRef) -> bool {
403        self.labels.contains_key(reference)
404    }
405
406    /// Every child whose recorded parent no longer exists.
407    ///
408    /// Each retains the **recorded** number so the reference can be repaired
409    /// rather than merely reported (`data-model.md` §5).
410    pub fn unresolved(&self) -> Vec<UnresolvedParentState> {
411        let mut out: Vec<UnresolvedParentState> = self
412            .parent_by_child
413            .iter()
414            .filter(|(_, parent)| !self.labels.contains_key(parent))
415            .map(|(child, parent)| UnresolvedParentState {
416                child: self.number_for_identity(child).unwrap_or_default(),
417                recorded_parent: *parent,
418                source_file: self.filenames.get(child).cloned().unwrap_or_default(),
419                reason: unresolved_reason(
420                    self.number_for_identity(child).unwrap_or_default(),
421                    *parent,
422                ),
423            })
424            .collect();
425        out.sort_by_key(|s| s.child);
426        out
427    }
428}
429
430// ---------------------------------------------------------------------------
431// T005 — UnresolvedParentState and deletion impact
432// ---------------------------------------------------------------------------
433
434/// The diagnosed state of a child whose parent no longer exists
435/// (`data-model.md` §5).
436///
437/// The child's file is **not** modified when its parent is deleted: the
438/// reference survives so it can be repaired. Any cascade would be an escalation
439/// trigger.
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct UnresolvedParentState {
442    /// The child holding the reference.
443    pub child: u32,
444    /// The reference exactly as stored, retained for repair.
445    pub recorded_parent: IssueRef,
446    /// The child file carrying the unresolved reference.
447    pub source_file: String,
448    /// Human-readable text, reused verbatim by lint, the CLI, the TUI and MCP.
449    ///
450    /// One string, produced here, because four surfaces re-deriving it is four
451    /// chances to word it differently.
452    pub reason: String,
453}
454
455/// The single wording for an unresolved parent reference.
456pub fn unresolved_reason(child: u32, parent: impl std::fmt::Display) -> String {
457    let parent = parent.to_string();
458    let parent = if parent.bytes().all(|byte| byte.is_ascii_digit()) {
459        format!("#{parent}")
460    } else {
461        parent
462    };
463    format!(
464        "{child}'s parent {parent} no longer exists (the reference is kept so it can be repaired)"
465    )
466}
467
468/// The issues a delete of `parent` would orphan, in issue-number order.
469///
470/// It **reports**; it does not delete, and the module has no mutating sibling
471/// (T005 step 2). WP05 consumes this for the delete-time impact notice.
472pub fn affected_by_deletion<N: HierarchyNode>(issues: &[N], parent: u32) -> Vec<ChildRef> {
473    let index = HierarchyIndex::build(issues);
474    let Some(parent_identity) = index.identity_for_number(parent) else {
475        return Vec::new();
476    };
477    index
478        .children_by_parent
479        .get(&parent_identity)
480        .map(|children| {
481            children
482                .iter()
483                .map(|child| ChildRef {
484                    id: *child,
485                    number: index.number_for_identity(child).unwrap_or_default(),
486                    display: index.label_ref(child),
487                })
488                .collect()
489        })
490        .unwrap_or_default()
491}
492
493// ---------------------------------------------------------------------------
494// T002 — set / change / clear, with single-parent enforced by the API
495// ---------------------------------------------------------------------------
496
497/// The three hierarchy mutations, as the `## History` section records them.
498///
499/// Declared here rather than added to `model::HistoryEventKind` because WP01 may
500/// not edit `src/model.rs`; WP02 maps these onto that enum.
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502pub enum ParentEventKind {
503    /// An issue gained its first parent.
504    ParentSet,
505    /// An existing parent reference was repointed.
506    ParentChanged,
507    /// The reference was removed.
508    ParentCleared,
509}
510
511impl ParentEventKind {
512    /// The canonical snake_case spelling the History section will carry.
513    pub fn as_str(self) -> &'static str {
514        match self {
515            ParentEventKind::ParentSet => "parent_set",
516            ParentEventKind::ParentChanged => "parent_changed",
517            ParentEventKind::ParentCleared => "parent_cleared",
518        }
519    }
520}
521
522/// One hierarchy mutation, ready for storage to append to `## History`.
523#[derive(Debug, Clone, PartialEq, Eq)]
524pub struct ParentEvent {
525    /// Which of the three mutations this is.
526    pub kind: ParentEventKind,
527    /// The child that changed.
528    pub child: u32,
529    /// The parent after the change; `None` for a clear.
530    pub parent: Option<u32>,
531}
532
533/// The outcome of a hierarchy mutation: the new state plus the event to record.
534///
535/// The service produces the event and **does not write**; storage performs the
536/// write using it, so the atomicity rule lives in exactly one place.
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct ParentChange {
539    /// The child whose reference changed.
540    pub child: u32,
541    /// The child's parent after the change.
542    pub parent: Option<u32>,
543    /// The child's parent number before the mutation, for the human audit line.
544    pub previous: Option<u32>,
545    /// The canonical parent reference to persist; `None` for a clear.
546    pub parent_ref: Option<IssueRef>,
547    /// The event to append, or `None` when the mutation was a no-op.
548    ///
549    /// `None` for clearing a child that had no parent: that is not a change, and
550    /// recording it would put an entry in the audit trail for nothing.
551    pub event: Option<ParentEvent>,
552}
553
554/// Why a hierarchy mutation was refused.
555#[derive(Debug, Clone, PartialEq, Eq)]
556pub enum HierarchyError {
557    /// A validation rule rejected the proposal. Blocking: no write, no event.
558    ///
559    /// This is the **only** refusal the service produces. It used to carry a
560    /// second variant, `UnknownIssue { selector }`, for "the named issue is not in
561    /// the snapshot" — but the service takes issue **numbers**, not selectors, so a
562    /// selector-shaped variant was the wrong shape and nothing ever constructed it.
563    /// An unknown child is now a `Rejected` result carrying
564    /// [`HierarchyDiagnosticKind::UnknownChild`], which flows through the same
565    /// renderer as every other refusal (decision on tix #1373, option 1).
566    Rejected(HierarchyValidationResult),
567}
568
569impl std::fmt::Display for HierarchyError {
570    /// The user-facing message, **without** an `invalid input:` prefix.
571    ///
572    /// The prefix belongs to [`crate::error::TixError::InvalidInput`], which
573    /// [`From`] supplies. Emitting it here too produced the doubled
574    /// `invalid input: invalid input: ...` on the one path that reached this impl.
575    ///
576    /// A rejected proposal renders **every** diagnostic through the shared
577    /// [`render_diagnostics`], the same renderer the relationship service uses, so
578    /// a user who learned `tix link`'s refusal format does not have to learn a
579    /// second one. It deliberately does not report only the first: a rejection can
580    /// carry several findings, and showing one at a time makes a user fix and retry
581    /// repeatedly to discover the rest.
582    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583        match self {
584            HierarchyError::Rejected(result) if result.errors.is_empty() => {
585                f.write_str("hierarchy validation failed")
586            }
587            HierarchyError::Rejected(result) => f.write_str(&render_diagnostics(
588                "E-tix-hierarchy",
589                result
590                    .errors
591                    .iter()
592                    .map(|e| (e.kind.as_str(), e.message.as_str())),
593            )),
594        }
595    }
596}
597
598impl std::error::Error for HierarchyError {}
599
600/// A hierarchy refusal becomes user input error, so `?` works at the call site.
601///
602/// The conversion is total and lossless: the type's own `Display` already renders
603/// the full diagnostic set, and `InvalidInput` supplies the `invalid input:` prefix
604/// every other validation failure in the crate carries.
605impl From<HierarchyError> for TixError {
606    fn from(err: HierarchyError) -> TixError {
607        TixError::Refused(err.to_string())
608    }
609}
610
611/// Point `child` at `parent`, whatever it pointed at before.
612///
613/// Single-parent is enforced by the **shape**: `parent` is one `Option<u32>`, and
614/// no function here accepts a collection. A request for two parents is not
615/// expressible, not merely rejected (C-001).
616///
617/// Validates before producing anything, so a refused call yields no change and
618/// no event.
619///
620/// Setting the parent the child already has is a **no-op**, and reports one:
621/// `ParentChange::event` documents `None` "when the mutation was a no-op: that is
622/// not a change, and recording it would put an entry in the audit trail for
623/// nothing". This function was the one place that did not honour its own return
624/// type's contract — it emitted a `parent_changed` whose detail read
625/// `parent #1 -> #1` — while `change_parent` did.
626///
627/// **Validation runs first, and the no-op check second.** The order is the whole
628/// point. An early return for the no-op case was tried and rejected: it skipped
629/// `validate_proposed`, so a child that *already recorded* itself as its own
630/// parent — or a parent that no longer exists — was accepted as a no-op, while
631/// the identical request against a child that did not already record it was
632/// correctly refused. Contract §3 says a self-parent is "**always** rejected" and
633/// an unknown parent is "rejected at write time"; no prior state may buy an
634/// exception. A no-op is a statement about a *valid* request that happens to be
635/// redundant, so it can only be recognised after the request is known to be valid.
636pub fn set_parent(
637    index: &HierarchyIndex,
638    child: u32,
639    parent: u32,
640) -> Result<ParentChange, HierarchyError> {
641    set_parent_ref(
642        index,
643        index
644            .identity_for_number(child)
645            .unwrap_or(IssueRef::Number(child)),
646        index
647            .identity_for_number(parent)
648            .unwrap_or(IssueRef::Number(parent)),
649    )
650}
651
652/// Canonical-reference form of [`set_parent`].
653pub fn set_parent_ref(
654    index: &HierarchyIndex,
655    child: IssueRef,
656    parent: IssueRef,
657) -> Result<ParentChange, HierarchyError> {
658    let result = validate_proposed_ref(index, child, Some(parent));
659    if result.blocking {
660        return Err(HierarchyError::Rejected(result));
661    }
662    let child_number = index
663        .number_for_identity(&child)
664        .or_else(|| child.as_number())
665        .unwrap_or_default();
666    let parent_number = index
667        .number_for_identity(&parent)
668        .or_else(|| parent.as_number())
669        .unwrap_or_default();
670    let previous = index.parent_ref_for(&child).copied();
671    if previous == Some(parent) {
672        return Ok(ParentChange {
673            child: child_number,
674            parent: Some(parent_number),
675            previous: previous.and_then(|reference| index.number_for_identity(&reference)),
676            parent_ref: Some(parent),
677            event: None,
678        });
679    }
680    let kind = if previous.is_some() {
681        ParentEventKind::ParentChanged
682    } else {
683        ParentEventKind::ParentSet
684    };
685    Ok(ParentChange {
686        child: child_number,
687        parent: Some(parent_number),
688        previous: previous.and_then(|reference| index.number_for_identity(&reference)),
689        parent_ref: Some(parent),
690        event: Some(ParentEvent {
691            kind,
692            child: child_number,
693            parent: Some(parent_number),
694        }),
695    })
696}
697
698/// Repoint `child` at a new parent.
699///
700/// The same rule as [`set_parent`]; the distinct name is what callers read at the
701/// call site, and the History event is the same `parent_changed` kind. Re-applying
702/// the same parent is a no-op and yields **no** event, so a retried command does
703/// not pad the audit trail.
704///
705/// Delegates with no early return of its own. An early return here looked like it
706/// documented "repoint if different", but it also **skipped validation**: a child
707/// that already recorded itself as its own parent, or already recorded a parent
708/// that no longer exists, was accepted as a no-op — the one state where the
709/// contract's "always rejected" rules must not bend. [`set_parent`] validates
710/// first and no-ops second, and the two operations then cannot disagree.
711pub fn change_parent(
712    index: &HierarchyIndex,
713    child: u32,
714    parent: u32,
715) -> Result<ParentChange, HierarchyError> {
716    set_parent(index, child, parent)
717}
718
719/// Canonical-reference form of [`change_parent`].
720pub fn change_parent_ref(
721    index: &HierarchyIndex,
722    child: IssueRef,
723    parent: IssueRef,
724) -> Result<ParentChange, HierarchyError> {
725    set_parent_ref(index, child, parent)
726}
727
728/// Remove `child`'s parent, yielding the absent state rather than a dangling
729/// value (`data-model.md` §1).
730///
731/// Clearing a child that has no parent is a no-op that produces no event.
732///
733/// An unknown **child** is refused, not treated as the no-op: validation runs
734/// first (contract §3.5), so "valid and unchanged" stays distinct from "invalid"
735/// (decision on tix #1373, option 1).
736pub fn clear_parent(index: &HierarchyIndex, child: u32) -> Result<ParentChange, HierarchyError> {
737    clear_parent_ref(
738        index,
739        index
740            .identity_for_number(child)
741            .unwrap_or(IssueRef::Number(child)),
742    )
743}
744
745/// Canonical-reference form of [`clear_parent`].
746pub fn clear_parent_ref(
747    index: &HierarchyIndex,
748    child: IssueRef,
749) -> Result<ParentChange, HierarchyError> {
750    let result = validate_proposed_ref(index, child, None);
751    if result.blocking {
752        return Err(HierarchyError::Rejected(result));
753    }
754    if index.parent_ref_for(&child).is_none() {
755        let child_number = index.number_for_identity(&child).unwrap_or_default();
756        return Ok(ParentChange {
757            child: child_number,
758            parent: None,
759            previous: None,
760            parent_ref: None,
761            event: None,
762        });
763    }
764    let child_number = index.number_for_identity(&child).unwrap_or_default();
765    let previous = index
766        .parent_ref_for(&child)
767        .and_then(|reference| index.number_for_identity(reference));
768    Ok(ParentChange {
769        child: child_number,
770        parent: None,
771        previous,
772        parent_ref: None,
773        event: Some(ParentEvent {
774            kind: ParentEventKind::ParentCleared,
775            child: child_number,
776            parent: None,
777        }),
778    })
779}
780
781// ---------------------------------------------------------------------------
782// T003 — validation
783// ---------------------------------------------------------------------------
784
785/// The diagnostic kinds `contracts/hierarchy-service.md` §3 names, spelled exactly.
786#[derive(Debug, Clone, Copy, PartialEq, Eq)]
787pub enum HierarchyDiagnosticKind {
788    /// `child == parent`.
789    SelfParent,
790    /// The proposed chain would close a cycle.
791    Cycle,
792    /// The child is not in the snapshot at all, so the write was refused.
793    ///
794    /// **Sibling of [`Self::UnknownParent`]**: the service validates both ends of
795    /// the proposed edge. `Store` resolves selectors before calling in, so the CLI
796    /// cannot reach this today, but two direct consumers can — the parity harness
797    /// and any future caller that hands the service a snapshot and a number
798    /// (decision on tix #1373, option 1).
799    UnknownChild,
800    /// The parent selector resolved to no issue, so the write was refused.
801    UnknownParent,
802    /// A numeric parent alias matches more than one issue.
803    AmbiguousTarget,
804    /// Two issues claim the same canonical id used by the parent graph.
805    DuplicateIdentity,
806    /// An issue records a parent that is not in the snapshot (WP05 T022).
807    ///
808    /// **Distinct from [`Self::UnknownParent`]**, and the distinction is the
809    /// difference between a rejected write and retained data. `unknown_parent` means
810    /// the service refused to write anything, so there is nothing to repair;
811    /// `unresolved_parent` means a reference *was* written and its target has since
812    /// gone, so a user must re-point it. Contract §3 rule 4 draws the line ("a
813    /// reference that *later* becomes unresolvable is not rejected — it becomes the
814    /// `UnresolvedParentState` described in §5") and T022 step 1 fixes the name.
815    UnresolvedParent,
816}
817
818impl HierarchyDiagnosticKind {
819    /// The contract's spelling, used verbatim in lint and in MCP payloads.
820    pub fn as_str(self) -> &'static str {
821        match self {
822            HierarchyDiagnosticKind::SelfParent => "self_parent",
823            HierarchyDiagnosticKind::UnresolvedParent => "unresolved_parent",
824            HierarchyDiagnosticKind::Cycle => "cycle",
825            HierarchyDiagnosticKind::UnknownChild => "unknown_child",
826            HierarchyDiagnosticKind::UnknownParent => "unknown_parent",
827            HierarchyDiagnosticKind::AmbiguousTarget => "ambiguous_target",
828            HierarchyDiagnosticKind::DuplicateIdentity => "duplicate_identity",
829        }
830    }
831}
832
833fn alias_error_text(error: &crate::id::AliasError) -> String {
834    match error {
835        crate::id::AliasError::Missing(value) => format!("`{value}` resolves to no issue"),
836        crate::id::AliasError::Ambiguous { alias, candidates } => format!(
837            "`{alias}` is ambiguous: {}",
838            candidates
839                .iter()
840                .map(ToString::to_string)
841                .collect::<Vec<_>>()
842                .join(", ")
843        ),
844        crate::id::AliasError::DuplicateId { id, numbers } => {
845            format!("id `{id}` is claimed by issues {numbers:?}")
846        }
847    }
848}
849
850/// One field-aware hierarchy diagnostic.
851#[derive(Debug, Clone, PartialEq, Eq)]
852pub struct HierarchyDiagnostic {
853    /// Which rule was broken.
854    pub kind: HierarchyDiagnosticKind,
855    /// The issue the diagnostic is about.
856    pub issue: u32,
857    /// The frontmatter field at fault — always [`PARENT_KEY`] today, carried
858    /// explicitly so a future field does not need a new diagnostic type.
859    pub field: String,
860    /// Human-readable message.
861    pub message: String,
862    /// The exact source file, when the issue-number alias is ambiguous.
863    pub source_file: Option<String>,
864    /// The offending chain, for a cycle: the walk from the proposed parent up to
865    /// the issue that closes the loop, inclusive.
866    pub path: Option<Vec<u32>>,
867}
868
869impl HierarchyDiagnostic {
870    fn new(kind: HierarchyDiagnosticKind, issue: u32, message: String) -> Self {
871        Self {
872            kind,
873            issue,
874            field: PARENT_KEY.to_string(),
875            message,
876            source_file: None,
877            path: None,
878        }
879    }
880
881    fn with_source_file(mut self, filename: impl Into<String>) -> Self {
882        self.source_file = Some(filename.into());
883        self
884    }
885}
886
887/// The result of hierarchy validation (`data-model.md` §4).
888///
889/// Mirrors the relationship service's shape, so lint consumes both the same way.
890#[derive(Debug, Clone, PartialEq, Eq)]
891pub struct HierarchyValidationResult {
892    /// Every diagnostic found.
893    pub errors: Vec<HierarchyDiagnostic>,
894    /// True when the write must be rejected.
895    pub blocking: bool,
896}
897
898impl HierarchyValidationResult {
899    /// A clean result: no findings, not blocking.
900    pub fn clean() -> Self {
901        Self {
902            errors: Vec::new(),
903            blocking: false,
904        }
905    }
906
907    /// A result carrying one blocking diagnostic.
908    pub fn blocking(diagnostic: HierarchyDiagnostic) -> Self {
909        Self {
910            blocking: true,
911            errors: vec![diagnostic],
912        }
913    }
914
915    /// Whether anything at all was found, blocking or not.
916    pub fn is_empty(&self) -> bool {
917        self.errors.is_empty()
918    }
919
920    /// The diagnostic kinds present, in order.
921    pub fn kinds(&self) -> Vec<HierarchyDiagnosticKind> {
922        self.errors.iter().map(|e| e.kind).collect()
923    }
924}
925
926/// Validate a proposed parent change, before any write.
927///
928/// Checks, in order: the child exists, the parent exists, `child != parent`, and
929/// the proposed chain does not close a cycle. Returns a result rather than
930/// `Err` for rule violations so `tix lint` can call the same code.
931///
932/// **Both ends of the edge are validated.** The child check is the symmetric half
933/// of the `unknown_parent` check: a service that emits an event for a child that
934/// does not exist is the kind of thing a later direct caller relies on by
935/// accident. It also runs for a `clear` (`parent: None`), so an unknown child is
936/// refused rather than returning the "already absent" no-op (decision on tix
937/// #1373, option 1).
938pub fn validate_proposed(
939    index: &HierarchyIndex,
940    child: u32,
941    parent: Option<u32>,
942) -> HierarchyValidationResult {
943    validate_proposed_ref(
944        index,
945        index
946            .identity_for_number(child)
947            .unwrap_or(IssueRef::Number(child)),
948        parent.map(|parent| {
949            index
950                .identity_for_number(parent)
951                .unwrap_or(IssueRef::Number(parent))
952        }),
953    )
954}
955
956/// Canonical-reference form of [`validate_proposed`].
957pub fn validate_proposed_ref(
958    index: &HierarchyIndex,
959    child: IssueRef,
960    parent: Option<IssueRef>,
961) -> HierarchyValidationResult {
962    let child_number = index
963        .number_for_identity(&child)
964        .or_else(|| child.as_number())
965        .unwrap_or_default();
966    if !index.contains_ref(&child) {
967        return HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
968            HierarchyDiagnosticKind::UnknownChild,
969            child_number,
970            format!("{PARENT_KEY}: child {} does not exist", child.human()),
971        ));
972    }
973    let Some(parent) = parent else {
974        // A clear cannot violate a hierarchy rule: it removes an edge.
975        return HierarchyValidationResult::clean();
976    };
977    let parent_number = index
978        .number_for_identity(&parent)
979        .or_else(|| parent.as_number())
980        .unwrap_or_default();
981    if !index.contains_ref(&parent) {
982        return HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
983            HierarchyDiagnosticKind::UnknownParent,
984            child_number,
985            format!("{PARENT_KEY}: {} does not exist", parent.human()),
986        ));
987    }
988    if child == parent {
989        return HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
990            HierarchyDiagnosticKind::SelfParent,
991            child_number,
992            format!("{PARENT_KEY}: an issue cannot be its own parent"),
993        ));
994    }
995    match cycle_path_ref(index, child, parent) {
996        Some(path) => {
997            let mut diagnostic = HierarchyDiagnostic::new(
998                HierarchyDiagnosticKind::Cycle,
999                child_number,
1000                format!(
1001                    "{PARENT_KEY}: setting parent {} closes a cycle ({})",
1002                    parent_number,
1003                    render_path(&path)
1004                ),
1005            );
1006            diagnostic.path = Some(path);
1007            HierarchyValidationResult::blocking(diagnostic)
1008        }
1009        None => HierarchyValidationResult::clean(),
1010    }
1011}
1012
1013/// The `## History` detail line for a parent change: `parent #1 -> #2`.
1014///
1015/// **One renderer, in the service, for every caller.** Three call sites need this
1016/// string — the storage write path, the `--parent` edit flag, and MCP's `update_issue`
1017/// when WP07 lands — and a spelling rule that lives at the call site is a spelling
1018/// rule that will drift. It lives here, beside the event kinds it describes, and
1019/// takes plain numbers so it depends on no model type.
1020pub fn change_detail<B, A>(before: Option<B>, after: Option<A>) -> String
1021where
1022    B: Into<IssueRef>,
1023    A: Into<IssueRef>,
1024{
1025    let render = |reference: Option<IssueRef>| match reference {
1026        Some(reference) => reference.human(),
1027        None => "none".to_string(),
1028    };
1029    format!(
1030        "parent {} -> {}",
1031        render(before.map(Into::into)),
1032        render(after.map(Into::into))
1033    )
1034}
1035
1036/// Validate a proposed parent for an issue that **does not exist yet**.
1037///
1038/// The create path's entry point, and the one place the two obvious rules cannot
1039/// apply: a not-yet-created child has no number, so it cannot be its own parent and
1040/// cannot close a cycle. `unknown_parent` is therefore the only rule that can bite,
1041/// and asking `validate_proposed` with a placeholder child number would encode that
1042/// reasoning as a lie in a diagnostic.
1043///
1044/// The diagnostic's `issue` is [`NO_ISSUE_YET`] for this path, because there is no
1045/// issue to name. A caller with a real child must use [`set_parent`] or
1046/// [`change_parent`], which report the real number.
1047pub fn validate_new_parent(index: &HierarchyIndex, parent: u32) -> Result<(), HierarchyError> {
1048    if index.contains(parent) {
1049        return Ok(());
1050    }
1051    Err(HierarchyError::Rejected(
1052        HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
1053            HierarchyDiagnosticKind::UnknownParent,
1054            NO_ISSUE_YET,
1055            format!("{PARENT_KEY}: #{parent} does not exist"),
1056        )),
1057    ))
1058}
1059
1060/// The issue number a diagnostic carries when it is about an issue that does not
1061/// exist yet — the create path's "no child" marker.
1062///
1063/// `u32::MAX`, and **provably** not a real issue number: `Store::resolve_next_number`
1064/// allocates with `max.checked_add(1)` and returns `Err("issue-number overflow")`
1065/// when that would wrap, so `u32::MAX` is never handed out.
1066///
1067/// `0` was the first choice and it is **wrong** — `resolve_next_number` returns
1068/// `Ok(0)` for an empty directory, so the first issue in every fresh repository is
1069/// `#0` (found by running the binary, not by reading the code). A `0` sentinel would
1070/// have named a real issue in exactly the repositories most likely to use the create
1071/// path. Pre-existing number allocation, filed separately as tix #1376.
1072pub const NO_ISSUE_YET: u32 = u32::MAX;
1073
1074/// The chain that closes a cycle if `child` were pointed at `parent`, or `None`.
1075///
1076/// **Iterative**, from `parent` upward through the existing parents. Because each
1077/// issue has at most one parent the hierarchy is a *functional* graph, so this is
1078/// the only walk needed — there is no branching to explore. Recursion would
1079/// overflow the stack on a 10,000-deep chain (NFR-001), so the loop carries its
1080/// own `visited` set and terminates on the first repeat or the root.
1081fn cycle_path_ref(index: &HierarchyIndex, child: IssueRef, parent: IssueRef) -> Option<Vec<u32>> {
1082    let mut path = vec![index.number_for_identity(&child).unwrap_or_default()];
1083    let mut visited: BTreeSet<IssueRef> = BTreeSet::new();
1084    visited.insert(child);
1085    let mut current = parent;
1086    loop {
1087        if !visited.insert(current) {
1088            // `current` already appears in the walk, so the proposed edge closes
1089            // the loop. The path is reported from the first occurrence so the
1090            // message shows the cycle itself, not the tail that led to it.
1091            let current_number = index.number_for_identity(&current).unwrap_or_default();
1092            let start = path
1093                .iter()
1094                .position(|number| *number == current_number)
1095                .unwrap_or(0);
1096            return Some(path[start..].to_vec());
1097        }
1098        path.push(index.number_for_identity(&current).unwrap_or_default());
1099        match index.parent_ref_for(&current).copied() {
1100            // A dangling reference ends the walk. It cannot close a cycle, and
1101            // it is reported by `unresolved` instead.
1102            Some(next) if index.contains_ref(&next) => current = next,
1103            _ => return None,
1104        }
1105    }
1106}
1107
1108/// `1 -> 2 -> 3`, for a cycle message.
1109fn render_path(path: &[u32]) -> String {
1110    path.iter()
1111        .map(|n| n.to_string())
1112        .collect::<Vec<_>>()
1113        .join(" -> ")
1114}
1115
1116/// Validate a whole in-memory snapshot.
1117///
1118/// **Never** returns `Err` — lint calls it directly and must keep scanning, so a
1119/// single bad issue cannot abort the sweep (the relationship service's FR-018
1120/// rule, reused per C-002).
1121///
1122/// Reports every self-parent, every dangling reference, and every cycle.
1123pub fn validate_hierarchy<N: HierarchyNode>(issues: &[N]) -> HierarchyValidationResult {
1124    let index = HierarchyIndex::build(issues);
1125    let mut errors: Vec<HierarchyDiagnostic> = index.alias_diagnostics().to_vec();
1126
1127    let mut dangling: Vec<HierarchyDiagnostic> = index
1128        .unresolved()
1129        .into_iter()
1130        .map(|state| {
1131            // `UnresolvedParent`, not `UnknownParent`: the reference exists and is
1132            // retained, so there is something for a user to repair. The two are one
1133            // situation only if you forget that one of them already wrote a file.
1134            HierarchyDiagnostic::new(
1135                HierarchyDiagnosticKind::UnresolvedParent,
1136                state.child,
1137                state.reason,
1138            )
1139            .with_source_file(state.source_file)
1140        })
1141        .collect();
1142    errors.append(&mut dangling);
1143
1144    let mut self_parents: Vec<HierarchyDiagnostic> = issues
1145        .iter()
1146        .filter(|issue| {
1147            let reference = hierarchy_ref(*issue);
1148            index
1149                .parent_ref_for(&reference)
1150                .is_some_and(|parent| *parent == reference)
1151        })
1152        .map(|issue| {
1153            HierarchyDiagnostic::new(
1154                HierarchyDiagnosticKind::SelfParent,
1155                issue.number(),
1156                format!("{PARENT_KEY}: an issue cannot be its own parent"),
1157            )
1158            .with_source_file(issue.filename().unwrap_or(""))
1159        })
1160        .collect();
1161    errors.append(&mut self_parents);
1162
1163    // One cycle per connected component, reported on the issue whose parent edge
1164    // closes it. Deterministic because the walk order is issue-number order.
1165    let mut seen: BTreeSet<IssueRef> = BTreeSet::new();
1166    let mut sorted: Vec<IssueRef> = issues.iter().map(hierarchy_ref).collect();
1167    sorted.sort_by_key(|reference| index.number_for_identity(reference).unwrap_or_default());
1168    for child in sorted {
1169        let Some(parent) = index.parent_ref_for(&child).copied() else {
1170            continue;
1171        };
1172        if !index.contains_ref(&parent) || seen.contains(&child) {
1173            continue;
1174        }
1175        if let Some(path) = cycle_path_ref(&index, child, parent) {
1176            for node in &path {
1177                if let Some(reference) = index
1178                    .refs_by_number
1179                    .get(node)
1180                    .and_then(|refs| (refs.len() == 1).then(|| refs[0]))
1181                {
1182                    seen.insert(reference);
1183                }
1184            }
1185            let child_number = index.number_for_identity(&child).unwrap_or_default();
1186            let mut diagnostic = HierarchyDiagnostic::new(
1187                HierarchyDiagnosticKind::Cycle,
1188                child_number,
1189                format!("{PARENT_KEY}: cycle ({})", render_path(&path)),
1190            );
1191            if let Some(filename) = index.filenames.get(&child) {
1192                diagnostic.source_file = Some(filename.clone());
1193            }
1194            diagnostic.path = Some(path);
1195            errors.push(diagnostic);
1196        }
1197    }
1198
1199    HierarchyValidationResult {
1200        blocking: !errors.is_empty(),
1201        errors,
1202    }
1203}
1204
1205// ---------------------------------------------------------------------------
1206// T004 — the typed rollup
1207// ---------------------------------------------------------------------------
1208
1209/// Which population the rollup's figures describe (`data-model.md` §3).
1210///
1211/// Carried on the value so a consumer can never mistake one figure for the other,
1212/// and so widening the basis later is a visible change.
1213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1214pub enum CompletionBasis {
1215    /// Only the issue's direct children.
1216    DirectChildren,
1217}
1218
1219impl CompletionBasis {
1220    /// The wording a surface should print next to the figure.
1221    pub fn as_str(self) -> &'static str {
1222        match self {
1223            CompletionBasis::DirectChildren => "direct children",
1224        }
1225    }
1226}
1227
1228/// The derived progress figures for a parent (`data-model.md` §3, FR-003).
1229///
1230/// A typed value, never a rendered string, so CLI, TUI and MCP can be compared
1231/// for equality (NFR-002).
1232#[derive(Debug, Clone, PartialEq, Eq)]
1233pub struct ParentRollup {
1234    /// The issue the rollup is about.
1235    pub parent: u32,
1236    /// Direct children.
1237    pub direct_children: usize,
1238    /// Direct children in the caller's completion column.
1239    pub completed_children: usize,
1240    /// The typed progress value — [`planning::Progress`], reused rather than
1241    /// reinvented (research R3).
1242    pub progress: Progress,
1243    /// Which population the figures describe.
1244    pub completion_basis: CompletionBasis,
1245}
1246
1247/// Compute the rollup for `issue`.
1248///
1249/// `done_column` is the board's **configured** completion column, resolved by the
1250/// caller from config. It is a parameter and never a literal `"done"` here, so a
1251/// board that calls its completion column something else still reports correctly.
1252///
1253/// Reuses the index's child list rather than re-scanning the issue set, so a
1254/// board render stays linear in the number of children.
1255pub fn rollup(index: &HierarchyIndex, issue: u32, done_column: &str) -> ParentRollup {
1256    let membership = index.children_of(issue);
1257    let completed_children = membership
1258        .children
1259        .iter()
1260        .filter(|child| index.status_of(child.number) == Some(done_column))
1261        .count();
1262    ParentRollup {
1263        parent: issue,
1264        direct_children: membership.child_count,
1265        completed_children,
1266        progress: Progress {
1267            done: completed_children,
1268            total: membership.child_count,
1269        },
1270        completion_basis: CompletionBasis::DirectChildren,
1271    }
1272}
1273
1274/// Reuse the relationship graph's validation idiom rather than a second one.
1275///
1276/// Not used by this module's own validation — the hierarchy's functional-graph
1277/// walk is genuinely different from a relationship cycle check, and reusing a
1278/// general graph walk here would mean exploring branches that cannot exist. This
1279/// exists so the *naming* has one home and a future reader can see that the
1280/// similarity was considered and rejected for a reason.
1281#[allow(dead_code)]
1282fn relationship_graph_of<'a, N: HierarchyNode + 'a>(issues: &'a [N]) -> RelationshipGraph<'a> {
1283    // A hierarchy-only snapshot has no edges, so the graph is built empty. The
1284    // call exists to document the decision, not to validate anything.
1285    let _ = issues;
1286    RelationshipGraph::new(&[], "done")
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291    #[allow(clippy::wildcard_imports)]
1292    use super::*;
1293
1294    /// An in-memory issue. Nothing here touches the filesystem, which is the point.
1295    #[derive(Debug, Clone, PartialEq, Eq)]
1296    struct Node {
1297        number: u32,
1298        title: String,
1299        status: String,
1300        parent: Option<u32>,
1301    }
1302
1303    impl Node {
1304        fn new(number: u32, title: &str) -> Self {
1305            Self {
1306                number,
1307                title: title.to_string(),
1308                status: "todo".to_string(),
1309                parent: None,
1310            }
1311        }
1312        fn with_status(mut self, status: &str) -> Self {
1313            self.status = status.to_string();
1314            self
1315        }
1316    }
1317
1318    impl HierarchyNode for Node {
1319        fn number(&self) -> u32 {
1320            self.number
1321        }
1322        fn title(&self) -> &str {
1323            &self.title
1324        }
1325        fn status(&self) -> &str {
1326            &self.status
1327        }
1328        fn parent_number(&self) -> Option<u32> {
1329            self.parent
1330        }
1331    }
1332
1333    /// A node with an explicit status and parent, for the rollup fixtures.
1334    fn node(number: u32, title: &str, status: &str, parent: Option<u32>) -> Node {
1335        let mut n = Node::new(number, title).with_status(status);
1336        n.parent = parent;
1337        n
1338    }
1339
1340    fn nodes(spec: &[(u32, &str, Option<u32>)]) -> Vec<Node> {
1341        spec.iter()
1342            .map(|(n, t, p)| {
1343                let mut node = Node::new(*n, t);
1344                node.parent = *p;
1345                node
1346            })
1347            .collect()
1348    }
1349
1350    // -- T001 ------------------------------------------------------------
1351
1352    #[test]
1353    fn children_are_ordered_by_issue_number_regardless_of_input_order() {
1354        let shuffled = nodes(&[
1355            (9, "I", Some(1)),
1356            (3, "C", Some(1)),
1357            (7, "G", Some(1)),
1358            (5, "E", Some(1)),
1359        ]);
1360        let index = HierarchyIndex::build(&shuffled);
1361        let membership = index.children_of(1);
1362        assert_eq!(
1363            membership
1364                .children
1365                .iter()
1366                .map(|c| c.number)
1367                .collect::<Vec<_>>(),
1368            vec![3, 5, 7, 9],
1369            "children must be ascending by number even when the input is not"
1370        );
1371        assert_eq!(membership.child_count, 4);
1372        assert_eq!(membership.children[0].display, "#3 C");
1373    }
1374
1375    #[test]
1376    fn the_index_is_stable_across_repeated_builds_from_the_same_input() {
1377        let issues = nodes(&[(1, "A", None), (2, "B", Some(1)), (3, "C", Some(1))]);
1378        let first = HierarchyIndex::build(&issues);
1379        let second = HierarchyIndex::build(&issues);
1380        assert_eq!(first, second, "two builds of one snapshot must be equal");
1381        // And the derived reads, not just the struct, are byte-identical.
1382        assert_eq!(first.children_of(1), second.children_of(1));
1383        assert_eq!(rollup(&first, 1, "done"), rollup(&second, 1, "done"));
1384    }
1385
1386    #[test]
1387    fn a_child_appears_under_exactly_one_parent() {
1388        let issues = nodes(&[(1, "A", None), (2, "B", Some(1)), (3, "C", Some(2))]);
1389        let index = HierarchyIndex::build(&issues);
1390        let mut appearances = 0;
1391        for parent in 1..=3 {
1392            appearances += index.children_of(parent).child_count;
1393        }
1394        assert_eq!(
1395            appearances, 2,
1396            "each of the two parented issues must be counted exactly once"
1397        );
1398        assert_eq!(index.parent_of(2), Some(1));
1399        assert_eq!(index.parent_of(1), None);
1400    }
1401
1402    #[test]
1403    fn no_child_list_is_stored_in_the_index() {
1404        // C-003's mechanical guard: the index holds maps of canonical references. There is
1405        // nowhere for a child list to live, so the "derived, never stored"
1406        // invariant cannot be violated by a later change that adds one.
1407        let issues = nodes(&[(1, "A", None), (2, "B", Some(1))]);
1408        let index = HierarchyIndex::build(&issues);
1409        // `HierarchyIndex` has no field that is a `Vec<ChildRef>`; if one were
1410        // added, this file would fail to compile against the struct literal below.
1411        let rebuilt = HierarchyIndex {
1412            parent_by_child: index.parent_by_child.clone(),
1413            children_by_parent: index.children_by_parent.clone(),
1414            numbers: index.numbers.clone(),
1415            refs_by_number: index.refs_by_number.clone(),
1416            labels: index.labels.clone(),
1417            statuses: index.statuses.clone(),
1418            filenames: index.filenames.clone(),
1419            aliases: index.aliases.clone(),
1420            alias_diagnostics: index.alias_diagnostics.clone(),
1421        };
1422        assert_eq!(rebuilt, index, "the index is exactly these fields");
1423        // And `children_of` is the only way to get children — the private map is
1424        // number-only, so a display label can only come from derivation.
1425        assert!(
1426            index
1427                .children_by_parent
1428                .values()
1429                .all(|v| v.iter().all(|n| index.labels.contains_key(n)))
1430        );
1431    }
1432
1433    #[test]
1434    fn an_issue_with_no_children_returns_an_empty_membership_not_an_error() {
1435        let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
1436        let membership = index.children_of(1);
1437        assert_eq!(membership.child_count, 0);
1438        assert_eq!(membership.children, [] as [crate::hierarchy::ChildRef; 0]);
1439        assert_eq!(membership.parent, 1);
1440    }
1441
1442    // -- T002 ------------------------------------------------------------
1443
1444    #[test]
1445    fn set_then_change_then_clear_produces_three_distinct_states() {
1446        let issues = nodes(&[
1447            (1, "P", None),
1448            (2, "A", None),
1449            (3, "B", None),
1450            (4, "C", None),
1451        ]);
1452        let index = HierarchyIndex::build(&issues);
1453
1454        let set = set_parent(&index, 2, 1).unwrap();
1455        assert_eq!(set.parent, Some(1));
1456        assert_eq!(
1457            set.event.as_ref().map(|e| e.kind),
1458            Some(ParentEventKind::ParentSet)
1459        );
1460
1461        // Change needs an index that already reflects the set.
1462        let mut after_set = issues.clone();
1463        after_set[1].parent = Some(1);
1464        let index2 = HierarchyIndex::build(&after_set);
1465        let changed = change_parent(&index2, 2, 3).unwrap();
1466        assert_eq!(changed.parent, Some(3));
1467        assert_eq!(
1468            changed.event.as_ref().map(|e| e.kind),
1469            Some(ParentEventKind::ParentChanged)
1470        );
1471
1472        let mut after_change = after_set.clone();
1473        after_change[1].parent = Some(3);
1474        let index3 = HierarchyIndex::build(&after_change);
1475        let cleared = clear_parent(&index3, 2).unwrap();
1476        assert_eq!(cleared.parent, None);
1477        assert_eq!(
1478            cleared.event.as_ref().map(|e| e.kind),
1479            Some(ParentEventKind::ParentCleared)
1480        );
1481
1482        // Three distinct resulting states, so the sequence is observable.
1483        let states = [set.parent, changed.parent, cleared.parent];
1484        assert_eq!(states, [Some(1), Some(3), None]);
1485    }
1486
1487    #[test]
1488    fn clearing_a_child_with_no_parent_is_a_no_op_with_no_event() {
1489        let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
1490        let cleared = clear_parent(&index, 1).unwrap();
1491        assert_eq!(cleared.parent, None);
1492        assert!(
1493            cleared.event.is_none(),
1494            "a no-op must not pad the audit trail"
1495        );
1496    }
1497
1498    #[test]
1499    fn setting_the_parent_the_child_already_has_is_a_no_op_with_no_event() {
1500        // The `set_parent` half of the pair. `change_parent` already had this
1501        // behaviour; `set_parent` emitted a `parent_changed` naming nothing,
1502        // contradicting `ParentChange::event`'s own documentation.
1503        let issues = nodes(&[(1, "P", None), (2, "C", Some(1))]);
1504        let index = HierarchyIndex::build(&issues);
1505        let again = set_parent(&index, 2, 1).unwrap();
1506        assert_eq!(
1507            again.parent,
1508            Some(1),
1509            "the resulting state is still reported"
1510        );
1511        assert!(
1512            again.event.is_none(),
1513            "an accepted mutation that changes nothing must not pad the audit trail"
1514        );
1515    }
1516
1517    #[test]
1518    fn a_no_op_check_must_not_launder_a_self_parent() {
1519        // Regression. The no-op guard used to run *before* validation, so a child
1520        // that already recorded itself as its own parent was accepted as a no-op
1521        // while the same request against any other child was correctly refused.
1522        // Contract §3: a self-parent is rejected *always*.
1523        let index = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", Some(2))]));
1524        for (label, outcome) in [
1525            ("set_parent", set_parent(&index, 2, 2)),
1526            ("change_parent", change_parent(&index, 2, 2)),
1527        ] {
1528            let err = outcome.expect_err(&format!(
1529                "{label}(child=2, parent=2) must be refused even when 2 already records it"
1530            ));
1531            assert!(
1532                matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::SelfParent]),
1533                "{label} refused for the wrong reason: {err:?}"
1534            );
1535        }
1536        // The control, so the test cannot pass by the rule being absent entirely.
1537        let clean = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", None)]));
1538        assert!(
1539            set_parent(&clean, 2, 2).is_err(),
1540            "the rule must still fire"
1541        );
1542    }
1543
1544    #[test]
1545    fn a_no_op_check_must_not_launder_an_unknown_parent() {
1546        // Same regression, second rule. A child already pointing at a parent that
1547        // no longer exists must still be *refused* when asked to re-point there,
1548        // not accepted as a redundant request. The stored reference is kept for
1549        // repair (UnresolvedParentState); keeping it is not the same as accepting
1550        // a new write of the same dangling value.
1551        let index = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", Some(999))]));
1552        for (label, outcome) in [
1553            ("set_parent", set_parent(&index, 2, 999)),
1554            ("change_parent", change_parent(&index, 2, 999)),
1555        ] {
1556            let err = outcome.expect_err(&format!(
1557                "{label}(child=2, parent=999) must be refused: #999 is not in the snapshot"
1558            ));
1559            assert!(
1560                matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::UnknownParent]),
1561                "{label} refused for the wrong reason: {err:?}"
1562            );
1563        }
1564        // A self-parent against a *clean* child is still refused, and a
1565        // legitimately redundant request is still a no-op.
1566        let clean = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", None)]));
1567        assert!(set_parent(&clean, 2, 2).is_err());
1568        let ok = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", Some(1))]));
1569        assert!(
1570            set_parent(&ok, 2, 1).unwrap().event.is_none(),
1571            "no-op still a no-op"
1572        );
1573    }
1574
1575    #[test]
1576    fn re_applying_the_same_parent_is_a_no_op_with_no_event() {
1577        let issues = nodes(&[(1, "P", None), (2, "C", Some(1))]);
1578        let index = HierarchyIndex::build(&issues);
1579        let again = change_parent(&index, 2, 1).unwrap();
1580        assert_eq!(again.parent, Some(1));
1581        assert!(
1582            again.event.is_none(),
1583            "a retried command must not append a second event"
1584        );
1585    }
1586
1587    #[test]
1588    fn a_second_parent_cannot_be_expressed() {
1589        // C-001 is enforced by the *shape*: `parent` is one `Option<u32>`, so a
1590        // request for two parents does not compile. The runtime half of the rule
1591        // is that a change replaces rather than adds.
1592        let issues = nodes(&[(1, "A", None), (2, "B", Some(1)), (3, "C", Some(1))]);
1593        assert_eq!(
1594            HierarchyIndex::build(&issues).children_of(1).child_count,
1595            2,
1596            "precondition: two children start under #1"
1597        );
1598        let mut after = issues.clone();
1599        after[1].parent = Some(3);
1600        let index2 = HierarchyIndex::build(&after);
1601        assert_eq!(
1602            index2.children_of(1).child_count,
1603            1,
1604            "repointing must move the child, never duplicate it"
1605        );
1606        assert_eq!(index2.parent_of(2), Some(3));
1607        assert_eq!(index2.children_of(3).child_count, 1);
1608    }
1609
1610    // -- T003 ------------------------------------------------------------
1611
1612    #[test]
1613    fn a_self_parent_is_rejected_with_its_own_diagnostic_kind() {
1614        let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
1615        let result = validate_proposed(&index, 1, Some(1));
1616        assert!(result.blocking);
1617        assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::SelfParent]);
1618        assert_eq!(result.errors[0].field, PARENT_KEY);
1619    }
1620
1621    #[test]
1622    fn an_unknown_parent_is_rejected_with_its_own_diagnostic_kind() {
1623        let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
1624        let result = validate_proposed(&index, 1, Some(99));
1625        assert!(result.blocking);
1626        assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::UnknownParent]);
1627        assert!(
1628            result.errors[0].message.contains("#99"),
1629            "the message must name the missing parent: {}",
1630            result.errors[0].message
1631        );
1632    }
1633
1634    #[test]
1635    fn a_three_node_cycle_is_rejected_and_the_path_is_reported() {
1636        let index = HierarchyIndex::build(&nodes(&[
1637            (1, "A", Some(2)),
1638            (2, "B", Some(3)),
1639            (3, "C", None),
1640        ]));
1641        let result = validate_proposed(&index, 3, Some(1));
1642        assert!(result.blocking);
1643        assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::Cycle]);
1644        let path = result.errors[0]
1645            .path
1646            .as_ref()
1647            .expect("a cycle must report its path");
1648        // The path is the loop in walk order: 3 -> 1 -> 2, and the *proposed* edge
1649        // 3->1 is what closes it, because 2's stored parent is 3. So the invariant
1650        // is that the last element's stored parent is the first — not that the
1651        // first and last are equal, which is what the first version asserted and
1652        // which was simply wrong about what a cycle path is.
1653        assert_eq!(
1654            index.parent_of(*path.last().expect("a cycle path is never empty")),
1655            path.first().copied(),
1656            "the reported path must be a closed loop"
1657        );
1658        assert_eq!(path.len(), 3, "the whole 3-node cycle must be shown");
1659        assert!(
1660            result.errors[0].message.contains("->"),
1661            "the message must render the chain: {}",
1662            result.errors[0].message
1663        );
1664    }
1665
1666    #[test]
1667    fn a_deep_chain_is_walked_without_stack_overflow() {
1668        // NFR-001's guard. A recursive DFS passes every small case and dies here.
1669        const DEPTH: u32 = 10_000;
1670        let mut issues: Vec<Node> = (1..=DEPTH + 1)
1671            .map(|n| {
1672                let mut node = Node::new(n, "n");
1673                // Node `DEPTH + 1` is a second root, so the child lives in the
1674                // snapshot but outside the chain. It has to be *in* the index at
1675                // all: the service refuses a child it cannot see (#1373), so the
1676                // walk is what is under test here, not the membership check.
1677                node.parent = if n == 1 || n == DEPTH + 1 {
1678                    None
1679                } else {
1680                    Some(n - 1)
1681                };
1682                node
1683            })
1684            .collect();
1685        let index = HierarchyIndex::build(&issues);
1686
1687        // A walk that runs all the way to the root and finds no cycle. The child
1688        // sits outside the chain, so this is the long *non-cyclic* path — the case
1689        // a recursive DFS blows the stack on.
1690        //
1691        // (The first version proposed `1 -> 2` and asserted it was *not* a cycle.
1692        // It is one: 2's stored parent is already 1. The assertion was wrong, not
1693        // the walk.)
1694        let outside_child = DEPTH + 1;
1695        let result = validate_proposed(&index, outside_child, Some(DEPTH / 2));
1696        assert!(
1697            !result.blocking,
1698            "pointing a new child into the middle of a 10,000-deep chain is not a \
1699             cycle: {:?}",
1700            result.errors
1701        );
1702
1703        // And closing the loop at the far end *is* detected, iteratively.
1704        let root = DEPTH;
1705        issues[root as usize - 1].parent = Some(root);
1706        let index2 = HierarchyIndex::build(&issues);
1707        let result = validate_proposed(&index2, 1, Some(root));
1708        assert!(
1709            result.blocking,
1710            "a 10,000-deep chain closed into a cycle must be caught"
1711        );
1712        assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::Cycle]);
1713    }
1714
1715    #[test]
1716    fn a_clean_hierarchy_validates_with_no_findings() {
1717        let issues = nodes(&[
1718            (1, "Root", None),
1719            (2, "A", Some(1)),
1720            (3, "B", Some(1)),
1721            (4, "C", Some(2)),
1722        ]);
1723        let result = validate_hierarchy(&issues);
1724        assert!(result.is_empty(), "unexpected: {:?}", result.errors);
1725        assert!(!result.blocking);
1726    }
1727
1728    #[test]
1729    fn validate_hierarchy_reports_each_kind_over_a_snapshot() {
1730        let issues = nodes(&[
1731            (1, "Self", Some(1)),
1732            (2, "Dangling", Some(99)),
1733            (3, "A", Some(4)),
1734            (4, "B", Some(3)),
1735        ]);
1736        let result = validate_hierarchy(&issues);
1737        assert!(result.blocking);
1738        let kinds = result.kinds();
1739        for expected in [
1740            HierarchyDiagnosticKind::SelfParent,
1741            // `UnresolvedParent`, not `UnknownParent`. WP01 folded the two together
1742            // and this test asserted the folded spelling; WP05 T022 step 1 fixes the
1743            // name, and the distinction is the difference between a write the
1744            // service **refused** and a reference that was written and whose target
1745            // has since gone. The diagnostic's `issue` is the **child**, which is
1746            // what lint attributes to a file — so the contract's "names the child and
1747            // the recorded parent" is satisfied by the pair `(issue, message)`.
1748            HierarchyDiagnosticKind::UnresolvedParent,
1749            HierarchyDiagnosticKind::Cycle,
1750        ] {
1751            assert!(
1752                kinds.contains(&expected),
1753                "expected {expected:?} in {kinds:?}"
1754            );
1755        }
1756        // The unknown-parent kind still exists and still means a refused write.
1757        assert_eq!(
1758            HierarchyDiagnosticKind::UnknownParent.as_str(),
1759            "unknown_parent"
1760        );
1761        assert_eq!(
1762            HierarchyDiagnosticKind::UnresolvedParent.as_str(),
1763            "unresolved_parent",
1764            "T022 step 1 fixes this spelling; a different one is a contract violation"
1765        );
1766    }
1767
1768    #[test]
1769    fn a_rejected_proposal_yields_no_change_and_no_event() {
1770        let index = HierarchyIndex::build(&nodes(&[(1, "A", None), (2, "B", Some(1))]));
1771        let err = set_parent(&index, 1, 1).unwrap_err();
1772        let HierarchyError::Rejected(result) = err;
1773        assert!(result.blocking);
1774    }
1775
1776    #[test]
1777    fn an_unknown_child_is_refused_by_set_change_and_clear() {
1778        // Decision on tix #1373, option 1. The child half of the edge is validated
1779        // symmetrically with the parent half: a service that emits an event for a
1780        // child it cannot see is the kind of thing a later direct caller relies on
1781        // by accident.
1782        let index = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", None)]));
1783        let missing = 99;
1784
1785        // `validate_proposed` carries the finding, so a lint or direct caller sees
1786        // it too, not only the three mutation entry points.
1787        let result = validate_proposed(&index, missing, Some(1));
1788        assert!(result.blocking);
1789        assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::UnknownChild]);
1790        assert_eq!(result.errors[0].issue, missing);
1791        assert!(
1792            result.errors[0].message.contains("#99"),
1793            "the message must name the missing child: {}",
1794            result.errors[0].message
1795        );
1796
1797        for (label, outcome) in [
1798            ("set_parent", set_parent(&index, missing, 1)),
1799            ("change_parent", change_parent(&index, missing, 1)),
1800            ("clear_parent", clear_parent(&index, missing)),
1801        ] {
1802            let err = outcome.expect_err(&format!("{label}(child={missing}) must be refused"));
1803            assert!(
1804                matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::UnknownChild]),
1805                "{label} refused for the wrong reason: {err:?}"
1806            );
1807        }
1808
1809        // The control, so the test cannot pass by the rule firing everywhere: a
1810        // known child is still accepted (set) and still a no-op (clear).
1811        assert!(set_parent(&index, 2, 1).is_ok());
1812        assert!(clear_parent(&index, 2).unwrap().event.is_none());
1813    }
1814
1815    #[test]
1816    fn the_child_check_runs_before_the_parent_check() {
1817        // Both ends are missing. The child is named, because it is the end the
1818        // caller passed first and the one it can act on by re-resolving the child.
1819        let index = HierarchyIndex::build(&nodes(&[(1, "P", None)]));
1820        let result = validate_proposed(&index, 77, Some(88));
1821        assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::UnknownChild]);
1822    }
1823
1824    #[test]
1825    fn an_unknown_child_is_not_laundered_into_a_clear_no_op() {
1826        // Contract §3.5: the no-op path is reachable only after validation. An
1827        // unknown child that trivially "has no parent" must not return the absent
1828        // state as if the request had been accepted.
1829        let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
1830        assert!(clear_parent(&index, 42).is_err());
1831        assert!(
1832            clear_parent(&index, 1).is_ok(),
1833            "a known child with no parent is still a no-op"
1834        );
1835    }
1836
1837    // -- T004 ------------------------------------------------------------
1838
1839    #[test]
1840    fn a_parent_with_three_children_reports_counts_and_typed_progress() {
1841        let issues = nodes(&[
1842            (1, "Parent", None),
1843            (2, "A", Some(1)),
1844            (3, "B", Some(1)),
1845            (4, "C", Some(1)),
1846        ]);
1847        let mut issues = issues;
1848        issues[1].status = "done".to_string();
1849        let index = HierarchyIndex::build(&issues);
1850        let rollup = rollup(&index, 1, "done");
1851        assert_eq!(rollup.direct_children, 3);
1852        assert_eq!(rollup.completed_children, 1);
1853        assert_eq!(
1854            rollup.progress,
1855            Progress { done: 1, total: 3 },
1856            "progress must be the typed value, not a formatted string"
1857        );
1858        assert_eq!(rollup.completion_basis, CompletionBasis::DirectChildren);
1859    }
1860
1861    #[test]
1862    fn an_issue_with_no_children_rolls_up_to_zero() {
1863        let index = HierarchyIndex::build(&nodes(&[(1, "Lonely", None)]));
1864        let rollup = rollup(&index, 1, "done");
1865        assert_eq!(rollup.direct_children, 0);
1866        assert_eq!(rollup.completed_children, 0);
1867        assert_eq!(rollup.progress, Progress { done: 0, total: 0 });
1868    }
1869
1870    #[test]
1871    fn completion_is_resolved_through_the_supplied_column_not_a_literal() {
1872        // A board that calls its completion column "shipped".
1873        let issues = vec![
1874            Node::new(1, "Parent"),
1875            Node {
1876                parent: Some(1),
1877                ..Node::new(2, "A").with_status("shipped")
1878            },
1879            Node {
1880                parent: Some(1),
1881                ..Node::new(3, "B").with_status("done")
1882            },
1883        ];
1884        let index = HierarchyIndex::build(&issues);
1885        assert_eq!(
1886            index.children_of(1).child_count,
1887            2,
1888            "precondition: the parent must actually have two children — the first \
1889             version of this test never set a parent, so it was measuring zero \
1890             children and asserting a count of one"
1891        );
1892        assert_eq!(
1893            rollup(&index, 1, "shipped").completed_children,
1894            1,
1895            "with 'shipped' as the completion column, A counts and B does not"
1896        );
1897        assert_eq!(
1898            rollup(&index, 1, "done").completed_children,
1899            1,
1900            "and with 'done', B counts instead"
1901        );
1902        // Both columns find exactly one child, and it is a *different* one: the
1903        // figure is produced by the supplied column, not by a hardcoded "done".
1904        // `children_of` exposes the labels, so the difference is checkable.
1905        let membership = index.children_of(1);
1906        let complete_under_shipped = membership
1907            .children
1908            .iter()
1909            .find(|c| index.status_of(c.number) == Some("shipped"))
1910            .map(|c| c.number);
1911        let complete_under_done = membership
1912            .children
1913            .iter()
1914            .find(|c| index.status_of(c.number) == Some("done"))
1915            .map(|c| c.number);
1916        assert_eq!(complete_under_shipped, Some(2));
1917        assert_eq!(complete_under_done, Some(3));
1918        assert_ne!(complete_under_shipped, complete_under_done);
1919    }
1920
1921    #[test]
1922    fn repeated_rollup_over_the_same_snapshot_returns_equal_values() {
1923        let issues = nodes(&[(1, "P", None), (2, "A", Some(1)), (3, "B", Some(1))]);
1924        let index = HierarchyIndex::build(&issues);
1925        assert_eq!(rollup(&index, 1, "done"), rollup(&index, 1, "done"));
1926    }
1927
1928    // -- T005 ------------------------------------------------------------
1929
1930    #[test]
1931    fn an_unresolved_parent_retains_the_recorded_reference_for_repair() {
1932        let issues = nodes(&[(1, "Child", Some(42))]);
1933        let index = HierarchyIndex::build(&issues);
1934        let unresolved = index.unresolved();
1935        assert_eq!(unresolved.len(), 1);
1936        assert_eq!(unresolved[0].child, 1);
1937        assert_eq!(
1938            unresolved[0].recorded_parent, 42,
1939            "the reference must survive so it can be repaired, not be forgotten"
1940        );
1941        assert!(
1942            unresolved[0].reason.contains("#42"),
1943            "the reason must name the missing parent: {}",
1944            unresolved[0].reason
1945        );
1946    }
1947
1948    #[test]
1949    fn deletion_reports_affected_children_without_deleting_them() {
1950        let issues = nodes(&[
1951            (1, "Parent", None),
1952            (2, "A", Some(1)),
1953            (3, "B", Some(1)),
1954            (4, "Elsewhere", None),
1955        ]);
1956        let affected = affected_by_deletion(&issues, 1);
1957        assert_eq!(
1958            affected.iter().map(|c| c.number).collect::<Vec<_>>(),
1959            vec![2, 3]
1960        );
1961        // The set is untouched: this reports, it does not mutate.
1962        assert_eq!(issues.len(), 4);
1963        assert_eq!(issues[1].parent, Some(1));
1964        assert!(
1965            affected_by_deletion(&issues, 4).is_empty(),
1966            "an issue with no children affects nobody"
1967        );
1968    }
1969
1970    #[test]
1971    fn the_whole_surface_runs_without_a_store_or_a_tempfile() {
1972        // T005 step 4 as an executable property: everything above ran on
1973        // in-memory `Node`s. This asserts the module exposes no filesystem entry
1974        // point by naming the only constructor and calling the full path.
1975        let issues = nodes(&[
1976            (1, "Root", None),
1977            (2, "A", Some(1)),
1978            (3, "B", Some(1)),
1979            (4, "Orphan", Some(77)),
1980        ]);
1981        let index = HierarchyIndex::build(&issues);
1982        assert_eq!(index.children_of(1).child_count, 2);
1983        assert_eq!(rollup(&index, 1, "done").direct_children, 2);
1984        assert_eq!(index.unresolved().len(), 1);
1985        assert!(!validate_hierarchy(&issues).is_empty());
1986        assert!(set_parent(&index, 3, 2).is_ok());
1987    }
1988
1989    // -----------------------------------------------------------------------
1990    // WP03 — the create path's own entry point
1991    // -----------------------------------------------------------------------
1992
1993    #[test]
1994    fn a_new_issues_parent_must_exist() {
1995        // `validate_new_parent` is the only rule a not-yet-created child can break,
1996        // and it is tested at its own level because the CLI cannot reach it: a
1997        // selector that does not resolve is refused by `Store::resolve_issue` first,
1998        // and a selector that does resolve names an issue that is necessarily in the
1999        // index. Asserted here so the function is not untested code waiting for a
2000        // caller that never arrives.
2001        let index = HierarchyIndex::build(&nodes(&[(1, "P", None)]));
2002        assert!(
2003            validate_new_parent(&index, 1).is_ok(),
2004            "an existing issue is a valid parent"
2005        );
2006
2007        let err = validate_new_parent(&index, 404).expect_err("a missing issue is not");
2008        assert!(
2009            matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::UnknownParent]),
2010            "refused for the wrong reason: {err:?}"
2011        );
2012        // The diagnostic has no child to name, and says so with the documented
2013        // marker rather than a number that could be a real issue.
2014        let HierarchyError::Rejected(r) = err;
2015        assert_eq!(r.errors[0].issue, NO_ISSUE_YET);
2016        assert!(
2017            r.errors[0].message.contains("404"),
2018            "the message must name the missing parent: {}",
2019            r.errors[0].message
2020        );
2021    }
2022
2023    #[test]
2024    fn no_issue_yet_is_not_a_number_the_store_can_hand_out() {
2025        // The sentinel is only sound if it can never be a real issue number.
2026        // `Store::resolve_next_number` allocates with `checked_add(1)` and errors on
2027        // overflow, so `u32::MAX` is unreachable.
2028        //
2029        // `0` was the first choice and is **not** sound. It was unsound for a
2030        // different reason than "it looks like a default": the allocator *did*
2031        // hand it out, because `max()` over an empty issue set has no answer and
2032        // `0` was the fallback — so every fresh repository's first issue was `#0`,
2033        // making the sentinel a real number. `resolve_next_number` now starts at
2034        // `1` (tix #1376), which is what closes the hole; this constant is the
2035        // other half of the same fix.
2036        assert_eq!(NO_ISSUE_YET, u32::MAX);
2037        assert!(
2038            NO_ISSUE_YET.checked_add(1).is_none(),
2039            "the sentinel must be the one value the allocator cannot produce"
2040        );
2041    }
2042
2043    // -----------------------------------------------------------------------
2044    // WP04 / T015 — the completion basis, pinned
2045    // -----------------------------------------------------------------------
2046
2047    #[test]
2048    fn t015_the_completion_basis_is_direct_children_and_the_rollup_says_so() {
2049        // data-model.md §3.1 records the decision; this makes the artifact and the
2050        // code agree, and stops a later reader reinterpreting the figure.
2051        //
2052        // The rollup is 1/2 for a parent whose two direct children are `done` and
2053        // `todo` — **even though the `done` child has a `done` child of its own**.
2054        // A subtree-recursive basis would report 2/2 here, so the fixture is built
2055        // to distinguish the two: the expected value only holds for a
2056        // direct-children basis.
2057        let issues = vec![
2058            node(1, "Parent", "todo", None),
2059            node(2, "Done child", "done", Some(1)),
2060            node(3, "Todo child", "todo", Some(1)),
2061            node(4, "Grandchild of #2", "done", Some(2)),
2062        ];
2063        let index = HierarchyIndex::build(&issues);
2064        let r = rollup(&index, 1, "done");
2065        assert_eq!(r.completion_basis, CompletionBasis::DirectChildren);
2066        assert_eq!(r.direct_children, 2, "the direct children are counted");
2067        assert_eq!(
2068            r.completed_children, 1,
2069            "only #2 is in the completion column"
2070        );
2071        assert_eq!(r.progress.done, 1);
2072        assert_eq!(r.progress.total, 2);
2073        // The grandchild must not have leaked into either figure.
2074        assert_eq!(r.direct_children + r.progress.total, 4, "2 + 2, not 3");
2075    }
2076
2077    #[test]
2078    fn t015_a_grandchild_completed_does_not_complete_its_grandparent() {
2079        // The other half of the same distinction, stated as its own case: a
2080        // grandparent whose only child is done, but whose grandchild is not.
2081        let issues = vec![
2082            node(1, "Grandparent", "todo", None),
2083            node(2, "Child", "done", Some(1)),
2084            node(3, "Grandchild", "todo", Some(2)),
2085        ];
2086        let index = HierarchyIndex::build(&issues);
2087        // Bound as `r`, not `rollup`: the binding shadows the function for the rest
2088        // of the body, and the first version of this test hit exactly that.
2089        let r = rollup(&index, 1, "done");
2090        assert_eq!(
2091            r.completed_children, 1,
2092            "the child counts, not the grandchild"
2093        );
2094        assert_eq!(r.progress.total, 1);
2095        // And the child, whose own grandchild is not done, is itself 0/1.
2096        let child_rollup = rollup(&index, 2, "done");
2097        assert_eq!(child_rollup.completed_children, 0);
2098        assert_eq!(child_rollup.progress.total, 1);
2099    }
2100
2101    #[test]
2102    fn t015_direct_children_are_reported_separately_from_the_rollup() {
2103        // T015's note: "direct children must be reported separately from the
2104        // rollup figure". Two fields, so `children: 40  completed: 12/40` cannot be
2105        // misread as a single figure.
2106        let issues: Vec<Node> = (1..=6)
2107            .map(|n| {
2108                node(
2109                    n,
2110                    &format!("Child {n}"),
2111                    if n % 2 == 0 { "done" } else { "todo" },
2112                    Some(1),
2113                )
2114            })
2115            .collect();
2116        let index = HierarchyIndex::build(&issues);
2117        let r = rollup(&index, 1, "done");
2118        assert_eq!(r.direct_children, 6);
2119        assert_eq!(r.completed_children, 3);
2120        assert_eq!(r.progress.done, 3);
2121        assert_eq!(r.progress.total, 6);
2122    }
2123
2124    // -----------------------------------------------------------------------
2125    // WP05 / T020 — the unresolved-parent contract, confirmed
2126    // -----------------------------------------------------------------------
2127    //
2128    // T020 is a *confirm* subtask: WP01 already provides both behaviours. These
2129    // tests exist so the confirmation is evidence rather than a reading of the
2130    // source, and so the two properties the subtask names that were **not**
2131    // previously covered are covered now.
2132
2133    #[test]
2134    fn t020_dropping_a_parent_from_the_set_does_not_clear_the_childs_reference() {
2135        // The mechanical guard against a cascade. A child whose parent leaves the
2136        // snapshot keeps its recorded number — `unresolved()` exists to report it,
2137        // and a reference that vanished with its target could not be repaired.
2138        let before = vec![
2139            node(1, "Parent", "todo", None),
2140            node(2, "Child", "todo", Some(1)),
2141        ];
2142        let mut after = before.clone();
2143        after.retain(|n| n.number != 1);
2144
2145        let index = HierarchyIndex::build(&after);
2146        assert_eq!(
2147            index.parent_of(2),
2148            Some(1),
2149            "the recorded reference must survive its target"
2150        );
2151        let unresolved = index.unresolved();
2152        assert_eq!(unresolved.len(), 1, "and be diagnosed");
2153        assert_eq!(unresolved[0].child, 2);
2154        assert_eq!(unresolved[0].recorded_parent, 1);
2155        // The child's own `parent` field is untouched too — nothing in this module
2156        // can reach the issue set to rewrite it, and the type carries no mutator.
2157        assert_eq!(after[0].parent, Some(1));
2158    }
2159
2160    #[test]
2161    fn t020_repairing_a_child_resolves_it_with_no_cleanup_step() {
2162        // T020 step 4. Re-pointing the child is the *only* repair: there is no
2163        // separate "clear the unresolved state" call, because there is no stored
2164        // unresolved state to clear — it is derived on every read.
2165        let dangling = vec![node(2, "Child", "todo", Some(1))];
2166        let index = HierarchyIndex::build(&dangling);
2167        assert_eq!(index.unresolved().len(), 1, "starts unresolved");
2168
2169        // The repair: a parent exists and the child points at it.
2170        let repaired = vec![
2171            node(1, "Parent", "todo", None),
2172            node(2, "Child", "todo", Some(1)),
2173        ];
2174        let index = HierarchyIndex::build(&repaired);
2175        assert!(
2176            index.unresolved().is_empty(),
2177            "the unresolved state disappears because it is derived, not stored"
2178        );
2179        assert_eq!(index.parent_of(2), Some(1), "and the parent now resolves");
2180        assert!(index.contains(1), "and the target is in the index");
2181
2182        // Re-pointing at a *different* live parent also resolves it, with no
2183        // intermediate step.
2184        let repointed = vec![
2185            node(1, "Parent", "todo", None),
2186            node(2, "Child", "todo", Some(9)),
2187            node(9, "Other", "todo", None),
2188        ];
2189        let index = HierarchyIndex::build(&repointed);
2190        assert_eq!(
2191            index.unresolved(),
2192            [] as [crate::hierarchy::UnresolvedParentState; 0]
2193        );
2194        assert_eq!(index.parent_of(2), Some(9));
2195    }
2196
2197    #[test]
2198    fn t020_unresolved_is_ordered_by_child_so_a_lint_run_is_deterministic() {
2199        // T022's determinism acceptance criterion rests on this: `unresolved()`
2200        // iterates a `BTreeMap`, so the order is by child number regardless of the
2201        // order issues were read in. Two different input orders must give the same
2202        // output.
2203        let forward = vec![
2204            node(1, "A", "todo", Some(99)),
2205            node(2, "B", "todo", Some(98)),
2206            node(3, "C", "todo", Some(97)),
2207        ];
2208        let mut reversed = forward.clone();
2209        reversed.reverse();
2210        let a = HierarchyIndex::build(&forward).unresolved();
2211        let b = HierarchyIndex::build(&reversed).unresolved();
2212        assert_eq!(a, b, "the report must not depend on the read order");
2213        assert_eq!(
2214            a.iter().map(|s| s.child).collect::<Vec<_>>(),
2215            vec![1, 2, 3],
2216            "and it is ordered by child number"
2217        );
2218    }
2219
2220    #[test]
2221    fn t020_a_resolved_parent_is_never_reported_unresolved() {
2222        // The control for the three above. Without it, a `unresolved()` that
2223        // reported everything would pass `repaired`-style assertions written as
2224        // "is_empty" only by accident, and a `retain` that dropped nothing would
2225        // pass the first.
2226        let clean = vec![
2227            node(1, "Parent", "todo", None),
2228            node(2, "Child", "todo", Some(1)),
2229            node(3, "Grandchild", "todo", Some(2)),
2230        ];
2231        let index = HierarchyIndex::build(&clean);
2232        assert!(
2233            index.unresolved().is_empty(),
2234            "a clean hierarchy reports nothing: {:?}",
2235            index.unresolved()
2236        );
2237    }
2238}