1use std::collections::{BTreeMap, BTreeSet};
37
38use schemars::JsonSchema;
39use serde::{Deserialize, Serialize};
40
41use crate::order::natural_cmp;
42use crate::plan::{overlap, Plan, PlanIssue};
43
44use super::readiness::TaskReadiness;
45use super::task::{DevTaskDiagnostic, RecordRef};
46use super::{AttestedCount, AttestedList, AttestedText, FieldProvenance};
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50pub struct MilestoneNode {
51 pub issue: String,
53 pub title: String,
55 pub readiness: TaskReadiness,
57 pub canonical_status: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub wave: Option<u32>,
62 pub priority: String,
64 pub parallel_safe: bool,
66 pub blocked_by: Vec<String>,
68 pub dependents: Vec<String>,
70 pub transitive_dependents: u32,
72 pub scope: Vec<String>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
78pub struct MilestoneEdge {
79 pub from: String,
81 pub to: String,
83 pub external: bool,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
90pub struct CriticalBlocker {
91 pub issue: String,
93 pub readiness: TaskReadiness,
95 pub blocks: Vec<String>,
98 pub weight: u32,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104pub struct ScopeConflict {
105 pub held: String,
107 pub excluded: String,
109 pub path: String,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115pub struct ParallelSet {
116 pub wave: u32,
118 pub issues: Vec<String>,
120 pub serialised: Vec<ScopeConflict>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
127pub struct MilestoneCounts {
128 pub total: u32,
130 pub required: u32,
132 pub by_readiness: BTreeMap<String, u32>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
138pub struct MilestoneGraph {
139 pub milestone: String,
141 pub declared: bool,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub record: Option<String>,
146 pub title: AttestedText,
148 pub outcome: AttestedText,
150 pub status: AttestedText,
152 pub rank: AttestedCount,
154 pub depends_on: AttestedList,
156 pub blocked_by: AttestedList,
159 pub dependents: AttestedList,
161 pub nodes: Vec<MilestoneNode>,
163 pub edges: Vec<MilestoneEdge>,
165 pub ready: Vec<String>,
167 pub blocked: Vec<String>,
169 pub waiting: Vec<String>,
171 pub active: Vec<String>,
173 pub review: Vec<String>,
175 pub completion_blocked: Vec<String>,
177 pub complete: Vec<String>,
179 pub cancelled: Vec<String>,
181 pub critical_blockers: Vec<CriticalBlocker>,
183 pub parallelizable: Vec<ParallelSet>,
185 pub cycles: Vec<Vec<String>>,
188 pub diagnostics: Vec<DevTaskDiagnostic>,
190 pub counts: MilestoneCounts,
192}
193
194pub fn transitive_dependents(issues: &[PlanIssue], id: &str) -> Vec<String> {
215 let by_id: BTreeMap<&str, &PlanIssue> = issues.iter().map(|i| (i.id.as_str(), i)).collect();
216 let mut seen: BTreeSet<String> = BTreeSet::new();
217 let mut queue: Vec<&str> = vec![id];
218 while let Some(next) = queue.pop() {
219 let Some(node) = by_id.get(next) else {
220 continue;
221 };
222 for d in &node.dependents {
223 if d != id && seen.insert(d.clone()) {
224 queue.push(d.as_str());
225 }
226 }
227 }
228 let mut out: Vec<String> = seen.into_iter().collect();
229 crate::order::canonical_strings(&mut out);
230 out
231}
232
233pub fn cycles(issues: &[PlanIssue]) -> Vec<Vec<String>> {
258 let mut order: Vec<&str> = issues.iter().map(|i| i.id.as_str()).collect();
260 crate::order::canonical_strings(&mut order);
261 let position: BTreeMap<&str, usize> =
262 order.iter().enumerate().map(|(n, id)| (*id, n)).collect();
263 let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); order.len()];
264 for i in issues {
265 let Some(&from) = position.get(i.id.as_str()) else {
266 continue;
267 };
268 let mut targets: Vec<usize> = i
269 .depends_on
270 .iter()
271 .filter_map(|d| position.get(d.as_str()).copied())
272 .collect();
273 targets.sort_unstable();
274 targets.dedup();
275 adjacency[from] = targets;
276 }
277
278 let n = order.len();
279 let unvisited = usize::MAX;
280 let mut index = vec![unvisited; n];
281 let mut low = vec![0usize; n];
282 let mut on_stack = vec![false; n];
283 let mut stack: Vec<usize> = Vec::new();
284 let mut next: usize = 0;
285 let mut components: Vec<Vec<String>> = Vec::new();
286
287 for root in 0..n {
289 if index[root] != unvisited {
290 continue;
291 }
292 let mut work: Vec<(usize, usize)> = vec![(root, 0)];
293 index[root] = next;
294 low[root] = next;
295 next += 1;
296 stack.push(root);
297 on_stack[root] = true;
298 while let Some((v, edge)) = work.pop() {
299 if edge < adjacency[v].len() {
300 work.push((v, edge + 1));
301 let w = adjacency[v][edge];
302 if index[w] == unvisited {
303 index[w] = next;
304 low[w] = next;
305 next += 1;
306 stack.push(w);
307 on_stack[w] = true;
308 work.push((w, 0));
309 } else if on_stack[w] {
310 low[v] = low[v].min(index[w]);
311 }
312 continue;
313 }
314 if let Some(&(parent, _)) = work.last() {
316 low[parent] = low[parent].min(low[v]);
317 }
318 if low[v] == index[v] {
319 let mut component: Vec<String> = Vec::new();
320 while let Some(w) = stack.pop() {
321 on_stack[w] = false;
322 component.push(order[w].to_string());
323 if w == v {
324 break;
325 }
326 }
327 let cyclic = component.len() > 1 || adjacency[v].contains(&v);
328 if cyclic {
329 crate::order::canonical_strings(&mut component);
330 components.push(component);
331 }
332 }
333 }
334 }
335 components.sort_by(|a, b| natural_cmp(&a[0], &b[0]));
336 components
337}
338
339pub fn parallel_sets(nodes: &[MilestoneNode]) -> Vec<ParallelSet> {
351 let mut by_wave: BTreeMap<u32, Vec<&MilestoneNode>> = BTreeMap::new();
352 for node in nodes.iter().filter(|n| n.readiness.is_startable()) {
353 let Some(wave) = node.wave else { continue };
354 by_wave.entry(wave).or_default().push(node);
355 }
356 let mut out: Vec<ParallelSet> = Vec::new();
357 for (wave, mut candidates) in by_wave {
358 candidates.sort_by(|a, b| natural_cmp(&a.issue, &b.issue));
359 let mut sets: Vec<(Vec<&MilestoneNode>, Vec<ScopeConflict>)> = Vec::new();
360 for candidate in candidates {
361 if !candidate.parallel_safe {
362 sets.push((vec![candidate], Vec::new()));
363 continue;
364 }
365 let mut refused: Vec<ScopeConflict> = Vec::new();
366 let mut placed = false;
367 for (members, conflicts) in &mut sets {
368 if members.iter().any(|m| !m.parallel_safe) {
369 continue;
370 }
371 match first_conflict(members, candidate) {
372 Some(conflict) => refused.push(conflict),
373 None => {
374 members.push(candidate);
375 conflicts.append(&mut refused);
376 placed = true;
377 break;
378 }
379 }
380 }
381 if !placed {
382 sets.push((vec![candidate], refused));
383 }
384 }
385 for (members, serialised) in sets {
386 let mut issues: Vec<String> = members.iter().map(|m| m.issue.clone()).collect();
387 crate::order::canonical_strings(&mut issues);
388 out.push(ParallelSet {
389 wave,
390 issues,
391 serialised,
392 });
393 }
394 }
395 out
396}
397
398fn first_conflict(members: &[&MilestoneNode], candidate: &MilestoneNode) -> Option<ScopeConflict> {
400 for member in members {
401 for held in &member.scope {
402 for wanted in &candidate.scope {
403 if overlap(held, wanted) {
404 return Some(ScopeConflict {
405 held: member.issue.clone(),
406 excluded: candidate.issue.clone(),
407 path: held.clone(),
408 });
409 }
410 }
411 }
412 }
413 None
414}
415
416impl MilestoneGraph {
417 const PLAN: &'static str = "plan.model";
419
420 #[allow(clippy::too_many_lines)] pub fn build(plan: &Plan, milestone: &str, record: Option<RecordRef<'_>>) -> MilestoneGraph {
427 let derived = plan.milestone(milestone);
428 let absent = "the canonical model declares no milestone with this id";
429 let authored = |key: &str| -> AttestedText {
430 let source = record.map_or_else(
431 || format!(".ai/repo/project/milestones#{key}"),
432 |r| format!("{}#{key}", r.path),
433 );
434 match record.map(|r| r.metadata).and_then(|m| m.get(key)) {
435 Some(v) if !v.is_null() => {
436 AttestedText::explicit(crate::plan::scalar(Some(v)), source)
437 }
438 _ if record.is_none() => AttestedText::unknown(source, absent),
439 _ => AttestedText::unknown(source, format!("the record declares no `{key}`")),
440 }
441 };
442 let authored_list = |key: &str| -> AttestedList {
443 let source = record.map_or_else(
444 || format!(".ai/repo/project/milestones#{key}"),
445 |r| format!("{}#{key}", r.path),
446 );
447 match record.map(|r| r.metadata).and_then(|m| m.get(key)) {
448 Some(serde_json::Value::Array(items)) => AttestedList::explicit(
449 items.iter().map(|v| crate::plan::scalar(Some(v))).collect(),
450 source,
451 ),
452 Some(v) if !v.is_null() => {
453 AttestedList::explicit(vec![crate::plan::scalar(Some(v))], source)
454 }
455 _ if record.is_none() => AttestedList::unknown(source, absent),
456 _ => AttestedList::unknown(source, format!("the record declares no `{key}`")),
457 }
458 };
459
460 let mut nodes: Vec<MilestoneNode> = plan
461 .issues
462 .iter()
463 .filter(|i| i.milestone == milestone)
464 .map(|i| Self::node(plan, i))
465 .collect();
466 nodes.sort_by(|a, b| natural_cmp(&a.issue, &b.issue));
467
468 let inside: BTreeSet<&str> = nodes.iter().map(|n| n.issue.as_str()).collect();
469 let mut edges: Vec<MilestoneEdge> = plan
470 .edges
471 .iter()
472 .filter(|e| inside.contains(e.from.as_str()) || inside.contains(e.to.as_str()))
473 .map(|e| MilestoneEdge {
474 from: e.from.clone(),
475 to: e.to.clone(),
476 external: !inside.contains(e.from.as_str()) || !inside.contains(e.to.as_str()),
477 })
478 .collect();
479 edges.sort();
480
481 let of = |state: TaskReadiness| -> Vec<String> {
482 nodes
483 .iter()
484 .filter(|n| n.readiness == state)
485 .map(|n| n.issue.clone())
486 .collect()
487 };
488 let mut counts = MilestoneCounts {
489 total: u32::try_from(nodes.len()).unwrap_or(u32::MAX),
490 required: 0,
491 by_readiness: BTreeMap::new(),
492 };
493 for n in &nodes {
494 *counts
495 .by_readiness
496 .entry(n.readiness.as_str().to_string())
497 .or_insert(0) += 1;
498 }
499 counts.required = counts.total
500 - counts
501 .by_readiness
502 .get(TaskReadiness::Cancelled.as_str())
503 .copied()
504 .unwrap_or(0);
505
506 let mut critical: Vec<CriticalBlocker> = nodes
507 .iter()
508 .filter(|n| !n.readiness.is_terminal())
509 .filter_map(|n| {
510 let blocks: Vec<String> = transitive_dependents(&plan.issues, &n.issue)
511 .into_iter()
512 .filter(|d| {
513 plan.issue(d)
514 .is_some_and(|i| !matches!(i.status.as_str(), "DONE" | "CANCELLED"))
515 })
516 .collect();
517 if blocks.is_empty() {
518 return None;
519 }
520 Some(CriticalBlocker {
521 issue: n.issue.clone(),
522 readiness: n.readiness,
523 weight: u32::try_from(blocks.len()).unwrap_or(u32::MAX),
524 blocks,
525 })
526 })
527 .collect();
528 critical.sort_by(|a, b| {
531 b.weight
532 .cmp(&a.weight)
533 .then_with(|| natural_cmp(&a.issue, &b.issue))
534 });
535
536 let all_cycles = cycles(&plan.issues);
537 let here: Vec<Vec<String>> = all_cycles
538 .into_iter()
539 .filter(|c| c.iter().any(|id| inside.contains(id.as_str())))
540 .collect();
541
542 let diagnostics: Vec<DevTaskDiagnostic> = plan
543 .findings
544 .iter()
545 .filter(|f| {
546 f.subject == milestone
547 || inside.contains(f.subject.as_str())
548 || f.message
549 .split_whitespace()
550 .any(|w| inside.contains(w.trim_end_matches(',')))
551 })
552 .map(|f| DevTaskDiagnostic {
553 level: f.level.clone(),
554 code: f.code.clone(),
555 message: format!("{}: {}", f.subject, f.message),
556 reproduce: "majordomus plan validate".into(),
557 })
558 .collect();
559
560 MilestoneGraph {
561 milestone: milestone.to_string(),
562 declared: derived.is_some(),
563 record: record.map(|r| r.path.to_string()),
564 title: authored("title"),
565 outcome: authored("outcome"),
566 status: derived.map_or_else(
567 || AttestedText::unknown(Self::PLAN, absent),
568 |m| AttestedText::derived(&m.status, Self::PLAN),
569 ),
570 rank: derived.map_or_else(
571 || AttestedCount::unknown(Self::PLAN, absent),
572 |m| AttestedCount::derived(m.rank, Self::PLAN),
573 ),
574 depends_on: authored_list("depends_on"),
575 blocked_by: derived.map_or_else(
576 || AttestedList::unknown(Self::PLAN, absent),
577 |m| AttestedList::derived(m.blocked_by.clone(), Self::PLAN),
578 ),
579 dependents: derived.map_or_else(
580 || AttestedList::unknown(Self::PLAN, absent),
581 |m| AttestedList::derived(m.dependents.clone(), Self::PLAN),
582 ),
583 ready: of(TaskReadiness::Ready),
584 blocked: of(TaskReadiness::Blocked),
585 waiting: of(TaskReadiness::Waiting),
586 active: of(TaskReadiness::InProgress),
587 review: of(TaskReadiness::Review),
588 completion_blocked: of(TaskReadiness::CompletionBlocked),
589 complete: of(TaskReadiness::Complete),
590 cancelled: of(TaskReadiness::Cancelled),
591 critical_blockers: critical,
592 parallelizable: parallel_sets(&nodes),
593 cycles: here,
594 diagnostics,
595 counts,
596 nodes,
597 edges,
598 }
599 }
600
601 fn node(plan: &Plan, i: &PlanIssue) -> MilestoneNode {
602 let cyclic = plan
603 .findings
604 .iter()
605 .any(|f| f.code == "cycle" && f.message.split_whitespace().any(|w| w == i.id));
606 let covered = i.evidence_have >= i.evidence_need;
607 MilestoneNode {
608 issue: i.id.clone(),
609 title: i.title.clone(),
610 readiness: TaskReadiness::derive(
611 &i.status,
612 &i.blocked_by,
613 !i.completed_at.is_empty(),
614 covered,
615 ),
616 canonical_status: i.status.clone(),
617 wave: if cyclic { None } else { Some(i.wave) },
618 priority: i.priority.clone(),
619 parallel_safe: i.parallel_safe,
620 blocked_by: i.blocked_by.clone(),
621 dependents: i.dependents.clone(),
622 transitive_dependents: u32::try_from(transitive_dependents(&plan.issues, &i.id).len())
623 .unwrap_or(u32::MAX),
624 scope: i.scope.clone(),
625 }
626 }
627
628 pub fn is_partitioned(&self) -> bool {
635 let mut counted = 0usize;
636 for bucket in [
637 &self.ready,
638 &self.blocked,
639 &self.waiting,
640 &self.active,
641 &self.review,
642 &self.completion_blocked,
643 &self.complete,
644 &self.cancelled,
645 ] {
646 counted += bucket.len();
647 if !bucket
648 .iter()
649 .all(|id| self.nodes.iter().any(|n| &n.issue == id))
650 {
651 return false;
652 }
653 }
654 counted == self.nodes.len()
655 }
656
657 pub fn has_failures(&self) -> bool {
659 self.diagnostics.iter().any(|d| d.level == "FAIL")
660 }
661
662 pub fn is_consistent(&self) -> bool {
664 [&self.title, &self.outcome, &self.status].iter().all(|f| {
665 f.is_consistent() && (f.provenance != FieldProvenance::Unknown || f.reason.is_some())
666 })
667 }
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use crate::plan::{
674 PlanCounts, PlanEdge, PlanFinding, PlanMilestone, PlanProject, PlanVocabulary,
675 };
676
677 fn node(id: &str, status: &str, depends_on: &[&str], scope: &[&str]) -> PlanIssue {
678 PlanIssue {
679 id: id.into(),
680 milestone: "m1".into(),
681 status: status.into(),
682 wave: 0,
683 priority: "p1".into(),
684 profile: "implementation".into(),
685 parallel_safe: true,
686 title: format!("{id} title"),
687 slug: String::new(),
688 depends_on: depends_on.iter().map(|s| (*s).into()).collect(),
689 blocked_by: Vec::new(),
690 dependents: Vec::new(),
691 scope: scope.iter().map(|s| (*s).into()).collect(),
692 serves: Vec::new(),
693 objective: String::new(),
694 evidence_have: 0,
695 evidence_need: 0,
696 started_at: String::new(),
697 verified_at: String::new(),
698 completed_at: String::new(),
699 }
700 }
701
702 fn close(mut issues: Vec<PlanIssue>) -> Vec<PlanIssue> {
705 let done: BTreeSet<String> = issues
706 .iter()
707 .filter(|i| i.status == "DONE")
708 .map(|i| i.id.clone())
709 .collect();
710 let edges: Vec<(String, String)> = issues
711 .iter()
712 .flat_map(|i| i.depends_on.iter().map(|d| (d.clone(), i.id.clone())))
713 .collect();
714 for i in &mut issues {
715 i.dependents = edges
716 .iter()
717 .filter(|(from, _)| from == &i.id)
718 .map(|(_, to)| to.clone())
719 .collect();
720 i.blocked_by = i
721 .depends_on
722 .iter()
723 .filter(|d| !done.contains(*d))
724 .cloned()
725 .collect();
726 if !i.blocked_by.is_empty() && i.status == "READY" {
727 i.status = "BLOCKED".into();
728 }
729 }
730 issues
731 }
732
733 fn plan_of(issues: Vec<PlanIssue>, findings: Vec<PlanFinding>) -> Plan {
734 let issues = close(issues);
735 let edges: Vec<PlanEdge> = {
736 let mut e: Vec<PlanEdge> = issues
737 .iter()
738 .flat_map(|i| {
739 i.depends_on.iter().map(|d| PlanEdge {
740 from: d.clone(),
741 to: i.id.clone(),
742 })
743 })
744 .collect();
745 e.sort();
746 e
747 };
748 Plan {
749 project: PlanProject {
750 name: "fixture".into(),
751 repository: "owner/fixture".into(),
752 default_branch: "master".into(),
753 active_milestone: "m1".into(),
754 },
755 statuses: PlanVocabulary {
756 issue: crate::plan::ISSUE_STATUSES
757 .iter()
758 .map(|s| (*s).to_string())
759 .collect(),
760 milestone: crate::plan::MILESTONE_STATUSES
761 .iter()
762 .map(|s| (*s).to_string())
763 .collect(),
764 },
765 milestones: vec![PlanMilestone {
766 id: "m1".into(),
767 status: "ACTIVE".into(),
768 order: 1,
769 priority: "p1".into(),
770 title: "The milestone".into(),
771 slug: "m1".into(),
772 version: String::new(),
773 rank: 0,
774 depends_on: Vec::new(),
775 blocked_by: Vec::new(),
776 dependents: Vec::new(),
777 claims: Vec::new(),
778 counts: PlanCounts {
779 total: u32::try_from(issues.len()).unwrap_or(0),
780 required: u32::try_from(issues.len()).unwrap_or(0),
781 by_status: BTreeMap::new(),
782 },
783 issues: issues.iter().map(|i| i.id.clone()).collect(),
784 outcome: String::new(),
785 }],
786 issues,
787 waves: Vec::new(),
788 edges,
789 milestone_edges: Vec::new(),
790 findings,
791 }
792 }
793
794 #[test]
797 fn a_dependency_chain_has_one_ready_head_and_one_critical_blocker() {
798 let plan = plan_of(
799 vec![
800 node("I0001", "READY", &[], &[]),
801 node("I0002", "READY", &["I0001"], &[]),
802 node("I0003", "READY", &["I0002"], &[]),
803 ],
804 Vec::new(),
805 );
806 let g = MilestoneGraph::build(&plan, "m1", None);
807 assert_eq!(g.ready, ["I0001"]);
808 assert_eq!(g.blocked, ["I0002", "I0003"]);
809 assert_eq!(g.critical_blockers[0].issue, "I0001");
810 assert_eq!(g.critical_blockers[0].weight, 2);
811 assert_eq!(g.critical_blockers[0].blocks, ["I0002", "I0003"]);
812 assert!(g.is_partitioned());
813 }
814
815 #[test]
818 fn fan_out_weights_the_head_by_everything_downstream() {
819 let plan = plan_of(
820 vec![
821 node("I0001", "DONE", &[], &[]),
822 node("I0002", "READY", &["I0001"], &["a"]),
823 node("I0003", "READY", &["I0001"], &["b"]),
824 node("I0004", "READY", &["I0001"], &["c"]),
825 ],
826 Vec::new(),
827 );
828 let g = MilestoneGraph::build(&plan, "m1", None);
829 assert_eq!(g.ready, ["I0002", "I0003", "I0004"]);
830 assert_eq!(g.complete, ["I0001"]);
831 assert_eq!(g.parallelizable.len(), 1);
833 assert_eq!(g.parallelizable[0].issues, ["I0002", "I0003", "I0004"]);
834 }
835
836 #[test]
839 fn fan_in_ties_the_weights_and_orders_by_identity() {
840 let plan = plan_of(
841 vec![
842 node("I0001", "READY", &[], &[]),
843 node("I0002", "READY", &[], &[]),
844 node("I0003", "READY", &[], &[]),
845 node("I0004", "READY", &["I0001", "I0002", "I0003"], &[]),
846 ],
847 Vec::new(),
848 );
849 let g = MilestoneGraph::build(&plan, "m1", None);
850 assert_eq!(g.blocked, ["I0004"]);
851 let names: Vec<&str> = g
852 .critical_blockers
853 .iter()
854 .map(|c| c.issue.as_str())
855 .collect();
856 assert_eq!(names, ["I0001", "I0002", "I0003"]);
857 assert!(g.critical_blockers.iter().all(|c| c.weight == 1));
858 }
859
860 #[test]
863 fn a_closed_dependency_releases_what_waited_on_it() {
864 let plan = plan_of(
865 vec![
866 node("I0001", "DONE", &[], &[]),
867 node("I0002", "READY", &["I0001"], &[]),
868 ],
869 Vec::new(),
870 );
871 let g = MilestoneGraph::build(&plan, "m1", None);
872 assert_eq!(g.ready, ["I0002"]);
873 assert!(g.blocked.is_empty());
874 }
875
876 #[test]
879 fn a_shared_scope_path_serialises_two_otherwise_parallel_issues() {
880 let plan = plan_of(
881 vec![
882 node("I0001", "READY", &[], &["apps/cli/src"]),
883 node("I0002", "READY", &[], &["apps/cli/src/main.rs"]),
884 ],
885 Vec::new(),
886 );
887 let g = MilestoneGraph::build(&plan, "m1", None);
888 assert_eq!(g.parallelizable.len(), 2);
889 assert_eq!(g.parallelizable[0].issues, ["I0001"]);
890 assert_eq!(g.parallelizable[1].issues, ["I0002"]);
891 let conflict = &g.parallelizable[1].serialised[0];
892 assert_eq!(conflict.held, "I0001");
893 assert_eq!(conflict.excluded, "I0002");
894 assert_eq!(conflict.path, "apps/cli/src");
895 }
896
897 #[test]
899 fn an_issue_that_is_not_parallel_safe_runs_alone() {
900 let mut alone = node("I0002", "READY", &[], &[]);
901 alone.parallel_safe = false;
902 let plan = plan_of(vec![node("I0001", "READY", &[], &[]), alone], Vec::new());
903 let g = MilestoneGraph::build(&plan, "m1", None);
904 let sets: Vec<Vec<String>> = g.parallelizable.iter().map(|s| s.issues.clone()).collect();
905 assert_eq!(
906 sets,
907 vec![vec!["I0001".to_string()], vec!["I0002".to_string()]]
908 );
909 }
910
911 #[test]
914 fn a_cycle_is_reported_as_a_component_and_costs_the_wave() {
915 let plan = plan_of(
916 vec![
917 node("I0001", "READY", &["I0002"], &[]),
918 node("I0002", "READY", &["I0001"], &[]),
919 node("I0003", "READY", &[], &[]),
920 ],
921 vec![PlanFinding {
922 level: "FAIL".into(),
923 code: "cycle".into(),
924 subject: "graph".into(),
925 message:
926 "a dependency cycle prevents these issues from ever becoming ready: I0001 I0002"
927 .into(),
928 }],
929 );
930 let g = MilestoneGraph::build(&plan, "m1", None);
931 assert_eq!(
932 g.cycles,
933 vec![vec!["I0001".to_string(), "I0002".to_string()]]
934 );
935 for id in ["I0001", "I0002"] {
936 let n = g.nodes.iter().find(|n| n.issue == id).unwrap();
937 assert!(n.wave.is_none(), "{id} was given a wave despite the cycle");
938 }
939 assert!(g
940 .nodes
941 .iter()
942 .find(|n| n.issue == "I0003")
943 .unwrap()
944 .wave
945 .is_some());
946 assert!(g.has_failures());
947 }
948
949 #[test]
952 fn a_missing_dependency_is_a_diagnostic_rather_than_a_silent_omission() {
953 let plan = plan_of(
954 vec![node("I0001", "READY", &["I9999"], &[])],
955 vec![PlanFinding {
956 level: "FAIL".into(),
957 code: "unknown_dependency".into(),
958 subject: "I0001".into(),
959 message: "depends on I9999, which is not an issue".into(),
960 }],
961 );
962 let g = MilestoneGraph::build(&plan, "m1", None);
963 assert!(g
964 .diagnostics
965 .iter()
966 .any(|d| d.code == "unknown_dependency" && d.level == "FAIL"));
967 assert!(g.has_failures());
968 }
969
970 #[test]
973 fn an_undeclared_milestone_is_answered_with_nothing_claiming_to_be_authored() {
974 let plan = plan_of(vec![node("I0001", "READY", &[], &[])], Vec::new());
975 let g = MilestoneGraph::build(&plan, "nonesuch", None);
976 assert!(!g.declared);
977 assert!(g.nodes.is_empty());
978 assert_eq!(g.status.provenance, FieldProvenance::Unknown);
979 assert_eq!(g.title.provenance, FieldProvenance::Unknown);
980 assert!(g.is_consistent());
981 assert!(g.is_partitioned(), "an empty graph is partitioned");
982 }
983
984 #[test]
987 fn the_graph_is_the_same_whatever_order_the_records_arrive_in() {
988 let forward = plan_of(
989 vec![
990 node("I0001", "READY", &[], &["a"]),
991 node("I0002", "READY", &["I0001"], &["b"]),
992 node("I0010", "READY", &[], &["c"]),
993 ],
994 Vec::new(),
995 );
996 let backward = plan_of(
997 vec![
998 node("I0010", "READY", &[], &["c"]),
999 node("I0002", "READY", &["I0001"], &["b"]),
1000 node("I0001", "READY", &[], &["a"]),
1001 ],
1002 Vec::new(),
1003 );
1004 let a = MilestoneGraph::build(&forward, "m1", None);
1005 let b = MilestoneGraph::build(&backward, "m1", None);
1006 assert_eq!(
1007 serde_json::to_string(&a).unwrap(),
1008 serde_json::to_string(&b).unwrap()
1009 );
1010 let ids: Vec<&str> = a.nodes.iter().map(|n| n.issue.as_str()).collect();
1012 assert_eq!(ids, ["I0001", "I0002", "I0010"]);
1013 }
1014}