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}