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