1use crate::plan::{NodeKind, Plan, Subtask, SubtaskStatus};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum NodeVerdict {
22 Done,
24 NotYet(String),
27 Unsupported(String),
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct NodeFacts {
35 pub commit_present: bool,
37 pub verify: Option<bool>,
39 pub children_all_done: bool,
41 pub pr_merged: Option<bool>,
44 pub ci_green: Option<bool>,
47 pub issue: Option<IssueState>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum IssueState {
58 Closed,
60 Open,
62 Unknown,
64}
65
66pub trait GitFacts {
69 fn commit_present(&self, commit: &str, branch: Option<&str>) -> bool;
71}
72
73pub trait VerifyRunner {
76 fn run(&self, cmd: &str) -> bool;
77}
78
79pub trait ForgeFacts {
82 fn pr_merged(&self, pr: u64) -> Option<bool>;
84 fn issue_closed(&self, issue: u64) -> Option<bool> {
88 let _ = issue;
89 None
90 }
91}
92
93pub trait CiFacts {
96 fn pipelines_green(&self) -> Option<bool>;
98}
99
100pub struct Facts<'a> {
103 pub git: &'a dyn GitFacts,
104 pub verify: &'a dyn VerifyRunner,
105 pub forge: &'a dyn ForgeFacts,
106 pub ci: &'a dyn CiFacts,
107}
108
109#[must_use]
111pub fn gather_facts(node: &Subtask, tree: &Plan, facts: &Facts) -> NodeFacts {
112 let artifact = node.artifact_ref.as_ref();
113 let commit_present = artifact
114 .and_then(|a| a.commit.as_deref())
115 .map(|c| {
116 facts
117 .git
118 .commit_present(c, artifact.and_then(|a| a.branch.as_deref()))
119 })
120 .unwrap_or(false);
121 let verify = node.verify.as_deref().map(|cmd| facts.verify.run(cmd));
122 let kids = tree.children(&node.id);
125 let children_all_done =
126 !kids.is_empty() && kids.iter().all(|c| c.status == SubtaskStatus::Done);
127 let pr_merged = artifact
130 .and_then(|a| a.pr)
131 .and_then(|pr| facts.forge.pr_merged(pr));
132 let ci_green = if node.kind == NodeKind::Roadmap {
133 facts.ci.pipelines_green()
134 } else {
135 None
136 };
137 let issue = artifact
140 .and_then(|a| a.issue)
141 .map(|n| match facts.forge.issue_closed(n) {
142 Some(true) => IssueState::Closed,
143 Some(false) => IssueState::Open,
144 None => IssueState::Unknown,
145 });
146 NodeFacts {
147 commit_present,
148 verify,
149 children_all_done,
150 pr_merged,
151 ci_green,
152 issue,
153 }
154}
155
156#[must_use]
158pub fn evaluate_node(node: &Subtask, facts: &NodeFacts) -> NodeVerdict {
159 match facts.issue {
163 Some(IssueState::Open) => {
164 return NodeVerdict::NotYet("the referenced issue is still open".into());
165 }
166 Some(IssueState::Unknown) => {
167 return NodeVerdict::Unsupported(
168 "the node references an issue but its state is unavailable — ensure `gh` \
169 can reach the forge"
170 .into(),
171 );
172 }
173 Some(IssueState::Closed) | None => {}
174 }
175 let verify_ok = facts.verify != Some(false);
177 match node.kind {
178 NodeKind::Task => {
179 if !facts.commit_present {
180 NodeVerdict::NotYet("no commit on the branch yet (set artifact_ref.commit)".into())
181 } else if !verify_ok {
182 NodeVerdict::NotYet("the task's verify command is failing".into())
183 } else {
184 NodeVerdict::Done
185 }
186 }
187 NodeKind::Plan => {
188 if !facts.children_all_done {
189 NodeVerdict::NotYet("not all child tasks are done".into())
190 } else if !verify_ok {
191 NodeVerdict::NotYet("the plan's branch verify is failing".into())
192 } else {
193 NodeVerdict::Done
194 }
195 }
196 NodeKind::Phase => {
197 if !facts.children_all_done {
198 NodeVerdict::NotYet("not all child plans are done".into())
199 } else {
200 match facts.pr_merged {
201 Some(true) => NodeVerdict::Done,
202 Some(false) => NodeVerdict::NotYet("the phase's PR is not merged yet".into()),
203 None => NodeVerdict::Unsupported(
204 "phase completion needs a merged PR — set artifact_ref.pr and ensure `gh` is available".into(),
205 ),
206 }
207 }
208 }
209 NodeKind::Roadmap => {
210 if !facts.children_all_done {
211 NodeVerdict::NotYet("not all child phases are done".into())
212 } else {
213 match facts.ci_green {
214 Some(true) => NodeVerdict::Done,
215 Some(false) => NodeVerdict::NotYet("the pipelines are not green".into()),
216 None => NodeVerdict::Unsupported(
217 "roadmap completion needs green CI — no pipeline status available".into(),
218 ),
219 }
220 }
221 }
222 }
223}
224
225#[must_use]
227pub fn evaluate(node: &Subtask, tree: &Plan, facts: &Facts) -> NodeVerdict {
228 evaluate_node(node, &gather_facts(node, tree, facts))
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum DriveStep {
240 Advanced { node: String },
242 Blocked { node: String, reason: String },
245 Complete,
247}
248
249#[must_use]
264pub fn drive_cursor(tree: &Plan) -> Option<&Subtask> {
265 tree.subtasks.iter().find(|s| {
266 matches!(s.status, SubtaskStatus::Pending | SubtaskStatus::Running)
267 && s.deps
268 .iter()
269 .all(|d| matches!(tree.subtask(d).map(|t| t.status), Some(SubtaskStatus::Done)))
270 && tree
271 .children(&s.id)
272 .iter()
273 .all(|c| c.status == SubtaskStatus::Done)
274 })
275}
276
277pub fn drive_once(tree: &mut Plan, facts: &Facts) -> DriveStep {
289 let Some(node_id) = drive_cursor(tree).map(|s| s.id.clone()) else {
290 return DriveStep::Complete;
291 };
292 let node = tree
294 .subtask(&node_id)
295 .cloned()
296 .expect("cursor node exists in the tree");
297 match evaluate(&node, tree, facts) {
298 NodeVerdict::Done => {
299 tree.mark(&node_id, SubtaskStatus::Done, None);
300 DriveStep::Advanced { node: node_id }
301 }
302 NodeVerdict::NotYet(reason) | NodeVerdict::Unsupported(reason) => DriveStep::Blocked {
303 node: node_id,
304 reason,
305 },
306 }
307}
308
309pub fn drive_to_fixpoint(tree: &mut Plan, facts: &Facts) -> Vec<DriveStep> {
321 let bound = tree.subtasks.len() + 1;
322 let mut steps = Vec::new();
323 for _ in 0..bound {
324 let step = drive_once(tree, facts);
325 let advanced = matches!(step, DriveStep::Advanced { .. });
326 steps.push(step);
327 if !advanced {
328 break;
329 }
330 }
331 steps
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::plan::{ArtifactRef, Subtask};
338
339 struct FakeGit(&'static [&'static str]);
340 impl GitFacts for FakeGit {
341 fn commit_present(&self, commit: &str, _branch: Option<&str>) -> bool {
342 self.0.contains(&commit)
343 }
344 }
345 struct FakeVerify(bool);
346 impl VerifyRunner for FakeVerify {
347 fn run(&self, _cmd: &str) -> bool {
348 self.0
349 }
350 }
351 struct FakeForge(Option<bool>);
353 impl ForgeFacts for FakeForge {
354 fn pr_merged(&self, _pr: u64) -> Option<bool> {
355 self.0
356 }
357 }
358 struct FakeCi(Option<bool>);
359 impl CiFacts for FakeCi {
360 fn pipelines_green(&self) -> Option<bool> {
361 self.0
362 }
363 }
364
365 fn facts<'a>(
367 git: &'a FakeGit,
368 verify: &'a FakeVerify,
369 forge: &'a FakeForge,
370 ci: &'a FakeCi,
371 ) -> Facts<'a> {
372 Facts {
373 git,
374 verify,
375 forge,
376 ci,
377 }
378 }
379
380 fn task_with(commit: Option<&str>, verify: Option<&str>) -> Subtask {
381 let mut t = Subtask::node("t", "the task", NodeKind::Task, None);
382 t.artifact_ref = commit.map(|c| ArtifactRef {
383 branch: Some("feat/x".into()),
384 commit: Some(c.into()),
385 pr: None,
386 issue: None,
387 });
388 t.verify = verify.map(str::to_string);
389 t
390 }
391
392 #[test]
393 fn task_is_done_only_with_a_present_commit_and_passing_verify() {
394 let tree = Plan::default();
395 let (git, pass, fail, forge, ci) = (
396 FakeGit(&["deadbeef"]),
397 FakeVerify(true),
398 FakeVerify(false),
399 FakeForge(None),
400 FakeCi(None),
401 );
402 let ok = facts(&git, &pass, &forge, &ci);
403 let bad = facts(&git, &fail, &forge, &ci);
404
405 assert_eq!(
406 evaluate(&task_with(Some("deadbeef"), Some("cargo test")), &tree, &ok),
407 NodeVerdict::Done
408 );
409 assert_eq!(
410 evaluate(&task_with(Some("deadbeef"), None), &tree, &ok),
411 NodeVerdict::Done
412 );
413 assert!(matches!(
414 evaluate(
415 &task_with(Some("deadbeef"), Some("cargo test")),
416 &tree,
417 &bad
418 ),
419 NodeVerdict::NotYet(_)
420 ));
421 assert!(matches!(
422 evaluate(&task_with(Some("nope"), None), &tree, &ok),
423 NodeVerdict::NotYet(_)
424 ));
425 assert!(matches!(
426 evaluate(&task_with(None, None), &tree, &ok),
427 NodeVerdict::NotYet(_)
428 ));
429 }
430
431 struct FakeIssueForge(Option<bool>, Option<bool>);
435 impl ForgeFacts for FakeIssueForge {
436 fn pr_merged(&self, _pr: u64) -> Option<bool> {
437 self.0
438 }
439 fn issue_closed(&self, _issue: u64) -> Option<bool> {
440 self.1
441 }
442 }
443
444 fn done_task_with_issue(issue: u64) -> Subtask {
446 let mut t = task_with(Some("deadbeef"), None);
447 t.artifact_ref.as_mut().unwrap().issue = Some(issue);
448 t
449 }
450
451 #[test]
452 fn issue_gate_blocks_done_while_the_referenced_issue_is_open() {
453 let tree = Plan::default();
454 let (git, verify, ci) = (FakeGit(&["deadbeef"]), FakeVerify(true), FakeCi(None));
455 let open = FakeIssueForge(None, Some(false));
456 let closed = FakeIssueForge(None, Some(true));
457 let t = done_task_with_issue(1083);
458
459 let verdict = evaluate(
460 &t,
461 &tree,
462 &Facts {
463 git: &git,
464 verify: &verify,
465 forge: &open,
466 ci: &ci,
467 },
468 );
469 assert!(
470 matches!(&verdict, NodeVerdict::NotYet(r) if r.contains("issue")),
471 "open issue must block: {verdict:?}"
472 );
473 assert_eq!(
474 evaluate(
475 &t,
476 &tree,
477 &Facts {
478 git: &git,
479 verify: &verify,
480 forge: &closed,
481 ci: &ci,
482 }
483 ),
484 NodeVerdict::Done
485 );
486 }
487
488 #[test]
489 fn issue_gate_unknown_state_is_unsupported_never_done() {
490 let tree = Plan::default();
493 let (git, verify, ci) = (FakeGit(&["deadbeef"]), FakeVerify(true), FakeCi(None));
494 let legacy = FakeForge(None); let verdict = evaluate(
496 &done_task_with_issue(1083),
497 &tree,
498 &Facts {
499 git: &git,
500 verify: &verify,
501 forge: &legacy,
502 ci: &ci,
503 },
504 );
505 assert!(
506 matches!(&verdict, NodeVerdict::Unsupported(r) if r.contains("issue")),
507 "unknown issue state must be Unsupported: {verdict:?}"
508 );
509 }
510
511 #[test]
512 fn nodes_without_an_issue_ref_ignore_the_issue_gate() {
513 let tree = Plan::default();
515 let (git, verify, ci) = (FakeGit(&["deadbeef"]), FakeVerify(true), FakeCi(None));
516 let open = FakeIssueForge(None, Some(false));
517 assert_eq!(
518 evaluate(
519 &task_with(Some("deadbeef"), None),
520 &tree,
521 &Facts {
522 git: &git,
523 verify: &verify,
524 forge: &open,
525 ci: &ci,
526 }
527 ),
528 NodeVerdict::Done
529 );
530 }
531
532 #[test]
533 fn plan_is_done_when_all_child_tasks_are_done_and_verify_passes() {
534 let toml = r#"
535[[subtask]]
536id = "plan-1"
537instruction = "the plan"
538kind = "plan"
539
540[[subtask]]
541id = "t1"
542instruction = "task 1"
543kind = "task"
544parent = "plan-1"
545
546[[subtask]]
547id = "t2"
548instruction = "task 2"
549kind = "task"
550parent = "plan-1"
551"#;
552 let mut tree = Plan::from_toml_str(toml).unwrap();
553 let (git, pass, forge, ci) = (
554 FakeGit(&[]),
555 FakeVerify(true),
556 FakeForge(None),
557 FakeCi(None),
558 );
559 let ok = facts(&git, &pass, &forge, &ci);
560 let plan = tree.subtask("plan-1").unwrap().clone();
561
562 assert!(matches!(
563 evaluate(&plan, &tree, &ok),
564 NodeVerdict::NotYet(_)
565 ));
566 tree.mark("t1", SubtaskStatus::Done, None);
567 tree.mark("t2", SubtaskStatus::Done, None);
568 assert_eq!(evaluate(&plan, &tree, &ok), NodeVerdict::Done);
569 }
570
571 #[test]
572 fn phase_is_done_when_children_done_and_pr_merged() {
573 let toml = r#"
575[[subtask]]
576id = "ph"
577instruction = "phase one"
578kind = "phase"
579
580[[subtask]]
581id = "plan-1"
582instruction = "a plan"
583kind = "plan"
584parent = "ph"
585
586[subtask.artifact_ref]
587pr = 42
588"#;
589 let mut tree = Plan::from_toml_str(toml).unwrap();
592 if let Some(phase) = tree.subtasks.iter_mut().find(|s| s.id == "ph") {
594 phase.artifact_ref = Some(ArtifactRef {
595 branch: None,
596 commit: None,
597 pr: Some(42),
598 issue: None,
599 });
600 }
601 let git = FakeGit(&[]);
602 let verify = FakeVerify(true);
603 let ci = FakeCi(None);
604 let phase = tree.subtask("ph").unwrap().clone();
605
606 let merged = facts(&git, &verify, &FakeForge(Some(true)), &ci);
608 assert!(matches!(
609 evaluate(&phase, &tree, &merged),
610 NodeVerdict::NotYet(_)
611 ));
612 tree.mark("plan-1", SubtaskStatus::Done, None);
614 assert_eq!(evaluate(&phase, &tree, &merged), NodeVerdict::Done);
615 let open = facts(&git, &verify, &FakeForge(Some(false)), &ci);
617 assert!(matches!(
618 evaluate(&phase, &tree, &open),
619 NodeVerdict::NotYet(_)
620 ));
621 let no_forge = facts(&git, &verify, &FakeForge(None), &ci);
623 assert!(matches!(
624 evaluate(&phase, &tree, &no_forge),
625 NodeVerdict::Unsupported(_)
626 ));
627 }
628
629 #[test]
630 fn roadmap_is_done_when_children_done_and_ci_green() {
631 let toml = r#"
632[[subtask]]
633id = "rd"
634instruction = "the roadmap"
635kind = "roadmap"
636
637[[subtask]]
638id = "ph"
639instruction = "a phase"
640kind = "phase"
641parent = "rd"
642"#;
643 let mut tree = Plan::from_toml_str(toml).unwrap();
644 let (git, verify, forge) = (FakeGit(&[]), FakeVerify(true), FakeForge(None));
645 let road = tree.subtask("rd").unwrap().clone();
646
647 let green = facts(&git, &verify, &forge, &FakeCi(Some(true)));
649 assert!(matches!(
650 evaluate(&road, &tree, &green),
651 NodeVerdict::NotYet(_)
652 ));
653 tree.mark("ph", SubtaskStatus::Done, None);
655 assert_eq!(evaluate(&road, &tree, &green), NodeVerdict::Done);
656 let red = facts(&git, &verify, &forge, &FakeCi(Some(false)));
658 assert!(matches!(
659 evaluate(&road, &tree, &red),
660 NodeVerdict::NotYet(_)
661 ));
662 let no_ci = facts(&git, &verify, &forge, &FakeCi(None));
664 assert!(matches!(
665 evaluate(&road, &tree, &no_ci),
666 NodeVerdict::Unsupported(_)
667 ));
668 }
669
670 #[test]
671 fn empty_plan_is_not_done_by_vacuity() {
672 let plan = Subtask::node("p", "empty plan", NodeKind::Plan, None);
673 let tree = Plan {
674 subtasks: vec![plan.clone()],
675 ..Plan::default()
676 };
677 let (git, verify, forge, ci) = (
678 FakeGit(&[]),
679 FakeVerify(true),
680 FakeForge(None),
681 FakeCi(None),
682 );
683 assert!(matches!(
684 evaluate(&plan, &tree, &facts(&git, &verify, &forge, &ci)),
685 NodeVerdict::NotYet(_)
686 ));
687 }
688
689 fn cascade_tree() -> Plan {
695 let toml = r#"
696[[subtask]]
697id = "ph"
698instruction = "phase one"
699kind = "phase"
700
701[[subtask]]
702id = "plan-1"
703instruction = "a plan"
704kind = "plan"
705parent = "ph"
706
707[[subtask]]
708id = "t1"
709instruction = "task 1"
710kind = "task"
711parent = "plan-1"
712
713[[subtask]]
714id = "t2"
715instruction = "task 2"
716kind = "task"
717parent = "plan-1"
718"#;
719 let mut tree = Plan::from_toml_str(toml).unwrap();
720 for (id, commit) in [("t1", "c1"), ("t2", "c2")] {
721 if let Some(t) = tree.subtasks.iter_mut().find(|s| s.id == id) {
722 t.artifact_ref = Some(ArtifactRef {
723 branch: Some("feat/x".into()),
724 commit: Some(commit.into()),
725 pr: None,
726 issue: None,
727 });
728 }
729 }
730 if let Some(ph) = tree.subtasks.iter_mut().find(|s| s.id == "ph") {
731 ph.artifact_ref = Some(ArtifactRef {
732 branch: None,
733 commit: None,
734 pr: Some(42),
735 issue: None,
736 });
737 }
738 tree
739 }
740
741 #[test]
742 fn drive_once_advances_a_completable_task_and_marks_it_done() {
743 let mut tree = cascade_tree();
744 let (git, verify, forge, ci) = (
745 FakeGit(&["c1", "c2"]),
746 FakeVerify(true),
747 FakeForge(Some(true)),
748 FakeCi(None),
749 );
750 let step = drive_once(&mut tree, &facts(&git, &verify, &forge, &ci));
753 assert_eq!(
754 step,
755 DriveStep::Advanced {
756 node: "t1".to_string()
757 }
758 );
759 assert_eq!(tree.subtask("t1").unwrap().status, SubtaskStatus::Done);
760 }
761
762 #[test]
763 fn drive_once_blocks_on_a_task_without_a_commit() {
764 let mut tree = cascade_tree();
765 let (git, verify, forge, ci) = (
767 FakeGit(&[]),
768 FakeVerify(true),
769 FakeForge(None),
770 FakeCi(None),
771 );
772 let step = drive_once(&mut tree, &facts(&git, &verify, &forge, &ci));
773 match step {
774 DriveStep::Blocked { node, reason } => {
775 assert_eq!(node, "t1");
776 assert!(reason.contains("commit"), "reason was: {reason}");
777 }
778 other => panic!("expected Blocked, got {other:?}"),
779 }
780 assert_eq!(tree.subtask("t1").unwrap().status, SubtaskStatus::Pending);
782 }
783
784 #[test]
785 fn drive_to_fixpoint_cascades_completion_up_the_tree() {
786 let mut tree = cascade_tree();
787 let (git, verify, forge, ci) = (
788 FakeGit(&["c1", "c2"]),
789 FakeVerify(true),
790 FakeForge(Some(true)),
791 FakeCi(None),
792 );
793 let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
794 assert_eq!(
796 steps,
797 vec![
798 DriveStep::Advanced { node: "t1".into() },
799 DriveStep::Advanced { node: "t2".into() },
800 DriveStep::Advanced {
801 node: "plan-1".into()
802 },
803 DriveStep::Advanced { node: "ph".into() },
804 DriveStep::Complete,
805 ]
806 );
807 assert!(tree
808 .subtasks
809 .iter()
810 .all(|s| s.status == SubtaskStatus::Done));
811 }
812
813 #[test]
814 fn drive_to_fixpoint_halts_at_the_first_blocked_node() {
815 let mut tree = cascade_tree();
816 let (git, verify, forge, ci) = (
819 FakeGit(&["c1"]),
820 FakeVerify(true),
821 FakeForge(Some(true)),
822 FakeCi(None),
823 );
824 let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
825 assert_eq!(steps.len(), 2);
826 assert_eq!(steps[0], DriveStep::Advanced { node: "t1".into() });
827 assert!(matches!(&steps[1], DriveStep::Blocked { node, .. } if node == "t2"));
828 assert_eq!(
829 tree.subtask("plan-1").unwrap().status,
830 SubtaskStatus::Pending
831 );
832 assert_eq!(tree.subtask("ph").unwrap().status, SubtaskStatus::Pending);
833 }
834
835 #[test]
836 fn drive_to_fixpoint_on_an_empty_tree_is_a_single_complete_step() {
837 let mut tree = Plan::default();
838 let (git, verify, forge, ci) = (
839 FakeGit(&[]),
840 FakeVerify(true),
841 FakeForge(None),
842 FakeCi(None),
843 );
844 let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
845 assert_eq!(steps, vec![DriveStep::Complete]);
846 }
847
848 #[test]
849 fn drive_cursor_descends_past_a_running_plan_then_closes_it() {
850 let mut tree = cascade_tree(); tree.mark("plan-1", SubtaskStatus::Running, None); assert_eq!(
857 drive_cursor(&tree).map(|s| s.id.as_str()),
858 Some("t1"),
859 "descend past the Running plan-1 to the leftmost ready Task"
860 );
861 tree.mark("t1", SubtaskStatus::Done, None);
863 tree.mark("t2", SubtaskStatus::Done, None);
864 assert_eq!(
865 drive_cursor(&tree).map(|s| s.id.as_str()),
866 Some("plan-1"),
867 "a Running Plan with Done children is the cursor so drive closes it"
868 );
869 }
870
871 #[test]
872 fn drive_to_fixpoint_closes_a_bound_running_plan_and_its_tasks() {
873 let mut tree = cascade_tree();
878 tree.mark("plan-1", SubtaskStatus::Running, None); let (git, verify, forge, ci) = (
880 FakeGit(&["c1", "c2"]),
881 FakeVerify(true),
882 FakeForge(Some(true)),
883 FakeCi(None),
884 );
885 let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
886 assert_eq!(
887 steps,
888 vec![
889 DriveStep::Advanced { node: "t1".into() },
890 DriveStep::Advanced { node: "t2".into() },
891 DriveStep::Advanced {
892 node: "plan-1".into()
893 },
894 DriveStep::Advanced { node: "ph".into() },
895 DriveStep::Complete,
896 ],
897 "the bound Running Plan and its Tasks close from git truth, unattended"
898 );
899 assert!(tree
900 .subtasks
901 .iter()
902 .all(|s| s.status == SubtaskStatus::Done));
903 }
904}