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}