1use std::collections::{HashMap, HashSet, VecDeque};
88use std::fmt;
89use std::str::FromStr;
90
91use serde::{Deserialize, Serialize};
92
93use crate::id::{AliasIndex, IdentityNode, IssueRef, canonical_ref};
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum RelationKind {
107 Blocks,
110 BlockedBy,
114 Duplicates,
116 Related,
119}
120
121impl RelationKind {
122 pub const ALL: [RelationKind; 4] = [
124 RelationKind::Blocks,
125 RelationKind::BlockedBy,
126 RelationKind::Duplicates,
127 RelationKind::Related,
128 ];
129
130 pub fn as_str(self) -> &'static str {
132 match self {
133 RelationKind::Blocks => "blocks",
134 RelationKind::BlockedBy => "blocked_by",
135 RelationKind::Duplicates => "duplicates",
136 RelationKind::Related => "related",
137 }
138 }
139
140 pub fn is_directional(self) -> bool {
142 !matches!(self, RelationKind::Related)
143 }
144
145 pub fn inverse(self) -> Option<Self> {
150 match self {
151 RelationKind::Blocks => Some(RelationKind::BlockedBy),
152 RelationKind::BlockedBy => Some(RelationKind::Blocks),
153 RelationKind::Duplicates | RelationKind::Related => None,
154 }
155 }
156
157 fn canonical(self) -> (Self, bool) {
163 match self {
164 RelationKind::BlockedBy => (RelationKind::Blocks, true),
165 other => (other, false),
166 }
167 }
168}
169
170impl fmt::Display for RelationKind {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 f.write_str(self.as_str())
173 }
174}
175
176impl FromStr for RelationKind {
177 type Err = Diagnostic;
178
179 fn from_str(s: &str) -> Result<Self, Self::Err> {
183 match s {
184 "blocks" => Ok(RelationKind::Blocks),
185 "blocked_by" => Ok(RelationKind::BlockedBy),
186 "duplicates" => Ok(RelationKind::Duplicates),
187 "related" => Ok(RelationKind::Related),
188 other => Err(Diagnostic::unknown_kind(
189 "kind",
190 format!(
191 "unknown relation kind `{other}`; expected one of blocks, blocked_by, duplicates, related"
192 ),
193 )),
194 }
195 }
196}
197
198pub type RelationshipId = String;
204
205#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
210pub struct Relationship {
211 #[serde(default)]
213 pub id: RelationshipId,
214 pub from: IssueRef,
216 pub to: IssueRef,
218 pub kind: RelationKind,
222 #[serde(default, skip_serializing_if = "String::is_empty")]
226 pub display: String,
227}
228
229impl Relationship {
230 pub fn new(
233 from: impl Into<IssueRef>,
234 to: impl Into<IssueRef>,
235 kind: RelationKind,
236 target_title: &str,
237 ) -> Self {
238 let canonical = CanonicalEdge::new(from, to, kind);
239 let target_display = canonical
240 .to
241 .as_number()
242 .map_or_else(|| canonical.to.to_string(), |number| number.to_string());
243 Self {
244 id: generate_relationship_id(),
245 from: canonical.from,
246 to: canonical.to,
247 kind: canonical.kind,
248 display: display_name_text(canonical.kind, &target_display, target_title),
249 }
250 }
251
252 pub fn refresh_display(&mut self, target_title: &str) {
255 let target_display = self
256 .to
257 .as_number()
258 .map_or_else(|| self.to.to_string(), |number| number.to_string());
259 self.display = display_name_text(self.kind, &target_display, target_title);
260 }
261
262 pub fn refresh_display_for(&mut self, target_number: u32, target_title: &str) {
264 self.display = display_name(self.kind, target_number, target_title);
265 }
266
267 pub fn canonical(&self) -> CanonicalEdge {
271 CanonicalEdge::new(self.from, self.to, self.kind)
272 }
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278enum Duplicate {
279 Allowed,
282 Rejected,
286}
287
288pub fn display_name(kind: RelationKind, target: u32, target_title: &str) -> String {
290 display_name_text(kind, &target.to_string(), target_title)
291}
292
293fn display_name_text(kind: RelationKind, target: &str, target_title: &str) -> String {
294 let target = if crate::id::is_issue_id(target) {
295 target.to_string()
296 } else {
297 format!("#{target}")
298 };
299 if target_title.trim().is_empty() {
300 format!("{} {target}", kind.as_str())
301 } else {
302 format!("{} {target} {}", kind.as_str(), target_title.trim())
303 }
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
312pub struct CanonicalEdge {
313 pub from: IssueRef,
315 pub to: IssueRef,
317 pub kind: RelationKind,
320}
321
322impl CanonicalEdge {
323 pub fn new(from: impl Into<IssueRef>, to: impl Into<IssueRef>, kind: RelationKind) -> Self {
340 let from = from.into();
341 let to = to.into();
342 let (kind, swap) = kind.canonical();
343 if swap || (!kind.is_directional() && from.key() > to.key()) {
347 Self {
348 from: to,
349 to: from,
350 kind,
351 }
352 } else {
353 Self { from, to, kind }
354 }
355 }
356
357 pub fn is_self_link(self) -> bool {
359 self.from == self.to
360 }
361}
362
363pub fn normalize(
368 from: impl Into<IssueRef>,
369 to: impl Into<IssueRef>,
370 kind: RelationKind,
371) -> CanonicalEdge {
372 CanonicalEdge::new(from, to, kind)
373}
374
375pub fn normalize_all<F, T, I>(authored: I) -> Vec<CanonicalEdge>
382where
383 F: Into<IssueRef>,
384 T: Into<IssueRef>,
385 I: IntoIterator<Item = (F, T, RelationKind)>,
386{
387 let mut seen: HashSet<CanonicalEdge> = HashSet::new();
388 for (from, to, kind) in authored {
389 seen.insert(CanonicalEdge::new(from, to, kind));
390 }
391 let mut edges: Vec<CanonicalEdge> = seen.into_iter().collect();
392 edges.sort_unstable();
393 edges
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
405#[serde(rename_all = "snake_case")]
406pub enum DiagnosticKind {
407 SelfLink,
409 MissingTarget,
411 AmbiguousTarget,
413 DuplicateIdentity,
415 ConflictingEdge,
417 Cycle,
419 UnknownKind,
421 UnknownRelation,
423 RetiredLinksKey,
425}
426
427impl DiagnosticKind {
428 pub fn as_str(self) -> &'static str {
430 match self {
431 DiagnosticKind::SelfLink => "self_link",
432 DiagnosticKind::MissingTarget => "missing_target",
433 DiagnosticKind::AmbiguousTarget => "ambiguous_target",
434 DiagnosticKind::DuplicateIdentity => "duplicate_identity",
435 DiagnosticKind::ConflictingEdge => "conflicting_edge",
436 DiagnosticKind::Cycle => "cycle",
437 DiagnosticKind::UnknownKind => "unknown_kind",
438 DiagnosticKind::UnknownRelation => "unknown_relation",
439 DiagnosticKind::RetiredLinksKey => "retired_links_key",
440 }
441 }
442}
443
444impl fmt::Display for DiagnosticKind {
445 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
446 f.write_str(self.as_str())
447 }
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452pub struct Diagnostic {
453 pub kind: DiagnosticKind,
455 pub field: String,
457 pub message: String,
460 pub issue: Option<u32>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub source_file: Option<String>,
494}
495
496impl Diagnostic {
497 pub fn new(kind: DiagnosticKind, field: impl Into<String>, message: impl Into<String>) -> Self {
499 Self {
500 kind,
501 field: field.into(),
502 message: message.into(),
503 issue: None,
504 source_file: None,
505 }
506 }
507
508 pub fn about(
511 issue: u32,
512 kind: DiagnosticKind,
513 field: impl Into<String>,
514 message: impl Into<String>,
515 ) -> Self {
516 Self {
517 kind,
518 field: field.into(),
519 message: message.into(),
520 issue: Some(issue),
521 source_file: None,
522 }
523 }
524
525 #[must_use]
527 pub fn with_source_file(mut self, filename: impl Into<String>) -> Self {
528 self.source_file = Some(filename.into());
529 self
530 }
531
532 pub fn self_link(issue: u32) -> Self {
534 Self::about(
535 issue,
536 DiagnosticKind::SelfLink,
537 "to",
538 format!("#{issue} cannot block itself: a self-link is not a valid relationship"),
539 )
540 }
541
542 pub fn missing_target(issue: u32, selector: impl fmt::Display) -> Self {
544 Self::about(
545 issue,
546 DiagnosticKind::MissingTarget,
547 "to",
548 format!("issue #{issue} references `{selector}`, which resolves to no issue"),
549 )
550 }
551
552 pub fn unknown_kind(field: impl Into<String>, message: impl Into<String>) -> Self {
554 Self::new(DiagnosticKind::UnknownKind, field, message)
555 }
556
557 pub fn unknown_relation(id: &str) -> Self {
559 Self::new(
560 DiagnosticKind::UnknownRelation,
561 "id",
562 format!("no relationship with id `{id}`"),
563 )
564 }
565}
566
567impl fmt::Display for Diagnostic {
568 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
569 write!(
570 f,
571 "{} ({}): {}",
572 self.kind.as_str(),
573 self.field,
574 self.message
575 )
576 }
577}
578
579fn alias_diagnostic(
580 issue: u32,
581 filename: &str,
582 field: &str,
583 reference: IssueRef,
584 error: &crate::id::AliasError,
585) -> Diagnostic {
586 let (kind, message) = match error {
587 crate::id::AliasError::Missing(_) => (
588 DiagnosticKind::MissingTarget,
589 format!("{field} `{reference}` resolves to no issue"),
590 ),
591 crate::id::AliasError::Ambiguous { candidates, .. } => (
592 DiagnosticKind::AmbiguousTarget,
593 format!(
594 "{field} `{reference}` is ambiguous: {}",
595 candidates
596 .iter()
597 .map(ToString::to_string)
598 .collect::<Vec<_>>()
599 .join(", ")
600 ),
601 ),
602 crate::id::AliasError::DuplicateId { .. } => (
603 DiagnosticKind::DuplicateIdentity,
604 format!("{field} `{reference}` shares a canonical id with another issue"),
605 ),
606 };
607 Diagnostic::about(issue, kind, field, message).with_source_file(filename)
608}
609
610fn duplicate_identity_diagnostics(aliases: &AliasIndex) -> Vec<Diagnostic> {
611 aliases
612 .duplicate_records()
613 .iter()
614 .map(|record| {
615 let files = aliases
616 .duplicate_id_files()
617 .get(&record.id)
618 .map(|files| files.join(", "))
619 .unwrap_or_default();
620 Diagnostic::about(
621 record.number,
622 DiagnosticKind::DuplicateIdentity,
623 "id",
624 format!(
625 "canonical id `{}` is claimed by {files}; ids must be unique",
626 record.id
627 ),
628 )
629 .with_source_file(&record.filename)
630 })
631 .collect()
632}
633
634#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
639pub struct RelationshipValidationResult {
640 pub errors: Vec<Diagnostic>,
642}
643
644impl RelationshipValidationResult {
645 pub fn clean() -> Self {
647 Self::default()
648 }
649
650 pub fn with(diagnostic: Diagnostic) -> Self {
652 Self {
653 errors: vec![diagnostic],
654 }
655 }
656
657 pub fn is_clean(&self) -> bool {
659 self.errors.is_empty()
660 }
661
662 pub fn blocking(&self) -> bool {
668 !self.errors.is_empty()
669 }
670
671 pub fn has(&self, kind: DiagnosticKind) -> bool {
673 self.errors.iter().any(|d| d.kind == kind)
674 }
675
676 pub fn merge(&mut self, other: Self) {
678 self.errors.extend(other.errors);
679 }
680
681 pub fn sort_diagnostics(&mut self) {
684 self.errors
685 .sort_by(|a, b| (a.kind, &a.field, &a.message).cmp(&(b.kind, &b.field, &b.message)));
686 }
687}
688
689#[derive(Debug, Clone, Copy)]
700pub struct IssueView<'a> {
701 pub id: Option<&'a str>,
703 pub filename: &'a str,
705 pub number: u32,
707 pub title: &'a str,
709 pub status: &'a str,
711 pub relationships: &'a [Relationship],
713 pub has_retired_links_key: bool,
717 pub parent: Option<IssueRef>,
729}
730
731impl<'a> IssueView<'a> {
732 pub fn new(
734 number: u32,
735 title: &'a str,
736 status: &'a str,
737 relationships: &'a [Relationship],
738 ) -> Self {
739 Self {
740 id: None,
741 filename: "",
742 number,
743 title,
744 status,
745 relationships,
746 has_retired_links_key: false,
747 parent: None,
748 }
749 }
750
751 pub fn with_identity(mut self, id: Option<&'a str>, filename: &'a str) -> Self {
753 self.id = id.map(|id| id.trim()).filter(|id| !id.is_empty());
754 self.filename = filename;
755 self
756 }
757
758 pub fn with_retired_links_key(mut self, flagged: bool) -> Self {
760 self.has_retired_links_key = flagged;
761 self
762 }
763
764 pub fn with_parent(mut self, parent: Option<IssueRef>) -> Self {
766 self.parent = parent;
767 self
768 }
769
770 #[must_use]
772 pub fn graph_ref(&self) -> IssueRef {
773 canonical_ref(self)
774 }
775}
776
777impl crate::hierarchy::HierarchyNode for IssueView<'_> {
782 fn number(&self) -> u32 {
783 self.number
784 }
785 fn title(&self) -> &str {
786 self.title
787 }
788 fn status(&self) -> &str {
789 self.status
790 }
791 fn canonical_id(&self) -> Option<&str> {
792 self.id
793 }
794 fn filename(&self) -> Option<&str> {
795 Some(self.filename)
796 }
797 fn parent_ref(&self) -> Option<&IssueRef> {
798 self.parent.as_ref()
799 }
800 fn parent_number(&self) -> Option<u32> {
801 self.parent.as_ref().and_then(IssueRef::as_number)
802 }
803}
804
805impl IdentityNode for IssueView<'_> {
806 fn canonical_id(&self) -> Option<&str> {
807 self.id
808 }
809
810 fn issue_number(&self) -> u32 {
811 self.number
812 }
813
814 fn issue_filename(&self) -> &str {
815 self.filename
816 }
817
818 fn issue_title(&self) -> &str {
819 self.title
820 }
821}
822
823#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
825pub struct ChainNode {
826 pub number: u32,
828 pub title: String,
830}
831
832impl ChainNode {
833 pub fn new(number: u32, title: impl Into<String>) -> Self {
835 Self {
836 number,
837 title: title.into(),
838 }
839 }
840}
841
842#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
844pub struct DependencyChain {
845 pub root: u32,
847 pub paths: Vec<Vec<ChainNode>>,
849 pub truncated: bool,
852}
853
854impl DependencyChain {
855 pub fn nodes(&self) -> Vec<&ChainNode> {
857 let mut seen = HashSet::new();
858 self.paths
859 .iter()
860 .flatten()
861 .filter(|n| seen.insert(n.number))
862 .collect()
863 }
864}
865
866#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
868pub struct BlockedState {
869 pub is_blocked: bool,
871 pub blockers: Vec<ChainNode>,
873 pub reason: String,
876}
877
878pub const MAX_CHAIN_NODES: usize = 1_000;
885
886#[derive(Debug)]
893pub struct RelationshipGraph<'a> {
894 issues: HashMap<IssueRef, IssueView<'a>>,
895 aliases: AliasIndex,
896 alias_diagnostics: Vec<Diagnostic>,
897 refs_by_number: HashMap<u32, Vec<IssueRef>>,
898 numbers_by_ref: HashMap<IssueRef, u32>,
899 issues_by_number: Vec<u32>,
900 edges: Vec<Relationship>,
903 by_id: HashMap<RelationshipId, usize>,
904 edge_owners: HashMap<RelationshipId, String>,
906 by_issue: HashMap<IssueRef, Vec<usize>>,
908 blocks_out: HashMap<IssueRef, Vec<IssueRef>>,
910 blocks_in: HashMap<IssueRef, Vec<IssueRef>>,
911 blocks_out_numbers: HashMap<u32, Vec<u32>>,
912 blocks_in_numbers: HashMap<u32, Vec<u32>>,
913 done_column: String,
914 validation_passes: std::sync::atomic::AtomicUsize,
927}
928
929impl Clone for RelationshipGraph<'_> {
930 fn clone(&self) -> Self {
933 Self {
934 issues: self.issues.clone(),
935 aliases: self.aliases.clone(),
936 alias_diagnostics: self.alias_diagnostics.clone(),
937 refs_by_number: self.refs_by_number.clone(),
938 numbers_by_ref: self.numbers_by_ref.clone(),
939 issues_by_number: self.issues_by_number.clone(),
940 edges: self.edges.clone(),
941 by_id: self.by_id.clone(),
942 edge_owners: self.edge_owners.clone(),
943 by_issue: self.by_issue.clone(),
944 blocks_out: self.blocks_out.clone(),
945 blocks_in: self.blocks_in.clone(),
946 blocks_out_numbers: self.blocks_out_numbers.clone(),
947 blocks_in_numbers: self.blocks_in_numbers.clone(),
948 done_column: self.done_column.clone(),
949 validation_passes: std::sync::atomic::AtomicUsize::new(0),
950 }
951 }
952}
953
954impl<'a> RelationshipGraph<'a> {
955 pub fn validation_passes(&self) -> usize {
958 self.validation_passes
959 .load(std::sync::atomic::Ordering::Relaxed)
960 }
961
962 pub fn new(issues: &[IssueView<'a>], done_column: &str) -> Self {
974 let mut issues_by_number: Vec<u32> = issues.iter().map(|i| i.number).collect();
975 issues_by_number.sort_unstable();
976 let mut refs_by_number: HashMap<u32, Vec<IssueRef>> = HashMap::new();
977 let mut numbers_by_ref = HashMap::new();
978 let mut by_ref = HashMap::new();
979 for view in issues {
980 let reference = view.graph_ref();
981 refs_by_number
982 .entry(view.number)
983 .or_default()
984 .push(reference);
985 numbers_by_ref.insert(reference, view.number);
986 by_ref.insert(reference, *view);
987 }
988
989 let aliases = AliasIndex::build(issues);
990 let mut graph = Self {
991 issues: by_ref,
992 aliases,
993 alias_diagnostics: Vec::new(),
994 refs_by_number,
995 numbers_by_ref,
996 issues_by_number,
997 edges: Vec::new(),
998 by_id: HashMap::new(),
999 edge_owners: HashMap::new(),
1000 by_issue: HashMap::new(),
1001 blocks_out: HashMap::new(),
1002 blocks_in: HashMap::new(),
1003 blocks_out_numbers: HashMap::new(),
1004 blocks_in_numbers: HashMap::new(),
1005 done_column: done_column.to_string(),
1006 validation_passes: std::sync::atomic::AtomicUsize::new(0),
1007 };
1008 graph.rebuild_edges();
1009 graph
1010 }
1011
1012 fn reference_for_number(&self, number: u32) -> Option<IssueRef> {
1013 match self.refs_by_number.get(&number)?.as_slice() {
1014 [one] => Some(*one),
1015 _ => None,
1016 }
1017 }
1018
1019 fn number_for_reference(&self, reference: IssueRef) -> Option<u32> {
1020 self.numbers_by_ref.get(&reference).copied()
1021 }
1022
1023 fn conflict_key(&self, edge: CanonicalEdge) -> (IssueRef, IssueRef) {
1031 if edge.kind != RelationKind::Related || edge.from == edge.to {
1032 return (edge.from, edge.to);
1033 }
1034 let from_number = self.number_for_reference(edge.from);
1035 let to_number = self.number_for_reference(edge.to);
1036 if from_number.is_some_and(|from| to_number.is_some_and(|to| from > to)) {
1037 (edge.to, edge.from)
1038 } else {
1039 (edge.from, edge.to)
1040 }
1041 }
1042
1043 fn rebuild_edges(&mut self) {
1054 let mut edges: Vec<Relationship> = Vec::new();
1055 self.alias_diagnostics = duplicate_identity_diagnostics(&self.aliases);
1056 self.edge_owners.clear();
1057 let mut references: Vec<IssueRef> = self.issues.keys().copied().collect();
1058 references.sort_unstable();
1059 for reference in references {
1060 let view = self.issues[&reference];
1061 for rel in view.relationships {
1062 let mut stored = rel.clone();
1063 self.edge_owners
1064 .insert(stored.id.clone(), view.filename.to_string());
1065 let owner = self.number_for_reference(reference).unwrap_or_default();
1066 stored.from = match self.aliases.resolve(&stored.from) {
1067 Ok(resolved) => resolved,
1068 Err(error) => {
1069 if !matches!(&error, crate::id::AliasError::Missing(_)) {
1070 self.alias_diagnostics.push(alias_diagnostic(
1071 owner,
1072 view.filename,
1073 "relationships.from",
1074 stored.from,
1075 &error,
1076 ));
1077 }
1078 stored.from
1079 }
1080 };
1081 stored.to = match self.aliases.resolve(&stored.to) {
1082 Ok(resolved) => resolved,
1083 Err(error) => {
1084 if !matches!(&error, crate::id::AliasError::Missing(_)) {
1085 self.alias_diagnostics.push(alias_diagnostic(
1086 owner,
1087 view.filename,
1088 "relationships.to",
1089 stored.to,
1090 &error,
1091 ));
1092 }
1093 stored.to
1094 }
1095 };
1096 let canonical = stored.canonical();
1097 stored.from = canonical.from;
1098 stored.to = canonical.to;
1099 stored.kind = canonical.kind;
1100 if let Some(target) = self.issues.get(&stored.to) {
1107 let target_number = self.number_for_reference(stored.to).unwrap_or_default();
1108 stored.refresh_display_for(target_number, target.title);
1109 }
1110 edges.push(stored);
1111 }
1112 }
1113
1114 edges.sort_by(|a, b| (a.kind, a.to, a.from, &a.id).cmp(&(b.kind, b.to, b.from, &b.id)));
1117
1118 self.by_id = edges
1119 .iter()
1120 .enumerate()
1121 .map(|(i, e)| (e.id.clone(), i))
1122 .collect();
1123 self.by_issue = HashMap::new();
1124 self.blocks_out = HashMap::new();
1125 self.blocks_in = HashMap::new();
1126 self.blocks_out_numbers = HashMap::new();
1127 self.blocks_in_numbers = HashMap::new();
1128
1129 for (i, edge) in edges.iter().enumerate() {
1130 self.by_issue.entry(edge.from).or_default().push(i);
1135 if edge.to != edge.from {
1136 self.by_issue.entry(edge.to).or_default().push(i);
1137 }
1138 if edge.kind == RelationKind::Blocks && edge.from != edge.to {
1144 self.blocks_out.entry(edge.from).or_default().push(edge.to);
1145 self.blocks_in.entry(edge.to).or_default().push(edge.from);
1146 }
1147 }
1148
1149 for adjacency in [&mut self.blocks_out, &mut self.blocks_in] {
1159 for targets in adjacency.values_mut() {
1160 targets.sort_unstable();
1161 targets.dedup();
1162 }
1163 }
1164
1165 for (from, targets) in &self.blocks_out {
1166 let Some(from_number) = self.number_for_reference(*from) else {
1167 continue;
1168 };
1169 let numbers: Vec<u32> = targets
1170 .iter()
1171 .filter_map(|target| self.number_for_reference(*target))
1172 .collect();
1173 self.blocks_out_numbers.insert(from_number, numbers);
1174 }
1175 for (to, sources) in &self.blocks_in {
1176 let Some(to_number) = self.number_for_reference(*to) else {
1177 continue;
1178 };
1179 let numbers: Vec<u32> = sources
1180 .iter()
1181 .filter_map(|source| self.number_for_reference(*source))
1182 .collect();
1183 self.blocks_in_numbers.insert(to_number, numbers);
1184 }
1185
1186 self.edges = edges;
1187 }
1188
1189 pub fn done_column(&self) -> &str {
1191 &self.done_column
1192 }
1193
1194 pub fn issue_count(&self) -> usize {
1196 self.issues_by_number.len()
1197 }
1198
1199 #[must_use]
1201 pub fn identity_index_size(&self) -> usize {
1202 self.aliases.len()
1203 }
1204
1205 #[must_use]
1207 pub fn identity_nodes_scanned(&self) -> usize {
1208 self.aliases.scanned_nodes()
1209 }
1210
1211 pub fn edge_count(&self) -> usize {
1213 self.edges.len()
1214 }
1215
1216 pub fn issue_numbers(&self) -> &[u32] {
1218 &self.issues_by_number
1219 }
1220
1221 pub fn has_issue(&self, number: u32) -> bool {
1223 self.reference_for_number(number).is_some()
1224 }
1225
1226 pub fn find_relationship(&self, id: &str) -> Option<&Relationship> {
1228 self.by_id.get(id).map(|i| &self.edges[*i])
1229 }
1230
1231 pub fn find_canonical(&self, from: u32, to: u32, kind: RelationKind) -> Option<&Relationship> {
1251 let from = self.reference_for_number(from)?;
1252 let to = self.reference_for_number(to)?;
1253 let canonical = CanonicalEdge::new(from, to, kind);
1254 self.by_issue
1258 .get(&canonical.from)?
1259 .iter()
1260 .map(|i| &self.edges[*i])
1261 .find(|edge| edge.canonical() == canonical)
1262 }
1263
1264 pub fn all_relationships(&self) -> &[Relationship] {
1266 &self.edges
1267 }
1268
1269 pub fn relationships_for(&self, number: u32) -> Vec<&Relationship> {
1276 match self
1277 .reference_for_number(number)
1278 .and_then(|reference| self.by_issue.get(&reference))
1279 {
1280 Some(indices) => indices.iter().map(|i| &self.edges[*i]).collect(),
1281 None => Vec::new(),
1282 }
1283 }
1284
1285 pub fn relationships_of_kind(&self, number: u32, kind: RelationKind) -> Vec<&Relationship> {
1287 self.relationships_for(number)
1288 .into_iter()
1289 .filter(|r| r.kind == kind)
1290 .collect()
1291 }
1292
1293 pub fn blocks(&self, number: u32) -> &[u32] {
1295 self.blocks_out_numbers
1296 .get(&number)
1297 .map(Vec::as_slice)
1298 .unwrap_or(&[])
1299 }
1300
1301 pub fn blocked_by(&self, number: u32) -> &[u32] {
1307 self.blocks_in_numbers
1308 .get(&number)
1309 .map(Vec::as_slice)
1310 .unwrap_or(&[])
1311 }
1312
1313 pub fn duplicates_of(&self, number: u32) -> Vec<u32> {
1322 let mut targets: Vec<u32> = self
1323 .relationships_of_kind(number, RelationKind::Duplicates)
1324 .into_iter()
1325 .filter(|edge| {
1326 self.reference_for_number(number)
1327 .is_some_and(|reference| edge.from == reference)
1328 })
1329 .filter_map(|edge| self.number_for_reference(edge.to))
1330 .collect();
1331 targets.sort_unstable();
1332 targets
1333 }
1334
1335 pub fn related_to(&self, number: u32) -> Vec<u32> {
1348 let Some(reference) = self.reference_for_number(number) else {
1349 return Vec::new();
1350 };
1351 let mut others: Vec<u32> = self
1352 .relationships_of_kind(number, RelationKind::Related)
1353 .into_iter()
1354 .filter_map(|edge| {
1355 let other = if edge.from == reference {
1356 edge.to
1357 } else {
1358 edge.from
1359 };
1360 self.number_for_reference(other)
1361 })
1362 .collect();
1363 others.sort_unstable();
1364 others.dedup();
1365 others
1366 }
1367
1368 pub fn open_blockers(&self, number: u32) -> Vec<ChainNode> {
1379 self.blocked_by(number)
1380 .iter()
1381 .filter(|blocker| self.is_open_blocker(number, **blocker))
1382 .map(|blocker| self.chain_node(*blocker))
1383 .collect()
1384 }
1385
1386 fn is_open_blocker(&self, subject: u32, blocker: u32) -> bool {
1405 if blocker == subject {
1406 return false;
1407 }
1408 let Some(blocker_reference) = self.reference_for_number(blocker) else {
1409 return false;
1410 };
1411 let Some(blocker) = self.issues.get(&blocker_reference) else {
1412 return false;
1413 };
1414 self.done_column.is_empty() || blocker.status != self.done_column
1421 }
1422
1423 pub fn blocked_state(&self, number: u32) -> BlockedState {
1428 let blockers = self.open_blockers(number);
1429 let is_blocked = !blockers.is_empty();
1430 BlockedState {
1431 is_blocked,
1432 reason: if is_blocked {
1433 blocked_reason(&blockers)
1434 } else {
1435 String::new()
1436 },
1437 blockers,
1438 }
1439 }
1440
1441 pub fn dependency_chain(&self, root: u32) -> DependencyChain {
1453 let mut chain = DependencyChain {
1454 root,
1455 paths: Vec::new(),
1456 truncated: false,
1457 };
1458 if !self.has_issue(root) {
1459 return chain;
1460 }
1461
1462 let mut visited_nodes = 1usize;
1463 let mut path = vec![self.chain_node(root)];
1464 self.walk_downstream(root, &mut path, &mut chain.paths, &mut visited_nodes);
1465 chain.truncated = visited_nodes >= MAX_CHAIN_NODES;
1466 chain
1467 }
1468
1469 fn walk_downstream(
1471 &self,
1472 node: u32,
1473 path: &mut Vec<ChainNode>,
1474 paths: &mut Vec<Vec<ChainNode>>,
1475 visited_nodes: &mut usize,
1476 ) {
1477 let downstream = self.blocks(node);
1478 if downstream.is_empty() {
1479 return;
1480 }
1481 let mut unique: Vec<u32> = Vec::new();
1484 for target in downstream {
1485 if !unique.contains(target) {
1486 unique.push(*target);
1487 }
1488 }
1489 for target in unique {
1490 if *visited_nodes >= MAX_CHAIN_NODES {
1491 return;
1492 }
1493 *visited_nodes += 1;
1494 if path.iter().any(|n| n.number == target) {
1495 continue;
1498 }
1499 path.push(self.chain_node(target));
1500 if self.blocks(target).is_empty() {
1501 paths.push(path.clone());
1502 } else {
1503 self.walk_downstream(target, path, paths, visited_nodes);
1504 path.pop();
1505 }
1506 }
1507 }
1508
1509 fn chain_node(&self, number: u32) -> ChainNode {
1512 match self
1513 .reference_for_number(number)
1514 .and_then(|reference| self.issues.get(&reference))
1515 {
1516 Some(issue) => ChainNode::new(number, issue.title),
1517 None => ChainNode::new(number, String::new()),
1518 }
1519 }
1520
1521 pub fn validate_snapshot(&self) -> RelationshipValidationResult {
1531 let mut result = RelationshipValidationResult::clean();
1532 result.errors.extend(self.alias_diagnostics.clone());
1533
1534 let mut references: Vec<IssueRef> = self.issues.keys().copied().collect();
1535 references.sort_unstable();
1536 for reference in references {
1537 let view = self.issues[&reference];
1538 let number = self.number_for_reference(reference).unwrap_or_default();
1539 if view.has_retired_links_key {
1557 result.errors.push(Diagnostic::about(
1558 number,
1559 DiagnosticKind::RetiredLinksKey,
1560 "links",
1561 format!(
1562 "#{number} still carries the retired `links:` key, which the typed \
1563 model no longer reads \u{2014} remove the key, then recreate each \
1564 relationship with `tix link <from> <to> --type <kind>` (or the \
1565 `link_issue` tool), where `<kind>` is one of `blocks`, \
1566 `blocked_by`, `duplicates`, or `related`"
1567 ),
1568 ));
1569 }
1570 }
1571
1572 let mut pair_kind: HashMap<(IssueRef, IssueRef), RelationKind> = HashMap::new();
1575 for edge in &self.edges {
1576 if edge.from == edge.to {
1577 result.errors.push(
1578 Diagnostic::self_link(self.number_for_reference(edge.from).unwrap_or_default())
1579 .with_source_file(
1580 self.edge_owners.get(&edge.id).cloned().unwrap_or_default(),
1581 ),
1582 );
1583 continue;
1584 }
1585 if !self.issues.contains_key(&edge.to) {
1586 result.errors.push(
1587 Diagnostic::missing_target(
1588 self.number_for_reference(edge.from).unwrap_or_default(),
1589 edge.to,
1590 )
1591 .with_source_file(self.edge_owners.get(&edge.id).cloned().unwrap_or_default()),
1592 );
1593 continue;
1594 }
1595 let key = self.conflict_key(edge.canonical());
1598 match pair_kind.get(&key) {
1599 Some(existing) if *existing != edge.kind => {
1600 result.errors.push(
1603 Diagnostic::about(
1604 self.number_for_reference(edge.from).unwrap_or_default(),
1605 DiagnosticKind::ConflictingEdge,
1606 "kind",
1607 format!(
1608 "#{from} → #{to} is already `{existing}`; refusing to store it as `{}` as well",
1609 edge.kind.as_str(),
1610 from = edge.from,
1611 to = edge.to
1612 ),
1613 )
1614 .with_source_file(
1615 self.edge_owners.get(&edge.id).cloned().unwrap_or_default(),
1616 ),
1617 );
1618 }
1619 Some(_) => {}
1620 None => {
1621 pair_kind.insert(key, edge.kind);
1622 }
1623 }
1624 }
1625
1626 result.merge(self.validate_cycles());
1627 result.sort_diagnostics();
1628 result
1629 }
1630
1631 pub fn validate_create(
1636 &self,
1637 from: u32,
1638 to: u32,
1639 kind: RelationKind,
1640 ) -> RelationshipValidationResult {
1641 let Some(from) = self.reference_for_number(from) else {
1642 let mut result = RelationshipValidationResult::clean();
1643 result
1644 .errors
1645 .push(Diagnostic::missing_target(0, to.to_string()));
1646 return result;
1647 };
1648 let Some(to) = self.reference_for_number(to) else {
1649 let mut result = RelationshipValidationResult::clean();
1650 result.errors.push(Diagnostic::missing_target(
1651 self.number_for_reference(from).unwrap_or_default(),
1652 "missing",
1653 ));
1654 return result;
1655 };
1656 self.validate_authored_ref(from, to, kind, None, Duplicate::Allowed)
1657 }
1658
1659 pub fn validate_create_ref(
1661 &self,
1662 from: IssueRef,
1663 to: IssueRef,
1664 kind: RelationKind,
1665 ) -> RelationshipValidationResult {
1666 self.validate_authored_ref(from, to, kind, None, Duplicate::Allowed)
1667 }
1668
1669 fn validate_authored_ref(
1680 &self,
1681 from: IssueRef,
1682 to: IssueRef,
1683 kind: RelationKind,
1684 exclude: Option<&str>,
1685 duplicate: Duplicate,
1686 ) -> RelationshipValidationResult {
1687 let mut result = RelationshipValidationResult::clean();
1688 let canonical = CanonicalEdge::new(from, to, kind);
1689 let from_number = self.number_for_reference(from).unwrap_or_default();
1690 let to_number = self.number_for_reference(to).unwrap_or_default();
1691
1692 if !self.issues.contains_key(&from) {
1693 result
1694 .errors
1695 .push(Diagnostic::missing_target(to_number, from));
1696 }
1697 if canonical.is_self_link() {
1698 result.errors.push(Diagnostic::self_link(from_number));
1699 } else if !self.issues.contains_key(&to) {
1700 result
1701 .errors
1702 .push(Diagnostic::missing_target(from_number, to));
1703 }
1704
1705 if let Some(existing) = self
1706 .by_issue
1707 .get(&from)
1708 .into_iter()
1709 .flatten()
1710 .map(|index| &self.edges[*index])
1711 .find(|r| {
1712 let c = r.canonical();
1713 self.conflict_key(c) == self.conflict_key(canonical)
1714 && c.kind != canonical.kind
1715 && exclude != Some(r.id.as_str())
1716 })
1717 {
1718 result.errors.push(Diagnostic::new(
1719 DiagnosticKind::ConflictingEdge,
1720 "kind",
1721 format!(
1722 "#{} → #{} is already `{}`; refusing to store it as `{}` as well",
1723 from_number,
1724 to_number,
1725 existing.kind.as_str(),
1726 canonical.kind.as_str()
1727 ),
1728 ));
1729 }
1730
1731 if let Some(cycle) = self.cycle_if_added(canonical) {
1732 result.errors.push(cycle);
1733 }
1734
1735 if duplicate == Duplicate::Rejected {
1743 if let Some(clash) = self
1744 .by_issue
1745 .get(&from)
1746 .into_iter()
1747 .flatten()
1748 .map(|index| &self.edges[*index])
1749 .find(|r| r.canonical() == canonical && exclude != Some(r.id.as_str()))
1750 {
1751 result.errors.push(Diagnostic::new(
1752 DiagnosticKind::ConflictingEdge,
1753 "kind",
1754 format!(
1755 "#{from_number} already has a `{}` relationship to #{} (`{}`); this edit would store a second one — drop the existing edge or pick a different kind",
1756 canonical.kind.as_str(),
1757 to_number,
1758 clash.id
1759 ),
1760 ));
1761 }
1762 }
1763
1764 result.sort_diagnostics();
1765 result
1766 }
1767
1768 pub fn validate_edit(
1775 &self,
1776 id: &str,
1777 new_to: Option<u32>,
1778 new_kind: Option<RelationKind>,
1779 ) -> RelationshipValidationResult {
1780 let Some(existing) = self.find_relationship(id) else {
1781 let mut result = RelationshipValidationResult::clean();
1782 result.errors.push(Diagnostic::unknown_relation(id));
1783 return result;
1784 };
1785
1786 let from = existing.from;
1787 let to = new_to
1788 .and_then(|number| self.reference_for_number(number))
1789 .unwrap_or(existing.to);
1790 let kind = new_kind.unwrap_or(existing.kind);
1791 self.validate_authored_ref(from, to, kind, Some(id), Duplicate::Rejected)
1792 }
1793
1794 fn validate_cycles(&self) -> RelationshipValidationResult {
1802 let mut result = RelationshipValidationResult::clean();
1803 for path in self.cycles() {
1804 result.errors.push(Diagnostic::about(
1810 path[0],
1811 DiagnosticKind::Cycle,
1812 "to",
1813 format!("cycle: {}", render_cycle(&path)),
1814 ));
1815 }
1816 result
1817 }
1818
1819 fn cycles(&self) -> Vec<Vec<u32>> {
1823 self.validation_passes
1831 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1832 let residue = self.cyclic_residue();
1833 if residue.is_empty() {
1834 return Vec::new();
1835 }
1836
1837 const WHITE: u8 = 0;
1838 const GREY: u8 = 1;
1839 const BLACK: u8 = 2;
1840
1841 let mut color: HashMap<u32, u8> = HashMap::new();
1842 let mut cycles: Vec<Vec<u32>> = Vec::new();
1843 let mut reported: HashSet<Vec<u32>> = HashSet::new();
1844 let mut path: Vec<u32> = Vec::new();
1845 let mut position: HashMap<u32, usize> = HashMap::new();
1846 let mut work: Vec<(u32, usize)> = Vec::new();
1847
1848 for start in &self.issues_by_number {
1849 let start = *start;
1850 if !residue.contains(&start) || color.get(&start) == Some(&BLACK) {
1851 continue;
1852 }
1853 color.insert(start, GREY);
1854 position.insert(start, path.len());
1855 path.push(start);
1856 work.push((start, 0));
1857
1858 while let Some(top) = work.last_mut() {
1859 let (node, cursor) = (top.0, top.1);
1860 let downstream = self.blocks(node);
1861 if cursor < downstream.len() {
1862 top.1 += 1;
1863 let next = downstream[cursor];
1864 if !residue.contains(&next) {
1865 continue;
1866 }
1867 match color.get(&next).copied().unwrap_or(WHITE) {
1868 GREY => {
1869 if let Some(start_at) = position.get(&next) {
1871 let cycle = path[*start_at..].to_vec();
1872 if reported.insert(cycle_key(&cycle)) {
1873 cycles.push(cycle);
1874 }
1875 }
1876 }
1877 WHITE => {
1878 color.insert(next, GREY);
1879 position.insert(next, path.len());
1880 path.push(next);
1881 work.push((next, 0));
1882 }
1883 _ => {}
1884 }
1885 } else {
1886 color.insert(node, BLACK);
1887 position.remove(&node);
1888 path.pop();
1889 work.pop();
1890 }
1891 }
1892 }
1893 cycles
1894 }
1895
1896 fn cyclic_residue(&self) -> HashSet<u32> {
1899 let mut indegree: HashMap<u32, usize> =
1900 self.issues_by_number.iter().map(|n| (*n, 0usize)).collect();
1901 for edge in &self.edges {
1902 if edge.kind != RelationKind::Blocks || edge.from == edge.to {
1903 continue;
1904 }
1905 if !self.issues.contains_key(&edge.from) || !self.issues.contains_key(&edge.to) {
1906 continue;
1907 }
1908 let (Some(from), Some(to)) = (
1909 self.number_for_reference(edge.from),
1910 self.number_for_reference(edge.to),
1911 ) else {
1912 continue;
1913 };
1914 if indegree.contains_key(&from) {
1915 *indegree.entry(to).or_default() += 1;
1916 }
1917 }
1918
1919 let mut queue: VecDeque<u32> = indegree
1920 .iter()
1921 .filter(|(_, degree)| **degree == 0)
1922 .map(|(node, _)| *node)
1923 .collect();
1924 while let Some(node) = queue.pop_front() {
1925 for next in self.blocks(node) {
1929 if let Some(degree) = indegree.get_mut(next) {
1930 if *degree > 0 {
1931 *degree -= 1;
1932 if *degree == 0 {
1933 queue.push_back(*next);
1934 }
1935 }
1936 }
1937 }
1938 }
1939
1940 indegree
1941 .into_iter()
1942 .filter(|(_, degree)| *degree > 0)
1943 .map(|(node, _)| node)
1944 .collect()
1945 }
1946
1947 fn cycle_if_added(&self, edge: CanonicalEdge) -> Option<Diagnostic> {
1952 if edge.is_self_link() {
1953 return Some(Diagnostic::self_link(
1954 self.number_for_reference(edge.from).unwrap_or_default(),
1955 ));
1956 }
1957 if edge.kind != RelationKind::Blocks {
1974 return None;
1975 }
1976 if !self.issues.contains_key(&edge.to) {
1977 return None;
1978 }
1979 let path = self.shortest_path(edge.to, edge.from)?;
1980 let mut full = Vec::with_capacity(path.len() + 1);
1981 full.push(edge.from);
1982 full.extend(path);
1983 let numbers: Vec<u32> = full
1984 .iter()
1985 .map(|reference| self.number_for_reference(*reference).unwrap_or_default())
1986 .collect();
1987 Some(Diagnostic::new(
1988 DiagnosticKind::Cycle,
1989 "to",
1990 format!("cycle: {}", render_cycle(&numbers)),
1991 ))
1992 }
1993
1994 fn shortest_path(&self, start: IssueRef, goal: IssueRef) -> Option<Vec<IssueRef>> {
1997 if start == goal {
1998 return Some(vec![start]);
1999 }
2000 let mut previous: HashMap<IssueRef, IssueRef> = HashMap::new();
2001 let mut seen: HashSet<IssueRef> = HashSet::new();
2002 let mut queue: std::collections::VecDeque<IssueRef> = std::collections::VecDeque::new();
2003 seen.insert(start);
2004 queue.push_back(start);
2005 while let Some(node) = queue.pop_front() {
2006 for next in self.blocks_out.get(&node).map(Vec::as_slice).unwrap_or(&[]) {
2007 if !seen.insert(*next) {
2008 continue;
2009 }
2010 previous.insert(*next, node);
2011 if *next == goal {
2012 let mut path = vec![goal];
2013 let mut cursor = goal;
2014 while let Some(step) = previous.get(&cursor) {
2015 path.push(*step);
2016 cursor = *step;
2017 }
2018 path.reverse();
2019 return Some(path);
2020 }
2021 queue.push_back(*next);
2022 }
2023 }
2024 None
2025 }
2026}
2027
2028pub fn blocked_reason(blockers: &[ChainNode]) -> String {
2033 if blockers.len() == 1 {
2034 let node = &blockers[0];
2035 return format!("Blocked by #{} {}", node.number, node.title)
2036 .trim_end()
2037 .to_string();
2038 }
2039 let list = blockers
2040 .iter()
2041 .map(|n| format!("#{} {}", n.number, n.title).trim_end().to_string())
2042 .collect::<Vec<_>>()
2043 .join(", ");
2044 format!("Blocked by {} issues: {list}", blockers.len())
2045}
2046
2047fn render_cycle(path: &[u32]) -> String {
2048 let rendered = path
2049 .iter()
2050 .map(u32::to_string)
2051 .collect::<Vec<_>>()
2052 .join(" -> ");
2053 format!("{rendered} -> #{}", path[0])
2054}
2055
2056fn cycle_key(path: &[u32]) -> Vec<u32> {
2057 let Some(min_position) = path
2060 .iter()
2061 .enumerate()
2062 .min_by_key(|(_, n)| **n)
2063 .map(|(i, _)| i)
2064 else {
2065 return path.to_vec();
2066 };
2067 let mut rotated = path[min_position..].to_vec();
2068 rotated.extend_from_slice(&path[..min_position]);
2069 rotated
2070}
2071
2072pub fn generate_relationship_id() -> RelationshipId {
2086 use std::hash::{BuildHasher, Hasher};
2087 use std::sync::atomic::{AtomicU64, Ordering};
2088 use std::time::{SystemTime, UNIX_EPOCH};
2089
2090 static COUNTER: AtomicU64 = AtomicU64::new(0);
2091
2092 let millis = SystemTime::now()
2093 .duration_since(UNIX_EPOCH)
2094 .map(|d| d.as_millis())
2095 .unwrap_or(0);
2096 let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
2097 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
2098 hasher.write_u128(millis);
2099 hasher.write_u64(counter);
2100 let entropy = hasher.finish();
2101 let mut second = std::collections::hash_map::RandomState::new().build_hasher();
2103 second.write_u64(entropy);
2104 second.write_u64(counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
2105 let high = second.finish() & 0xFFFF;
2106
2107 let value = (millis << 80u32) | (u128::from(entropy) << 16) | u128::from(high);
2108 format!("rel_{}", crate::id::crockford(value, 26))
2111}
2112
2113#[cfg(test)]
2118mod tests {
2119 #[allow(clippy::wildcard_imports)]
2120 use super::*;
2121
2122 #[derive(Clone)]
2127 struct Owned {
2128 id: Option<String>,
2129 filename: String,
2130 number: u32,
2131 title: String,
2132 status: String,
2133 relationships: Vec<Relationship>,
2134 retired: bool,
2135 }
2136
2137 impl Owned {
2138 fn new(number: u32, title: &str, status: &str) -> Self {
2139 Self {
2140 id: None,
2141 filename: format!("{number}.md"),
2142 number,
2143 title: title.to_string(),
2144 status: status.to_string(),
2145 relationships: Vec::new(),
2146 retired: false,
2147 }
2148 }
2149
2150 fn with_id(mut self, id: &str) -> Self {
2151 self.id = Some(id.to_string());
2152 self.filename = format!("{id}.md");
2153 self
2154 }
2155
2156 fn blocking(mut self, to: u32, title: &str) -> Self {
2158 self.relationships.push(Relationship::new(
2159 self.number,
2160 to,
2161 RelationKind::Blocks,
2162 title,
2163 ));
2164 self
2165 }
2166
2167 fn push_block(&mut self, to: u32, title: &str) {
2169 self.relationships.push(Relationship::new(
2170 self.number,
2171 to,
2172 RelationKind::Blocks,
2173 title,
2174 ));
2175 }
2176
2177 fn with_related(self, to: u32, title: &str) -> Self {
2179 let mut this = self;
2180 this.relationships.push(Relationship::new(
2181 this.number,
2182 to,
2183 RelationKind::Related,
2184 title,
2185 ));
2186 this
2187 }
2188
2189 fn with_retired_links_key(mut self) -> Self {
2190 self.retired = true;
2191 self
2192 }
2193
2194 fn view(&self) -> IssueView<'_> {
2195 IssueView::new(self.number, &self.title, &self.status, &self.relationships)
2196 .with_identity(self.id.as_deref(), &self.filename)
2197 .with_retired_links_key(self.retired)
2198 }
2199 }
2200
2201 fn graph_of<'x>(owned: &'x [Owned], done_column: &str) -> RelationshipGraph<'x> {
2202 let views: Vec<IssueView<'x>> = owned.iter().map(Owned::view).collect();
2203 RelationshipGraph::new(&views, done_column)
2204 }
2205
2206 #[test]
2209 fn relation_kind_has_exactly_four_variants_with_canonical_spellings() {
2210 let spellings: Vec<&str> = RelationKind::ALL.iter().map(|k| k.as_str()).collect();
2211 assert_eq!(spellings, ["blocks", "blocked_by", "duplicates", "related"]);
2212 }
2213
2214 #[test]
2215 fn is_directional_is_false_only_for_related() {
2216 for kind in RelationKind::ALL {
2217 assert_eq!(
2218 kind.is_directional(),
2219 kind != RelationKind::Related,
2220 "{kind} directionality"
2221 );
2222 }
2223 }
2224
2225 #[test]
2226 fn inverse_truth_table_matches_the_contract() {
2227 assert_eq!(
2228 RelationKind::Blocks.inverse(),
2229 Some(RelationKind::BlockedBy)
2230 );
2231 assert_eq!(
2232 RelationKind::BlockedBy.inverse(),
2233 Some(RelationKind::Blocks)
2234 );
2235 assert_eq!(RelationKind::Duplicates.inverse(), None);
2236 assert_eq!(RelationKind::Related.inverse(), None);
2237 }
2238
2239 #[test]
2240 fn from_str_parses_every_canonical_spelling() {
2241 for kind in RelationKind::ALL {
2242 assert_eq!(kind.as_str().parse::<RelationKind>().unwrap(), kind);
2243 }
2244 }
2245
2246 #[test]
2247 fn unknown_kind_is_rejected_and_never_coerced_to_related() {
2248 let err = "depends_on".parse::<RelationKind>().unwrap_err();
2249 assert_eq!(err.kind, DiagnosticKind::UnknownKind);
2250 assert!(err.message.contains("depends_on"));
2251 assert!(err.message.contains("blocks"));
2252 }
2253
2254 #[test]
2255 fn relationship_serde_round_trip_is_stable() {
2256 let rel = Relationship::new(42, 51, RelationKind::Blocks, "Refactor store");
2257 let yaml = serde_yaml::to_string(&rel).unwrap();
2258 let parsed: Relationship = serde_yaml::from_str(&yaml).unwrap();
2259 assert_eq!(rel, parsed);
2260 assert_eq!(serde_yaml::to_string(&parsed).unwrap(), yaml);
2261 }
2262
2263 #[test]
2264 fn relationship_parses_with_display_omitted() {
2265 let yaml = "id: rel_1\nfrom: 1\nto: 2\nkind: blocks\n";
2266 let rel: Relationship = serde_yaml::from_str(yaml).unwrap();
2267 assert_eq!(rel.display, "");
2268 assert!(!serde_yaml::to_string(&rel).unwrap().contains("display"));
2270 }
2271
2272 #[test]
2273 fn display_name_combines_kind_number_and_title() {
2274 assert_eq!(
2275 display_name(RelationKind::Blocks, 42, "Fix flaky test"),
2276 "blocks #42 Fix flaky test"
2277 );
2278 assert_eq!(
2279 display_name(RelationKind::Related, 7, " "),
2280 "related #7",
2281 "a blank title must not leave a trailing space"
2282 );
2283 }
2284
2285 #[test]
2286 fn stable_ids_are_unique_across_a_batch_and_prefixed() {
2287 let mut seen = std::collections::HashSet::new();
2288 for _ in 0..2_000 {
2289 let id = generate_relationship_id();
2290 assert!(id.starts_with("rel_"), "unexpected id shape: {id}");
2291 assert_eq!(id.len(), "rel_".len() + 26, "unexpected id length: {id}");
2292 assert!(
2293 id["rel_".len()..]
2294 .bytes()
2295 .all(|b| crate::id::CROCKFORD.contains(&b)),
2296 "non-Crockford character in {id}"
2297 );
2298 assert!(seen.insert(id), "duplicate relationship id generated");
2299 }
2300 }
2301
2302 #[test]
2305 fn both_spellings_of_a_blocking_pair_normalize_to_one_canonical_edge() {
2306 let forward = normalize(1, 2, RelationKind::Blocks);
2307 let inverse = normalize(1, 2, RelationKind::BlockedBy);
2308 assert_eq!(
2309 forward,
2310 CanonicalEdge {
2311 from: IssueRef::Number(1),
2312 to: IssueRef::Number(2),
2313 kind: RelationKind::Blocks
2314 }
2315 );
2316 assert_eq!(
2317 inverse,
2318 CanonicalEdge {
2319 from: IssueRef::Number(2),
2320 to: IssueRef::Number(1),
2321 kind: RelationKind::Blocks
2322 },
2323 "blocked_by must swap the endpoints into the canonical blocks form"
2324 );
2325 assert_eq!(
2326 forward.to, inverse.from,
2327 "the two spellings are the same edge"
2328 );
2329 }
2330
2331 #[test]
2332 fn a_persisted_blocking_edge_never_carries_the_blocked_by_kind() {
2333 let stored = Relationship::new(1, 2, RelationKind::BlockedBy, "T");
2334 assert_eq!(stored.kind, RelationKind::Blocks);
2335 assert_eq!(
2336 (stored.from.as_number(), stored.to.as_number()),
2337 (Some(2), Some(1))
2338 );
2339 }
2340
2341 #[test]
2342 fn normalize_all_deduplicates_both_spellings_and_is_idempotent() {
2343 let authored = [
2345 (1, 2, RelationKind::Blocks),
2346 (2, 1, RelationKind::BlockedBy),
2347 (3, 4, RelationKind::Related),
2348 ];
2349 let once = normalize_all(authored);
2350 assert_eq!(once.len(), 2, "the two spellings collapse to one edge");
2351 assert_eq!(
2352 once,
2353 normalize_all(authored),
2354 "normalization is deterministic"
2355 );
2356 assert_eq!(
2357 once,
2358 normalize_all(
2359 once.iter()
2360 .map(|e| (e.from, e.to, e.kind))
2361 .collect::<Vec<_>>()
2362 )
2363 );
2364 }
2365
2366 #[test]
2367 fn related_and_duplicates_never_swap_endpoints() {
2368 for kind in [RelationKind::Related, RelationKind::Duplicates] {
2369 let edge = normalize(1, 2, kind);
2370 assert_eq!(
2371 (edge.from.as_number(), edge.to.as_number(), edge.kind),
2372 (Some(1), Some(2), kind)
2373 );
2374 }
2375 }
2376
2377 #[test]
2378 fn a_reciprocal_blocking_pair_is_rejected_as_a_cycle() {
2379 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2380 let b = Owned::new(2, "B", "todo").blocking(1, "A");
2381 let owned = vec![a, b];
2382 let graph = graph_of(&owned, "done");
2383
2384 let result = graph.validate_snapshot();
2385 assert!(
2386 result.has(DiagnosticKind::Cycle),
2387 "expected a cycle diagnostic: {result:?}"
2388 );
2389 assert!(result.blocking(), "a reciprocal pair must be blocking");
2390 }
2391
2392 #[test]
2393 fn a_conflicting_kind_for_the_same_pair_is_reported() {
2394 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2395 let mut a = a;
2396 a.relationships
2397 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2398 let owned = vec![a, Owned::new(2, "B", "todo")];
2399 let result = graph_of(&owned, "done").validate_snapshot();
2400 assert!(
2401 result.has(DiagnosticKind::ConflictingEdge),
2402 "expected conflicting_edge: {result:?}"
2403 );
2404 }
2405
2406 #[test]
2409 fn edges_are_returned_from_both_endpoints() {
2410 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2411 let owned = vec![a, Owned::new(2, "B", "todo")];
2412 let graph = graph_of(&owned, "done");
2413 assert_eq!(graph.relationships_for(1).len(), 1);
2414 assert_eq!(
2415 graph.relationships_for(2).len(),
2416 1,
2417 "the target sees the edge too"
2418 );
2419 }
2420
2421 #[test]
2422 fn ordering_is_stable_across_repeated_builds() {
2423 let a = Owned::new(1, "A", "todo")
2424 .blocking(4, "D")
2425 .blocking(2, "B")
2426 .blocking(3, "C");
2427 let owned = vec![
2428 a,
2429 Owned::new(2, "B", "todo"),
2430 Owned::new(3, "C", "todo"),
2431 Owned::new(4, "D", "todo"),
2432 ];
2433 let first: Vec<(RelationKind, u32)> = graph_of(&owned, "done")
2434 .relationships_for(1)
2435 .iter()
2436 .map(|r| (r.kind, r.to.as_number().unwrap_or_default()))
2437 .collect();
2438 let second: Vec<(RelationKind, u32)> = graph_of(&owned, "done")
2439 .relationships_for(1)
2440 .iter()
2441 .map(|r| (r.kind, r.to.as_number().unwrap_or_default()))
2442 .collect();
2443 assert_eq!(first, second, "repeated builds must be byte-comparable");
2444 assert_eq!(
2445 first,
2446 vec![
2447 (RelationKind::Blocks, 2),
2448 (RelationKind::Blocks, 3),
2449 (RelationKind::Blocks, 4)
2450 ],
2451 "edges are ordered by (kind, target)"
2452 );
2453 }
2454
2455 #[test]
2456 fn related_and_duplicates_never_enter_blocked_adjacency() {
2457 let mut a = Owned::new(1, "A", "todo");
2458 a.relationships
2459 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2460 a.relationships
2461 .push(Relationship::new(1, 3, RelationKind::Duplicates, "C"));
2462 let owned = vec![a, Owned::new(2, "B", "todo"), Owned::new(3, "C", "todo")];
2463 let graph = graph_of(&owned, "done");
2464 assert!(
2465 graph.blocks(1).is_empty(),
2466 "related/duplicates are not blockers"
2467 );
2468 assert_eq!(graph.blocked_by(2), [] as [u32; 0]);
2469 assert_eq!(graph.blocked_by(3), [] as [u32; 0]);
2470 assert_eq!(graph.duplicates_of(1), vec![3]);
2471 assert_eq!(graph.related_to(1), vec![2]);
2472 }
2473
2474 #[test]
2475 fn an_issue_with_no_edges_yields_empty_results_not_an_error() {
2476 let owned = vec![Owned::new(1, "A", "todo")];
2477 let graph = graph_of(&owned, "done");
2478 assert_eq!(
2479 graph.relationships_for(1),
2480 [] as [&crate::relationships::Relationship; 0]
2481 );
2482 assert_eq!(graph.blocks(1), [] as [u32; 0]);
2483 assert_eq!(graph.blocked_by(1), [] as [u32; 0]);
2484 assert_eq!(
2485 graph.open_blockers(1),
2486 [] as [crate::relationships::ChainNode; 0]
2487 );
2488 assert!(graph.dependencies_are_empty_for_unknown_issue());
2489 }
2490
2491 impl RelationshipGraph<'_> {
2492 fn dependencies_are_empty_for_unknown_issue(&self) -> bool {
2494 self.dependency_chain(9_999).paths.is_empty()
2495 }
2496 }
2497
2498 #[test]
2501 fn an_empty_snapshot_validates_clean() {
2502 let owned = vec![Owned::new(1, "A", "todo")];
2503 let result = graph_of(&owned, "done").validate_snapshot();
2504 assert!(result.is_clean(), "unexpected diagnostics: {result:?}");
2505 assert!(!result.blocking());
2506 }
2507
2508 #[test]
2509 fn self_link_create_is_rejected() {
2510 let owned = vec![Owned::new(1, "A", "todo")];
2511 let result = graph_of(&owned, "done").validate_create(1, 1, RelationKind::Blocks);
2512 assert!(result.has(DiagnosticKind::SelfLink), "{result:?}");
2513 assert!(result.blocking());
2514 }
2515
2516 #[test]
2517 fn missing_target_create_is_rejected() {
2518 let owned = vec![Owned::new(1, "A", "todo")];
2519 let result = graph_of(&owned, "done").validate_create(1, 99, RelationKind::Blocks);
2520 assert!(result.has(DiagnosticKind::MissingTarget), "{result:?}");
2521 }
2522
2523 #[test]
2524 fn validate_create_accepts_both_spellings_of_the_same_pair() {
2525 let owned = vec![Owned::new(1, "A", "todo"), Owned::new(2, "B", "todo")];
2526 let graph = graph_of(&owned, "done");
2527 assert!(graph.validate_create(1, 2, RelationKind::Blocks).is_clean());
2528 assert!(
2529 graph
2530 .validate_create(1, 2, RelationKind::BlockedBy)
2531 .is_clean(),
2532 "the inverse spelling is the same edge, not a conflict"
2533 );
2534 }
2535
2536 #[test]
2537 fn editing_an_unknown_relationship_id_is_reported() {
2538 let owned = vec![Owned::new(1, "A", "todo")];
2539 let result = graph_of(&owned, "done").validate_edit("rel_missing", None, None);
2540 assert!(result.has(DiagnosticKind::UnknownRelation), "{result:?}");
2541 }
2542
2543 #[test]
2544 fn a_retired_links_key_is_reported_and_never_read_as_typed_data() {
2545 let owned = vec![
2546 Owned::new(1, "A", "todo").with_retired_links_key(),
2547 Owned::new(2, "B", "todo"),
2548 ];
2549 let result = graph_of(&owned, "done").validate_snapshot();
2550 assert!(result.has(DiagnosticKind::RetiredLinksKey), "{result:?}");
2551 assert_eq!(
2552 result
2553 .errors
2554 .iter()
2555 .find(|d| d.kind == DiagnosticKind::RetiredLinksKey)
2556 .map(|d| d.field.as_str()),
2557 Some("links")
2558 );
2559 }
2560
2561 #[test]
2562 fn a_snapshot_with_three_distinct_problems_reports_exactly_three_diagnostics() {
2563 let mut self_linked = Owned::new(1, "A", "todo");
2564 self_linked
2565 .relationships
2566 .push(Relationship::new(3, 3, RelationKind::Blocks, "C"));
2567 let mut dangling = Owned::new(2, "B", "todo");
2568 dangling
2569 .relationships
2570 .push(Relationship::new(2, 404, RelationKind::Blocks, "gone"));
2571 let owned = vec![
2572 self_linked,
2573 dangling,
2574 Owned::new(3, "C", "todo").with_retired_links_key(),
2575 ];
2576 let result = graph_of(&owned, "done").validate_snapshot();
2577 assert_eq!(
2578 result.errors.len(),
2579 3,
2580 "expected no duplicate or implied reports: {result:?}"
2581 );
2582 }
2583
2584 #[test]
2587 fn a_three_chain_returns_the_ordered_path() {
2588 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2589 let b = Owned::new(2, "B", "todo").blocking(3, "C");
2590 let owned = vec![a, b, Owned::new(3, "C", "todo")];
2591 let chain = graph_of(&owned, "done").dependency_chain(1);
2592 assert_eq!(chain.root, 1);
2593 assert_eq!(chain.paths.len(), 1);
2594 assert_eq!(
2595 chain.paths[0].iter().map(|n| n.number).collect::<Vec<_>>(),
2596 vec![1, 2, 3],
2597 "the chain walks upstream from the root toward its blockers"
2598 );
2599 assert!(!chain.truncated);
2600 }
2601
2602 #[test]
2603 fn a_cycle_closing_edge_is_rejected() {
2604 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2605 let b = Owned::new(2, "B", "todo").blocking(3, "C");
2606 let owned = vec![a, b, Owned::new(3, "C", "todo")];
2607 let result = graph_of(&owned, "done").validate_create(3, 1, RelationKind::Blocks);
2608 assert!(result.has(DiagnosticKind::Cycle), "{result:?}");
2609 assert!(result.blocking());
2610 }
2611
2612 #[test]
2613 fn blocked_state_is_true_only_for_an_open_inbound_blocks_edge() {
2614 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2615 let owned = vec![a, Owned::new(2, "B", "todo")];
2616 let graph = graph_of(&owned, "done");
2617
2618 let state = graph.blocked_state(2);
2619 assert!(state.is_blocked);
2620 assert_eq!(
2621 state.blockers.iter().map(|n| n.number).collect::<Vec<_>>(),
2622 vec![1]
2623 );
2624 assert!(
2625 state.reason.contains("#1"),
2626 "the reason names the blocker: {}",
2627 state.reason
2628 );
2629
2630 assert!(
2631 !graph.blocked_state(1).is_blocked,
2632 "the blocker is not itself blocked"
2633 );
2634 }
2635
2636 #[test]
2637 fn moving_the_blocker_into_the_configured_done_column_clears_blocked_state() {
2638 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2639 let owned = vec![a, Owned::new(2, "B", "todo")];
2640 let graph = graph_of(&owned, "shipped");
2642 assert!(
2643 graph.blocked_state(2).is_blocked,
2644 "A is not in the shipped column yet"
2645 );
2646
2647 let mut moved = owned.clone();
2648 moved[0].status = "shipped".to_string();
2649 let after = graph_of(&moved, "shipped");
2650 assert!(
2651 !after.blocked_state(2).is_blocked,
2652 "a blocker in the configured done_column stops blocking"
2653 );
2654 }
2655
2656 #[test]
2657 fn a_blocker_in_a_non_completion_column_keeps_blocking() {
2658 let a = Owned::new(1, "A", "in review").blocking(2, "B");
2659 let owned = vec![a, Owned::new(2, "B", "todo")];
2660 let graph = graph_of(&owned, "shipped");
2661 assert!(graph.blocked_state(2).is_blocked);
2662 assert_ne!(graph.blocked_state(2).reason, "");
2663 }
2664
2665 #[test]
2666 fn a_dangling_edge_is_diagnosed_but_does_not_freeze_the_issue() {
2667 let mut blocker = Owned::new(1, "A", "todo");
2670 blocker
2671 .relationships
2672 .push(Relationship::new(1, 404, RelationKind::Blocks, "gone"));
2673 let owned = vec![blocker, Owned::new(2, "B", "todo")];
2674 let graph = graph_of(&owned, "done");
2675
2676 let result = graph.validate_snapshot();
2677 assert!(
2678 result.has(DiagnosticKind::MissingTarget),
2679 "the dangling edge is still reported: {result:?}"
2680 );
2681 assert!(
2682 !graph.blocked_state(2).is_blocked,
2683 "invalid data must not permanently freeze an issue"
2684 );
2685 }
2686
2687 #[test]
2688 fn a_hand_edited_cyclic_file_terminates_instead_of_hanging() {
2689 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2692 let b = Owned::new(2, "B", "todo").blocking(1, "A");
2693 let owned = vec![a, b];
2694 let graph = graph_of(&owned, "done");
2695
2696 assert!(graph.validate_snapshot().has(DiagnosticKind::Cycle));
2697 let chain = graph.dependency_chain(1);
2698 assert!(
2699 chain.paths.iter().all(|p| p.len() <= owned.len()),
2700 "the visited-set guard must bound every path"
2701 );
2702 }
2703
2704 #[test]
2707 fn a_ten_thousand_edge_graph_validates_in_one_pass() {
2708 const ISSUES: u32 = 2_000;
2709 const PER_ISSUE: u32 = 6;
2710 let mut owned: Vec<Owned> = (0..ISSUES)
2713 .map(|n| Owned::new(n + 1, &format!("Issue {n}"), "todo"))
2714 .collect();
2715 for n in 0..ISSUES {
2716 for k in 1..=PER_ISSUE {
2717 let target = n + k;
2718 if target < ISSUES {
2719 owned[n as usize].push_block(target + 1, "Target");
2720 }
2721 }
2722 }
2723 let graph = graph_of(&owned, "done");
2724 assert!(
2725 graph.edge_count() >= 10_000,
2726 "expected a 10,000-edge graph, built {}",
2727 graph.edge_count()
2728 );
2729
2730 let result = graph.validate_snapshot();
2733 assert!(
2734 result.is_clean(),
2735 "a well-formed graph validates clean: {result:?}"
2736 );
2737 assert_eq!(graph.issue_count(), ISSUES as usize);
2738 assert_eq!(
2739 graph.identity_index_size(),
2740 ISSUES as usize,
2741 "one project-wide alias index covers the whole graph"
2742 );
2743 assert!(graph.blocked_state(2).is_blocked);
2744 }
2745
2746 #[test]
2747 fn a_ten_thousand_issue_graph_scans_identity_once_regardless_of_endpoint_count() {
2748 const ISSUES: u32 = 10_000;
2749 let mut owned: Vec<Owned> = (0..ISSUES)
2750 .map(|index| {
2751 Owned::new(index + 1, &format!("Issue {index}"), "todo")
2752 .with_id(&format!("A{index:07}"))
2753 })
2754 .collect();
2755 for index in 0..(ISSUES - 1) {
2756 owned[index as usize].push_block(index + 2, "Next");
2757 }
2758
2759 let graph = graph_of(&owned, "done");
2760 assert_eq!(graph.edge_count(), ISSUES as usize - 1);
2761 assert_eq!(graph.identity_index_size(), ISSUES as usize);
2762 assert_eq!(
2763 graph.identity_nodes_scanned(),
2764 ISSUES as usize,
2765 "one index pass must cover every node, not one scan per edge endpoint"
2766 );
2767 }
2768
2769 #[test]
2770 fn canonical_graph_reports_ambiguous_numbers_and_duplicate_ids_without_dropping_data() {
2771 let mut source = Owned::new(1, "Source", "todo").with_id("A1B2C3D4");
2772 source.relationships.push(Relationship {
2773 id: "rel_ambiguous".to_string(),
2774 from: IssueRef::Number(1),
2775 to: IssueRef::Number(7),
2776 kind: RelationKind::Related,
2777 display: String::new(),
2778 });
2779 let owned = vec![
2780 source,
2781 Owned::new(7, "First seven", "todo").with_id("B1B2C3D4"),
2782 Owned::new(7, "Second seven", "todo").with_id("C1B2C3D4"),
2783 ];
2784 let result = graph_of(&owned, "done").validate_snapshot();
2785 assert!(
2786 result.has(DiagnosticKind::AmbiguousTarget),
2787 "numeric aliases with two matches are diagnosed: {result:?}"
2788 );
2789
2790 let duplicate = vec![
2791 Owned::new(1, "A", "todo").with_id("A1B2C3D4"),
2792 Owned::new(2, "B", "todo").with_id("A1B2C3D4"),
2793 ];
2794 let result = graph_of(&duplicate, "done").validate_snapshot();
2795 assert!(
2796 result.has(DiagnosticKind::DuplicateIdentity),
2797 "duplicate canonical ids are diagnosed: {result:?}"
2798 );
2799 }
2800
2801 #[test]
2802 fn a_ten_thousand_node_chain_does_not_overflow_the_stack() {
2803 const DEPTH: u32 = 10_000;
2804 let mut owned: Vec<Owned> = (0..DEPTH)
2805 .map(|n| Owned::new(n + 1, &format!("Node {n}"), "todo"))
2806 .collect();
2807 for n in 0..DEPTH - 1 {
2808 owned[n as usize].push_block(n + 2, "Next");
2809 }
2810 let graph = graph_of(&owned, "done");
2811 assert!(graph.validate_snapshot().is_clean());
2813 let chain = graph.dependency_chain(1);
2814 assert!(chain.truncated, "the defensive node bound engages");
2815 assert!(
2816 graph
2817 .cycle_if_added(CanonicalEdge {
2818 from: IssueRef::Number(DEPTH),
2819 to: IssueRef::Number(1),
2820 kind: RelationKind::Blocks
2821 })
2822 .is_some()
2823 );
2824 }
2825
2826 #[test]
2829 fn d1_inverse_spellings_find_the_same_row() {
2830 let owned = vec![
2832 Owned::new(1, "A", "todo").blocking(2, "B"),
2833 Owned::new(2, "B", "todo"),
2834 ];
2835 let graph = graph_of(&owned, "done");
2836 let forward = graph
2837 .find_canonical(1, 2, RelationKind::Blocks)
2838 .map(|r| r.id.clone());
2839 let inverse = graph
2840 .find_canonical(2, 1, RelationKind::BlockedBy)
2841 .map(|r| r.id.clone());
2842 assert!(forward.is_some(), "the stored edge must be found");
2843 assert_eq!(
2844 forward, inverse,
2845 "1 blocks 2 and 2 blocked_by 1 name one relationship"
2846 );
2847 assert_ne!(
2848 forward,
2849 graph
2850 .find_canonical(1, 2, RelationKind::BlockedBy)
2851 .map(|r| r.id.clone()),
2852 "1 blocked_by 2 is the inverse of the stored edge, so it must not match"
2853 );
2854 }
2855
2856 #[test]
2857 fn d1_related_is_found_from_either_orientation() {
2858 let mut two = Owned::new(2, "B", "todo");
2859 two.relationships
2860 .push(Relationship::new(2, 1, RelationKind::Related, "A"));
2861 let owned = vec![Owned::new(1, "A", "todo"), two];
2862 let graph = graph_of(&owned, "done");
2863 let a = graph
2864 .find_canonical(1, 2, RelationKind::Related)
2865 .map(|r| r.id.clone());
2866 let b = graph
2867 .find_canonical(2, 1, RelationKind::Related)
2868 .map(|r| r.id.clone());
2869 assert!(a.is_some(), "related is order-normalized, so it is found");
2870 assert_eq!(a, b);
2871 }
2872
2873 #[test]
2874 fn d1_a_self_link_or_unrelated_pair_does_not_match() {
2875 let owned = vec![
2876 Owned::new(1, "A", "todo").blocking(2, "B"),
2877 Owned::new(2, "B", "todo"),
2878 Owned::new(3, "C", "todo"),
2879 ];
2880 let graph = graph_of(&owned, "done");
2881 assert!(graph.find_canonical(1, 1, RelationKind::Blocks).is_none());
2882 assert!(graph.find_canonical(1, 3, RelationKind::Blocks).is_none());
2883 assert!(graph.find_canonical(3, 1, RelationKind::Blocks).is_none());
2884 assert!(graph.find_canonical(1, 2, RelationKind::Related).is_none());
2885 assert!(
2886 graph.find_canonical(1, 2, RelationKind::Blocks).is_some(),
2887 "the real edge still resolves"
2888 );
2889 }
2890
2891 #[test]
2892 fn d1_redundant_rows_resolve_to_the_same_row_every_time() {
2893 let one = Owned::new(1, "A", "todo").with_related(2, "B");
2894 let two = Owned::new(2, "B", "todo").with_related(1, "A");
2895 let owned = vec![one, two];
2896 let first = graph_of(&owned, "done")
2897 .find_canonical(1, 2, RelationKind::Related)
2898 .map(|r| r.id.clone());
2899 let second = graph_of(&owned, "done")
2900 .find_canonical(1, 2, RelationKind::Related)
2901 .map(|r| r.id.clone());
2902 assert!(first.is_some());
2903 assert_eq!(first, second, "the answer must be stable, not arbitrary");
2904 }
2905
2906 #[test]
2907 fn d1_an_unknown_pair_returns_none() {
2908 let owned = vec![Owned::new(1, "A", "todo")];
2909 let graph = graph_of(&owned, "done");
2910 assert!(graph.find_canonical(1, 404, RelationKind::Blocks).is_none());
2911 assert!(graph.find_canonical(404, 1, RelationKind::Blocks).is_none());
2912 }
2913
2914 #[test]
2917 fn p1_related_reads_as_one_association_across_orientations() {
2918 let one = Owned::new(1, "A", "todo").with_related(2, "B");
2922 let two = Owned::new(2, "B", "todo").with_related(1, "A");
2923 let owned = vec![one, two];
2924 let graph = graph_of(&owned, "done");
2925 assert_eq!(graph.related_to(1), vec![2], "one association, not two");
2926 assert_eq!(graph.related_to(2), vec![1]);
2927 assert_eq!(
2928 normalize_all([(1, 2, RelationKind::Related), (2, 1, RelationKind::Related),]).len(),
2929 1,
2930 "both authorings normalize to one canonical edge"
2931 );
2932 }
2933
2934 #[test]
2937 fn a3_no_stored_relationship_id_silently_stops_resolving() {
2938 let one = Owned::new(1, "A", "todo").with_related(2, "B");
2942 let two = Owned::new(2, "B", "todo").with_related(1, "A");
2943 let owned = vec![one, two];
2944 let graph = graph_of(&owned, "done");
2945 let ids: Vec<String> = graph
2946 .all_relationships()
2947 .iter()
2948 .filter(|r| r.kind == RelationKind::Related)
2949 .map(|r| r.id.clone())
2950 .collect();
2951 assert_eq!(ids.len(), 2, "both stored rows are retained");
2952 for id in &ids {
2953 assert!(
2954 graph.find_relationship(id).is_some(),
2955 "stored id {id} must still resolve"
2956 );
2957 assert!(
2958 !graph
2959 .validate_edit(id, None, None)
2960 .has(DiagnosticKind::UnknownRelation),
2961 "editing by a stored id must not report it unknown"
2962 );
2963 }
2964 }
2965
2966 #[test]
2967 fn a3_the_blocked_adjacency_does_not_repeat_a_neighbour() {
2968 let mut one = Owned::new(1, "A", "todo");
2970 for _ in 0..3 {
2971 one.push_block(2, "B");
2972 }
2973 let owned = vec![one, Owned::new(2, "B", "todo")];
2974 let graph = graph_of(&owned, "done");
2975 assert_eq!(graph.blocks(1), &[2], "no repeated neighbour");
2976 assert_eq!(graph.blocked_by(2), &[1], "no repeated blocker");
2977 assert_eq!(
2978 graph.blocked_state(2).blockers.len(),
2979 1,
2980 "one blocker is reported once, not once per stored row"
2981 );
2982 }
2983
2984 #[test]
2985 fn c_an_unconfigured_done_column_fails_closed() {
2986 let mut one = Owned::new(1, "A", "");
2989 one.push_block(2, "B");
2990 let owned = vec![one, Owned::new(2, "B", "todo")];
2991 let graph = graph_of(&owned, "");
2992 assert!(
2993 graph.blocked_state(2).is_blocked,
2994 "an unconfigured completion column must not mark anything done"
2995 );
2996 }
2997
2998 #[test]
2999 fn p1_duplicates_orientation_is_preserved() {
3000 assert_eq!(
3002 normalize_all([
3003 (1, 2, RelationKind::Duplicates),
3004 (2, 1, RelationKind::Duplicates),
3005 ])
3006 .len(),
3007 2
3008 );
3009 }
3010
3011 #[test]
3012 fn p2_an_edit_cannot_land_on_a_triple_another_edge_occupies() {
3013 let mut a = Owned::new(1, "A", "todo");
3014 a.push_block(2, "B");
3015 a.relationships
3016 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
3017 let owned = vec![a, Owned::new(2, "B", "todo")];
3018 let graph = graph_of(&owned, "done");
3019 let blocks_id = graph
3020 .relationships_for(1)
3021 .iter()
3022 .find(|r| r.kind == RelationKind::Blocks)
3023 .expect("a blocks edge")
3024 .id
3025 .clone();
3026
3027 let res = graph.validate_edit(&blocks_id, None, Some(RelationKind::Related));
3028 assert!(
3029 res.has(DiagnosticKind::ConflictingEdge),
3030 "re-kinding onto an occupied triple must be rejected: {res:?}"
3031 );
3032 assert!(
3033 res.errors
3034 .iter()
3035 .any(|d| d.message.contains("would store a second one")),
3036 "the message must name the duplicate case: {res:?}"
3037 );
3038 }
3039
3040 #[test]
3041 fn p2_creating_an_edge_that_already_exists_stays_idempotent() {
3042 let a = Owned::new(1, "A", "todo").blocking(2, "B");
3045 let owned = vec![a, Owned::new(2, "B", "todo")];
3046 let graph = graph_of(&owned, "done");
3047 assert!(
3048 graph.validate_create(1, 2, RelationKind::Blocks).is_clean(),
3049 "re-asserting an existing edge is the idempotent path"
3050 );
3051 }
3052
3053 #[test]
3054 fn p3_p4_the_other_endpoint_is_returned_in_both_orientations() {
3055 let mut two = Owned::new(2, "B", "todo");
3057 two.relationships
3058 .push(Relationship::new(2, 1, RelationKind::Related, "A"));
3059 two.relationships
3060 .push(Relationship::new(2, 1, RelationKind::Duplicates, "A"));
3061 let owned = vec![Owned::new(1, "A", "todo"), two];
3062 let graph = graph_of(&owned, "done");
3063
3064 assert_eq!(graph.related_to(1), vec![2], "never the subject itself");
3066 assert_eq!(graph.related_to(2), vec![1]);
3067 assert!(
3069 graph.duplicates_of(1).is_empty(),
3070 "a directional edge pointing at #1 does not mean #1 duplicates it: {:?}",
3071 graph.duplicates_of(1)
3072 );
3073 assert_eq!(graph.duplicates_of(2), vec![1]);
3074 }
3075
3076 #[test]
3077 fn p5_the_blocked_adjacency_never_contains_the_subject() {
3078 let mut a = Owned::new(1, "A", "todo");
3082 a.relationships
3083 .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
3084 a.push_block(2, "B");
3085 let owned = vec![a, Owned::new(2, "B", "todo")];
3086 let graph = graph_of(&owned, "done");
3087 for number in [1, 2] {
3088 assert!(
3089 !graph.blocked_by(number).contains(&number),
3090 "issue #{number} must never appear in its own inbound blocks adjacency"
3091 );
3092 }
3093 }
3094
3095 #[test]
3098 fn r1_display_is_refreshed_when_the_target_is_renamed() {
3099 let a = Owned::new(1, "A", "todo").blocking(2, "Old title");
3101 let owned = vec![a, Owned::new(2, "New title", "todo")];
3102 let graph = graph_of(&owned, "done");
3103 assert_eq!(
3104 graph.relationships_for(1)[0].display,
3105 "blocks #2 New title",
3106 "a persisted display must not outlive the title it was built from"
3107 );
3108 }
3109
3110 #[test]
3111 fn r1_a_dangling_edge_keeps_its_persisted_display() {
3112 let mut a = Owned::new(1, "A", "todo");
3114 a.relationships
3115 .push(Relationship::new(1, 404, RelationKind::Blocks, "gone"));
3116 let owned = vec![a];
3117 assert_eq!(
3118 graph_of(&owned, "done").relationships_for(1)[0].display,
3119 "blocks #404 gone"
3120 );
3121 }
3122
3123 #[test]
3124 fn r2_a_self_link_is_reported_once_not_twice() {
3125 let mut a = Owned::new(1, "A", "todo");
3126 a.relationships
3127 .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
3128 let owned = vec![a];
3129 let graph = graph_of(&owned, "done");
3130 assert_eq!(graph.edge_count(), 1);
3131 assert_eq!(
3132 graph.relationships_for(1).len(),
3133 1,
3134 "a self-link has one endpoint, so it must be registered once"
3135 );
3136 }
3137
3138 #[test]
3139 fn r3_an_issue_is_never_blocked_by_itself() {
3140 let mut a = Owned::new(1, "A", "todo");
3141 a.relationships
3142 .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
3143 let owned = vec![a];
3144 let graph = graph_of(&owned, "done");
3145
3146 assert!(graph.validate_snapshot().has(DiagnosticKind::SelfLink));
3148 assert_eq!(graph.blocks(1), [] as [u32; 0]);
3150 let state = graph.blocked_state(1);
3151 assert!(!state.is_blocked, "an issue must not block itself");
3152 assert_eq!(state.reason, "");
3153 }
3154
3155 #[test]
3156 fn r4_validate_edit_does_not_hide_a_pre_existing_conflict() {
3157 let mut a = Owned::new(1, "A", "todo");
3159 a.push_block(2, "B");
3160 a.relationships
3161 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
3162 let owned = vec![a, Owned::new(2, "B", "todo")];
3163 let graph = graph_of(&owned, "done");
3164 let blocks_id = graph
3165 .relationships_for(1)
3166 .iter()
3167 .find(|r| r.kind == RelationKind::Blocks)
3168 .expect("a blocks edge")
3169 .id
3170 .clone();
3171
3172 assert!(
3173 graph
3174 .validate_snapshot()
3175 .has(DiagnosticKind::ConflictingEdge)
3176 );
3177 let edit = graph.validate_edit(&blocks_id, None, None);
3178 assert!(
3179 edit.has(DiagnosticKind::ConflictingEdge),
3180 "editing an unrelated field must not return a clean verdict on corrupt data: {edit:?}"
3181 );
3182 }
3183
3184 #[test]
3185 fn r4_an_edit_does_not_conflict_with_itself() {
3186 let a = Owned::new(1, "A", "todo").blocking(2, "B");
3187 let owned = vec![a, Owned::new(2, "B", "todo"), Owned::new(3, "C", "todo")];
3188 let graph = graph_of(&owned, "done");
3189 let id = graph.relationships_for(1)[0].id.clone();
3190
3191 assert!(
3192 graph.validate_edit(&id, None, None).is_clean(),
3193 "an edge never conflicts with itself"
3194 );
3195 assert!(
3196 graph.validate_edit(&id, Some(3), None).is_clean(),
3197 "retargeting to an unrelated issue is not a conflict"
3198 );
3199 }
3200
3201 #[test]
3204 fn every_contract_diagnostic_kind_exists_with_its_exact_name() {
3205 let names: Vec<&str> = [
3206 DiagnosticKind::SelfLink,
3207 DiagnosticKind::MissingTarget,
3208 DiagnosticKind::ConflictingEdge,
3209 DiagnosticKind::Cycle,
3210 DiagnosticKind::UnknownKind,
3211 DiagnosticKind::UnknownRelation,
3212 DiagnosticKind::RetiredLinksKey,
3213 ]
3214 .iter()
3215 .map(|k| k.as_str())
3216 .collect();
3217 assert_eq!(
3218 names,
3219 [
3220 "self_link",
3221 "missing_target",
3222 "conflicting_edge",
3223 "cycle",
3224 "unknown_kind",
3225 "unknown_relation",
3226 "retired_links_key"
3227 ]
3228 );
3229 }
3230
3231 #[test]
3232 fn validate_snapshot_never_returns_an_err() {
3233 let owned = vec![Owned::new(1, "A", "todo").with_retired_links_key()];
3235 let result: RelationshipValidationResult = graph_of(&owned, "done").validate_snapshot();
3236 assert!(result.blocking());
3237 }
3238
3239 #[test]
3240 fn diagnostics_sort_deterministically() {
3241 let mut a = RelationshipValidationResult::with(Diagnostic::self_link(1));
3242 a.merge(RelationshipValidationResult::with(
3243 Diagnostic::unknown_relation("rel_x"),
3244 ));
3245 let mut b = a.clone();
3246 b.sort_diagnostics();
3247 a.sort_diagnostics();
3248 assert_eq!(a, b);
3249 }
3250
3251 #[test]
3252 fn blocked_reason_is_shared_by_every_surface() {
3253 let single = vec![ChainNode::new(1, "A")];
3254 assert_eq!(blocked_reason(&single), "Blocked by #1 A");
3255 let many = vec![ChainNode::new(1, "A"), ChainNode::new(2, "B")];
3256 assert_eq!(blocked_reason(&many), "Blocked by 2 issues: #1 A, #2 B");
3257 }
3258
3259 #[test]
3260 fn cycle_paths_are_reported_once_regardless_of_starting_node() {
3261 let a = Owned::new(1, "A", "todo").blocking(2, "B");
3262 let b = Owned::new(2, "B", "todo").blocking(3, "C");
3263 let c = Owned::new(3, "C", "todo").blocking(1, "A");
3264 let owned = vec![a, b, c];
3265 let result = graph_of(&owned, "done").validate_snapshot();
3266 let cycles: Vec<&Diagnostic> = result
3267 .errors
3268 .iter()
3269 .filter(|d| d.kind == DiagnosticKind::Cycle)
3270 .collect();
3271 assert_eq!(
3272 cycles.len(),
3273 1,
3274 "one distinct cycle, one diagnostic: {result:?}"
3275 );
3276 }
3277
3278 #[test]
3279 fn hand_edited_blocked_by_persisted_edges_still_block() {
3280 let mut owner = Owned::new(2, "B", "todo");
3284 owner.relationships.push(Relationship {
3285 id: "rel_hand".to_string(),
3286 from: IssueRef::Number(1),
3287 to: IssueRef::Number(2),
3288 kind: RelationKind::BlockedBy,
3289 display: String::new(),
3290 });
3291 let owned = vec![Owned::new(1, "A", "todo"), owner];
3292 let graph = graph_of(&owned, "done");
3293 assert!(
3294 graph.blocked_state(1).is_blocked,
3295 "the normalized adjacency must still carry the edge"
3296 );
3297 assert_eq!(graph.blocks(2), &[1], "stored in canonical blocks form");
3298 }
3299}