1use std::collections::{HashMap, HashSet, VecDeque};
88use std::fmt;
89use std::str::FromStr;
90
91use serde::{Deserialize, Serialize};
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum RelationKind {
105 Blocks,
108 BlockedBy,
112 Duplicates,
114 Related,
117}
118
119impl RelationKind {
120 pub const ALL: [RelationKind; 4] = [
122 RelationKind::Blocks,
123 RelationKind::BlockedBy,
124 RelationKind::Duplicates,
125 RelationKind::Related,
126 ];
127
128 pub fn as_str(self) -> &'static str {
130 match self {
131 RelationKind::Blocks => "blocks",
132 RelationKind::BlockedBy => "blocked_by",
133 RelationKind::Duplicates => "duplicates",
134 RelationKind::Related => "related",
135 }
136 }
137
138 pub fn is_directional(self) -> bool {
140 !matches!(self, RelationKind::Related)
141 }
142
143 pub fn inverse(self) -> Option<Self> {
148 match self {
149 RelationKind::Blocks => Some(RelationKind::BlockedBy),
150 RelationKind::BlockedBy => Some(RelationKind::Blocks),
151 RelationKind::Duplicates | RelationKind::Related => None,
152 }
153 }
154
155 fn canonical(self) -> (Self, bool) {
161 match self {
162 RelationKind::BlockedBy => (RelationKind::Blocks, true),
163 other => (other, false),
164 }
165 }
166}
167
168impl fmt::Display for RelationKind {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 f.write_str(self.as_str())
171 }
172}
173
174impl FromStr for RelationKind {
175 type Err = Diagnostic;
176
177 fn from_str(s: &str) -> Result<Self, Self::Err> {
181 match s {
182 "blocks" => Ok(RelationKind::Blocks),
183 "blocked_by" => Ok(RelationKind::BlockedBy),
184 "duplicates" => Ok(RelationKind::Duplicates),
185 "related" => Ok(RelationKind::Related),
186 other => Err(Diagnostic::unknown_kind(
187 "kind",
188 format!(
189 "unknown relation kind `{other}`; expected one of blocks, blocked_by, duplicates, related"
190 ),
191 )),
192 }
193 }
194}
195
196pub type RelationshipId = String;
202
203#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
208pub struct Relationship {
209 #[serde(default)]
211 pub id: RelationshipId,
212 pub from: u32,
214 pub to: u32,
216 pub kind: RelationKind,
220 #[serde(default, skip_serializing_if = "String::is_empty")]
224 pub display: String,
225}
226
227impl Relationship {
228 pub fn new(from: u32, to: u32, kind: RelationKind, target_title: &str) -> Self {
231 let canonical = CanonicalEdge::new(from, to, kind);
232 Self {
233 id: generate_relationship_id(),
234 from: canonical.from,
235 to: canonical.to,
236 kind: canonical.kind,
237 display: display_name(canonical.kind, canonical.to, target_title),
238 }
239 }
240
241 pub fn refresh_display(&mut self, target_title: &str) {
244 self.display = display_name(self.kind, self.to, target_title);
245 }
246
247 pub fn canonical(&self) -> CanonicalEdge {
251 CanonicalEdge::new(self.from, self.to, self.kind)
252 }
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258enum Duplicate {
259 Allowed,
262 Rejected,
266}
267
268pub fn display_name(kind: RelationKind, target: u32, target_title: &str) -> String {
270 if target_title.trim().is_empty() {
271 format!("{} #{target}", kind.as_str())
272 } else {
273 format!("{} #{target} {}", kind.as_str(), target_title.trim())
274 }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
283pub struct CanonicalEdge {
284 pub from: u32,
286 pub to: u32,
288 pub kind: RelationKind,
291}
292
293impl CanonicalEdge {
294 pub fn new(from: u32, to: u32, kind: RelationKind) -> Self {
311 let (kind, swap) = kind.canonical();
312 if swap || (!kind.is_directional() && from > to) {
316 Self {
317 from: to,
318 to: from,
319 kind,
320 }
321 } else {
322 Self { from, to, kind }
323 }
324 }
325
326 pub fn is_self_link(self) -> bool {
328 self.from == self.to
329 }
330}
331
332pub fn normalize(from: u32, to: u32, kind: RelationKind) -> CanonicalEdge {
337 CanonicalEdge::new(from, to, kind)
338}
339
340pub fn normalize_all<I>(authored: I) -> Vec<CanonicalEdge>
347where
348 I: IntoIterator<Item = (u32, u32, RelationKind)>,
349{
350 let mut seen: HashSet<CanonicalEdge> = HashSet::new();
351 for (from, to, kind) in authored {
352 seen.insert(CanonicalEdge::new(from, to, kind));
353 }
354 let mut edges: Vec<CanonicalEdge> = seen.into_iter().collect();
355 edges.sort_unstable();
356 edges
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
368#[serde(rename_all = "snake_case")]
369pub enum DiagnosticKind {
370 SelfLink,
372 MissingTarget,
374 ConflictingEdge,
376 Cycle,
378 UnknownKind,
380 UnknownRelation,
382 RetiredLinksKey,
384}
385
386impl DiagnosticKind {
387 pub fn as_str(self) -> &'static str {
389 match self {
390 DiagnosticKind::SelfLink => "self_link",
391 DiagnosticKind::MissingTarget => "missing_target",
392 DiagnosticKind::ConflictingEdge => "conflicting_edge",
393 DiagnosticKind::Cycle => "cycle",
394 DiagnosticKind::UnknownKind => "unknown_kind",
395 DiagnosticKind::UnknownRelation => "unknown_relation",
396 DiagnosticKind::RetiredLinksKey => "retired_links_key",
397 }
398 }
399}
400
401impl fmt::Display for DiagnosticKind {
402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403 f.write_str(self.as_str())
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409pub struct Diagnostic {
410 pub kind: DiagnosticKind,
412 pub field: String,
414 pub message: String,
417 pub issue: Option<u32>,
444}
445
446impl Diagnostic {
447 pub fn new(kind: DiagnosticKind, field: impl Into<String>, message: impl Into<String>) -> Self {
449 Self {
450 kind,
451 field: field.into(),
452 message: message.into(),
453 issue: None,
454 }
455 }
456
457 pub fn about(
460 issue: u32,
461 kind: DiagnosticKind,
462 field: impl Into<String>,
463 message: impl Into<String>,
464 ) -> Self {
465 Self {
466 kind,
467 field: field.into(),
468 message: message.into(),
469 issue: Some(issue),
470 }
471 }
472
473 pub fn self_link(issue: u32) -> Self {
475 Self::about(
476 issue,
477 DiagnosticKind::SelfLink,
478 "to",
479 format!("#{issue} cannot block itself: a self-link is not a valid relationship"),
480 )
481 }
482
483 pub fn missing_target(issue: u32, selector: impl fmt::Display) -> Self {
485 Self::about(
486 issue,
487 DiagnosticKind::MissingTarget,
488 "to",
489 format!("issue #{issue} references `{selector}`, which resolves to no issue"),
490 )
491 }
492
493 pub fn unknown_kind(field: impl Into<String>, message: impl Into<String>) -> Self {
495 Self::new(DiagnosticKind::UnknownKind, field, message)
496 }
497
498 pub fn unknown_relation(id: &str) -> Self {
500 Self::new(
501 DiagnosticKind::UnknownRelation,
502 "id",
503 format!("no relationship with id `{id}`"),
504 )
505 }
506}
507
508impl fmt::Display for Diagnostic {
509 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
510 write!(
511 f,
512 "{} ({}): {}",
513 self.kind.as_str(),
514 self.field,
515 self.message
516 )
517 }
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
525pub struct RelationshipValidationResult {
526 pub errors: Vec<Diagnostic>,
528}
529
530impl RelationshipValidationResult {
531 pub fn clean() -> Self {
533 Self::default()
534 }
535
536 pub fn with(diagnostic: Diagnostic) -> Self {
538 Self {
539 errors: vec![diagnostic],
540 }
541 }
542
543 pub fn is_clean(&self) -> bool {
545 self.errors.is_empty()
546 }
547
548 pub fn blocking(&self) -> bool {
554 !self.errors.is_empty()
555 }
556
557 pub fn has(&self, kind: DiagnosticKind) -> bool {
559 self.errors.iter().any(|d| d.kind == kind)
560 }
561
562 pub fn merge(&mut self, other: Self) {
564 self.errors.extend(other.errors);
565 }
566
567 pub fn sort_diagnostics(&mut self) {
570 self.errors
571 .sort_by(|a, b| (a.kind, &a.field, &a.message).cmp(&(b.kind, &b.field, &b.message)));
572 }
573}
574
575#[derive(Debug, Clone, Copy)]
586pub struct IssueView<'a> {
587 pub number: u32,
589 pub title: &'a str,
591 pub status: &'a str,
593 pub relationships: &'a [Relationship],
595 pub has_retired_links_key: bool,
599 pub parent: Option<u32>,
611}
612
613impl<'a> IssueView<'a> {
614 pub fn new(
616 number: u32,
617 title: &'a str,
618 status: &'a str,
619 relationships: &'a [Relationship],
620 ) -> Self {
621 Self {
622 number,
623 title,
624 status,
625 relationships,
626 has_retired_links_key: false,
627 parent: None,
628 }
629 }
630
631 pub fn with_retired_links_key(mut self, flagged: bool) -> Self {
633 self.has_retired_links_key = flagged;
634 self
635 }
636
637 pub fn with_parent(mut self, parent: Option<u32>) -> Self {
639 self.parent = parent;
640 self
641 }
642}
643
644impl crate::hierarchy::HierarchyNode for IssueView<'_> {
649 fn number(&self) -> u32 {
650 self.number
651 }
652 fn title(&self) -> &str {
653 self.title
654 }
655 fn status(&self) -> &str {
656 self.status
657 }
658 fn parent_number(&self) -> Option<u32> {
659 self.parent
660 }
661}
662
663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
665pub struct ChainNode {
666 pub number: u32,
668 pub title: String,
670}
671
672impl ChainNode {
673 pub fn new(number: u32, title: impl Into<String>) -> Self {
675 Self {
676 number,
677 title: title.into(),
678 }
679 }
680}
681
682#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
684pub struct DependencyChain {
685 pub root: u32,
687 pub paths: Vec<Vec<ChainNode>>,
689 pub truncated: bool,
692}
693
694impl DependencyChain {
695 pub fn nodes(&self) -> Vec<&ChainNode> {
697 let mut seen = HashSet::new();
698 self.paths
699 .iter()
700 .flatten()
701 .filter(|n| seen.insert(n.number))
702 .collect()
703 }
704}
705
706#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
708pub struct BlockedState {
709 pub is_blocked: bool,
711 pub blockers: Vec<ChainNode>,
713 pub reason: String,
716}
717
718pub const MAX_CHAIN_NODES: usize = 1_000;
725
726#[derive(Debug)]
733pub struct RelationshipGraph<'a> {
734 issues: HashMap<u32, IssueView<'a>>,
735 issues_by_number: Vec<u32>,
736 edges: Vec<Relationship>,
739 by_id: HashMap<RelationshipId, usize>,
740 by_issue: HashMap<u32, Vec<usize>>,
742 blocks_out: HashMap<u32, Vec<u32>>,
744 blocks_in: HashMap<u32, Vec<u32>>,
745 done_column: String,
746 validation_passes: std::sync::atomic::AtomicUsize,
759}
760
761impl Clone for RelationshipGraph<'_> {
762 fn clone(&self) -> Self {
765 Self {
766 issues: self.issues.clone(),
767 issues_by_number: self.issues_by_number.clone(),
768 edges: self.edges.clone(),
769 by_id: self.by_id.clone(),
770 by_issue: self.by_issue.clone(),
771 blocks_out: self.blocks_out.clone(),
772 blocks_in: self.blocks_in.clone(),
773 done_column: self.done_column.clone(),
774 validation_passes: std::sync::atomic::AtomicUsize::new(0),
775 }
776 }
777}
778
779impl<'a> RelationshipGraph<'a> {
780 pub fn validation_passes(&self) -> usize {
783 self.validation_passes
784 .load(std::sync::atomic::Ordering::Relaxed)
785 }
786
787 pub fn new(issues: &[IssueView<'a>], done_column: &str) -> Self {
799 let mut issues_by_number: Vec<u32> = issues.iter().map(|i| i.number).collect();
800 issues_by_number.sort_unstable();
801
802 let mut graph = Self {
803 issues: issues.iter().map(|i| (i.number, *i)).collect(),
804 issues_by_number,
805 edges: Vec::new(),
806 by_id: HashMap::new(),
807 by_issue: HashMap::new(),
808 blocks_out: HashMap::new(),
809 blocks_in: HashMap::new(),
810 done_column: done_column.to_string(),
811 validation_passes: std::sync::atomic::AtomicUsize::new(0),
812 };
813 graph.rebuild_edges();
814 graph
815 }
816
817 fn rebuild_edges(&mut self) {
828 let mut edges: Vec<Relationship> = Vec::new();
829 for number in &self.issues_by_number {
830 let view = self.issues[number];
831 for rel in view.relationships {
832 let mut stored = rel.clone();
833 let canonical = stored.canonical();
834 stored.from = canonical.from;
835 stored.to = canonical.to;
836 stored.kind = canonical.kind;
837 if let Some(target) = self.issues.get(&stored.to) {
844 stored.refresh_display(target.title);
845 }
846 edges.push(stored);
847 }
848 }
849
850 edges.sort_by(|a, b| (a.kind, a.to, a.from, &a.id).cmp(&(b.kind, b.to, b.from, &b.id)));
853
854 self.by_id = edges
855 .iter()
856 .enumerate()
857 .map(|(i, e)| (e.id.clone(), i))
858 .collect();
859 self.by_issue = HashMap::new();
860 self.blocks_out = HashMap::new();
861 self.blocks_in = HashMap::new();
862
863 for (i, edge) in edges.iter().enumerate() {
864 self.by_issue.entry(edge.from).or_default().push(i);
869 if edge.to != edge.from {
870 self.by_issue.entry(edge.to).or_default().push(i);
871 }
872 if edge.kind == RelationKind::Blocks && edge.from != edge.to {
878 self.blocks_out.entry(edge.from).or_default().push(edge.to);
879 self.blocks_in.entry(edge.to).or_default().push(edge.from);
880 }
881 }
882
883 for adjacency in [&mut self.blocks_out, &mut self.blocks_in] {
893 for targets in adjacency.values_mut() {
894 targets.sort_unstable();
895 targets.dedup();
896 }
897 }
898
899 self.edges = edges;
900 }
901
902 pub fn done_column(&self) -> &str {
904 &self.done_column
905 }
906
907 pub fn issue_count(&self) -> usize {
909 self.issues_by_number.len()
910 }
911
912 pub fn edge_count(&self) -> usize {
914 self.edges.len()
915 }
916
917 pub fn issue_numbers(&self) -> &[u32] {
919 &self.issues_by_number
920 }
921
922 pub fn has_issue(&self, number: u32) -> bool {
924 self.issues.contains_key(&number)
925 }
926
927 pub fn find_relationship(&self, id: &str) -> Option<&Relationship> {
929 self.by_id.get(id).map(|i| &self.edges[*i])
930 }
931
932 pub fn find_canonical(&self, from: u32, to: u32, kind: RelationKind) -> Option<&Relationship> {
952 let canonical = CanonicalEdge::new(from, to, kind);
953 self.by_issue
957 .get(&canonical.from)?
958 .iter()
959 .map(|i| &self.edges[*i])
960 .find(|edge| edge.canonical() == canonical)
961 }
962
963 pub fn all_relationships(&self) -> &[Relationship] {
965 &self.edges
966 }
967
968 pub fn relationships_for(&self, number: u32) -> Vec<&Relationship> {
975 match self.by_issue.get(&number) {
976 Some(indices) => indices.iter().map(|i| &self.edges[*i]).collect(),
977 None => Vec::new(),
978 }
979 }
980
981 pub fn relationships_of_kind(&self, number: u32, kind: RelationKind) -> Vec<&Relationship> {
983 self.relationships_for(number)
984 .into_iter()
985 .filter(|r| r.kind == kind)
986 .collect()
987 }
988
989 pub fn blocks(&self, number: u32) -> &[u32] {
991 self.blocks_out
992 .get(&number)
993 .map(Vec::as_slice)
994 .unwrap_or(&[])
995 }
996
997 pub fn blocked_by(&self, number: u32) -> &[u32] {
1003 self.blocks_in
1004 .get(&number)
1005 .map(Vec::as_slice)
1006 .unwrap_or(&[])
1007 }
1008
1009 pub fn duplicates_of(&self, number: u32) -> Vec<u32> {
1018 let mut targets: Vec<u32> = self
1019 .relationships_of_kind(number, RelationKind::Duplicates)
1020 .into_iter()
1021 .filter(|edge| edge.from == number)
1022 .map(|edge| edge.to)
1023 .collect();
1024 targets.sort_unstable();
1025 targets
1026 }
1027
1028 pub fn related_to(&self, number: u32) -> Vec<u32> {
1041 let mut others: Vec<u32> = self
1042 .relationships_of_kind(number, RelationKind::Related)
1043 .into_iter()
1044 .map(|edge| {
1045 if edge.from == number {
1046 edge.to
1047 } else {
1048 edge.from
1049 }
1050 })
1051 .collect();
1052 others.sort_unstable();
1053 others.dedup();
1054 others
1055 }
1056
1057 pub fn open_blockers(&self, number: u32) -> Vec<ChainNode> {
1068 self.blocked_by(number)
1069 .iter()
1070 .filter(|blocker| self.is_open_blocker(number, **blocker))
1071 .map(|blocker| self.chain_node(*blocker))
1072 .collect()
1073 }
1074
1075 fn is_open_blocker(&self, subject: u32, blocker: u32) -> bool {
1094 if blocker == subject {
1095 return false;
1096 }
1097 let Some(blocker) = self.issues.get(&blocker) else {
1098 return false;
1099 };
1100 self.done_column.is_empty() || blocker.status != self.done_column
1107 }
1108
1109 pub fn blocked_state(&self, number: u32) -> BlockedState {
1114 let blockers = self.open_blockers(number);
1115 let is_blocked = !blockers.is_empty();
1116 BlockedState {
1117 is_blocked,
1118 reason: if is_blocked {
1119 blocked_reason(&blockers)
1120 } else {
1121 String::new()
1122 },
1123 blockers,
1124 }
1125 }
1126
1127 pub fn dependency_chain(&self, root: u32) -> DependencyChain {
1139 let mut chain = DependencyChain {
1140 root,
1141 paths: Vec::new(),
1142 truncated: false,
1143 };
1144 if !self.has_issue(root) {
1145 return chain;
1146 }
1147
1148 let mut visited_nodes = 1usize;
1149 let mut path = vec![self.chain_node(root)];
1150 self.walk_downstream(root, &mut path, &mut chain.paths, &mut visited_nodes);
1151 chain.truncated = visited_nodes >= MAX_CHAIN_NODES;
1152 chain
1153 }
1154
1155 fn walk_downstream(
1157 &self,
1158 node: u32,
1159 path: &mut Vec<ChainNode>,
1160 paths: &mut Vec<Vec<ChainNode>>,
1161 visited_nodes: &mut usize,
1162 ) {
1163 let downstream = self.blocks(node);
1164 if downstream.is_empty() {
1165 return;
1166 }
1167 let mut unique: Vec<u32> = Vec::new();
1170 for target in downstream {
1171 if !unique.contains(target) {
1172 unique.push(*target);
1173 }
1174 }
1175 for target in unique {
1176 if *visited_nodes >= MAX_CHAIN_NODES {
1177 return;
1178 }
1179 *visited_nodes += 1;
1180 if path.iter().any(|n| n.number == target) {
1181 continue;
1184 }
1185 path.push(self.chain_node(target));
1186 if self.blocks(target).is_empty() {
1187 paths.push(path.clone());
1188 } else {
1189 self.walk_downstream(target, path, paths, visited_nodes);
1190 path.pop();
1191 }
1192 }
1193 }
1194
1195 fn chain_node(&self, number: u32) -> ChainNode {
1198 match self.issues.get(&number) {
1199 Some(issue) => ChainNode::new(number, issue.title),
1200 None => ChainNode::new(number, String::new()),
1201 }
1202 }
1203
1204 pub fn validate_snapshot(&self) -> RelationshipValidationResult {
1214 let mut result = RelationshipValidationResult::clean();
1215
1216 for number in &self.issues_by_number {
1217 let view = self.issues[number];
1218 if view.has_retired_links_key {
1236 result.errors.push(Diagnostic::about(
1237 *number,
1238 DiagnosticKind::RetiredLinksKey,
1239 "links",
1240 format!(
1241 "#{number} still carries the retired `links:` key, which the typed \
1242 model no longer reads \u{2014} remove the key, then recreate each \
1243 relationship with `tix link <from> <to> --type <kind>` (or the \
1244 `link_issue` tool), where `<kind>` is one of `blocks`, \
1245 `blocked_by`, `duplicates`, or `related`"
1246 ),
1247 ));
1248 }
1249 }
1250
1251 let mut pair_kind: HashMap<(u32, u32), RelationKind> = HashMap::new();
1254 for edge in &self.edges {
1255 if edge.from == edge.to {
1256 result.errors.push(Diagnostic::self_link(edge.from));
1257 continue;
1258 }
1259 if !self.has_issue(edge.to) {
1260 result
1261 .errors
1262 .push(Diagnostic::missing_target(edge.from, edge.to));
1263 continue;
1264 }
1265 let key = (edge.from, edge.to);
1268 match pair_kind.get(&key) {
1269 Some(existing) if *existing != edge.kind => {
1270 result.errors.push(Diagnostic::about(
1273 edge.from,
1274 DiagnosticKind::ConflictingEdge,
1275 "kind",
1276 format!(
1277 "#{from} → #{to} is already `{existing}`; refusing to store it as `{}` as well",
1278 edge.kind.as_str(),
1279 from = edge.from,
1280 to = edge.to
1281 ),
1282 ));
1283 }
1284 Some(_) => {}
1285 None => {
1286 pair_kind.insert(key, edge.kind);
1287 }
1288 }
1289 }
1290
1291 result.merge(self.validate_cycles());
1292 result.sort_diagnostics();
1293 result
1294 }
1295
1296 pub fn validate_create(
1301 &self,
1302 from: u32,
1303 to: u32,
1304 kind: RelationKind,
1305 ) -> RelationshipValidationResult {
1306 self.validate_authored(from, to, kind, None, Duplicate::Allowed)
1307 }
1308
1309 fn validate_authored(
1320 &self,
1321 from: u32,
1322 to: u32,
1323 kind: RelationKind,
1324 exclude: Option<&str>,
1325 duplicate: Duplicate,
1326 ) -> RelationshipValidationResult {
1327 let mut result = RelationshipValidationResult::clean();
1328 let canonical = CanonicalEdge::new(from, to, kind);
1329
1330 if !self.has_issue(from) {
1331 result.errors.push(Diagnostic::missing_target(to, from));
1332 }
1333 if canonical.is_self_link() {
1334 result.errors.push(Diagnostic::self_link(from));
1335 } else if !self.has_issue(to) {
1336 result.errors.push(Diagnostic::missing_target(from, to));
1337 }
1338
1339 if let Some(existing) = self.relationships_for(from).into_iter().find(|r| {
1340 let c = r.canonical();
1341 c.from == canonical.from
1342 && c.to == canonical.to
1343 && c.kind != canonical.kind
1344 && exclude != Some(r.id.as_str())
1345 }) {
1346 result.errors.push(Diagnostic::new(
1347 DiagnosticKind::ConflictingEdge,
1348 "kind",
1349 format!(
1350 "#{} → #{} is already `{}`; refusing to store it as `{}` as well",
1351 canonical.from,
1352 canonical.to,
1353 existing.kind.as_str(),
1354 canonical.kind.as_str()
1355 ),
1356 ));
1357 }
1358
1359 if let Some(cycle) = self.cycle_if_added(canonical) {
1360 result.errors.push(cycle);
1361 }
1362
1363 if duplicate == Duplicate::Rejected {
1371 if let Some(clash) = self
1372 .relationships_for(from)
1373 .into_iter()
1374 .find(|r| r.canonical() == canonical && exclude != Some(r.id.as_str()))
1375 {
1376 result.errors.push(Diagnostic::new(
1377 DiagnosticKind::ConflictingEdge,
1378 "kind",
1379 format!(
1380 "#{from} already has a `{}` relationship to #{} (`{}`); this edit would store a second one — drop the existing edge or pick a different kind",
1381 canonical.kind.as_str(),
1382 canonical.to,
1383 clash.id
1384 ),
1385 ));
1386 }
1387 }
1388
1389 result.sort_diagnostics();
1390 result
1391 }
1392
1393 pub fn validate_edit(
1400 &self,
1401 id: &str,
1402 new_to: Option<u32>,
1403 new_kind: Option<RelationKind>,
1404 ) -> RelationshipValidationResult {
1405 let Some(existing) = self.find_relationship(id) else {
1406 let mut result = RelationshipValidationResult::clean();
1407 result.errors.push(Diagnostic::unknown_relation(id));
1408 return result;
1409 };
1410
1411 let from = existing.from;
1412 let to = new_to.unwrap_or(existing.to);
1413 let kind = new_kind.unwrap_or(existing.kind);
1414 self.validate_authored(from, to, kind, Some(id), Duplicate::Rejected)
1415 }
1416
1417 fn validate_cycles(&self) -> RelationshipValidationResult {
1425 let mut result = RelationshipValidationResult::clean();
1426 for path in self.cycles() {
1427 result.errors.push(Diagnostic::about(
1433 path[0],
1434 DiagnosticKind::Cycle,
1435 "to",
1436 format!("cycle: {}", render_cycle(&path)),
1437 ));
1438 }
1439 result
1440 }
1441
1442 fn cycles(&self) -> Vec<Vec<u32>> {
1446 self.validation_passes
1454 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1455 let residue = self.cyclic_residue();
1456 if residue.is_empty() {
1457 return Vec::new();
1458 }
1459
1460 const WHITE: u8 = 0;
1461 const GREY: u8 = 1;
1462 const BLACK: u8 = 2;
1463
1464 let mut color: HashMap<u32, u8> = HashMap::new();
1465 let mut cycles: Vec<Vec<u32>> = Vec::new();
1466 let mut reported: HashSet<Vec<u32>> = HashSet::new();
1467 let mut path: Vec<u32> = Vec::new();
1468 let mut position: HashMap<u32, usize> = HashMap::new();
1469 let mut work: Vec<(u32, usize)> = Vec::new();
1470
1471 for start in &self.issues_by_number {
1472 let start = *start;
1473 if !residue.contains(&start) || color.get(&start) == Some(&BLACK) {
1474 continue;
1475 }
1476 color.insert(start, GREY);
1477 position.insert(start, path.len());
1478 path.push(start);
1479 work.push((start, 0));
1480
1481 while let Some(top) = work.last_mut() {
1482 let (node, cursor) = (top.0, top.1);
1483 let downstream = self.blocks(node);
1484 if cursor < downstream.len() {
1485 top.1 += 1;
1486 let next = downstream[cursor];
1487 if !residue.contains(&next) {
1488 continue;
1489 }
1490 match color.get(&next).copied().unwrap_or(WHITE) {
1491 GREY => {
1492 if let Some(start_at) = position.get(&next) {
1494 let cycle = path[*start_at..].to_vec();
1495 if reported.insert(cycle_key(&cycle)) {
1496 cycles.push(cycle);
1497 }
1498 }
1499 }
1500 WHITE => {
1501 color.insert(next, GREY);
1502 position.insert(next, path.len());
1503 path.push(next);
1504 work.push((next, 0));
1505 }
1506 _ => {}
1507 }
1508 } else {
1509 color.insert(node, BLACK);
1510 position.remove(&node);
1511 path.pop();
1512 work.pop();
1513 }
1514 }
1515 }
1516 cycles
1517 }
1518
1519 fn cyclic_residue(&self) -> HashSet<u32> {
1522 let mut indegree: HashMap<u32, usize> =
1523 self.issues_by_number.iter().map(|n| (*n, 0usize)).collect();
1524 for edge in &self.edges {
1525 if edge.kind != RelationKind::Blocks || edge.from == edge.to {
1526 continue;
1527 }
1528 if !self.has_issue(edge.from) || !self.has_issue(edge.to) {
1529 continue;
1530 }
1531 *indegree.entry(edge.to).or_default() += 1;
1532 }
1533
1534 let mut queue: VecDeque<u32> = indegree
1535 .iter()
1536 .filter(|(_, degree)| **degree == 0)
1537 .map(|(node, _)| *node)
1538 .collect();
1539 while let Some(node) = queue.pop_front() {
1540 for next in self.blocks(node) {
1544 if let Some(degree) = indegree.get_mut(next) {
1545 if *degree > 0 {
1546 *degree -= 1;
1547 if *degree == 0 {
1548 queue.push_back(*next);
1549 }
1550 }
1551 }
1552 }
1553 }
1554
1555 indegree
1556 .into_iter()
1557 .filter(|(_, degree)| *degree > 0)
1558 .map(|(node, _)| node)
1559 .collect()
1560 }
1561
1562 fn cycle_if_added(&self, edge: CanonicalEdge) -> Option<Diagnostic> {
1567 if edge.is_self_link() {
1568 return Some(Diagnostic::self_link(edge.from));
1569 }
1570 if edge.kind != RelationKind::Blocks {
1587 return None;
1588 }
1589 if !self.has_issue(edge.to) {
1590 return None;
1591 }
1592 let path = self.shortest_path(edge.to, edge.from)?;
1593 let mut full = Vec::with_capacity(path.len() + 1);
1594 full.push(edge.from);
1595 full.extend(path);
1596 Some(Diagnostic::new(
1597 DiagnosticKind::Cycle,
1598 "to",
1599 format!("cycle: {}", render_cycle(&full)),
1600 ))
1601 }
1602
1603 fn shortest_path(&self, start: u32, goal: u32) -> Option<Vec<u32>> {
1606 if start == goal {
1607 return Some(vec![start]);
1608 }
1609 let mut previous: HashMap<u32, u32> = HashMap::new();
1610 let mut seen: HashSet<u32> = HashSet::new();
1611 let mut queue: std::collections::VecDeque<u32> = std::collections::VecDeque::new();
1612 seen.insert(start);
1613 queue.push_back(start);
1614 while let Some(node) = queue.pop_front() {
1615 for next in self.blocks(node) {
1616 if !seen.insert(*next) {
1617 continue;
1618 }
1619 previous.insert(*next, node);
1620 if *next == goal {
1621 let mut path = vec![goal];
1622 let mut cursor = goal;
1623 while let Some(step) = previous.get(&cursor) {
1624 path.push(*step);
1625 cursor = *step;
1626 }
1627 path.reverse();
1628 return Some(path);
1629 }
1630 queue.push_back(*next);
1631 }
1632 }
1633 None
1634 }
1635}
1636
1637pub fn blocked_reason(blockers: &[ChainNode]) -> String {
1642 if blockers.len() == 1 {
1643 let node = &blockers[0];
1644 return format!("Blocked by #{} {}", node.number, node.title)
1645 .trim_end()
1646 .to_string();
1647 }
1648 let list = blockers
1649 .iter()
1650 .map(|n| format!("#{} {}", n.number, n.title).trim_end().to_string())
1651 .collect::<Vec<_>>()
1652 .join(", ");
1653 format!("Blocked by {} issues: {list}", blockers.len())
1654}
1655
1656fn render_cycle(path: &[u32]) -> String {
1657 let rendered = path
1658 .iter()
1659 .map(u32::to_string)
1660 .collect::<Vec<_>>()
1661 .join(" -> ");
1662 format!("{rendered} -> #{}", path[0])
1663}
1664
1665fn cycle_key(path: &[u32]) -> Vec<u32> {
1666 let Some(min_position) = path
1669 .iter()
1670 .enumerate()
1671 .min_by_key(|(_, n)| **n)
1672 .map(|(i, _)| i)
1673 else {
1674 return path.to_vec();
1675 };
1676 let mut rotated = path[min_position..].to_vec();
1677 rotated.extend_from_slice(&path[..min_position]);
1678 rotated
1679}
1680
1681pub fn generate_relationship_id() -> RelationshipId {
1695 use std::hash::{BuildHasher, Hasher};
1696 use std::sync::atomic::{AtomicU64, Ordering};
1697 use std::time::{SystemTime, UNIX_EPOCH};
1698
1699 static COUNTER: AtomicU64 = AtomicU64::new(0);
1700
1701 let millis = SystemTime::now()
1702 .duration_since(UNIX_EPOCH)
1703 .map(|d| d.as_millis())
1704 .unwrap_or(0);
1705 let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
1706 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
1707 hasher.write_u128(millis);
1708 hasher.write_u64(counter);
1709 let entropy = hasher.finish();
1710 let mut second = std::collections::hash_map::RandomState::new().build_hasher();
1712 second.write_u64(entropy);
1713 second.write_u64(counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1714 let high = second.finish() & 0xFFFF;
1715
1716 let value = (millis << 80u32) | (u128::from(entropy) << 16) | u128::from(high);
1717 format!("rel_{}", crate::id::crockford(value, 26))
1720}
1721
1722#[cfg(test)]
1727mod tests {
1728 use super::*;
1729
1730 #[derive(Clone)]
1735 struct Owned {
1736 number: u32,
1737 title: String,
1738 status: String,
1739 relationships: Vec<Relationship>,
1740 retired: bool,
1741 }
1742
1743 impl Owned {
1744 fn new(number: u32, title: &str, status: &str) -> Self {
1745 Self {
1746 number,
1747 title: title.to_string(),
1748 status: status.to_string(),
1749 relationships: Vec::new(),
1750 retired: false,
1751 }
1752 }
1753
1754 fn blocking(mut self, to: u32, title: &str) -> Self {
1756 self.relationships.push(Relationship::new(
1757 self.number,
1758 to,
1759 RelationKind::Blocks,
1760 title,
1761 ));
1762 self
1763 }
1764
1765 fn push_block(&mut self, to: u32, title: &str) {
1767 self.relationships.push(Relationship::new(
1768 self.number,
1769 to,
1770 RelationKind::Blocks,
1771 title,
1772 ));
1773 }
1774
1775 fn with_related(self, to: u32, title: &str) -> Self {
1777 let mut this = self;
1778 this.relationships.push(Relationship::new(
1779 this.number,
1780 to,
1781 RelationKind::Related,
1782 title,
1783 ));
1784 this
1785 }
1786
1787 fn with_retired_links_key(mut self) -> Self {
1788 self.retired = true;
1789 self
1790 }
1791
1792 fn view(&self) -> IssueView<'_> {
1793 IssueView::new(self.number, &self.title, &self.status, &self.relationships)
1794 .with_retired_links_key(self.retired)
1795 }
1796 }
1797
1798 fn graph_of<'x>(owned: &'x [Owned], done_column: &str) -> RelationshipGraph<'x> {
1799 let views: Vec<IssueView<'x>> = owned.iter().map(Owned::view).collect();
1800 RelationshipGraph::new(&views, done_column)
1801 }
1802
1803 #[test]
1806 fn relation_kind_has_exactly_four_variants_with_canonical_spellings() {
1807 let spellings: Vec<&str> = RelationKind::ALL.iter().map(|k| k.as_str()).collect();
1808 assert_eq!(spellings, ["blocks", "blocked_by", "duplicates", "related"]);
1809 }
1810
1811 #[test]
1812 fn is_directional_is_false_only_for_related() {
1813 for kind in RelationKind::ALL {
1814 assert_eq!(
1815 kind.is_directional(),
1816 kind != RelationKind::Related,
1817 "{kind} directionality"
1818 );
1819 }
1820 }
1821
1822 #[test]
1823 fn inverse_truth_table_matches_the_contract() {
1824 assert_eq!(
1825 RelationKind::Blocks.inverse(),
1826 Some(RelationKind::BlockedBy)
1827 );
1828 assert_eq!(
1829 RelationKind::BlockedBy.inverse(),
1830 Some(RelationKind::Blocks)
1831 );
1832 assert_eq!(RelationKind::Duplicates.inverse(), None);
1833 assert_eq!(RelationKind::Related.inverse(), None);
1834 }
1835
1836 #[test]
1837 fn from_str_parses_every_canonical_spelling() {
1838 for kind in RelationKind::ALL {
1839 assert_eq!(kind.as_str().parse::<RelationKind>().unwrap(), kind);
1840 }
1841 }
1842
1843 #[test]
1844 fn unknown_kind_is_rejected_and_never_coerced_to_related() {
1845 let err = "depends_on".parse::<RelationKind>().unwrap_err();
1846 assert_eq!(err.kind, DiagnosticKind::UnknownKind);
1847 assert!(err.message.contains("depends_on"));
1848 assert!(err.message.contains("blocks"));
1849 }
1850
1851 #[test]
1852 fn relationship_serde_round_trip_is_stable() {
1853 let rel = Relationship::new(42, 51, RelationKind::Blocks, "Refactor store");
1854 let yaml = serde_yaml::to_string(&rel).unwrap();
1855 let parsed: Relationship = serde_yaml::from_str(&yaml).unwrap();
1856 assert_eq!(rel, parsed);
1857 assert_eq!(serde_yaml::to_string(&parsed).unwrap(), yaml);
1858 }
1859
1860 #[test]
1861 fn relationship_parses_with_display_omitted() {
1862 let yaml = "id: rel_1\nfrom: 1\nto: 2\nkind: blocks\n";
1863 let rel: Relationship = serde_yaml::from_str(yaml).unwrap();
1864 assert_eq!(rel.display, "");
1865 assert!(!serde_yaml::to_string(&rel).unwrap().contains("display"));
1867 }
1868
1869 #[test]
1870 fn display_name_combines_kind_number_and_title() {
1871 assert_eq!(
1872 display_name(RelationKind::Blocks, 42, "Fix flaky test"),
1873 "blocks #42 Fix flaky test"
1874 );
1875 assert_eq!(
1876 display_name(RelationKind::Related, 7, " "),
1877 "related #7",
1878 "a blank title must not leave a trailing space"
1879 );
1880 }
1881
1882 #[test]
1883 fn stable_ids_are_unique_across_a_batch_and_prefixed() {
1884 let mut seen = std::collections::HashSet::new();
1885 for _ in 0..2_000 {
1886 let id = generate_relationship_id();
1887 assert!(id.starts_with("rel_"), "unexpected id shape: {id}");
1888 assert_eq!(id.len(), "rel_".len() + 26, "unexpected id length: {id}");
1889 assert!(
1890 id["rel_".len()..]
1891 .bytes()
1892 .all(|b| crate::id::CROCKFORD.contains(&b)),
1893 "non-Crockford character in {id}"
1894 );
1895 assert!(seen.insert(id), "duplicate relationship id generated");
1896 }
1897 }
1898
1899 #[test]
1902 fn both_spellings_of_a_blocking_pair_normalize_to_one_canonical_edge() {
1903 let forward = normalize(1, 2, RelationKind::Blocks);
1904 let inverse = normalize(1, 2, RelationKind::BlockedBy);
1905 assert_eq!(
1906 forward,
1907 CanonicalEdge {
1908 from: 1,
1909 to: 2,
1910 kind: RelationKind::Blocks
1911 }
1912 );
1913 assert_eq!(
1914 inverse,
1915 CanonicalEdge {
1916 from: 2,
1917 to: 1,
1918 kind: RelationKind::Blocks
1919 },
1920 "blocked_by must swap the endpoints into the canonical blocks form"
1921 );
1922 assert_eq!(
1923 forward.to, inverse.from,
1924 "the two spellings are the same edge"
1925 );
1926 }
1927
1928 #[test]
1929 fn a_persisted_blocking_edge_never_carries_the_blocked_by_kind() {
1930 let stored = Relationship::new(1, 2, RelationKind::BlockedBy, "T");
1931 assert_eq!(stored.kind, RelationKind::Blocks);
1932 assert_eq!((stored.from, stored.to), (2, 1));
1933 }
1934
1935 #[test]
1936 fn normalize_all_deduplicates_both_spellings_and_is_idempotent() {
1937 let authored = [
1939 (1, 2, RelationKind::Blocks),
1940 (2, 1, RelationKind::BlockedBy),
1941 (3, 4, RelationKind::Related),
1942 ];
1943 let once = normalize_all(authored);
1944 assert_eq!(once.len(), 2, "the two spellings collapse to one edge");
1945 assert_eq!(
1946 once,
1947 normalize_all(authored),
1948 "normalization is deterministic"
1949 );
1950 assert_eq!(
1951 once,
1952 normalize_all(
1953 once.iter()
1954 .map(|e| (e.from, e.to, e.kind))
1955 .collect::<Vec<_>>()
1956 )
1957 );
1958 }
1959
1960 #[test]
1961 fn related_and_duplicates_never_swap_endpoints() {
1962 for kind in [RelationKind::Related, RelationKind::Duplicates] {
1963 let edge = normalize(1, 2, kind);
1964 assert_eq!((edge.from, edge.to, edge.kind), (1, 2, kind));
1965 }
1966 }
1967
1968 #[test]
1969 fn a_reciprocal_blocking_pair_is_rejected_as_a_cycle() {
1970 let a = Owned::new(1, "A", "todo").blocking(2, "B");
1971 let b = Owned::new(2, "B", "todo").blocking(1, "A");
1972 let owned = vec![a, b];
1973 let graph = graph_of(&owned, "done");
1974
1975 let result = graph.validate_snapshot();
1976 assert!(
1977 result.has(DiagnosticKind::Cycle),
1978 "expected a cycle diagnostic: {result:?}"
1979 );
1980 assert!(result.blocking(), "a reciprocal pair must be blocking");
1981 }
1982
1983 #[test]
1984 fn a_conflicting_kind_for_the_same_pair_is_reported() {
1985 let a = Owned::new(1, "A", "todo").blocking(2, "B");
1986 let mut a = a;
1987 a.relationships
1988 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
1989 let owned = vec![a, Owned::new(2, "B", "todo")];
1990 let result = graph_of(&owned, "done").validate_snapshot();
1991 assert!(
1992 result.has(DiagnosticKind::ConflictingEdge),
1993 "expected conflicting_edge: {result:?}"
1994 );
1995 }
1996
1997 #[test]
2000 fn edges_are_returned_from_both_endpoints() {
2001 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2002 let owned = vec![a, Owned::new(2, "B", "todo")];
2003 let graph = graph_of(&owned, "done");
2004 assert_eq!(graph.relationships_for(1).len(), 1);
2005 assert_eq!(
2006 graph.relationships_for(2).len(),
2007 1,
2008 "the target sees the edge too"
2009 );
2010 }
2011
2012 #[test]
2013 fn ordering_is_stable_across_repeated_builds() {
2014 let a = Owned::new(1, "A", "todo")
2015 .blocking(4, "D")
2016 .blocking(2, "B")
2017 .blocking(3, "C");
2018 let owned = vec![
2019 a,
2020 Owned::new(2, "B", "todo"),
2021 Owned::new(3, "C", "todo"),
2022 Owned::new(4, "D", "todo"),
2023 ];
2024 let first: Vec<(RelationKind, u32)> = graph_of(&owned, "done")
2025 .relationships_for(1)
2026 .iter()
2027 .map(|r| (r.kind, r.to))
2028 .collect();
2029 let second: Vec<(RelationKind, u32)> = graph_of(&owned, "done")
2030 .relationships_for(1)
2031 .iter()
2032 .map(|r| (r.kind, r.to))
2033 .collect();
2034 assert_eq!(first, second, "repeated builds must be byte-comparable");
2035 assert_eq!(
2036 first,
2037 vec![
2038 (RelationKind::Blocks, 2),
2039 (RelationKind::Blocks, 3),
2040 (RelationKind::Blocks, 4)
2041 ],
2042 "edges are ordered by (kind, target)"
2043 );
2044 }
2045
2046 #[test]
2047 fn related_and_duplicates_never_enter_blocked_adjacency() {
2048 let mut a = Owned::new(1, "A", "todo");
2049 a.relationships
2050 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2051 a.relationships
2052 .push(Relationship::new(1, 3, RelationKind::Duplicates, "C"));
2053 let owned = vec![a, Owned::new(2, "B", "todo"), Owned::new(3, "C", "todo")];
2054 let graph = graph_of(&owned, "done");
2055 assert!(
2056 graph.blocks(1).is_empty(),
2057 "related/duplicates are not blockers"
2058 );
2059 assert_eq!(graph.blocked_by(2), [] as [u32; 0]);
2060 assert_eq!(graph.blocked_by(3), [] as [u32; 0]);
2061 assert_eq!(graph.duplicates_of(1), vec![3]);
2062 assert_eq!(graph.related_to(1), vec![2]);
2063 }
2064
2065 #[test]
2066 fn an_issue_with_no_edges_yields_empty_results_not_an_error() {
2067 let owned = vec![Owned::new(1, "A", "todo")];
2068 let graph = graph_of(&owned, "done");
2069 assert_eq!(
2070 graph.relationships_for(1),
2071 [] as [&crate::relationships::Relationship; 0]
2072 );
2073 assert_eq!(graph.blocks(1), [] as [u32; 0]);
2074 assert_eq!(graph.blocked_by(1), [] as [u32; 0]);
2075 assert_eq!(
2076 graph.open_blockers(1),
2077 [] as [crate::relationships::ChainNode; 0]
2078 );
2079 assert!(graph.dependencies_are_empty_for_unknown_issue());
2080 }
2081
2082 impl RelationshipGraph<'_> {
2083 fn dependencies_are_empty_for_unknown_issue(&self) -> bool {
2085 self.dependency_chain(9_999).paths.is_empty()
2086 }
2087 }
2088
2089 #[test]
2092 fn an_empty_snapshot_validates_clean() {
2093 let owned = vec![Owned::new(1, "A", "todo")];
2094 let result = graph_of(&owned, "done").validate_snapshot();
2095 assert!(result.is_clean(), "unexpected diagnostics: {result:?}");
2096 assert!(!result.blocking());
2097 }
2098
2099 #[test]
2100 fn self_link_create_is_rejected() {
2101 let owned = vec![Owned::new(1, "A", "todo")];
2102 let result = graph_of(&owned, "done").validate_create(1, 1, RelationKind::Blocks);
2103 assert!(result.has(DiagnosticKind::SelfLink), "{result:?}");
2104 assert!(result.blocking());
2105 }
2106
2107 #[test]
2108 fn missing_target_create_is_rejected() {
2109 let owned = vec![Owned::new(1, "A", "todo")];
2110 let result = graph_of(&owned, "done").validate_create(1, 99, RelationKind::Blocks);
2111 assert!(result.has(DiagnosticKind::MissingTarget), "{result:?}");
2112 }
2113
2114 #[test]
2115 fn validate_create_accepts_both_spellings_of_the_same_pair() {
2116 let owned = vec![Owned::new(1, "A", "todo"), Owned::new(2, "B", "todo")];
2117 let graph = graph_of(&owned, "done");
2118 assert!(graph.validate_create(1, 2, RelationKind::Blocks).is_clean());
2119 assert!(
2120 graph
2121 .validate_create(1, 2, RelationKind::BlockedBy)
2122 .is_clean(),
2123 "the inverse spelling is the same edge, not a conflict"
2124 );
2125 }
2126
2127 #[test]
2128 fn editing_an_unknown_relationship_id_is_reported() {
2129 let owned = vec![Owned::new(1, "A", "todo")];
2130 let result = graph_of(&owned, "done").validate_edit("rel_missing", None, None);
2131 assert!(result.has(DiagnosticKind::UnknownRelation), "{result:?}");
2132 }
2133
2134 #[test]
2135 fn a_retired_links_key_is_reported_and_never_read_as_typed_data() {
2136 let owned = vec![
2137 Owned::new(1, "A", "todo").with_retired_links_key(),
2138 Owned::new(2, "B", "todo"),
2139 ];
2140 let result = graph_of(&owned, "done").validate_snapshot();
2141 assert!(result.has(DiagnosticKind::RetiredLinksKey), "{result:?}");
2142 assert_eq!(
2143 result
2144 .errors
2145 .iter()
2146 .find(|d| d.kind == DiagnosticKind::RetiredLinksKey)
2147 .map(|d| d.field.as_str()),
2148 Some("links")
2149 );
2150 }
2151
2152 #[test]
2153 fn a_snapshot_with_three_distinct_problems_reports_exactly_three_diagnostics() {
2154 let mut self_linked = Owned::new(1, "A", "todo");
2155 self_linked
2156 .relationships
2157 .push(Relationship::new(3, 3, RelationKind::Blocks, "C"));
2158 let mut dangling = Owned::new(2, "B", "todo");
2159 dangling
2160 .relationships
2161 .push(Relationship::new(2, 404, RelationKind::Blocks, "gone"));
2162 let owned = vec![
2163 self_linked,
2164 dangling,
2165 Owned::new(3, "C", "todo").with_retired_links_key(),
2166 ];
2167 let result = graph_of(&owned, "done").validate_snapshot();
2168 assert_eq!(
2169 result.errors.len(),
2170 3,
2171 "expected no duplicate or implied reports: {result:?}"
2172 );
2173 }
2174
2175 #[test]
2178 fn a_three_chain_returns_the_ordered_path() {
2179 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2180 let b = Owned::new(2, "B", "todo").blocking(3, "C");
2181 let owned = vec![a, b, Owned::new(3, "C", "todo")];
2182 let chain = graph_of(&owned, "done").dependency_chain(1);
2183 assert_eq!(chain.root, 1);
2184 assert_eq!(chain.paths.len(), 1);
2185 assert_eq!(
2186 chain.paths[0].iter().map(|n| n.number).collect::<Vec<_>>(),
2187 vec![1, 2, 3],
2188 "the chain walks upstream from the root toward its blockers"
2189 );
2190 assert!(!chain.truncated);
2191 }
2192
2193 #[test]
2194 fn a_cycle_closing_edge_is_rejected() {
2195 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2196 let b = Owned::new(2, "B", "todo").blocking(3, "C");
2197 let owned = vec![a, b, Owned::new(3, "C", "todo")];
2198 let result = graph_of(&owned, "done").validate_create(3, 1, RelationKind::Blocks);
2199 assert!(result.has(DiagnosticKind::Cycle), "{result:?}");
2200 assert!(result.blocking());
2201 }
2202
2203 #[test]
2204 fn blocked_state_is_true_only_for_an_open_inbound_blocks_edge() {
2205 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2206 let owned = vec![a, Owned::new(2, "B", "todo")];
2207 let graph = graph_of(&owned, "done");
2208
2209 let state = graph.blocked_state(2);
2210 assert!(state.is_blocked);
2211 assert_eq!(
2212 state.blockers.iter().map(|n| n.number).collect::<Vec<_>>(),
2213 vec![1]
2214 );
2215 assert!(
2216 state.reason.contains("#1"),
2217 "the reason names the blocker: {}",
2218 state.reason
2219 );
2220
2221 assert!(
2222 !graph.blocked_state(1).is_blocked,
2223 "the blocker is not itself blocked"
2224 );
2225 }
2226
2227 #[test]
2228 fn moving_the_blocker_into_the_configured_done_column_clears_blocked_state() {
2229 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2230 let owned = vec![a, Owned::new(2, "B", "todo")];
2231 let graph = graph_of(&owned, "shipped");
2233 assert!(
2234 graph.blocked_state(2).is_blocked,
2235 "A is not in the shipped column yet"
2236 );
2237
2238 let mut moved = owned.clone();
2239 moved[0].status = "shipped".to_string();
2240 let after = graph_of(&moved, "shipped");
2241 assert!(
2242 !after.blocked_state(2).is_blocked,
2243 "a blocker in the configured done_column stops blocking"
2244 );
2245 }
2246
2247 #[test]
2248 fn a_blocker_in_a_non_completion_column_keeps_blocking() {
2249 let a = Owned::new(1, "A", "in review").blocking(2, "B");
2250 let owned = vec![a, Owned::new(2, "B", "todo")];
2251 let graph = graph_of(&owned, "shipped");
2252 assert!(graph.blocked_state(2).is_blocked);
2253 assert_ne!(graph.blocked_state(2).reason, "");
2254 }
2255
2256 #[test]
2257 fn a_dangling_edge_is_diagnosed_but_does_not_freeze_the_issue() {
2258 let mut blocker = Owned::new(1, "A", "todo");
2261 blocker
2262 .relationships
2263 .push(Relationship::new(1, 404, RelationKind::Blocks, "gone"));
2264 let owned = vec![blocker, Owned::new(2, "B", "todo")];
2265 let graph = graph_of(&owned, "done");
2266
2267 let result = graph.validate_snapshot();
2268 assert!(
2269 result.has(DiagnosticKind::MissingTarget),
2270 "the dangling edge is still reported: {result:?}"
2271 );
2272 assert!(
2273 !graph.blocked_state(2).is_blocked,
2274 "invalid data must not permanently freeze an issue"
2275 );
2276 }
2277
2278 #[test]
2279 fn a_hand_edited_cyclic_file_terminates_instead_of_hanging() {
2280 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2283 let b = Owned::new(2, "B", "todo").blocking(1, "A");
2284 let owned = vec![a, b];
2285 let graph = graph_of(&owned, "done");
2286
2287 assert!(graph.validate_snapshot().has(DiagnosticKind::Cycle));
2288 let chain = graph.dependency_chain(1);
2289 assert!(
2290 chain.paths.iter().all(|p| p.len() <= owned.len()),
2291 "the visited-set guard must bound every path"
2292 );
2293 }
2294
2295 #[test]
2298 fn a_ten_thousand_edge_graph_validates_in_one_pass() {
2299 const ISSUES: u32 = 2_000;
2300 const PER_ISSUE: u32 = 6;
2301 let mut owned: Vec<Owned> = (0..ISSUES)
2304 .map(|n| Owned::new(n + 1, &format!("Issue {n}"), "todo"))
2305 .collect();
2306 for n in 0..ISSUES {
2307 for k in 1..=PER_ISSUE {
2308 let target = n + k;
2309 if target < ISSUES {
2310 owned[n as usize].push_block(target + 1, "Target");
2311 }
2312 }
2313 }
2314 let graph = graph_of(&owned, "done");
2315 assert!(
2316 graph.edge_count() >= 10_000,
2317 "expected a 10,000-edge graph, built {}",
2318 graph.edge_count()
2319 );
2320
2321 let result = graph.validate_snapshot();
2324 assert!(
2325 result.is_clean(),
2326 "a well-formed graph validates clean: {result:?}"
2327 );
2328 assert_eq!(graph.issue_count(), ISSUES as usize);
2329 assert!(graph.blocked_state(2).is_blocked);
2330 }
2331
2332 #[test]
2333 fn a_ten_thousand_node_chain_does_not_overflow_the_stack() {
2334 const DEPTH: u32 = 10_000;
2335 let mut owned: Vec<Owned> = (0..DEPTH)
2336 .map(|n| Owned::new(n + 1, &format!("Node {n}"), "todo"))
2337 .collect();
2338 for n in 0..DEPTH - 1 {
2339 owned[n as usize].push_block(n + 2, "Next");
2340 }
2341 let graph = graph_of(&owned, "done");
2342 assert!(graph.validate_snapshot().is_clean());
2344 let chain = graph.dependency_chain(1);
2345 assert!(chain.truncated, "the defensive node bound engages");
2346 assert!(
2347 graph
2348 .cycle_if_added(CanonicalEdge {
2349 from: DEPTH,
2350 to: 1,
2351 kind: RelationKind::Blocks
2352 })
2353 .is_some()
2354 );
2355 }
2356
2357 #[test]
2360 fn d1_inverse_spellings_find_the_same_row() {
2361 let owned = vec![
2363 Owned::new(1, "A", "todo").blocking(2, "B"),
2364 Owned::new(2, "B", "todo"),
2365 ];
2366 let graph = graph_of(&owned, "done");
2367 let forward = graph
2368 .find_canonical(1, 2, RelationKind::Blocks)
2369 .map(|r| r.id.clone());
2370 let inverse = graph
2371 .find_canonical(2, 1, RelationKind::BlockedBy)
2372 .map(|r| r.id.clone());
2373 assert!(forward.is_some(), "the stored edge must be found");
2374 assert_eq!(
2375 forward, inverse,
2376 "1 blocks 2 and 2 blocked_by 1 name one relationship"
2377 );
2378 assert_ne!(
2379 forward,
2380 graph
2381 .find_canonical(1, 2, RelationKind::BlockedBy)
2382 .map(|r| r.id.clone()),
2383 "1 blocked_by 2 is the inverse of the stored edge, so it must not match"
2384 );
2385 }
2386
2387 #[test]
2388 fn d1_related_is_found_from_either_orientation() {
2389 let mut two = Owned::new(2, "B", "todo");
2390 two.relationships
2391 .push(Relationship::new(2, 1, RelationKind::Related, "A"));
2392 let owned = vec![Owned::new(1, "A", "todo"), two];
2393 let graph = graph_of(&owned, "done");
2394 let a = graph
2395 .find_canonical(1, 2, RelationKind::Related)
2396 .map(|r| r.id.clone());
2397 let b = graph
2398 .find_canonical(2, 1, RelationKind::Related)
2399 .map(|r| r.id.clone());
2400 assert!(a.is_some(), "related is order-normalized, so it is found");
2401 assert_eq!(a, b);
2402 }
2403
2404 #[test]
2405 fn d1_a_self_link_or_unrelated_pair_does_not_match() {
2406 let owned = vec![
2407 Owned::new(1, "A", "todo").blocking(2, "B"),
2408 Owned::new(2, "B", "todo"),
2409 Owned::new(3, "C", "todo"),
2410 ];
2411 let graph = graph_of(&owned, "done");
2412 assert!(graph.find_canonical(1, 1, RelationKind::Blocks).is_none());
2413 assert!(graph.find_canonical(1, 3, RelationKind::Blocks).is_none());
2414 assert!(graph.find_canonical(3, 1, RelationKind::Blocks).is_none());
2415 assert!(graph.find_canonical(1, 2, RelationKind::Related).is_none());
2416 assert!(
2417 graph.find_canonical(1, 2, RelationKind::Blocks).is_some(),
2418 "the real edge still resolves"
2419 );
2420 }
2421
2422 #[test]
2423 fn d1_redundant_rows_resolve_to_the_same_row_every_time() {
2424 let one = Owned::new(1, "A", "todo").with_related(2, "B");
2425 let two = Owned::new(2, "B", "todo").with_related(1, "A");
2426 let owned = vec![one, two];
2427 let first = graph_of(&owned, "done")
2428 .find_canonical(1, 2, RelationKind::Related)
2429 .map(|r| r.id.clone());
2430 let second = graph_of(&owned, "done")
2431 .find_canonical(1, 2, RelationKind::Related)
2432 .map(|r| r.id.clone());
2433 assert!(first.is_some());
2434 assert_eq!(first, second, "the answer must be stable, not arbitrary");
2435 }
2436
2437 #[test]
2438 fn d1_an_unknown_pair_returns_none() {
2439 let owned = vec![Owned::new(1, "A", "todo")];
2440 let graph = graph_of(&owned, "done");
2441 assert!(graph.find_canonical(1, 404, RelationKind::Blocks).is_none());
2442 assert!(graph.find_canonical(404, 1, RelationKind::Blocks).is_none());
2443 }
2444
2445 #[test]
2448 fn p1_related_reads_as_one_association_across_orientations() {
2449 let one = Owned::new(1, "A", "todo").with_related(2, "B");
2453 let two = Owned::new(2, "B", "todo").with_related(1, "A");
2454 let owned = vec![one, two];
2455 let graph = graph_of(&owned, "done");
2456 assert_eq!(graph.related_to(1), vec![2], "one association, not two");
2457 assert_eq!(graph.related_to(2), vec![1]);
2458 assert_eq!(
2459 normalize_all([(1, 2, RelationKind::Related), (2, 1, RelationKind::Related),]).len(),
2460 1,
2461 "both authorings normalize to one canonical edge"
2462 );
2463 }
2464
2465 #[test]
2468 fn a3_no_stored_relationship_id_silently_stops_resolving() {
2469 let one = Owned::new(1, "A", "todo").with_related(2, "B");
2473 let two = Owned::new(2, "B", "todo").with_related(1, "A");
2474 let owned = vec![one, two];
2475 let graph = graph_of(&owned, "done");
2476 let ids: Vec<String> = graph
2477 .all_relationships()
2478 .iter()
2479 .filter(|r| r.kind == RelationKind::Related)
2480 .map(|r| r.id.clone())
2481 .collect();
2482 assert_eq!(ids.len(), 2, "both stored rows are retained");
2483 for id in &ids {
2484 assert!(
2485 graph.find_relationship(id).is_some(),
2486 "stored id {id} must still resolve"
2487 );
2488 assert!(
2489 !graph
2490 .validate_edit(id, None, None)
2491 .has(DiagnosticKind::UnknownRelation),
2492 "editing by a stored id must not report it unknown"
2493 );
2494 }
2495 }
2496
2497 #[test]
2498 fn a3_the_blocked_adjacency_does_not_repeat_a_neighbour() {
2499 let mut one = Owned::new(1, "A", "todo");
2501 for _ in 0..3 {
2502 one.push_block(2, "B");
2503 }
2504 let owned = vec![one, Owned::new(2, "B", "todo")];
2505 let graph = graph_of(&owned, "done");
2506 assert_eq!(graph.blocks(1), &[2], "no repeated neighbour");
2507 assert_eq!(graph.blocked_by(2), &[1], "no repeated blocker");
2508 assert_eq!(
2509 graph.blocked_state(2).blockers.len(),
2510 1,
2511 "one blocker is reported once, not once per stored row"
2512 );
2513 }
2514
2515 #[test]
2516 fn c_an_unconfigured_done_column_fails_closed() {
2517 let mut one = Owned::new(1, "A", "");
2520 one.push_block(2, "B");
2521 let owned = vec![one, Owned::new(2, "B", "todo")];
2522 let graph = graph_of(&owned, "");
2523 assert!(
2524 graph.blocked_state(2).is_blocked,
2525 "an unconfigured completion column must not mark anything done"
2526 );
2527 }
2528
2529 #[test]
2530 fn p1_duplicates_orientation_is_preserved() {
2531 assert_eq!(
2533 normalize_all([
2534 (1, 2, RelationKind::Duplicates),
2535 (2, 1, RelationKind::Duplicates),
2536 ])
2537 .len(),
2538 2
2539 );
2540 }
2541
2542 #[test]
2543 fn p2_an_edit_cannot_land_on_a_triple_another_edge_occupies() {
2544 let mut a = Owned::new(1, "A", "todo");
2545 a.push_block(2, "B");
2546 a.relationships
2547 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2548 let owned = vec![a, Owned::new(2, "B", "todo")];
2549 let graph = graph_of(&owned, "done");
2550 let blocks_id = graph
2551 .relationships_for(1)
2552 .iter()
2553 .find(|r| r.kind == RelationKind::Blocks)
2554 .expect("a blocks edge")
2555 .id
2556 .clone();
2557
2558 let res = graph.validate_edit(&blocks_id, None, Some(RelationKind::Related));
2559 assert!(
2560 res.has(DiagnosticKind::ConflictingEdge),
2561 "re-kinding onto an occupied triple must be rejected: {res:?}"
2562 );
2563 assert!(
2564 res.errors
2565 .iter()
2566 .any(|d| d.message.contains("would store a second one")),
2567 "the message must name the duplicate case: {res:?}"
2568 );
2569 }
2570
2571 #[test]
2572 fn p2_creating_an_edge_that_already_exists_stays_idempotent() {
2573 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2576 let owned = vec![a, Owned::new(2, "B", "todo")];
2577 let graph = graph_of(&owned, "done");
2578 assert!(
2579 graph.validate_create(1, 2, RelationKind::Blocks).is_clean(),
2580 "re-asserting an existing edge is the idempotent path"
2581 );
2582 }
2583
2584 #[test]
2585 fn p3_p4_the_other_endpoint_is_returned_in_both_orientations() {
2586 let mut two = Owned::new(2, "B", "todo");
2588 two.relationships
2589 .push(Relationship::new(2, 1, RelationKind::Related, "A"));
2590 two.relationships
2591 .push(Relationship::new(2, 1, RelationKind::Duplicates, "A"));
2592 let owned = vec![Owned::new(1, "A", "todo"), two];
2593 let graph = graph_of(&owned, "done");
2594
2595 assert_eq!(graph.related_to(1), vec![2], "never the subject itself");
2597 assert_eq!(graph.related_to(2), vec![1]);
2598 assert!(
2600 graph.duplicates_of(1).is_empty(),
2601 "a directional edge pointing at #1 does not mean #1 duplicates it: {:?}",
2602 graph.duplicates_of(1)
2603 );
2604 assert_eq!(graph.duplicates_of(2), vec![1]);
2605 }
2606
2607 #[test]
2608 fn p5_the_blocked_adjacency_never_contains_the_subject() {
2609 let mut a = Owned::new(1, "A", "todo");
2613 a.relationships
2614 .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2615 a.push_block(2, "B");
2616 let owned = vec![a, Owned::new(2, "B", "todo")];
2617 let graph = graph_of(&owned, "done");
2618 for number in [1, 2] {
2619 assert!(
2620 !graph.blocked_by(number).contains(&number),
2621 "issue #{number} must never appear in its own inbound blocks adjacency"
2622 );
2623 }
2624 }
2625
2626 #[test]
2629 fn r1_display_is_refreshed_when_the_target_is_renamed() {
2630 let a = Owned::new(1, "A", "todo").blocking(2, "Old title");
2632 let owned = vec![a, Owned::new(2, "New title", "todo")];
2633 let graph = graph_of(&owned, "done");
2634 assert_eq!(
2635 graph.relationships_for(1)[0].display,
2636 "blocks #2 New title",
2637 "a persisted display must not outlive the title it was built from"
2638 );
2639 }
2640
2641 #[test]
2642 fn r1_a_dangling_edge_keeps_its_persisted_display() {
2643 let mut a = Owned::new(1, "A", "todo");
2645 a.relationships
2646 .push(Relationship::new(1, 404, RelationKind::Blocks, "gone"));
2647 let owned = vec![a];
2648 assert_eq!(
2649 graph_of(&owned, "done").relationships_for(1)[0].display,
2650 "blocks #404 gone"
2651 );
2652 }
2653
2654 #[test]
2655 fn r2_a_self_link_is_reported_once_not_twice() {
2656 let mut a = Owned::new(1, "A", "todo");
2657 a.relationships
2658 .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2659 let owned = vec![a];
2660 let graph = graph_of(&owned, "done");
2661 assert_eq!(graph.edge_count(), 1);
2662 assert_eq!(
2663 graph.relationships_for(1).len(),
2664 1,
2665 "a self-link has one endpoint, so it must be registered once"
2666 );
2667 }
2668
2669 #[test]
2670 fn r3_an_issue_is_never_blocked_by_itself() {
2671 let mut a = Owned::new(1, "A", "todo");
2672 a.relationships
2673 .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2674 let owned = vec![a];
2675 let graph = graph_of(&owned, "done");
2676
2677 assert!(graph.validate_snapshot().has(DiagnosticKind::SelfLink));
2679 assert_eq!(graph.blocks(1), [] as [u32; 0]);
2681 let state = graph.blocked_state(1);
2682 assert!(!state.is_blocked, "an issue must not block itself");
2683 assert_eq!(state.reason, "");
2684 }
2685
2686 #[test]
2687 fn r4_validate_edit_does_not_hide_a_pre_existing_conflict() {
2688 let mut a = Owned::new(1, "A", "todo");
2690 a.push_block(2, "B");
2691 a.relationships
2692 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2693 let owned = vec![a, Owned::new(2, "B", "todo")];
2694 let graph = graph_of(&owned, "done");
2695 let blocks_id = graph
2696 .relationships_for(1)
2697 .iter()
2698 .find(|r| r.kind == RelationKind::Blocks)
2699 .expect("a blocks edge")
2700 .id
2701 .clone();
2702
2703 assert!(
2704 graph
2705 .validate_snapshot()
2706 .has(DiagnosticKind::ConflictingEdge)
2707 );
2708 let edit = graph.validate_edit(&blocks_id, None, None);
2709 assert!(
2710 edit.has(DiagnosticKind::ConflictingEdge),
2711 "editing an unrelated field must not return a clean verdict on corrupt data: {edit:?}"
2712 );
2713 }
2714
2715 #[test]
2716 fn r4_an_edit_does_not_conflict_with_itself() {
2717 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2718 let owned = vec![a, Owned::new(2, "B", "todo"), Owned::new(3, "C", "todo")];
2719 let graph = graph_of(&owned, "done");
2720 let id = graph.relationships_for(1)[0].id.clone();
2721
2722 assert!(
2723 graph.validate_edit(&id, None, None).is_clean(),
2724 "an edge never conflicts with itself"
2725 );
2726 assert!(
2727 graph.validate_edit(&id, Some(3), None).is_clean(),
2728 "retargeting to an unrelated issue is not a conflict"
2729 );
2730 }
2731
2732 #[test]
2735 fn every_contract_diagnostic_kind_exists_with_its_exact_name() {
2736 let names: Vec<&str> = [
2737 DiagnosticKind::SelfLink,
2738 DiagnosticKind::MissingTarget,
2739 DiagnosticKind::ConflictingEdge,
2740 DiagnosticKind::Cycle,
2741 DiagnosticKind::UnknownKind,
2742 DiagnosticKind::UnknownRelation,
2743 DiagnosticKind::RetiredLinksKey,
2744 ]
2745 .iter()
2746 .map(|k| k.as_str())
2747 .collect();
2748 assert_eq!(
2749 names,
2750 [
2751 "self_link",
2752 "missing_target",
2753 "conflicting_edge",
2754 "cycle",
2755 "unknown_kind",
2756 "unknown_relation",
2757 "retired_links_key"
2758 ]
2759 );
2760 }
2761
2762 #[test]
2763 fn validate_snapshot_never_returns_an_err() {
2764 let owned = vec![Owned::new(1, "A", "todo").with_retired_links_key()];
2766 let result: RelationshipValidationResult = graph_of(&owned, "done").validate_snapshot();
2767 assert!(result.blocking());
2768 }
2769
2770 #[test]
2771 fn diagnostics_sort_deterministically() {
2772 let mut a = RelationshipValidationResult::with(Diagnostic::self_link(1));
2773 a.merge(RelationshipValidationResult::with(
2774 Diagnostic::unknown_relation("rel_x"),
2775 ));
2776 let mut b = a.clone();
2777 b.sort_diagnostics();
2778 a.sort_diagnostics();
2779 assert_eq!(a, b);
2780 }
2781
2782 #[test]
2783 fn blocked_reason_is_shared_by_every_surface() {
2784 let single = vec![ChainNode::new(1, "A")];
2785 assert_eq!(blocked_reason(&single), "Blocked by #1 A");
2786 let many = vec![ChainNode::new(1, "A"), ChainNode::new(2, "B")];
2787 assert_eq!(blocked_reason(&many), "Blocked by 2 issues: #1 A, #2 B");
2788 }
2789
2790 #[test]
2791 fn cycle_paths_are_reported_once_regardless_of_starting_node() {
2792 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2793 let b = Owned::new(2, "B", "todo").blocking(3, "C");
2794 let c = Owned::new(3, "C", "todo").blocking(1, "A");
2795 let owned = vec![a, b, c];
2796 let result = graph_of(&owned, "done").validate_snapshot();
2797 let cycles: Vec<&Diagnostic> = result
2798 .errors
2799 .iter()
2800 .filter(|d| d.kind == DiagnosticKind::Cycle)
2801 .collect();
2802 assert_eq!(
2803 cycles.len(),
2804 1,
2805 "one distinct cycle, one diagnostic: {result:?}"
2806 );
2807 }
2808
2809 #[test]
2810 fn hand_edited_blocked_by_persisted_edges_still_block() {
2811 let mut owner = Owned::new(2, "B", "todo");
2815 owner.relationships.push(Relationship {
2816 id: "rel_hand".to_string(),
2817 from: 1,
2818 to: 2,
2819 kind: RelationKind::BlockedBy,
2820 display: String::new(),
2821 });
2822 let owned = vec![Owned::new(1, "A", "todo"), owner];
2823 let graph = graph_of(&owned, "done");
2824 assert!(
2825 graph.blocked_state(1).is_blocked,
2826 "the normalized adjacency must still carry the edge"
2827 );
2828 assert_eq!(graph.blocks(2), &[1], "stored in canonical blocks form");
2829 }
2830}