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