1use std::collections::{BTreeMap, BTreeSet};
51
52use crate::error::{TixError, render_diagnostics};
53use crate::id::{AliasIndex, IdentityNode, IssueRef};
54use crate::planning::Progress;
55use crate::relationships::RelationshipGraph;
56
57pub const PARENT_KEY: &str = "parent";
61
62pub trait HierarchyNode {
68 fn number(&self) -> u32;
70 fn title(&self) -> &str;
72 fn status(&self) -> &str;
74 fn canonical_id(&self) -> Option<&str> {
76 None
77 }
78 fn filename(&self) -> Option<&str> {
80 None
81 }
82 fn parent_ref(&self) -> Option<&IssueRef> {
84 None
85 }
86 fn parent_number(&self) -> Option<u32>;
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ParentReference {
100 pub id: IssueRef,
102 pub number: u32,
104 pub display: String,
106}
107
108impl ParentReference {
109 pub fn new<N: HierarchyNode>(issue: &N) -> Self {
111 Self {
112 id: hierarchy_ref(issue),
113 number: issue.number(),
114 display: display_label(issue.number(), issue.title()),
115 }
116 }
117}
118
119fn hierarchy_ref<N: HierarchyNode>(issue: &N) -> IssueRef {
120 issue
121 .canonical_id()
122 .and_then(IssueRef::id)
123 .map(|reference| reference.with_number(issue.number()))
124 .unwrap_or_else(|| IssueRef::Number(issue.number()))
125}
126
127pub fn display_label(number: u32, title: &str) -> String {
132 let title = title.trim();
133 if title.is_empty() {
134 format!("#{number}")
135 } else {
136 format!("#{number} {title}")
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ChildRef {
143 pub id: IssueRef,
145 pub number: u32,
147 pub display: String,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ChildMembership {
157 pub parent: u32,
159 pub children: Vec<ChildRef>,
161 pub child_count: usize,
163}
164
165impl ChildMembership {
166 fn empty(parent: u32) -> Self {
167 Self {
168 parent,
169 children: Vec::new(),
170 child_count: 0,
171 }
172 }
173}
174
175#[derive(Debug, Clone, Default, PartialEq, Eq)]
181pub struct HierarchyIndex {
182 parent_by_child: BTreeMap<IssueRef, IssueRef>,
184 children_by_parent: BTreeMap<IssueRef, Vec<IssueRef>>,
186 numbers: BTreeMap<IssueRef, u32>,
188 refs_by_number: BTreeMap<u32, Vec<IssueRef>>,
190 labels: BTreeMap<IssueRef, String>,
192 statuses: BTreeMap<IssueRef, String>,
194 filenames: BTreeMap<IssueRef, String>,
196 aliases: AliasIndex,
198 alias_diagnostics: Vec<HierarchyDiagnostic>,
200}
201
202struct AliasNode<'a, N>(&'a N);
203
204impl<N: HierarchyNode> IdentityNode for AliasNode<'_, N> {
205 fn canonical_id(&self) -> Option<&str> {
206 self.0.canonical_id()
207 }
208 fn issue_number(&self) -> u32 {
209 self.0.number()
210 }
211 fn issue_filename(&self) -> &str {
212 self.0.filename().unwrap_or("")
213 }
214 fn issue_title(&self) -> &str {
215 self.0.title()
216 }
217}
218
219impl HierarchyIndex {
220 pub fn build<N: HierarchyNode>(issues: &[N]) -> Self {
227 let mut index = Self::default();
228 for issue in issues {
229 let number = issue.number();
230 let identity = hierarchy_ref(issue);
231 index.numbers.insert(identity, number);
232 index
233 .refs_by_number
234 .entry(number)
235 .or_default()
236 .push(identity);
237 index
238 .labels
239 .insert(identity, display_label(number, issue.title()));
240 index
241 .filenames
242 .insert(identity, issue.filename().unwrap_or("").to_string());
243 index.statuses.insert(identity, issue.status().to_string());
244 }
245 let alias_nodes: Vec<AliasNode<'_, N>> = issues.iter().map(AliasNode).collect();
246 index.aliases = AliasIndex::build(&alias_nodes);
247 for refs in index.refs_by_number.values_mut() {
248 refs.sort();
249 refs.dedup();
250 }
251 for issue in issues {
252 let child_identity = hierarchy_ref(issue);
253 let parent = issue
254 .parent_ref()
255 .copied()
256 .or_else(|| issue.parent_number().map(IssueRef::Number));
257 if let Some(parent) = parent {
258 let parent = match index.aliases.resolve(&parent) {
259 Ok(resolved) => resolved,
260 Err(error) => {
261 let kind = match error {
262 crate::id::AliasError::Ambiguous { .. } => {
263 HierarchyDiagnosticKind::AmbiguousTarget
264 }
265 crate::id::AliasError::DuplicateId { .. } => {
266 HierarchyDiagnosticKind::DuplicateIdentity
267 }
268 crate::id::AliasError::Missing(_) => {
269 HierarchyDiagnosticKind::UnresolvedParent
270 }
271 };
272 if kind != HierarchyDiagnosticKind::UnresolvedParent {
273 index.alias_diagnostics.push(
274 HierarchyDiagnostic::new(
275 kind,
276 issue.number(),
277 format!(
278 "{PARENT_KEY}: {} cannot be resolved: {}",
279 parent.human(),
280 alias_error_text(&error)
281 ),
282 )
283 .with_source_file(issue.filename().unwrap_or("")),
284 );
285 }
286 parent
287 }
288 };
289 index.parent_by_child.insert(child_identity, parent);
290 index
291 .children_by_parent
292 .entry(parent)
293 .or_default()
294 .push(child_identity);
295 }
296 }
297 for children in index.children_by_parent.values_mut() {
300 children.sort_by_key(|child| index.numbers.get(child).copied().unwrap_or_default());
301 }
302 index
303 }
304
305 fn identity_for_number(&self, issue: u32) -> Option<IssueRef> {
306 match self.refs_by_number.get(&issue)?.as_slice() {
307 [one] => Some(*one),
308 _ => None,
309 }
310 }
311
312 fn number_for_identity(&self, identity: &IssueRef) -> Option<u32> {
313 self.numbers.get(identity).copied()
314 }
315
316 pub fn alias_diagnostics(&self) -> &[HierarchyDiagnostic] {
318 &self.alias_diagnostics
319 }
320
321 pub fn parent_ref_of(&self, issue: u32) -> Option<&IssueRef> {
323 self.identity_for_number(issue)
324 .and_then(|identity| self.parent_by_child.get(&identity))
325 .or_else(|| self.parent_by_child.get(&IssueRef::Number(issue)))
326 }
327
328 pub fn parent_ref_for(&self, issue: &IssueRef) -> Option<&IssueRef> {
330 self.parent_by_child.get(issue)
331 }
332
333 pub fn identity_ref_of(&self, issue: u32) -> Option<IssueRef> {
335 self.identity_for_number(issue)
336 }
337
338 pub fn children_of(&self, issue: u32) -> ChildMembership {
343 let identity = self
344 .identity_for_number(issue)
345 .unwrap_or(IssueRef::Number(issue));
346 let Some(children) = self.children_by_parent.get(&identity) else {
347 return ChildMembership::empty(issue);
348 };
349 let children: Vec<ChildRef> = children
350 .iter()
351 .map(|child| ChildRef {
352 id: *child,
353 number: self.number_for_identity(child).unwrap_or_default(),
354 display: self.label_ref(child),
355 })
356 .collect();
357 ChildMembership {
358 parent: issue,
359 child_count: children.len(),
360 children,
361 }
362 }
363
364 pub fn parent_of(&self, issue: u32) -> Option<u32> {
369 self.parent_ref_of(issue).and_then(|parent| {
370 parent
371 .as_number()
372 .or_else(|| self.number_for_identity(parent))
373 })
374 }
375
376 pub fn label(&self, issue: u32) -> String {
378 self.identity_for_number(issue)
379 .map_or_else(|| format!("#{issue}"), |identity| self.label_ref(&identity))
380 }
381
382 fn label_ref(&self, identity: &IssueRef) -> String {
383 self.labels
384 .get(identity)
385 .cloned()
386 .unwrap_or_else(|| identity.to_string())
387 }
388
389 pub fn status_of(&self, issue: u32) -> Option<&str> {
391 self.identity_for_number(issue)
392 .and_then(|identity| self.statuses.get(&identity))
393 .map(String::as_str)
394 }
395
396 pub fn contains(&self, issue: u32) -> bool {
398 self.identity_for_number(issue).is_some()
399 }
400
401 pub fn contains_ref(&self, reference: &IssueRef) -> bool {
403 self.labels.contains_key(reference)
404 }
405
406 pub fn unresolved(&self) -> Vec<UnresolvedParentState> {
411 let mut out: Vec<UnresolvedParentState> = self
412 .parent_by_child
413 .iter()
414 .filter(|(_, parent)| !self.labels.contains_key(parent))
415 .map(|(child, parent)| UnresolvedParentState {
416 child: self.number_for_identity(child).unwrap_or_default(),
417 recorded_parent: *parent,
418 source_file: self.filenames.get(child).cloned().unwrap_or_default(),
419 reason: unresolved_reason(
420 self.number_for_identity(child).unwrap_or_default(),
421 *parent,
422 ),
423 })
424 .collect();
425 out.sort_by_key(|s| s.child);
426 out
427 }
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct UnresolvedParentState {
442 pub child: u32,
444 pub recorded_parent: IssueRef,
446 pub source_file: String,
448 pub reason: String,
453}
454
455pub fn unresolved_reason(child: u32, parent: impl std::fmt::Display) -> String {
457 let parent = parent.to_string();
458 let parent = if parent.bytes().all(|byte| byte.is_ascii_digit()) {
459 format!("#{parent}")
460 } else {
461 parent
462 };
463 format!(
464 "{child}'s parent {parent} no longer exists (the reference is kept so it can be repaired)"
465 )
466}
467
468pub fn affected_by_deletion<N: HierarchyNode>(issues: &[N], parent: u32) -> Vec<ChildRef> {
473 let index = HierarchyIndex::build(issues);
474 let Some(parent_identity) = index.identity_for_number(parent) else {
475 return Vec::new();
476 };
477 index
478 .children_by_parent
479 .get(&parent_identity)
480 .map(|children| {
481 children
482 .iter()
483 .map(|child| ChildRef {
484 id: *child,
485 number: index.number_for_identity(child).unwrap_or_default(),
486 display: index.label_ref(child),
487 })
488 .collect()
489 })
490 .unwrap_or_default()
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502pub enum ParentEventKind {
503 ParentSet,
505 ParentChanged,
507 ParentCleared,
509}
510
511impl ParentEventKind {
512 pub fn as_str(self) -> &'static str {
514 match self {
515 ParentEventKind::ParentSet => "parent_set",
516 ParentEventKind::ParentChanged => "parent_changed",
517 ParentEventKind::ParentCleared => "parent_cleared",
518 }
519 }
520}
521
522#[derive(Debug, Clone, PartialEq, Eq)]
524pub struct ParentEvent {
525 pub kind: ParentEventKind,
527 pub child: u32,
529 pub parent: Option<u32>,
531}
532
533#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct ParentChange {
539 pub child: u32,
541 pub parent: Option<u32>,
543 pub previous: Option<u32>,
545 pub parent_ref: Option<IssueRef>,
547 pub event: Option<ParentEvent>,
552}
553
554#[derive(Debug, Clone, PartialEq, Eq)]
556pub enum HierarchyError {
557 Rejected(HierarchyValidationResult),
567}
568
569impl std::fmt::Display for HierarchyError {
570 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583 match self {
584 HierarchyError::Rejected(result) if result.errors.is_empty() => {
585 f.write_str("hierarchy validation failed")
586 }
587 HierarchyError::Rejected(result) => f.write_str(&render_diagnostics(
588 "E-tix-hierarchy",
589 result
590 .errors
591 .iter()
592 .map(|e| (e.kind.as_str(), e.message.as_str())),
593 )),
594 }
595 }
596}
597
598impl std::error::Error for HierarchyError {}
599
600impl From<HierarchyError> for TixError {
606 fn from(err: HierarchyError) -> TixError {
607 TixError::Refused(err.to_string())
608 }
609}
610
611pub fn set_parent(
637 index: &HierarchyIndex,
638 child: u32,
639 parent: u32,
640) -> Result<ParentChange, HierarchyError> {
641 set_parent_ref(
642 index,
643 index
644 .identity_for_number(child)
645 .unwrap_or(IssueRef::Number(child)),
646 index
647 .identity_for_number(parent)
648 .unwrap_or(IssueRef::Number(parent)),
649 )
650}
651
652pub fn set_parent_ref(
654 index: &HierarchyIndex,
655 child: IssueRef,
656 parent: IssueRef,
657) -> Result<ParentChange, HierarchyError> {
658 let result = validate_proposed_ref(index, child, Some(parent));
659 if result.blocking {
660 return Err(HierarchyError::Rejected(result));
661 }
662 let child_number = index
663 .number_for_identity(&child)
664 .or_else(|| child.as_number())
665 .unwrap_or_default();
666 let parent_number = index
667 .number_for_identity(&parent)
668 .or_else(|| parent.as_number())
669 .unwrap_or_default();
670 let previous = index.parent_ref_for(&child).copied();
671 if previous == Some(parent) {
672 return Ok(ParentChange {
673 child: child_number,
674 parent: Some(parent_number),
675 previous: previous.and_then(|reference| index.number_for_identity(&reference)),
676 parent_ref: Some(parent),
677 event: None,
678 });
679 }
680 let kind = if previous.is_some() {
681 ParentEventKind::ParentChanged
682 } else {
683 ParentEventKind::ParentSet
684 };
685 Ok(ParentChange {
686 child: child_number,
687 parent: Some(parent_number),
688 previous: previous.and_then(|reference| index.number_for_identity(&reference)),
689 parent_ref: Some(parent),
690 event: Some(ParentEvent {
691 kind,
692 child: child_number,
693 parent: Some(parent_number),
694 }),
695 })
696}
697
698pub fn change_parent(
712 index: &HierarchyIndex,
713 child: u32,
714 parent: u32,
715) -> Result<ParentChange, HierarchyError> {
716 set_parent(index, child, parent)
717}
718
719pub fn change_parent_ref(
721 index: &HierarchyIndex,
722 child: IssueRef,
723 parent: IssueRef,
724) -> Result<ParentChange, HierarchyError> {
725 set_parent_ref(index, child, parent)
726}
727
728pub fn clear_parent(index: &HierarchyIndex, child: u32) -> Result<ParentChange, HierarchyError> {
737 clear_parent_ref(
738 index,
739 index
740 .identity_for_number(child)
741 .unwrap_or(IssueRef::Number(child)),
742 )
743}
744
745pub fn clear_parent_ref(
747 index: &HierarchyIndex,
748 child: IssueRef,
749) -> Result<ParentChange, HierarchyError> {
750 let result = validate_proposed_ref(index, child, None);
751 if result.blocking {
752 return Err(HierarchyError::Rejected(result));
753 }
754 if index.parent_ref_for(&child).is_none() {
755 let child_number = index.number_for_identity(&child).unwrap_or_default();
756 return Ok(ParentChange {
757 child: child_number,
758 parent: None,
759 previous: None,
760 parent_ref: None,
761 event: None,
762 });
763 }
764 let child_number = index.number_for_identity(&child).unwrap_or_default();
765 let previous = index
766 .parent_ref_for(&child)
767 .and_then(|reference| index.number_for_identity(reference));
768 Ok(ParentChange {
769 child: child_number,
770 parent: None,
771 previous,
772 parent_ref: None,
773 event: Some(ParentEvent {
774 kind: ParentEventKind::ParentCleared,
775 child: child_number,
776 parent: None,
777 }),
778 })
779}
780
781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
787pub enum HierarchyDiagnosticKind {
788 SelfParent,
790 Cycle,
792 UnknownChild,
800 UnknownParent,
802 AmbiguousTarget,
804 DuplicateIdentity,
806 UnresolvedParent,
816}
817
818impl HierarchyDiagnosticKind {
819 pub fn as_str(self) -> &'static str {
821 match self {
822 HierarchyDiagnosticKind::SelfParent => "self_parent",
823 HierarchyDiagnosticKind::UnresolvedParent => "unresolved_parent",
824 HierarchyDiagnosticKind::Cycle => "cycle",
825 HierarchyDiagnosticKind::UnknownChild => "unknown_child",
826 HierarchyDiagnosticKind::UnknownParent => "unknown_parent",
827 HierarchyDiagnosticKind::AmbiguousTarget => "ambiguous_target",
828 HierarchyDiagnosticKind::DuplicateIdentity => "duplicate_identity",
829 }
830 }
831}
832
833fn alias_error_text(error: &crate::id::AliasError) -> String {
834 match error {
835 crate::id::AliasError::Missing(value) => format!("`{value}` resolves to no issue"),
836 crate::id::AliasError::Ambiguous { alias, candidates } => format!(
837 "`{alias}` is ambiguous: {}",
838 candidates
839 .iter()
840 .map(ToString::to_string)
841 .collect::<Vec<_>>()
842 .join(", ")
843 ),
844 crate::id::AliasError::DuplicateId { id, numbers } => {
845 format!("id `{id}` is claimed by issues {numbers:?}")
846 }
847 }
848}
849
850#[derive(Debug, Clone, PartialEq, Eq)]
852pub struct HierarchyDiagnostic {
853 pub kind: HierarchyDiagnosticKind,
855 pub issue: u32,
857 pub field: String,
860 pub message: String,
862 pub source_file: Option<String>,
864 pub path: Option<Vec<u32>>,
867}
868
869impl HierarchyDiagnostic {
870 fn new(kind: HierarchyDiagnosticKind, issue: u32, message: String) -> Self {
871 Self {
872 kind,
873 issue,
874 field: PARENT_KEY.to_string(),
875 message,
876 source_file: None,
877 path: None,
878 }
879 }
880
881 fn with_source_file(mut self, filename: impl Into<String>) -> Self {
882 self.source_file = Some(filename.into());
883 self
884 }
885}
886
887#[derive(Debug, Clone, PartialEq, Eq)]
891pub struct HierarchyValidationResult {
892 pub errors: Vec<HierarchyDiagnostic>,
894 pub blocking: bool,
896}
897
898impl HierarchyValidationResult {
899 pub fn clean() -> Self {
901 Self {
902 errors: Vec::new(),
903 blocking: false,
904 }
905 }
906
907 pub fn blocking(diagnostic: HierarchyDiagnostic) -> Self {
909 Self {
910 blocking: true,
911 errors: vec![diagnostic],
912 }
913 }
914
915 pub fn is_empty(&self) -> bool {
917 self.errors.is_empty()
918 }
919
920 pub fn kinds(&self) -> Vec<HierarchyDiagnosticKind> {
922 self.errors.iter().map(|e| e.kind).collect()
923 }
924}
925
926pub fn validate_proposed(
939 index: &HierarchyIndex,
940 child: u32,
941 parent: Option<u32>,
942) -> HierarchyValidationResult {
943 validate_proposed_ref(
944 index,
945 index
946 .identity_for_number(child)
947 .unwrap_or(IssueRef::Number(child)),
948 parent.map(|parent| {
949 index
950 .identity_for_number(parent)
951 .unwrap_or(IssueRef::Number(parent))
952 }),
953 )
954}
955
956pub fn validate_proposed_ref(
958 index: &HierarchyIndex,
959 child: IssueRef,
960 parent: Option<IssueRef>,
961) -> HierarchyValidationResult {
962 let child_number = index
963 .number_for_identity(&child)
964 .or_else(|| child.as_number())
965 .unwrap_or_default();
966 if !index.contains_ref(&child) {
967 return HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
968 HierarchyDiagnosticKind::UnknownChild,
969 child_number,
970 format!("{PARENT_KEY}: child {} does not exist", child.human()),
971 ));
972 }
973 let Some(parent) = parent else {
974 return HierarchyValidationResult::clean();
976 };
977 let parent_number = index
978 .number_for_identity(&parent)
979 .or_else(|| parent.as_number())
980 .unwrap_or_default();
981 if !index.contains_ref(&parent) {
982 return HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
983 HierarchyDiagnosticKind::UnknownParent,
984 child_number,
985 format!("{PARENT_KEY}: {} does not exist", parent.human()),
986 ));
987 }
988 if child == parent {
989 return HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
990 HierarchyDiagnosticKind::SelfParent,
991 child_number,
992 format!("{PARENT_KEY}: an issue cannot be its own parent"),
993 ));
994 }
995 match cycle_path_ref(index, child, parent) {
996 Some(path) => {
997 let mut diagnostic = HierarchyDiagnostic::new(
998 HierarchyDiagnosticKind::Cycle,
999 child_number,
1000 format!(
1001 "{PARENT_KEY}: setting parent {} closes a cycle ({})",
1002 parent_number,
1003 render_path(&path)
1004 ),
1005 );
1006 diagnostic.path = Some(path);
1007 HierarchyValidationResult::blocking(diagnostic)
1008 }
1009 None => HierarchyValidationResult::clean(),
1010 }
1011}
1012
1013pub fn change_detail<B, A>(before: Option<B>, after: Option<A>) -> String
1021where
1022 B: Into<IssueRef>,
1023 A: Into<IssueRef>,
1024{
1025 let render = |reference: Option<IssueRef>| match reference {
1026 Some(reference) => reference.human(),
1027 None => "none".to_string(),
1028 };
1029 format!(
1030 "parent {} -> {}",
1031 render(before.map(Into::into)),
1032 render(after.map(Into::into))
1033 )
1034}
1035
1036pub fn validate_new_parent(index: &HierarchyIndex, parent: u32) -> Result<(), HierarchyError> {
1048 if index.contains(parent) {
1049 return Ok(());
1050 }
1051 Err(HierarchyError::Rejected(
1052 HierarchyValidationResult::blocking(HierarchyDiagnostic::new(
1053 HierarchyDiagnosticKind::UnknownParent,
1054 NO_ISSUE_YET,
1055 format!("{PARENT_KEY}: #{parent} does not exist"),
1056 )),
1057 ))
1058}
1059
1060pub const NO_ISSUE_YET: u32 = u32::MAX;
1073
1074fn cycle_path_ref(index: &HierarchyIndex, child: IssueRef, parent: IssueRef) -> Option<Vec<u32>> {
1082 let mut path = vec![index.number_for_identity(&child).unwrap_or_default()];
1083 let mut visited: BTreeSet<IssueRef> = BTreeSet::new();
1084 visited.insert(child);
1085 let mut current = parent;
1086 loop {
1087 if !visited.insert(current) {
1088 let current_number = index.number_for_identity(¤t).unwrap_or_default();
1092 let start = path
1093 .iter()
1094 .position(|number| *number == current_number)
1095 .unwrap_or(0);
1096 return Some(path[start..].to_vec());
1097 }
1098 path.push(index.number_for_identity(¤t).unwrap_or_default());
1099 match index.parent_ref_for(¤t).copied() {
1100 Some(next) if index.contains_ref(&next) => current = next,
1103 _ => return None,
1104 }
1105 }
1106}
1107
1108fn render_path(path: &[u32]) -> String {
1110 path.iter()
1111 .map(|n| n.to_string())
1112 .collect::<Vec<_>>()
1113 .join(" -> ")
1114}
1115
1116pub fn validate_hierarchy<N: HierarchyNode>(issues: &[N]) -> HierarchyValidationResult {
1124 let index = HierarchyIndex::build(issues);
1125 let mut errors: Vec<HierarchyDiagnostic> = index.alias_diagnostics().to_vec();
1126
1127 let mut dangling: Vec<HierarchyDiagnostic> = index
1128 .unresolved()
1129 .into_iter()
1130 .map(|state| {
1131 HierarchyDiagnostic::new(
1135 HierarchyDiagnosticKind::UnresolvedParent,
1136 state.child,
1137 state.reason,
1138 )
1139 .with_source_file(state.source_file)
1140 })
1141 .collect();
1142 errors.append(&mut dangling);
1143
1144 let mut self_parents: Vec<HierarchyDiagnostic> = issues
1145 .iter()
1146 .filter(|issue| {
1147 let reference = hierarchy_ref(*issue);
1148 index
1149 .parent_ref_for(&reference)
1150 .is_some_and(|parent| *parent == reference)
1151 })
1152 .map(|issue| {
1153 HierarchyDiagnostic::new(
1154 HierarchyDiagnosticKind::SelfParent,
1155 issue.number(),
1156 format!("{PARENT_KEY}: an issue cannot be its own parent"),
1157 )
1158 .with_source_file(issue.filename().unwrap_or(""))
1159 })
1160 .collect();
1161 errors.append(&mut self_parents);
1162
1163 let mut seen: BTreeSet<IssueRef> = BTreeSet::new();
1166 let mut sorted: Vec<IssueRef> = issues.iter().map(hierarchy_ref).collect();
1167 sorted.sort_by_key(|reference| index.number_for_identity(reference).unwrap_or_default());
1168 for child in sorted {
1169 let Some(parent) = index.parent_ref_for(&child).copied() else {
1170 continue;
1171 };
1172 if !index.contains_ref(&parent) || seen.contains(&child) {
1173 continue;
1174 }
1175 if let Some(path) = cycle_path_ref(&index, child, parent) {
1176 for node in &path {
1177 if let Some(reference) = index
1178 .refs_by_number
1179 .get(node)
1180 .and_then(|refs| (refs.len() == 1).then(|| refs[0]))
1181 {
1182 seen.insert(reference);
1183 }
1184 }
1185 let child_number = index.number_for_identity(&child).unwrap_or_default();
1186 let mut diagnostic = HierarchyDiagnostic::new(
1187 HierarchyDiagnosticKind::Cycle,
1188 child_number,
1189 format!("{PARENT_KEY}: cycle ({})", render_path(&path)),
1190 );
1191 if let Some(filename) = index.filenames.get(&child) {
1192 diagnostic.source_file = Some(filename.clone());
1193 }
1194 diagnostic.path = Some(path);
1195 errors.push(diagnostic);
1196 }
1197 }
1198
1199 HierarchyValidationResult {
1200 blocking: !errors.is_empty(),
1201 errors,
1202 }
1203}
1204
1205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1214pub enum CompletionBasis {
1215 DirectChildren,
1217}
1218
1219impl CompletionBasis {
1220 pub fn as_str(self) -> &'static str {
1222 match self {
1223 CompletionBasis::DirectChildren => "direct children",
1224 }
1225 }
1226}
1227
1228#[derive(Debug, Clone, PartialEq, Eq)]
1233pub struct ParentRollup {
1234 pub parent: u32,
1236 pub direct_children: usize,
1238 pub completed_children: usize,
1240 pub progress: Progress,
1243 pub completion_basis: CompletionBasis,
1245}
1246
1247pub fn rollup(index: &HierarchyIndex, issue: u32, done_column: &str) -> ParentRollup {
1256 let membership = index.children_of(issue);
1257 let completed_children = membership
1258 .children
1259 .iter()
1260 .filter(|child| index.status_of(child.number) == Some(done_column))
1261 .count();
1262 ParentRollup {
1263 parent: issue,
1264 direct_children: membership.child_count,
1265 completed_children,
1266 progress: Progress {
1267 done: completed_children,
1268 total: membership.child_count,
1269 },
1270 completion_basis: CompletionBasis::DirectChildren,
1271 }
1272}
1273
1274#[allow(dead_code)]
1282fn relationship_graph_of<'a, N: HierarchyNode + 'a>(issues: &'a [N]) -> RelationshipGraph<'a> {
1283 let _ = issues;
1286 RelationshipGraph::new(&[], "done")
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291 #[allow(clippy::wildcard_imports)]
1292 use super::*;
1293
1294 #[derive(Debug, Clone, PartialEq, Eq)]
1296 struct Node {
1297 number: u32,
1298 title: String,
1299 status: String,
1300 parent: Option<u32>,
1301 }
1302
1303 impl Node {
1304 fn new(number: u32, title: &str) -> Self {
1305 Self {
1306 number,
1307 title: title.to_string(),
1308 status: "todo".to_string(),
1309 parent: None,
1310 }
1311 }
1312 fn with_status(mut self, status: &str) -> Self {
1313 self.status = status.to_string();
1314 self
1315 }
1316 }
1317
1318 impl HierarchyNode for Node {
1319 fn number(&self) -> u32 {
1320 self.number
1321 }
1322 fn title(&self) -> &str {
1323 &self.title
1324 }
1325 fn status(&self) -> &str {
1326 &self.status
1327 }
1328 fn parent_number(&self) -> Option<u32> {
1329 self.parent
1330 }
1331 }
1332
1333 fn node(number: u32, title: &str, status: &str, parent: Option<u32>) -> Node {
1335 let mut n = Node::new(number, title).with_status(status);
1336 n.parent = parent;
1337 n
1338 }
1339
1340 fn nodes(spec: &[(u32, &str, Option<u32>)]) -> Vec<Node> {
1341 spec.iter()
1342 .map(|(n, t, p)| {
1343 let mut node = Node::new(*n, t);
1344 node.parent = *p;
1345 node
1346 })
1347 .collect()
1348 }
1349
1350 #[test]
1353 fn children_are_ordered_by_issue_number_regardless_of_input_order() {
1354 let shuffled = nodes(&[
1355 (9, "I", Some(1)),
1356 (3, "C", Some(1)),
1357 (7, "G", Some(1)),
1358 (5, "E", Some(1)),
1359 ]);
1360 let index = HierarchyIndex::build(&shuffled);
1361 let membership = index.children_of(1);
1362 assert_eq!(
1363 membership
1364 .children
1365 .iter()
1366 .map(|c| c.number)
1367 .collect::<Vec<_>>(),
1368 vec![3, 5, 7, 9],
1369 "children must be ascending by number even when the input is not"
1370 );
1371 assert_eq!(membership.child_count, 4);
1372 assert_eq!(membership.children[0].display, "#3 C");
1373 }
1374
1375 #[test]
1376 fn the_index_is_stable_across_repeated_builds_from_the_same_input() {
1377 let issues = nodes(&[(1, "A", None), (2, "B", Some(1)), (3, "C", Some(1))]);
1378 let first = HierarchyIndex::build(&issues);
1379 let second = HierarchyIndex::build(&issues);
1380 assert_eq!(first, second, "two builds of one snapshot must be equal");
1381 assert_eq!(first.children_of(1), second.children_of(1));
1383 assert_eq!(rollup(&first, 1, "done"), rollup(&second, 1, "done"));
1384 }
1385
1386 #[test]
1387 fn a_child_appears_under_exactly_one_parent() {
1388 let issues = nodes(&[(1, "A", None), (2, "B", Some(1)), (3, "C", Some(2))]);
1389 let index = HierarchyIndex::build(&issues);
1390 let mut appearances = 0;
1391 for parent in 1..=3 {
1392 appearances += index.children_of(parent).child_count;
1393 }
1394 assert_eq!(
1395 appearances, 2,
1396 "each of the two parented issues must be counted exactly once"
1397 );
1398 assert_eq!(index.parent_of(2), Some(1));
1399 assert_eq!(index.parent_of(1), None);
1400 }
1401
1402 #[test]
1403 fn no_child_list_is_stored_in_the_index() {
1404 let issues = nodes(&[(1, "A", None), (2, "B", Some(1))]);
1408 let index = HierarchyIndex::build(&issues);
1409 let rebuilt = HierarchyIndex {
1412 parent_by_child: index.parent_by_child.clone(),
1413 children_by_parent: index.children_by_parent.clone(),
1414 numbers: index.numbers.clone(),
1415 refs_by_number: index.refs_by_number.clone(),
1416 labels: index.labels.clone(),
1417 statuses: index.statuses.clone(),
1418 filenames: index.filenames.clone(),
1419 aliases: index.aliases.clone(),
1420 alias_diagnostics: index.alias_diagnostics.clone(),
1421 };
1422 assert_eq!(rebuilt, index, "the index is exactly these fields");
1423 assert!(
1426 index
1427 .children_by_parent
1428 .values()
1429 .all(|v| v.iter().all(|n| index.labels.contains_key(n)))
1430 );
1431 }
1432
1433 #[test]
1434 fn an_issue_with_no_children_returns_an_empty_membership_not_an_error() {
1435 let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
1436 let membership = index.children_of(1);
1437 assert_eq!(membership.child_count, 0);
1438 assert_eq!(membership.children, [] as [crate::hierarchy::ChildRef; 0]);
1439 assert_eq!(membership.parent, 1);
1440 }
1441
1442 #[test]
1445 fn set_then_change_then_clear_produces_three_distinct_states() {
1446 let issues = nodes(&[
1447 (1, "P", None),
1448 (2, "A", None),
1449 (3, "B", None),
1450 (4, "C", None),
1451 ]);
1452 let index = HierarchyIndex::build(&issues);
1453
1454 let set = set_parent(&index, 2, 1).unwrap();
1455 assert_eq!(set.parent, Some(1));
1456 assert_eq!(
1457 set.event.as_ref().map(|e| e.kind),
1458 Some(ParentEventKind::ParentSet)
1459 );
1460
1461 let mut after_set = issues.clone();
1463 after_set[1].parent = Some(1);
1464 let index2 = HierarchyIndex::build(&after_set);
1465 let changed = change_parent(&index2, 2, 3).unwrap();
1466 assert_eq!(changed.parent, Some(3));
1467 assert_eq!(
1468 changed.event.as_ref().map(|e| e.kind),
1469 Some(ParentEventKind::ParentChanged)
1470 );
1471
1472 let mut after_change = after_set.clone();
1473 after_change[1].parent = Some(3);
1474 let index3 = HierarchyIndex::build(&after_change);
1475 let cleared = clear_parent(&index3, 2).unwrap();
1476 assert_eq!(cleared.parent, None);
1477 assert_eq!(
1478 cleared.event.as_ref().map(|e| e.kind),
1479 Some(ParentEventKind::ParentCleared)
1480 );
1481
1482 let states = [set.parent, changed.parent, cleared.parent];
1484 assert_eq!(states, [Some(1), Some(3), None]);
1485 }
1486
1487 #[test]
1488 fn clearing_a_child_with_no_parent_is_a_no_op_with_no_event() {
1489 let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
1490 let cleared = clear_parent(&index, 1).unwrap();
1491 assert_eq!(cleared.parent, None);
1492 assert!(
1493 cleared.event.is_none(),
1494 "a no-op must not pad the audit trail"
1495 );
1496 }
1497
1498 #[test]
1499 fn setting_the_parent_the_child_already_has_is_a_no_op_with_no_event() {
1500 let issues = nodes(&[(1, "P", None), (2, "C", Some(1))]);
1504 let index = HierarchyIndex::build(&issues);
1505 let again = set_parent(&index, 2, 1).unwrap();
1506 assert_eq!(
1507 again.parent,
1508 Some(1),
1509 "the resulting state is still reported"
1510 );
1511 assert!(
1512 again.event.is_none(),
1513 "an accepted mutation that changes nothing must not pad the audit trail"
1514 );
1515 }
1516
1517 #[test]
1518 fn a_no_op_check_must_not_launder_a_self_parent() {
1519 let index = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", Some(2))]));
1524 for (label, outcome) in [
1525 ("set_parent", set_parent(&index, 2, 2)),
1526 ("change_parent", change_parent(&index, 2, 2)),
1527 ] {
1528 let err = outcome.expect_err(&format!(
1529 "{label}(child=2, parent=2) must be refused even when 2 already records it"
1530 ));
1531 assert!(
1532 matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::SelfParent]),
1533 "{label} refused for the wrong reason: {err:?}"
1534 );
1535 }
1536 let clean = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", None)]));
1538 assert!(
1539 set_parent(&clean, 2, 2).is_err(),
1540 "the rule must still fire"
1541 );
1542 }
1543
1544 #[test]
1545 fn a_no_op_check_must_not_launder_an_unknown_parent() {
1546 let index = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", Some(999))]));
1552 for (label, outcome) in [
1553 ("set_parent", set_parent(&index, 2, 999)),
1554 ("change_parent", change_parent(&index, 2, 999)),
1555 ] {
1556 let err = outcome.expect_err(&format!(
1557 "{label}(child=2, parent=999) must be refused: #999 is not in the snapshot"
1558 ));
1559 assert!(
1560 matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::UnknownParent]),
1561 "{label} refused for the wrong reason: {err:?}"
1562 );
1563 }
1564 let clean = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", None)]));
1567 assert!(set_parent(&clean, 2, 2).is_err());
1568 let ok = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", Some(1))]));
1569 assert!(
1570 set_parent(&ok, 2, 1).unwrap().event.is_none(),
1571 "no-op still a no-op"
1572 );
1573 }
1574
1575 #[test]
1576 fn re_applying_the_same_parent_is_a_no_op_with_no_event() {
1577 let issues = nodes(&[(1, "P", None), (2, "C", Some(1))]);
1578 let index = HierarchyIndex::build(&issues);
1579 let again = change_parent(&index, 2, 1).unwrap();
1580 assert_eq!(again.parent, Some(1));
1581 assert!(
1582 again.event.is_none(),
1583 "a retried command must not append a second event"
1584 );
1585 }
1586
1587 #[test]
1588 fn a_second_parent_cannot_be_expressed() {
1589 let issues = nodes(&[(1, "A", None), (2, "B", Some(1)), (3, "C", Some(1))]);
1593 assert_eq!(
1594 HierarchyIndex::build(&issues).children_of(1).child_count,
1595 2,
1596 "precondition: two children start under #1"
1597 );
1598 let mut after = issues.clone();
1599 after[1].parent = Some(3);
1600 let index2 = HierarchyIndex::build(&after);
1601 assert_eq!(
1602 index2.children_of(1).child_count,
1603 1,
1604 "repointing must move the child, never duplicate it"
1605 );
1606 assert_eq!(index2.parent_of(2), Some(3));
1607 assert_eq!(index2.children_of(3).child_count, 1);
1608 }
1609
1610 #[test]
1613 fn a_self_parent_is_rejected_with_its_own_diagnostic_kind() {
1614 let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
1615 let result = validate_proposed(&index, 1, Some(1));
1616 assert!(result.blocking);
1617 assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::SelfParent]);
1618 assert_eq!(result.errors[0].field, PARENT_KEY);
1619 }
1620
1621 #[test]
1622 fn an_unknown_parent_is_rejected_with_its_own_diagnostic_kind() {
1623 let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
1624 let result = validate_proposed(&index, 1, Some(99));
1625 assert!(result.blocking);
1626 assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::UnknownParent]);
1627 assert!(
1628 result.errors[0].message.contains("#99"),
1629 "the message must name the missing parent: {}",
1630 result.errors[0].message
1631 );
1632 }
1633
1634 #[test]
1635 fn a_three_node_cycle_is_rejected_and_the_path_is_reported() {
1636 let index = HierarchyIndex::build(&nodes(&[
1637 (1, "A", Some(2)),
1638 (2, "B", Some(3)),
1639 (3, "C", None),
1640 ]));
1641 let result = validate_proposed(&index, 3, Some(1));
1642 assert!(result.blocking);
1643 assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::Cycle]);
1644 let path = result.errors[0]
1645 .path
1646 .as_ref()
1647 .expect("a cycle must report its path");
1648 assert_eq!(
1654 index.parent_of(*path.last().expect("a cycle path is never empty")),
1655 path.first().copied(),
1656 "the reported path must be a closed loop"
1657 );
1658 assert_eq!(path.len(), 3, "the whole 3-node cycle must be shown");
1659 assert!(
1660 result.errors[0].message.contains("->"),
1661 "the message must render the chain: {}",
1662 result.errors[0].message
1663 );
1664 }
1665
1666 #[test]
1667 fn a_deep_chain_is_walked_without_stack_overflow() {
1668 const DEPTH: u32 = 10_000;
1670 let mut issues: Vec<Node> = (1..=DEPTH + 1)
1671 .map(|n| {
1672 let mut node = Node::new(n, "n");
1673 node.parent = if n == 1 || n == DEPTH + 1 {
1678 None
1679 } else {
1680 Some(n - 1)
1681 };
1682 node
1683 })
1684 .collect();
1685 let index = HierarchyIndex::build(&issues);
1686
1687 let outside_child = DEPTH + 1;
1695 let result = validate_proposed(&index, outside_child, Some(DEPTH / 2));
1696 assert!(
1697 !result.blocking,
1698 "pointing a new child into the middle of a 10,000-deep chain is not a \
1699 cycle: {:?}",
1700 result.errors
1701 );
1702
1703 let root = DEPTH;
1705 issues[root as usize - 1].parent = Some(root);
1706 let index2 = HierarchyIndex::build(&issues);
1707 let result = validate_proposed(&index2, 1, Some(root));
1708 assert!(
1709 result.blocking,
1710 "a 10,000-deep chain closed into a cycle must be caught"
1711 );
1712 assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::Cycle]);
1713 }
1714
1715 #[test]
1716 fn a_clean_hierarchy_validates_with_no_findings() {
1717 let issues = nodes(&[
1718 (1, "Root", None),
1719 (2, "A", Some(1)),
1720 (3, "B", Some(1)),
1721 (4, "C", Some(2)),
1722 ]);
1723 let result = validate_hierarchy(&issues);
1724 assert!(result.is_empty(), "unexpected: {:?}", result.errors);
1725 assert!(!result.blocking);
1726 }
1727
1728 #[test]
1729 fn validate_hierarchy_reports_each_kind_over_a_snapshot() {
1730 let issues = nodes(&[
1731 (1, "Self", Some(1)),
1732 (2, "Dangling", Some(99)),
1733 (3, "A", Some(4)),
1734 (4, "B", Some(3)),
1735 ]);
1736 let result = validate_hierarchy(&issues);
1737 assert!(result.blocking);
1738 let kinds = result.kinds();
1739 for expected in [
1740 HierarchyDiagnosticKind::SelfParent,
1741 HierarchyDiagnosticKind::UnresolvedParent,
1749 HierarchyDiagnosticKind::Cycle,
1750 ] {
1751 assert!(
1752 kinds.contains(&expected),
1753 "expected {expected:?} in {kinds:?}"
1754 );
1755 }
1756 assert_eq!(
1758 HierarchyDiagnosticKind::UnknownParent.as_str(),
1759 "unknown_parent"
1760 );
1761 assert_eq!(
1762 HierarchyDiagnosticKind::UnresolvedParent.as_str(),
1763 "unresolved_parent",
1764 "T022 step 1 fixes this spelling; a different one is a contract violation"
1765 );
1766 }
1767
1768 #[test]
1769 fn a_rejected_proposal_yields_no_change_and_no_event() {
1770 let index = HierarchyIndex::build(&nodes(&[(1, "A", None), (2, "B", Some(1))]));
1771 let err = set_parent(&index, 1, 1).unwrap_err();
1772 let HierarchyError::Rejected(result) = err;
1773 assert!(result.blocking);
1774 }
1775
1776 #[test]
1777 fn an_unknown_child_is_refused_by_set_change_and_clear() {
1778 let index = HierarchyIndex::build(&nodes(&[(1, "P", None), (2, "C", None)]));
1783 let missing = 99;
1784
1785 let result = validate_proposed(&index, missing, Some(1));
1788 assert!(result.blocking);
1789 assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::UnknownChild]);
1790 assert_eq!(result.errors[0].issue, missing);
1791 assert!(
1792 result.errors[0].message.contains("#99"),
1793 "the message must name the missing child: {}",
1794 result.errors[0].message
1795 );
1796
1797 for (label, outcome) in [
1798 ("set_parent", set_parent(&index, missing, 1)),
1799 ("change_parent", change_parent(&index, missing, 1)),
1800 ("clear_parent", clear_parent(&index, missing)),
1801 ] {
1802 let err = outcome.expect_err(&format!("{label}(child={missing}) must be refused"));
1803 assert!(
1804 matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::UnknownChild]),
1805 "{label} refused for the wrong reason: {err:?}"
1806 );
1807 }
1808
1809 assert!(set_parent(&index, 2, 1).is_ok());
1812 assert!(clear_parent(&index, 2).unwrap().event.is_none());
1813 }
1814
1815 #[test]
1816 fn the_child_check_runs_before_the_parent_check() {
1817 let index = HierarchyIndex::build(&nodes(&[(1, "P", None)]));
1820 let result = validate_proposed(&index, 77, Some(88));
1821 assert_eq!(result.kinds(), vec![HierarchyDiagnosticKind::UnknownChild]);
1822 }
1823
1824 #[test]
1825 fn an_unknown_child_is_not_laundered_into_a_clear_no_op() {
1826 let index = HierarchyIndex::build(&nodes(&[(1, "A", None)]));
1830 assert!(clear_parent(&index, 42).is_err());
1831 assert!(
1832 clear_parent(&index, 1).is_ok(),
1833 "a known child with no parent is still a no-op"
1834 );
1835 }
1836
1837 #[test]
1840 fn a_parent_with_three_children_reports_counts_and_typed_progress() {
1841 let issues = nodes(&[
1842 (1, "Parent", None),
1843 (2, "A", Some(1)),
1844 (3, "B", Some(1)),
1845 (4, "C", Some(1)),
1846 ]);
1847 let mut issues = issues;
1848 issues[1].status = "done".to_string();
1849 let index = HierarchyIndex::build(&issues);
1850 let rollup = rollup(&index, 1, "done");
1851 assert_eq!(rollup.direct_children, 3);
1852 assert_eq!(rollup.completed_children, 1);
1853 assert_eq!(
1854 rollup.progress,
1855 Progress { done: 1, total: 3 },
1856 "progress must be the typed value, not a formatted string"
1857 );
1858 assert_eq!(rollup.completion_basis, CompletionBasis::DirectChildren);
1859 }
1860
1861 #[test]
1862 fn an_issue_with_no_children_rolls_up_to_zero() {
1863 let index = HierarchyIndex::build(&nodes(&[(1, "Lonely", None)]));
1864 let rollup = rollup(&index, 1, "done");
1865 assert_eq!(rollup.direct_children, 0);
1866 assert_eq!(rollup.completed_children, 0);
1867 assert_eq!(rollup.progress, Progress { done: 0, total: 0 });
1868 }
1869
1870 #[test]
1871 fn completion_is_resolved_through_the_supplied_column_not_a_literal() {
1872 let issues = vec![
1874 Node::new(1, "Parent"),
1875 Node {
1876 parent: Some(1),
1877 ..Node::new(2, "A").with_status("shipped")
1878 },
1879 Node {
1880 parent: Some(1),
1881 ..Node::new(3, "B").with_status("done")
1882 },
1883 ];
1884 let index = HierarchyIndex::build(&issues);
1885 assert_eq!(
1886 index.children_of(1).child_count,
1887 2,
1888 "precondition: the parent must actually have two children — the first \
1889 version of this test never set a parent, so it was measuring zero \
1890 children and asserting a count of one"
1891 );
1892 assert_eq!(
1893 rollup(&index, 1, "shipped").completed_children,
1894 1,
1895 "with 'shipped' as the completion column, A counts and B does not"
1896 );
1897 assert_eq!(
1898 rollup(&index, 1, "done").completed_children,
1899 1,
1900 "and with 'done', B counts instead"
1901 );
1902 let membership = index.children_of(1);
1906 let complete_under_shipped = membership
1907 .children
1908 .iter()
1909 .find(|c| index.status_of(c.number) == Some("shipped"))
1910 .map(|c| c.number);
1911 let complete_under_done = membership
1912 .children
1913 .iter()
1914 .find(|c| index.status_of(c.number) == Some("done"))
1915 .map(|c| c.number);
1916 assert_eq!(complete_under_shipped, Some(2));
1917 assert_eq!(complete_under_done, Some(3));
1918 assert_ne!(complete_under_shipped, complete_under_done);
1919 }
1920
1921 #[test]
1922 fn repeated_rollup_over_the_same_snapshot_returns_equal_values() {
1923 let issues = nodes(&[(1, "P", None), (2, "A", Some(1)), (3, "B", Some(1))]);
1924 let index = HierarchyIndex::build(&issues);
1925 assert_eq!(rollup(&index, 1, "done"), rollup(&index, 1, "done"));
1926 }
1927
1928 #[test]
1931 fn an_unresolved_parent_retains_the_recorded_reference_for_repair() {
1932 let issues = nodes(&[(1, "Child", Some(42))]);
1933 let index = HierarchyIndex::build(&issues);
1934 let unresolved = index.unresolved();
1935 assert_eq!(unresolved.len(), 1);
1936 assert_eq!(unresolved[0].child, 1);
1937 assert_eq!(
1938 unresolved[0].recorded_parent, 42,
1939 "the reference must survive so it can be repaired, not be forgotten"
1940 );
1941 assert!(
1942 unresolved[0].reason.contains("#42"),
1943 "the reason must name the missing parent: {}",
1944 unresolved[0].reason
1945 );
1946 }
1947
1948 #[test]
1949 fn deletion_reports_affected_children_without_deleting_them() {
1950 let issues = nodes(&[
1951 (1, "Parent", None),
1952 (2, "A", Some(1)),
1953 (3, "B", Some(1)),
1954 (4, "Elsewhere", None),
1955 ]);
1956 let affected = affected_by_deletion(&issues, 1);
1957 assert_eq!(
1958 affected.iter().map(|c| c.number).collect::<Vec<_>>(),
1959 vec![2, 3]
1960 );
1961 assert_eq!(issues.len(), 4);
1963 assert_eq!(issues[1].parent, Some(1));
1964 assert!(
1965 affected_by_deletion(&issues, 4).is_empty(),
1966 "an issue with no children affects nobody"
1967 );
1968 }
1969
1970 #[test]
1971 fn the_whole_surface_runs_without_a_store_or_a_tempfile() {
1972 let issues = nodes(&[
1976 (1, "Root", None),
1977 (2, "A", Some(1)),
1978 (3, "B", Some(1)),
1979 (4, "Orphan", Some(77)),
1980 ]);
1981 let index = HierarchyIndex::build(&issues);
1982 assert_eq!(index.children_of(1).child_count, 2);
1983 assert_eq!(rollup(&index, 1, "done").direct_children, 2);
1984 assert_eq!(index.unresolved().len(), 1);
1985 assert!(!validate_hierarchy(&issues).is_empty());
1986 assert!(set_parent(&index, 3, 2).is_ok());
1987 }
1988
1989 #[test]
1994 fn a_new_issues_parent_must_exist() {
1995 let index = HierarchyIndex::build(&nodes(&[(1, "P", None)]));
2002 assert!(
2003 validate_new_parent(&index, 1).is_ok(),
2004 "an existing issue is a valid parent"
2005 );
2006
2007 let err = validate_new_parent(&index, 404).expect_err("a missing issue is not");
2008 assert!(
2009 matches!(&err, HierarchyError::Rejected(r) if r.kinds() == [HierarchyDiagnosticKind::UnknownParent]),
2010 "refused for the wrong reason: {err:?}"
2011 );
2012 let HierarchyError::Rejected(r) = err;
2015 assert_eq!(r.errors[0].issue, NO_ISSUE_YET);
2016 assert!(
2017 r.errors[0].message.contains("404"),
2018 "the message must name the missing parent: {}",
2019 r.errors[0].message
2020 );
2021 }
2022
2023 #[test]
2024 fn no_issue_yet_is_not_a_number_the_store_can_hand_out() {
2025 assert_eq!(NO_ISSUE_YET, u32::MAX);
2037 assert!(
2038 NO_ISSUE_YET.checked_add(1).is_none(),
2039 "the sentinel must be the one value the allocator cannot produce"
2040 );
2041 }
2042
2043 #[test]
2048 fn t015_the_completion_basis_is_direct_children_and_the_rollup_says_so() {
2049 let issues = vec![
2058 node(1, "Parent", "todo", None),
2059 node(2, "Done child", "done", Some(1)),
2060 node(3, "Todo child", "todo", Some(1)),
2061 node(4, "Grandchild of #2", "done", Some(2)),
2062 ];
2063 let index = HierarchyIndex::build(&issues);
2064 let r = rollup(&index, 1, "done");
2065 assert_eq!(r.completion_basis, CompletionBasis::DirectChildren);
2066 assert_eq!(r.direct_children, 2, "the direct children are counted");
2067 assert_eq!(
2068 r.completed_children, 1,
2069 "only #2 is in the completion column"
2070 );
2071 assert_eq!(r.progress.done, 1);
2072 assert_eq!(r.progress.total, 2);
2073 assert_eq!(r.direct_children + r.progress.total, 4, "2 + 2, not 3");
2075 }
2076
2077 #[test]
2078 fn t015_a_grandchild_completed_does_not_complete_its_grandparent() {
2079 let issues = vec![
2082 node(1, "Grandparent", "todo", None),
2083 node(2, "Child", "done", Some(1)),
2084 node(3, "Grandchild", "todo", Some(2)),
2085 ];
2086 let index = HierarchyIndex::build(&issues);
2087 let r = rollup(&index, 1, "done");
2090 assert_eq!(
2091 r.completed_children, 1,
2092 "the child counts, not the grandchild"
2093 );
2094 assert_eq!(r.progress.total, 1);
2095 let child_rollup = rollup(&index, 2, "done");
2097 assert_eq!(child_rollup.completed_children, 0);
2098 assert_eq!(child_rollup.progress.total, 1);
2099 }
2100
2101 #[test]
2102 fn t015_direct_children_are_reported_separately_from_the_rollup() {
2103 let issues: Vec<Node> = (1..=6)
2107 .map(|n| {
2108 node(
2109 n,
2110 &format!("Child {n}"),
2111 if n % 2 == 0 { "done" } else { "todo" },
2112 Some(1),
2113 )
2114 })
2115 .collect();
2116 let index = HierarchyIndex::build(&issues);
2117 let r = rollup(&index, 1, "done");
2118 assert_eq!(r.direct_children, 6);
2119 assert_eq!(r.completed_children, 3);
2120 assert_eq!(r.progress.done, 3);
2121 assert_eq!(r.progress.total, 6);
2122 }
2123
2124 #[test]
2134 fn t020_dropping_a_parent_from_the_set_does_not_clear_the_childs_reference() {
2135 let before = vec![
2139 node(1, "Parent", "todo", None),
2140 node(2, "Child", "todo", Some(1)),
2141 ];
2142 let mut after = before.clone();
2143 after.retain(|n| n.number != 1);
2144
2145 let index = HierarchyIndex::build(&after);
2146 assert_eq!(
2147 index.parent_of(2),
2148 Some(1),
2149 "the recorded reference must survive its target"
2150 );
2151 let unresolved = index.unresolved();
2152 assert_eq!(unresolved.len(), 1, "and be diagnosed");
2153 assert_eq!(unresolved[0].child, 2);
2154 assert_eq!(unresolved[0].recorded_parent, 1);
2155 assert_eq!(after[0].parent, Some(1));
2158 }
2159
2160 #[test]
2161 fn t020_repairing_a_child_resolves_it_with_no_cleanup_step() {
2162 let dangling = vec![node(2, "Child", "todo", Some(1))];
2166 let index = HierarchyIndex::build(&dangling);
2167 assert_eq!(index.unresolved().len(), 1, "starts unresolved");
2168
2169 let repaired = vec![
2171 node(1, "Parent", "todo", None),
2172 node(2, "Child", "todo", Some(1)),
2173 ];
2174 let index = HierarchyIndex::build(&repaired);
2175 assert!(
2176 index.unresolved().is_empty(),
2177 "the unresolved state disappears because it is derived, not stored"
2178 );
2179 assert_eq!(index.parent_of(2), Some(1), "and the parent now resolves");
2180 assert!(index.contains(1), "and the target is in the index");
2181
2182 let repointed = vec![
2185 node(1, "Parent", "todo", None),
2186 node(2, "Child", "todo", Some(9)),
2187 node(9, "Other", "todo", None),
2188 ];
2189 let index = HierarchyIndex::build(&repointed);
2190 assert_eq!(
2191 index.unresolved(),
2192 [] as [crate::hierarchy::UnresolvedParentState; 0]
2193 );
2194 assert_eq!(index.parent_of(2), Some(9));
2195 }
2196
2197 #[test]
2198 fn t020_unresolved_is_ordered_by_child_so_a_lint_run_is_deterministic() {
2199 let forward = vec![
2204 node(1, "A", "todo", Some(99)),
2205 node(2, "B", "todo", Some(98)),
2206 node(3, "C", "todo", Some(97)),
2207 ];
2208 let mut reversed = forward.clone();
2209 reversed.reverse();
2210 let a = HierarchyIndex::build(&forward).unresolved();
2211 let b = HierarchyIndex::build(&reversed).unresolved();
2212 assert_eq!(a, b, "the report must not depend on the read order");
2213 assert_eq!(
2214 a.iter().map(|s| s.child).collect::<Vec<_>>(),
2215 vec![1, 2, 3],
2216 "and it is ordered by child number"
2217 );
2218 }
2219
2220 #[test]
2221 fn t020_a_resolved_parent_is_never_reported_unresolved() {
2222 let clean = vec![
2227 node(1, "Parent", "todo", None),
2228 node(2, "Child", "todo", Some(1)),
2229 node(3, "Grandchild", "todo", Some(2)),
2230 ];
2231 let index = HierarchyIndex::build(&clean);
2232 assert!(
2233 index.unresolved().is_empty(),
2234 "a clean hierarchy reports nothing: {:?}",
2235 index.unresolved()
2236 );
2237 }
2238}