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
1681const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1687
1688pub fn generate_relationship_id() -> RelationshipId {
1698 use std::hash::{BuildHasher, Hasher};
1699 use std::sync::atomic::{AtomicU64, Ordering};
1700 use std::time::{SystemTime, UNIX_EPOCH};
1701
1702 static COUNTER: AtomicU64 = AtomicU64::new(0);
1703
1704 let millis = SystemTime::now()
1705 .duration_since(UNIX_EPOCH)
1706 .map(|d| d.as_millis())
1707 .unwrap_or(0);
1708 let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
1709 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
1710 hasher.write_u128(millis);
1711 hasher.write_u64(counter);
1712 let entropy = hasher.finish();
1713 let mut second = std::collections::hash_map::RandomState::new().build_hasher();
1715 second.write_u64(entropy);
1716 second.write_u64(counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1717 let high = second.finish() & 0xFFFF;
1718
1719 let value = (millis << 80u32) | (u128::from(entropy) << 16) | u128::from(high);
1720 format!("rel_{}", encode_crockford(value))
1721}
1722
1723fn encode_crockford(value: u128) -> String {
1725 let mut out = [b'0'; 26];
1726 let mut remaining = value;
1727 for slot in out.iter_mut().rev() {
1728 *slot = CROCKFORD[(remaining & 0x1F) as usize];
1729 remaining >>= 5;
1730 }
1731 String::from_utf8_lossy(&out).into_owned()
1732}
1733
1734#[cfg(test)]
1739mod tests {
1740 use super::*;
1741
1742 #[derive(Clone)]
1747 struct Owned {
1748 number: u32,
1749 title: String,
1750 status: String,
1751 relationships: Vec<Relationship>,
1752 retired: bool,
1753 }
1754
1755 impl Owned {
1756 fn new(number: u32, title: &str, status: &str) -> Self {
1757 Self {
1758 number,
1759 title: title.to_string(),
1760 status: status.to_string(),
1761 relationships: Vec::new(),
1762 retired: false,
1763 }
1764 }
1765
1766 fn blocking(mut self, to: u32, title: &str) -> Self {
1768 self.relationships.push(Relationship::new(
1769 self.number,
1770 to,
1771 RelationKind::Blocks,
1772 title,
1773 ));
1774 self
1775 }
1776
1777 fn push_block(&mut self, to: u32, title: &str) {
1779 self.relationships.push(Relationship::new(
1780 self.number,
1781 to,
1782 RelationKind::Blocks,
1783 title,
1784 ));
1785 }
1786
1787 fn with_related(self, to: u32, title: &str) -> Self {
1789 let mut this = self;
1790 this.relationships.push(Relationship::new(
1791 this.number,
1792 to,
1793 RelationKind::Related,
1794 title,
1795 ));
1796 this
1797 }
1798
1799 fn with_retired_links_key(mut self) -> Self {
1800 self.retired = true;
1801 self
1802 }
1803
1804 fn view(&self) -> IssueView<'_> {
1805 IssueView::new(self.number, &self.title, &self.status, &self.relationships)
1806 .with_retired_links_key(self.retired)
1807 }
1808 }
1809
1810 fn graph_of<'x>(owned: &'x [Owned], done_column: &str) -> RelationshipGraph<'x> {
1811 let views: Vec<IssueView<'x>> = owned.iter().map(Owned::view).collect();
1812 RelationshipGraph::new(&views, done_column)
1813 }
1814
1815 #[test]
1818 fn relation_kind_has_exactly_four_variants_with_canonical_spellings() {
1819 let spellings: Vec<&str> = RelationKind::ALL.iter().map(|k| k.as_str()).collect();
1820 assert_eq!(spellings, ["blocks", "blocked_by", "duplicates", "related"]);
1821 }
1822
1823 #[test]
1824 fn is_directional_is_false_only_for_related() {
1825 for kind in RelationKind::ALL {
1826 assert_eq!(
1827 kind.is_directional(),
1828 kind != RelationKind::Related,
1829 "{kind} directionality"
1830 );
1831 }
1832 }
1833
1834 #[test]
1835 fn inverse_truth_table_matches_the_contract() {
1836 assert_eq!(
1837 RelationKind::Blocks.inverse(),
1838 Some(RelationKind::BlockedBy)
1839 );
1840 assert_eq!(
1841 RelationKind::BlockedBy.inverse(),
1842 Some(RelationKind::Blocks)
1843 );
1844 assert_eq!(RelationKind::Duplicates.inverse(), None);
1845 assert_eq!(RelationKind::Related.inverse(), None);
1846 }
1847
1848 #[test]
1849 fn from_str_parses_every_canonical_spelling() {
1850 for kind in RelationKind::ALL {
1851 assert_eq!(kind.as_str().parse::<RelationKind>().unwrap(), kind);
1852 }
1853 }
1854
1855 #[test]
1856 fn unknown_kind_is_rejected_and_never_coerced_to_related() {
1857 let err = "depends_on".parse::<RelationKind>().unwrap_err();
1858 assert_eq!(err.kind, DiagnosticKind::UnknownKind);
1859 assert!(err.message.contains("depends_on"));
1860 assert!(err.message.contains("blocks"));
1861 }
1862
1863 #[test]
1864 fn relationship_serde_round_trip_is_stable() {
1865 let rel = Relationship::new(42, 51, RelationKind::Blocks, "Refactor store");
1866 let yaml = serde_yaml::to_string(&rel).unwrap();
1867 let parsed: Relationship = serde_yaml::from_str(&yaml).unwrap();
1868 assert_eq!(rel, parsed);
1869 assert_eq!(serde_yaml::to_string(&parsed).unwrap(), yaml);
1870 }
1871
1872 #[test]
1873 fn relationship_parses_with_display_omitted() {
1874 let yaml = "id: rel_1\nfrom: 1\nto: 2\nkind: blocks\n";
1875 let rel: Relationship = serde_yaml::from_str(yaml).unwrap();
1876 assert_eq!(rel.display, "");
1877 assert!(!serde_yaml::to_string(&rel).unwrap().contains("display"));
1879 }
1880
1881 #[test]
1882 fn display_name_combines_kind_number_and_title() {
1883 assert_eq!(
1884 display_name(RelationKind::Blocks, 42, "Fix flaky test"),
1885 "blocks #42 Fix flaky test"
1886 );
1887 assert_eq!(
1888 display_name(RelationKind::Related, 7, " "),
1889 "related #7",
1890 "a blank title must not leave a trailing space"
1891 );
1892 }
1893
1894 #[test]
1895 fn stable_ids_are_unique_across_a_batch_and_prefixed() {
1896 let mut seen = std::collections::HashSet::new();
1897 for _ in 0..2_000 {
1898 let id = generate_relationship_id();
1899 assert!(id.starts_with("rel_"), "unexpected id shape: {id}");
1900 assert_eq!(id.len(), "rel_".len() + 26, "unexpected id length: {id}");
1901 assert!(
1902 id["rel_".len()..].bytes().all(|b| CROCKFORD.contains(&b)),
1903 "non-Crockford character in {id}"
1904 );
1905 assert!(seen.insert(id), "duplicate relationship id generated");
1906 }
1907 }
1908
1909 #[test]
1912 fn both_spellings_of_a_blocking_pair_normalize_to_one_canonical_edge() {
1913 let forward = normalize(1, 2, RelationKind::Blocks);
1914 let inverse = normalize(1, 2, RelationKind::BlockedBy);
1915 assert_eq!(
1916 forward,
1917 CanonicalEdge {
1918 from: 1,
1919 to: 2,
1920 kind: RelationKind::Blocks
1921 }
1922 );
1923 assert_eq!(
1924 inverse,
1925 CanonicalEdge {
1926 from: 2,
1927 to: 1,
1928 kind: RelationKind::Blocks
1929 },
1930 "blocked_by must swap the endpoints into the canonical blocks form"
1931 );
1932 assert_eq!(
1933 forward.to, inverse.from,
1934 "the two spellings are the same edge"
1935 );
1936 }
1937
1938 #[test]
1939 fn a_persisted_blocking_edge_never_carries_the_blocked_by_kind() {
1940 let stored = Relationship::new(1, 2, RelationKind::BlockedBy, "T");
1941 assert_eq!(stored.kind, RelationKind::Blocks);
1942 assert_eq!((stored.from, stored.to), (2, 1));
1943 }
1944
1945 #[test]
1946 fn normalize_all_deduplicates_both_spellings_and_is_idempotent() {
1947 let authored = [
1949 (1, 2, RelationKind::Blocks),
1950 (2, 1, RelationKind::BlockedBy),
1951 (3, 4, RelationKind::Related),
1952 ];
1953 let once = normalize_all(authored);
1954 assert_eq!(once.len(), 2, "the two spellings collapse to one edge");
1955 assert_eq!(
1956 once,
1957 normalize_all(authored),
1958 "normalization is deterministic"
1959 );
1960 assert_eq!(
1961 once,
1962 normalize_all(
1963 once.iter()
1964 .map(|e| (e.from, e.to, e.kind))
1965 .collect::<Vec<_>>()
1966 )
1967 );
1968 }
1969
1970 #[test]
1971 fn related_and_duplicates_never_swap_endpoints() {
1972 for kind in [RelationKind::Related, RelationKind::Duplicates] {
1973 let edge = normalize(1, 2, kind);
1974 assert_eq!((edge.from, edge.to, edge.kind), (1, 2, kind));
1975 }
1976 }
1977
1978 #[test]
1979 fn a_reciprocal_blocking_pair_is_rejected_as_a_cycle() {
1980 let a = Owned::new(1, "A", "todo").blocking(2, "B");
1981 let b = Owned::new(2, "B", "todo").blocking(1, "A");
1982 let owned = vec![a, b];
1983 let graph = graph_of(&owned, "done");
1984
1985 let result = graph.validate_snapshot();
1986 assert!(
1987 result.has(DiagnosticKind::Cycle),
1988 "expected a cycle diagnostic: {result:?}"
1989 );
1990 assert!(result.blocking(), "a reciprocal pair must be blocking");
1991 }
1992
1993 #[test]
1994 fn a_conflicting_kind_for_the_same_pair_is_reported() {
1995 let a = Owned::new(1, "A", "todo").blocking(2, "B");
1996 let mut a = a;
1997 a.relationships
1998 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
1999 let owned = vec![a, Owned::new(2, "B", "todo")];
2000 let result = graph_of(&owned, "done").validate_snapshot();
2001 assert!(
2002 result.has(DiagnosticKind::ConflictingEdge),
2003 "expected conflicting_edge: {result:?}"
2004 );
2005 }
2006
2007 #[test]
2010 fn edges_are_returned_from_both_endpoints() {
2011 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2012 let owned = vec![a, Owned::new(2, "B", "todo")];
2013 let graph = graph_of(&owned, "done");
2014 assert_eq!(graph.relationships_for(1).len(), 1);
2015 assert_eq!(
2016 graph.relationships_for(2).len(),
2017 1,
2018 "the target sees the edge too"
2019 );
2020 }
2021
2022 #[test]
2023 fn ordering_is_stable_across_repeated_builds() {
2024 let a = Owned::new(1, "A", "todo")
2025 .blocking(4, "D")
2026 .blocking(2, "B")
2027 .blocking(3, "C");
2028 let owned = vec![
2029 a,
2030 Owned::new(2, "B", "todo"),
2031 Owned::new(3, "C", "todo"),
2032 Owned::new(4, "D", "todo"),
2033 ];
2034 let first: Vec<(RelationKind, u32)> = graph_of(&owned, "done")
2035 .relationships_for(1)
2036 .iter()
2037 .map(|r| (r.kind, r.to))
2038 .collect();
2039 let second: Vec<(RelationKind, u32)> = graph_of(&owned, "done")
2040 .relationships_for(1)
2041 .iter()
2042 .map(|r| (r.kind, r.to))
2043 .collect();
2044 assert_eq!(first, second, "repeated builds must be byte-comparable");
2045 assert_eq!(
2046 first,
2047 vec![
2048 (RelationKind::Blocks, 2),
2049 (RelationKind::Blocks, 3),
2050 (RelationKind::Blocks, 4)
2051 ],
2052 "edges are ordered by (kind, target)"
2053 );
2054 }
2055
2056 #[test]
2057 fn related_and_duplicates_never_enter_blocked_adjacency() {
2058 let mut a = Owned::new(1, "A", "todo");
2059 a.relationships
2060 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2061 a.relationships
2062 .push(Relationship::new(1, 3, RelationKind::Duplicates, "C"));
2063 let owned = vec![a, Owned::new(2, "B", "todo"), Owned::new(3, "C", "todo")];
2064 let graph = graph_of(&owned, "done");
2065 assert!(
2066 graph.blocks(1).is_empty(),
2067 "related/duplicates are not blockers"
2068 );
2069 assert!(graph.blocked_by(2).is_empty());
2070 assert!(graph.blocked_by(3).is_empty());
2071 assert_eq!(graph.duplicates_of(1), vec![3]);
2072 assert_eq!(graph.related_to(1), vec![2]);
2073 }
2074
2075 #[test]
2076 fn an_issue_with_no_edges_yields_empty_results_not_an_error() {
2077 let owned = vec![Owned::new(1, "A", "todo")];
2078 let graph = graph_of(&owned, "done");
2079 assert!(graph.relationships_for(1).is_empty());
2080 assert!(graph.blocks(1).is_empty());
2081 assert!(graph.blocked_by(1).is_empty());
2082 assert!(graph.open_blockers(1).is_empty());
2083 assert!(graph.dependencies_are_empty_for_unknown_issue());
2084 }
2085
2086 impl RelationshipGraph<'_> {
2087 fn dependencies_are_empty_for_unknown_issue(&self) -> bool {
2089 self.dependency_chain(9_999).paths.is_empty()
2090 }
2091 }
2092
2093 #[test]
2096 fn an_empty_snapshot_validates_clean() {
2097 let owned = vec![Owned::new(1, "A", "todo")];
2098 let result = graph_of(&owned, "done").validate_snapshot();
2099 assert!(result.is_clean(), "unexpected diagnostics: {result:?}");
2100 assert!(!result.blocking());
2101 }
2102
2103 #[test]
2104 fn self_link_create_is_rejected() {
2105 let owned = vec![Owned::new(1, "A", "todo")];
2106 let result = graph_of(&owned, "done").validate_create(1, 1, RelationKind::Blocks);
2107 assert!(result.has(DiagnosticKind::SelfLink), "{result:?}");
2108 assert!(result.blocking());
2109 }
2110
2111 #[test]
2112 fn missing_target_create_is_rejected() {
2113 let owned = vec![Owned::new(1, "A", "todo")];
2114 let result = graph_of(&owned, "done").validate_create(1, 99, RelationKind::Blocks);
2115 assert!(result.has(DiagnosticKind::MissingTarget), "{result:?}");
2116 }
2117
2118 #[test]
2119 fn validate_create_accepts_both_spellings_of_the_same_pair() {
2120 let owned = vec![Owned::new(1, "A", "todo"), Owned::new(2, "B", "todo")];
2121 let graph = graph_of(&owned, "done");
2122 assert!(graph.validate_create(1, 2, RelationKind::Blocks).is_clean());
2123 assert!(
2124 graph
2125 .validate_create(1, 2, RelationKind::BlockedBy)
2126 .is_clean(),
2127 "the inverse spelling is the same edge, not a conflict"
2128 );
2129 }
2130
2131 #[test]
2132 fn editing_an_unknown_relationship_id_is_reported() {
2133 let owned = vec![Owned::new(1, "A", "todo")];
2134 let result = graph_of(&owned, "done").validate_edit("rel_missing", None, None);
2135 assert!(result.has(DiagnosticKind::UnknownRelation), "{result:?}");
2136 }
2137
2138 #[test]
2139 fn a_retired_links_key_is_reported_and_never_read_as_typed_data() {
2140 let owned = vec![
2141 Owned::new(1, "A", "todo").with_retired_links_key(),
2142 Owned::new(2, "B", "todo"),
2143 ];
2144 let result = graph_of(&owned, "done").validate_snapshot();
2145 assert!(result.has(DiagnosticKind::RetiredLinksKey), "{result:?}");
2146 assert_eq!(
2147 result
2148 .errors
2149 .iter()
2150 .find(|d| d.kind == DiagnosticKind::RetiredLinksKey)
2151 .map(|d| d.field.as_str()),
2152 Some("links")
2153 );
2154 }
2155
2156 #[test]
2157 fn a_snapshot_with_three_distinct_problems_reports_exactly_three_diagnostics() {
2158 let mut self_linked = Owned::new(1, "A", "todo");
2159 self_linked
2160 .relationships
2161 .push(Relationship::new(3, 3, RelationKind::Blocks, "C"));
2162 let mut dangling = Owned::new(2, "B", "todo");
2163 dangling
2164 .relationships
2165 .push(Relationship::new(2, 404, RelationKind::Blocks, "gone"));
2166 let owned = vec![
2167 self_linked,
2168 dangling,
2169 Owned::new(3, "C", "todo").with_retired_links_key(),
2170 ];
2171 let result = graph_of(&owned, "done").validate_snapshot();
2172 assert_eq!(
2173 result.errors.len(),
2174 3,
2175 "expected no duplicate or implied reports: {result:?}"
2176 );
2177 }
2178
2179 #[test]
2182 fn a_three_chain_returns_the_ordered_path() {
2183 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2184 let b = Owned::new(2, "B", "todo").blocking(3, "C");
2185 let owned = vec![a, b, Owned::new(3, "C", "todo")];
2186 let chain = graph_of(&owned, "done").dependency_chain(1);
2187 assert_eq!(chain.root, 1);
2188 assert_eq!(chain.paths.len(), 1);
2189 assert_eq!(
2190 chain.paths[0].iter().map(|n| n.number).collect::<Vec<_>>(),
2191 vec![1, 2, 3],
2192 "the chain walks upstream from the root toward its blockers"
2193 );
2194 assert!(!chain.truncated);
2195 }
2196
2197 #[test]
2198 fn a_cycle_closing_edge_is_rejected() {
2199 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2200 let b = Owned::new(2, "B", "todo").blocking(3, "C");
2201 let owned = vec![a, b, Owned::new(3, "C", "todo")];
2202 let result = graph_of(&owned, "done").validate_create(3, 1, RelationKind::Blocks);
2203 assert!(result.has(DiagnosticKind::Cycle), "{result:?}");
2204 assert!(result.blocking());
2205 }
2206
2207 #[test]
2208 fn blocked_state_is_true_only_for_an_open_inbound_blocks_edge() {
2209 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2210 let owned = vec![a, Owned::new(2, "B", "todo")];
2211 let graph = graph_of(&owned, "done");
2212
2213 let state = graph.blocked_state(2);
2214 assert!(state.is_blocked);
2215 assert_eq!(
2216 state.blockers.iter().map(|n| n.number).collect::<Vec<_>>(),
2217 vec![1]
2218 );
2219 assert!(
2220 state.reason.contains("#1"),
2221 "the reason names the blocker: {}",
2222 state.reason
2223 );
2224
2225 assert!(
2226 !graph.blocked_state(1).is_blocked,
2227 "the blocker is not itself blocked"
2228 );
2229 }
2230
2231 #[test]
2232 fn moving_the_blocker_into_the_configured_done_column_clears_blocked_state() {
2233 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2234 let owned = vec![a, Owned::new(2, "B", "todo")];
2235 let graph = graph_of(&owned, "shipped");
2237 assert!(
2238 graph.blocked_state(2).is_blocked,
2239 "A is not in the shipped column yet"
2240 );
2241
2242 let mut moved = owned.clone();
2243 moved[0].status = "shipped".to_string();
2244 let after = graph_of(&moved, "shipped");
2245 assert!(
2246 !after.blocked_state(2).is_blocked,
2247 "a blocker in the configured done_column stops blocking"
2248 );
2249 }
2250
2251 #[test]
2252 fn a_blocker_in_a_non_completion_column_keeps_blocking() {
2253 let a = Owned::new(1, "A", "in review").blocking(2, "B");
2254 let owned = vec![a, Owned::new(2, "B", "todo")];
2255 let graph = graph_of(&owned, "shipped");
2256 assert!(graph.blocked_state(2).is_blocked);
2257 assert!(!graph.blocked_state(2).reason.is_empty());
2258 }
2259
2260 #[test]
2261 fn a_dangling_edge_is_diagnosed_but_does_not_freeze_the_issue() {
2262 let mut blocker = Owned::new(1, "A", "todo");
2265 blocker
2266 .relationships
2267 .push(Relationship::new(1, 404, RelationKind::Blocks, "gone"));
2268 let owned = vec![blocker, Owned::new(2, "B", "todo")];
2269 let graph = graph_of(&owned, "done");
2270
2271 let result = graph.validate_snapshot();
2272 assert!(
2273 result.has(DiagnosticKind::MissingTarget),
2274 "the dangling edge is still reported: {result:?}"
2275 );
2276 assert!(
2277 !graph.blocked_state(2).is_blocked,
2278 "invalid data must not permanently freeze an issue"
2279 );
2280 }
2281
2282 #[test]
2283 fn a_hand_edited_cyclic_file_terminates_instead_of_hanging() {
2284 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2287 let b = Owned::new(2, "B", "todo").blocking(1, "A");
2288 let owned = vec![a, b];
2289 let graph = graph_of(&owned, "done");
2290
2291 assert!(graph.validate_snapshot().has(DiagnosticKind::Cycle));
2292 let chain = graph.dependency_chain(1);
2293 assert!(
2294 chain.paths.iter().all(|p| p.len() <= owned.len()),
2295 "the visited-set guard must bound every path"
2296 );
2297 }
2298
2299 #[test]
2302 fn a_ten_thousand_edge_graph_validates_in_one_pass() {
2303 const ISSUES: u32 = 2_000;
2304 const PER_ISSUE: u32 = 6;
2305 let mut owned: Vec<Owned> = (0..ISSUES)
2308 .map(|n| Owned::new(n + 1, &format!("Issue {n}"), "todo"))
2309 .collect();
2310 for n in 0..ISSUES {
2311 for k in 1..=PER_ISSUE {
2312 let target = n + k;
2313 if target < ISSUES {
2314 owned[n as usize].push_block(target + 1, "Target");
2315 }
2316 }
2317 }
2318 let graph = graph_of(&owned, "done");
2319 assert!(
2320 graph.edge_count() >= 10_000,
2321 "expected a 10,000-edge graph, built {}",
2322 graph.edge_count()
2323 );
2324
2325 let result = graph.validate_snapshot();
2328 assert!(
2329 result.is_clean(),
2330 "a well-formed graph validates clean: {result:?}"
2331 );
2332 assert_eq!(graph.issue_count(), ISSUES as usize);
2333 assert!(graph.blocked_state(2).is_blocked);
2334 }
2335
2336 #[test]
2337 fn a_ten_thousand_node_chain_does_not_overflow_the_stack() {
2338 const DEPTH: u32 = 10_000;
2339 let mut owned: Vec<Owned> = (0..DEPTH)
2340 .map(|n| Owned::new(n + 1, &format!("Node {n}"), "todo"))
2341 .collect();
2342 for n in 0..DEPTH - 1 {
2343 owned[n as usize].push_block(n + 2, "Next");
2344 }
2345 let graph = graph_of(&owned, "done");
2346 assert!(graph.validate_snapshot().is_clean());
2348 let chain = graph.dependency_chain(1);
2349 assert!(chain.truncated, "the defensive node bound engages");
2350 assert!(
2351 graph
2352 .cycle_if_added(CanonicalEdge {
2353 from: DEPTH,
2354 to: 1,
2355 kind: RelationKind::Blocks
2356 })
2357 .is_some()
2358 );
2359 }
2360
2361 #[test]
2364 fn d1_inverse_spellings_find_the_same_row() {
2365 let owned = vec![
2367 Owned::new(1, "A", "todo").blocking(2, "B"),
2368 Owned::new(2, "B", "todo"),
2369 ];
2370 let graph = graph_of(&owned, "done");
2371 let forward = graph
2372 .find_canonical(1, 2, RelationKind::Blocks)
2373 .map(|r| r.id.clone());
2374 let inverse = graph
2375 .find_canonical(2, 1, RelationKind::BlockedBy)
2376 .map(|r| r.id.clone());
2377 assert!(forward.is_some(), "the stored edge must be found");
2378 assert_eq!(
2379 forward, inverse,
2380 "1 blocks 2 and 2 blocked_by 1 name one relationship"
2381 );
2382 assert_ne!(
2383 forward,
2384 graph
2385 .find_canonical(1, 2, RelationKind::BlockedBy)
2386 .map(|r| r.id.clone()),
2387 "1 blocked_by 2 is the inverse of the stored edge, so it must not match"
2388 );
2389 }
2390
2391 #[test]
2392 fn d1_related_is_found_from_either_orientation() {
2393 let mut two = Owned::new(2, "B", "todo");
2394 two.relationships
2395 .push(Relationship::new(2, 1, RelationKind::Related, "A"));
2396 let owned = vec![Owned::new(1, "A", "todo"), two];
2397 let graph = graph_of(&owned, "done");
2398 let a = graph
2399 .find_canonical(1, 2, RelationKind::Related)
2400 .map(|r| r.id.clone());
2401 let b = graph
2402 .find_canonical(2, 1, RelationKind::Related)
2403 .map(|r| r.id.clone());
2404 assert!(a.is_some(), "related is order-normalized, so it is found");
2405 assert_eq!(a, b);
2406 }
2407
2408 #[test]
2409 fn d1_a_self_link_or_unrelated_pair_does_not_match() {
2410 let owned = vec![
2411 Owned::new(1, "A", "todo").blocking(2, "B"),
2412 Owned::new(2, "B", "todo"),
2413 Owned::new(3, "C", "todo"),
2414 ];
2415 let graph = graph_of(&owned, "done");
2416 assert!(graph.find_canonical(1, 1, RelationKind::Blocks).is_none());
2417 assert!(graph.find_canonical(1, 3, RelationKind::Blocks).is_none());
2418 assert!(graph.find_canonical(3, 1, RelationKind::Blocks).is_none());
2419 assert!(graph.find_canonical(1, 2, RelationKind::Related).is_none());
2420 assert!(
2421 graph.find_canonical(1, 2, RelationKind::Blocks).is_some(),
2422 "the real edge still resolves"
2423 );
2424 }
2425
2426 #[test]
2427 fn d1_redundant_rows_resolve_to_the_same_row_every_time() {
2428 let one = Owned::new(1, "A", "todo").with_related(2, "B");
2429 let two = Owned::new(2, "B", "todo").with_related(1, "A");
2430 let owned = vec![one, two];
2431 let first = graph_of(&owned, "done")
2432 .find_canonical(1, 2, RelationKind::Related)
2433 .map(|r| r.id.clone());
2434 let second = graph_of(&owned, "done")
2435 .find_canonical(1, 2, RelationKind::Related)
2436 .map(|r| r.id.clone());
2437 assert!(first.is_some());
2438 assert_eq!(first, second, "the answer must be stable, not arbitrary");
2439 }
2440
2441 #[test]
2442 fn d1_an_unknown_pair_returns_none() {
2443 let owned = vec![Owned::new(1, "A", "todo")];
2444 let graph = graph_of(&owned, "done");
2445 assert!(graph.find_canonical(1, 404, RelationKind::Blocks).is_none());
2446 assert!(graph.find_canonical(404, 1, RelationKind::Blocks).is_none());
2447 }
2448
2449 #[test]
2452 fn p1_related_reads_as_one_association_across_orientations() {
2453 let one = Owned::new(1, "A", "todo").with_related(2, "B");
2457 let two = Owned::new(2, "B", "todo").with_related(1, "A");
2458 let owned = vec![one, two];
2459 let graph = graph_of(&owned, "done");
2460 assert_eq!(graph.related_to(1), vec![2], "one association, not two");
2461 assert_eq!(graph.related_to(2), vec![1]);
2462 assert_eq!(
2463 normalize_all([(1, 2, RelationKind::Related), (2, 1, RelationKind::Related),]).len(),
2464 1,
2465 "both authorings normalize to one canonical edge"
2466 );
2467 }
2468
2469 #[test]
2472 fn a3_no_stored_relationship_id_silently_stops_resolving() {
2473 let one = Owned::new(1, "A", "todo").with_related(2, "B");
2477 let two = Owned::new(2, "B", "todo").with_related(1, "A");
2478 let owned = vec![one, two];
2479 let graph = graph_of(&owned, "done");
2480 let ids: Vec<String> = graph
2481 .all_relationships()
2482 .iter()
2483 .filter(|r| r.kind == RelationKind::Related)
2484 .map(|r| r.id.clone())
2485 .collect();
2486 assert_eq!(ids.len(), 2, "both stored rows are retained");
2487 for id in &ids {
2488 assert!(
2489 graph.find_relationship(id).is_some(),
2490 "stored id {id} must still resolve"
2491 );
2492 assert!(
2493 !graph
2494 .validate_edit(id, None, None)
2495 .has(DiagnosticKind::UnknownRelation),
2496 "editing by a stored id must not report it unknown"
2497 );
2498 }
2499 }
2500
2501 #[test]
2502 fn a3_the_blocked_adjacency_does_not_repeat_a_neighbour() {
2503 let mut one = Owned::new(1, "A", "todo");
2505 for _ in 0..3 {
2506 one.push_block(2, "B");
2507 }
2508 let owned = vec![one, Owned::new(2, "B", "todo")];
2509 let graph = graph_of(&owned, "done");
2510 assert_eq!(graph.blocks(1), &[2], "no repeated neighbour");
2511 assert_eq!(graph.blocked_by(2), &[1], "no repeated blocker");
2512 assert_eq!(
2513 graph.blocked_state(2).blockers.len(),
2514 1,
2515 "one blocker is reported once, not once per stored row"
2516 );
2517 }
2518
2519 #[test]
2520 fn c_an_unconfigured_done_column_fails_closed() {
2521 let mut one = Owned::new(1, "A", "");
2524 one.push_block(2, "B");
2525 let owned = vec![one, Owned::new(2, "B", "todo")];
2526 let graph = graph_of(&owned, "");
2527 assert!(
2528 graph.blocked_state(2).is_blocked,
2529 "an unconfigured completion column must not mark anything done"
2530 );
2531 }
2532
2533 #[test]
2534 fn p1_duplicates_orientation_is_preserved() {
2535 assert_eq!(
2537 normalize_all([
2538 (1, 2, RelationKind::Duplicates),
2539 (2, 1, RelationKind::Duplicates),
2540 ])
2541 .len(),
2542 2
2543 );
2544 }
2545
2546 #[test]
2547 fn p2_an_edit_cannot_land_on_a_triple_another_edge_occupies() {
2548 let mut a = Owned::new(1, "A", "todo");
2549 a.push_block(2, "B");
2550 a.relationships
2551 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2552 let owned = vec![a, Owned::new(2, "B", "todo")];
2553 let graph = graph_of(&owned, "done");
2554 let blocks_id = graph
2555 .relationships_for(1)
2556 .iter()
2557 .find(|r| r.kind == RelationKind::Blocks)
2558 .expect("a blocks edge")
2559 .id
2560 .clone();
2561
2562 let res = graph.validate_edit(&blocks_id, None, Some(RelationKind::Related));
2563 assert!(
2564 res.has(DiagnosticKind::ConflictingEdge),
2565 "re-kinding onto an occupied triple must be rejected: {res:?}"
2566 );
2567 assert!(
2568 res.errors
2569 .iter()
2570 .any(|d| d.message.contains("would store a second one")),
2571 "the message must name the duplicate case: {res:?}"
2572 );
2573 }
2574
2575 #[test]
2576 fn p2_creating_an_edge_that_already_exists_stays_idempotent() {
2577 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2580 let owned = vec![a, Owned::new(2, "B", "todo")];
2581 let graph = graph_of(&owned, "done");
2582 assert!(
2583 graph.validate_create(1, 2, RelationKind::Blocks).is_clean(),
2584 "re-asserting an existing edge is the idempotent path"
2585 );
2586 }
2587
2588 #[test]
2589 fn p3_p4_the_other_endpoint_is_returned_in_both_orientations() {
2590 let mut two = Owned::new(2, "B", "todo");
2592 two.relationships
2593 .push(Relationship::new(2, 1, RelationKind::Related, "A"));
2594 two.relationships
2595 .push(Relationship::new(2, 1, RelationKind::Duplicates, "A"));
2596 let owned = vec![Owned::new(1, "A", "todo"), two];
2597 let graph = graph_of(&owned, "done");
2598
2599 assert_eq!(graph.related_to(1), vec![2], "never the subject itself");
2601 assert_eq!(graph.related_to(2), vec![1]);
2602 assert!(
2604 graph.duplicates_of(1).is_empty(),
2605 "a directional edge pointing at #1 does not mean #1 duplicates it: {:?}",
2606 graph.duplicates_of(1)
2607 );
2608 assert_eq!(graph.duplicates_of(2), vec![1]);
2609 }
2610
2611 #[test]
2612 fn p5_the_blocked_adjacency_never_contains_the_subject() {
2613 let mut a = Owned::new(1, "A", "todo");
2617 a.relationships
2618 .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2619 a.push_block(2, "B");
2620 let owned = vec![a, Owned::new(2, "B", "todo")];
2621 let graph = graph_of(&owned, "done");
2622 for number in [1, 2] {
2623 assert!(
2624 !graph.blocked_by(number).contains(&number),
2625 "issue #{number} must never appear in its own inbound blocks adjacency"
2626 );
2627 }
2628 }
2629
2630 #[test]
2633 fn r1_display_is_refreshed_when_the_target_is_renamed() {
2634 let a = Owned::new(1, "A", "todo").blocking(2, "Old title");
2636 let owned = vec![a, Owned::new(2, "New title", "todo")];
2637 let graph = graph_of(&owned, "done");
2638 assert_eq!(
2639 graph.relationships_for(1)[0].display,
2640 "blocks #2 New title",
2641 "a persisted display must not outlive the title it was built from"
2642 );
2643 }
2644
2645 #[test]
2646 fn r1_a_dangling_edge_keeps_its_persisted_display() {
2647 let mut a = Owned::new(1, "A", "todo");
2649 a.relationships
2650 .push(Relationship::new(1, 404, RelationKind::Blocks, "gone"));
2651 let owned = vec![a];
2652 assert_eq!(
2653 graph_of(&owned, "done").relationships_for(1)[0].display,
2654 "blocks #404 gone"
2655 );
2656 }
2657
2658 #[test]
2659 fn r2_a_self_link_is_reported_once_not_twice() {
2660 let mut a = Owned::new(1, "A", "todo");
2661 a.relationships
2662 .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2663 let owned = vec![a];
2664 let graph = graph_of(&owned, "done");
2665 assert_eq!(graph.edge_count(), 1);
2666 assert_eq!(
2667 graph.relationships_for(1).len(),
2668 1,
2669 "a self-link has one endpoint, so it must be registered once"
2670 );
2671 }
2672
2673 #[test]
2674 fn r3_an_issue_is_never_blocked_by_itself() {
2675 let mut a = Owned::new(1, "A", "todo");
2676 a.relationships
2677 .push(Relationship::new(1, 1, RelationKind::Blocks, "A"));
2678 let owned = vec![a];
2679 let graph = graph_of(&owned, "done");
2680
2681 assert!(graph.validate_snapshot().has(DiagnosticKind::SelfLink));
2683 assert!(graph.blocks(1).is_empty());
2685 let state = graph.blocked_state(1);
2686 assert!(!state.is_blocked, "an issue must not block itself");
2687 assert!(state.reason.is_empty());
2688 }
2689
2690 #[test]
2691 fn r4_validate_edit_does_not_hide_a_pre_existing_conflict() {
2692 let mut a = Owned::new(1, "A", "todo");
2694 a.push_block(2, "B");
2695 a.relationships
2696 .push(Relationship::new(1, 2, RelationKind::Related, "B"));
2697 let owned = vec![a, Owned::new(2, "B", "todo")];
2698 let graph = graph_of(&owned, "done");
2699 let blocks_id = graph
2700 .relationships_for(1)
2701 .iter()
2702 .find(|r| r.kind == RelationKind::Blocks)
2703 .expect("a blocks edge")
2704 .id
2705 .clone();
2706
2707 assert!(
2708 graph
2709 .validate_snapshot()
2710 .has(DiagnosticKind::ConflictingEdge)
2711 );
2712 let edit = graph.validate_edit(&blocks_id, None, None);
2713 assert!(
2714 edit.has(DiagnosticKind::ConflictingEdge),
2715 "editing an unrelated field must not return a clean verdict on corrupt data: {edit:?}"
2716 );
2717 }
2718
2719 #[test]
2720 fn r4_an_edit_does_not_conflict_with_itself() {
2721 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2722 let owned = vec![a, Owned::new(2, "B", "todo"), Owned::new(3, "C", "todo")];
2723 let graph = graph_of(&owned, "done");
2724 let id = graph.relationships_for(1)[0].id.clone();
2725
2726 assert!(
2727 graph.validate_edit(&id, None, None).is_clean(),
2728 "an edge never conflicts with itself"
2729 );
2730 assert!(
2731 graph.validate_edit(&id, Some(3), None).is_clean(),
2732 "retargeting to an unrelated issue is not a conflict"
2733 );
2734 }
2735
2736 #[test]
2739 fn every_contract_diagnostic_kind_exists_with_its_exact_name() {
2740 let names: Vec<&str> = [
2741 DiagnosticKind::SelfLink,
2742 DiagnosticKind::MissingTarget,
2743 DiagnosticKind::ConflictingEdge,
2744 DiagnosticKind::Cycle,
2745 DiagnosticKind::UnknownKind,
2746 DiagnosticKind::UnknownRelation,
2747 DiagnosticKind::RetiredLinksKey,
2748 ]
2749 .iter()
2750 .map(|k| k.as_str())
2751 .collect();
2752 assert_eq!(
2753 names,
2754 [
2755 "self_link",
2756 "missing_target",
2757 "conflicting_edge",
2758 "cycle",
2759 "unknown_kind",
2760 "unknown_relation",
2761 "retired_links_key"
2762 ]
2763 );
2764 }
2765
2766 #[test]
2767 fn validate_snapshot_never_returns_an_err() {
2768 let owned = vec![Owned::new(1, "A", "todo").with_retired_links_key()];
2770 let result: RelationshipValidationResult = graph_of(&owned, "done").validate_snapshot();
2771 assert!(result.blocking());
2772 }
2773
2774 #[test]
2775 fn diagnostics_sort_deterministically() {
2776 let mut a = RelationshipValidationResult::with(Diagnostic::self_link(1));
2777 a.merge(RelationshipValidationResult::with(
2778 Diagnostic::unknown_relation("rel_x"),
2779 ));
2780 let mut b = a.clone();
2781 b.sort_diagnostics();
2782 a.sort_diagnostics();
2783 assert_eq!(a, b);
2784 }
2785
2786 #[test]
2787 fn blocked_reason_is_shared_by_every_surface() {
2788 let single = vec![ChainNode::new(1, "A")];
2789 assert_eq!(blocked_reason(&single), "Blocked by #1 A");
2790 let many = vec![ChainNode::new(1, "A"), ChainNode::new(2, "B")];
2791 assert_eq!(blocked_reason(&many), "Blocked by 2 issues: #1 A, #2 B");
2792 }
2793
2794 #[test]
2795 fn cycle_paths_are_reported_once_regardless_of_starting_node() {
2796 let a = Owned::new(1, "A", "todo").blocking(2, "B");
2797 let b = Owned::new(2, "B", "todo").blocking(3, "C");
2798 let c = Owned::new(3, "C", "todo").blocking(1, "A");
2799 let owned = vec![a, b, c];
2800 let result = graph_of(&owned, "done").validate_snapshot();
2801 let cycles: Vec<&Diagnostic> = result
2802 .errors
2803 .iter()
2804 .filter(|d| d.kind == DiagnosticKind::Cycle)
2805 .collect();
2806 assert_eq!(
2807 cycles.len(),
2808 1,
2809 "one distinct cycle, one diagnostic: {result:?}"
2810 );
2811 }
2812
2813 #[test]
2814 fn hand_edited_blocked_by_persisted_edges_still_block() {
2815 let mut owner = Owned::new(2, "B", "todo");
2819 owner.relationships.push(Relationship {
2820 id: "rel_hand".to_string(),
2821 from: 1,
2822 to: 2,
2823 kind: RelationKind::BlockedBy,
2824 display: String::new(),
2825 });
2826 let owned = vec![Owned::new(1, "A", "todo"), owner];
2827 let graph = graph_of(&owned, "done");
2828 assert!(
2829 graph.blocked_state(1).is_blocked,
2830 "the normalized adjacency must still carry the edge"
2831 );
2832 assert_eq!(graph.blocks(2), &[1], "stored in canonical blocks form");
2833 }
2834}