use crate::plan::{NodeKind, Plan, Subtask, SubtaskStatus};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeVerdict {
Done,
NotYet(String),
Unsupported(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeFacts {
pub commit_present: bool,
pub verify: Option<bool>,
pub children_all_done: bool,
pub pr_merged: Option<bool>,
pub ci_green: Option<bool>,
pub issue: Option<IssueState>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueState {
Closed,
Open,
Unknown,
}
pub trait GitFacts {
fn commit_present(&self, commit: &str, branch: Option<&str>) -> bool;
}
pub trait VerifyRunner {
fn run(&self, cmd: &str) -> bool;
}
pub trait ForgeFacts {
fn pr_merged(&self, pr: u64) -> Option<bool>;
fn issue_closed(&self, issue: u64) -> Option<bool> {
let _ = issue;
None
}
}
pub trait CiFacts {
fn pipelines_green(&self) -> Option<bool>;
}
pub struct Facts<'a> {
pub git: &'a dyn GitFacts,
pub verify: &'a dyn VerifyRunner,
pub forge: &'a dyn ForgeFacts,
pub ci: &'a dyn CiFacts,
}
#[must_use]
pub fn gather_facts(node: &Subtask, tree: &Plan, facts: &Facts) -> NodeFacts {
let artifact = node.artifact_ref.as_ref();
let commit_present = artifact
.and_then(|a| a.commit.as_deref())
.map(|c| {
facts
.git
.commit_present(c, artifact.and_then(|a| a.branch.as_deref()))
})
.unwrap_or(false);
let verify = node.verify.as_deref().map(|cmd| facts.verify.run(cmd));
let kids = tree.children(&node.id);
let children_all_done =
!kids.is_empty() && kids.iter().all(|c| c.status == SubtaskStatus::Done);
let pr_merged = artifact
.and_then(|a| a.pr)
.and_then(|pr| facts.forge.pr_merged(pr));
let ci_green = if node.kind == NodeKind::Roadmap {
facts.ci.pipelines_green()
} else {
None
};
let issue = artifact
.and_then(|a| a.issue)
.map(|n| match facts.forge.issue_closed(n) {
Some(true) => IssueState::Closed,
Some(false) => IssueState::Open,
None => IssueState::Unknown,
});
NodeFacts {
commit_present,
verify,
children_all_done,
pr_merged,
ci_green,
issue,
}
}
#[must_use]
pub fn evaluate_node(node: &Subtask, facts: &NodeFacts) -> NodeVerdict {
match facts.issue {
Some(IssueState::Open) => {
return NodeVerdict::NotYet("the referenced issue is still open".into());
}
Some(IssueState::Unknown) => {
return NodeVerdict::Unsupported(
"the node references an issue but its state is unavailable — ensure `gh` \
can reach the forge"
.into(),
);
}
Some(IssueState::Closed) | None => {}
}
let verify_ok = facts.verify != Some(false);
match node.kind {
NodeKind::Task => {
if !facts.commit_present {
NodeVerdict::NotYet("no commit on the branch yet (set artifact_ref.commit)".into())
} else if !verify_ok {
NodeVerdict::NotYet("the task's verify command is failing".into())
} else {
NodeVerdict::Done
}
}
NodeKind::Plan => {
if !facts.children_all_done {
NodeVerdict::NotYet("not all child tasks are done".into())
} else if !verify_ok {
NodeVerdict::NotYet("the plan's branch verify is failing".into())
} else {
NodeVerdict::Done
}
}
NodeKind::Phase => {
if !facts.children_all_done {
NodeVerdict::NotYet("not all child plans are done".into())
} else {
match facts.pr_merged {
Some(true) => NodeVerdict::Done,
Some(false) => NodeVerdict::NotYet("the phase's PR is not merged yet".into()),
None => NodeVerdict::Unsupported(
"phase completion needs a merged PR — set artifact_ref.pr and ensure `gh` is available".into(),
),
}
}
}
NodeKind::Roadmap => {
if !facts.children_all_done {
NodeVerdict::NotYet("not all child phases are done".into())
} else {
match facts.ci_green {
Some(true) => NodeVerdict::Done,
Some(false) => NodeVerdict::NotYet("the pipelines are not green".into()),
None => NodeVerdict::Unsupported(
"roadmap completion needs green CI — no pipeline status available".into(),
),
}
}
}
}
}
#[must_use]
pub fn evaluate(node: &Subtask, tree: &Plan, facts: &Facts) -> NodeVerdict {
evaluate_node(node, &gather_facts(node, tree, facts))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DriveStep {
Advanced { node: String },
Blocked { node: String, reason: String },
Complete,
}
#[must_use]
pub fn drive_cursor(tree: &Plan) -> Option<&Subtask> {
tree.subtasks.iter().find(|s| {
matches!(s.status, SubtaskStatus::Pending | SubtaskStatus::Running)
&& s.deps
.iter()
.all(|d| matches!(tree.subtask(d).map(|t| t.status), Some(SubtaskStatus::Done)))
&& tree
.children(&s.id)
.iter()
.all(|c| c.status == SubtaskStatus::Done)
})
}
pub fn drive_once(tree: &mut Plan, facts: &Facts) -> DriveStep {
let Some(node_id) = drive_cursor(tree).map(|s| s.id.clone()) else {
return DriveStep::Complete;
};
let node = tree
.subtask(&node_id)
.cloned()
.expect("cursor node exists in the tree");
match evaluate(&node, tree, facts) {
NodeVerdict::Done => {
tree.mark(&node_id, SubtaskStatus::Done, None);
DriveStep::Advanced { node: node_id }
}
NodeVerdict::NotYet(reason) | NodeVerdict::Unsupported(reason) => DriveStep::Blocked {
node: node_id,
reason,
},
}
}
pub fn drive_to_fixpoint(tree: &mut Plan, facts: &Facts) -> Vec<DriveStep> {
let bound = tree.subtasks.len() + 1;
let mut steps = Vec::new();
for _ in 0..bound {
let step = drive_once(tree, facts);
let advanced = matches!(step, DriveStep::Advanced { .. });
steps.push(step);
if !advanced {
break;
}
}
steps
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::{ArtifactRef, Subtask};
struct FakeGit(&'static [&'static str]);
impl GitFacts for FakeGit {
fn commit_present(&self, commit: &str, _branch: Option<&str>) -> bool {
self.0.contains(&commit)
}
}
struct FakeVerify(bool);
impl VerifyRunner for FakeVerify {
fn run(&self, _cmd: &str) -> bool {
self.0
}
}
struct FakeForge(Option<bool>);
impl ForgeFacts for FakeForge {
fn pr_merged(&self, _pr: u64) -> Option<bool> {
self.0
}
}
struct FakeCi(Option<bool>);
impl CiFacts for FakeCi {
fn pipelines_green(&self) -> Option<bool> {
self.0
}
}
fn facts<'a>(
git: &'a FakeGit,
verify: &'a FakeVerify,
forge: &'a FakeForge,
ci: &'a FakeCi,
) -> Facts<'a> {
Facts {
git,
verify,
forge,
ci,
}
}
fn task_with(commit: Option<&str>, verify: Option<&str>) -> Subtask {
let mut t = Subtask::node("t", "the task", NodeKind::Task, None);
t.artifact_ref = commit.map(|c| ArtifactRef {
branch: Some("feat/x".into()),
commit: Some(c.into()),
pr: None,
issue: None,
});
t.verify = verify.map(str::to_string);
t
}
#[test]
fn task_is_done_only_with_a_present_commit_and_passing_verify() {
let tree = Plan::default();
let (git, pass, fail, forge, ci) = (
FakeGit(&["deadbeef"]),
FakeVerify(true),
FakeVerify(false),
FakeForge(None),
FakeCi(None),
);
let ok = facts(&git, &pass, &forge, &ci);
let bad = facts(&git, &fail, &forge, &ci);
assert_eq!(
evaluate(&task_with(Some("deadbeef"), Some("cargo test")), &tree, &ok),
NodeVerdict::Done
);
assert_eq!(
evaluate(&task_with(Some("deadbeef"), None), &tree, &ok),
NodeVerdict::Done
);
assert!(matches!(
evaluate(
&task_with(Some("deadbeef"), Some("cargo test")),
&tree,
&bad
),
NodeVerdict::NotYet(_)
));
assert!(matches!(
evaluate(&task_with(Some("nope"), None), &tree, &ok),
NodeVerdict::NotYet(_)
));
assert!(matches!(
evaluate(&task_with(None, None), &tree, &ok),
NodeVerdict::NotYet(_)
));
}
struct FakeIssueForge(Option<bool>, Option<bool>);
impl ForgeFacts for FakeIssueForge {
fn pr_merged(&self, _pr: u64) -> Option<bool> {
self.0
}
fn issue_closed(&self, _issue: u64) -> Option<bool> {
self.1
}
}
fn done_task_with_issue(issue: u64) -> Subtask {
let mut t = task_with(Some("deadbeef"), None);
t.artifact_ref.as_mut().unwrap().issue = Some(issue);
t
}
#[test]
fn issue_gate_blocks_done_while_the_referenced_issue_is_open() {
let tree = Plan::default();
let (git, verify, ci) = (FakeGit(&["deadbeef"]), FakeVerify(true), FakeCi(None));
let open = FakeIssueForge(None, Some(false));
let closed = FakeIssueForge(None, Some(true));
let t = done_task_with_issue(1083);
let verdict = evaluate(
&t,
&tree,
&Facts {
git: &git,
verify: &verify,
forge: &open,
ci: &ci,
},
);
assert!(
matches!(&verdict, NodeVerdict::NotYet(r) if r.contains("issue")),
"open issue must block: {verdict:?}"
);
assert_eq!(
evaluate(
&t,
&tree,
&Facts {
git: &git,
verify: &verify,
forge: &closed,
ci: &ci,
}
),
NodeVerdict::Done
);
}
#[test]
fn issue_gate_unknown_state_is_unsupported_never_done() {
let tree = Plan::default();
let (git, verify, ci) = (FakeGit(&["deadbeef"]), FakeVerify(true), FakeCi(None));
let legacy = FakeForge(None); let verdict = evaluate(
&done_task_with_issue(1083),
&tree,
&Facts {
git: &git,
verify: &verify,
forge: &legacy,
ci: &ci,
},
);
assert!(
matches!(&verdict, NodeVerdict::Unsupported(r) if r.contains("issue")),
"unknown issue state must be Unsupported: {verdict:?}"
);
}
#[test]
fn nodes_without_an_issue_ref_ignore_the_issue_gate() {
let tree = Plan::default();
let (git, verify, ci) = (FakeGit(&["deadbeef"]), FakeVerify(true), FakeCi(None));
let open = FakeIssueForge(None, Some(false));
assert_eq!(
evaluate(
&task_with(Some("deadbeef"), None),
&tree,
&Facts {
git: &git,
verify: &verify,
forge: &open,
ci: &ci,
}
),
NodeVerdict::Done
);
}
#[test]
fn plan_is_done_when_all_child_tasks_are_done_and_verify_passes() {
let toml = r#"
[[subtask]]
id = "plan-1"
instruction = "the plan"
kind = "plan"
[[subtask]]
id = "t1"
instruction = "task 1"
kind = "task"
parent = "plan-1"
[[subtask]]
id = "t2"
instruction = "task 2"
kind = "task"
parent = "plan-1"
"#;
let mut tree = Plan::from_toml_str(toml).unwrap();
let (git, pass, forge, ci) = (
FakeGit(&[]),
FakeVerify(true),
FakeForge(None),
FakeCi(None),
);
let ok = facts(&git, &pass, &forge, &ci);
let plan = tree.subtask("plan-1").unwrap().clone();
assert!(matches!(
evaluate(&plan, &tree, &ok),
NodeVerdict::NotYet(_)
));
tree.mark("t1", SubtaskStatus::Done, None);
tree.mark("t2", SubtaskStatus::Done, None);
assert_eq!(evaluate(&plan, &tree, &ok), NodeVerdict::Done);
}
#[test]
fn phase_is_done_when_children_done_and_pr_merged() {
let toml = r#"
[[subtask]]
id = "ph"
instruction = "phase one"
kind = "phase"
[[subtask]]
id = "plan-1"
instruction = "a plan"
kind = "plan"
parent = "ph"
[subtask.artifact_ref]
pr = 42
"#;
let mut tree = Plan::from_toml_str(toml).unwrap();
if let Some(phase) = tree.subtasks.iter_mut().find(|s| s.id == "ph") {
phase.artifact_ref = Some(ArtifactRef {
branch: None,
commit: None,
pr: Some(42),
issue: None,
});
}
let git = FakeGit(&[]);
let verify = FakeVerify(true);
let ci = FakeCi(None);
let phase = tree.subtask("ph").unwrap().clone();
let merged = facts(&git, &verify, &FakeForge(Some(true)), &ci);
assert!(matches!(
evaluate(&phase, &tree, &merged),
NodeVerdict::NotYet(_)
));
tree.mark("plan-1", SubtaskStatus::Done, None);
assert_eq!(evaluate(&phase, &tree, &merged), NodeVerdict::Done);
let open = facts(&git, &verify, &FakeForge(Some(false)), &ci);
assert!(matches!(
evaluate(&phase, &tree, &open),
NodeVerdict::NotYet(_)
));
let no_forge = facts(&git, &verify, &FakeForge(None), &ci);
assert!(matches!(
evaluate(&phase, &tree, &no_forge),
NodeVerdict::Unsupported(_)
));
}
#[test]
fn roadmap_is_done_when_children_done_and_ci_green() {
let toml = r#"
[[subtask]]
id = "rd"
instruction = "the roadmap"
kind = "roadmap"
[[subtask]]
id = "ph"
instruction = "a phase"
kind = "phase"
parent = "rd"
"#;
let mut tree = Plan::from_toml_str(toml).unwrap();
let (git, verify, forge) = (FakeGit(&[]), FakeVerify(true), FakeForge(None));
let road = tree.subtask("rd").unwrap().clone();
let green = facts(&git, &verify, &forge, &FakeCi(Some(true)));
assert!(matches!(
evaluate(&road, &tree, &green),
NodeVerdict::NotYet(_)
));
tree.mark("ph", SubtaskStatus::Done, None);
assert_eq!(evaluate(&road, &tree, &green), NodeVerdict::Done);
let red = facts(&git, &verify, &forge, &FakeCi(Some(false)));
assert!(matches!(
evaluate(&road, &tree, &red),
NodeVerdict::NotYet(_)
));
let no_ci = facts(&git, &verify, &forge, &FakeCi(None));
assert!(matches!(
evaluate(&road, &tree, &no_ci),
NodeVerdict::Unsupported(_)
));
}
#[test]
fn empty_plan_is_not_done_by_vacuity() {
let plan = Subtask::node("p", "empty plan", NodeKind::Plan, None);
let tree = Plan {
subtasks: vec![plan.clone()],
..Plan::default()
};
let (git, verify, forge, ci) = (
FakeGit(&[]),
FakeVerify(true),
FakeForge(None),
FakeCi(None),
);
assert!(matches!(
evaluate(&plan, &tree, &facts(&git, &verify, &forge, &ci)),
NodeVerdict::NotYet(_)
));
}
fn cascade_tree() -> Plan {
let toml = r#"
[[subtask]]
id = "ph"
instruction = "phase one"
kind = "phase"
[[subtask]]
id = "plan-1"
instruction = "a plan"
kind = "plan"
parent = "ph"
[[subtask]]
id = "t1"
instruction = "task 1"
kind = "task"
parent = "plan-1"
[[subtask]]
id = "t2"
instruction = "task 2"
kind = "task"
parent = "plan-1"
"#;
let mut tree = Plan::from_toml_str(toml).unwrap();
for (id, commit) in [("t1", "c1"), ("t2", "c2")] {
if let Some(t) = tree.subtasks.iter_mut().find(|s| s.id == id) {
t.artifact_ref = Some(ArtifactRef {
branch: Some("feat/x".into()),
commit: Some(commit.into()),
pr: None,
issue: None,
});
}
}
if let Some(ph) = tree.subtasks.iter_mut().find(|s| s.id == "ph") {
ph.artifact_ref = Some(ArtifactRef {
branch: None,
commit: None,
pr: Some(42),
issue: None,
});
}
tree
}
#[test]
fn drive_once_advances_a_completable_task_and_marks_it_done() {
let mut tree = cascade_tree();
let (git, verify, forge, ci) = (
FakeGit(&["c1", "c2"]),
FakeVerify(true),
FakeForge(Some(true)),
FakeCi(None),
);
let step = drive_once(&mut tree, &facts(&git, &verify, &forge, &ci));
assert_eq!(
step,
DriveStep::Advanced {
node: "t1".to_string()
}
);
assert_eq!(tree.subtask("t1").unwrap().status, SubtaskStatus::Done);
}
#[test]
fn drive_once_blocks_on_a_task_without_a_commit() {
let mut tree = cascade_tree();
let (git, verify, forge, ci) = (
FakeGit(&[]),
FakeVerify(true),
FakeForge(None),
FakeCi(None),
);
let step = drive_once(&mut tree, &facts(&git, &verify, &forge, &ci));
match step {
DriveStep::Blocked { node, reason } => {
assert_eq!(node, "t1");
assert!(reason.contains("commit"), "reason was: {reason}");
}
other => panic!("expected Blocked, got {other:?}"),
}
assert_eq!(tree.subtask("t1").unwrap().status, SubtaskStatus::Pending);
}
#[test]
fn drive_to_fixpoint_cascades_completion_up_the_tree() {
let mut tree = cascade_tree();
let (git, verify, forge, ci) = (
FakeGit(&["c1", "c2"]),
FakeVerify(true),
FakeForge(Some(true)),
FakeCi(None),
);
let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
assert_eq!(
steps,
vec![
DriveStep::Advanced { node: "t1".into() },
DriveStep::Advanced { node: "t2".into() },
DriveStep::Advanced {
node: "plan-1".into()
},
DriveStep::Advanced { node: "ph".into() },
DriveStep::Complete,
]
);
assert!(tree
.subtasks
.iter()
.all(|s| s.status == SubtaskStatus::Done));
}
#[test]
fn drive_to_fixpoint_halts_at_the_first_blocked_node() {
let mut tree = cascade_tree();
let (git, verify, forge, ci) = (
FakeGit(&["c1"]),
FakeVerify(true),
FakeForge(Some(true)),
FakeCi(None),
);
let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
assert_eq!(steps.len(), 2);
assert_eq!(steps[0], DriveStep::Advanced { node: "t1".into() });
assert!(matches!(&steps[1], DriveStep::Blocked { node, .. } if node == "t2"));
assert_eq!(
tree.subtask("plan-1").unwrap().status,
SubtaskStatus::Pending
);
assert_eq!(tree.subtask("ph").unwrap().status, SubtaskStatus::Pending);
}
#[test]
fn drive_to_fixpoint_on_an_empty_tree_is_a_single_complete_step() {
let mut tree = Plan::default();
let (git, verify, forge, ci) = (
FakeGit(&[]),
FakeVerify(true),
FakeForge(None),
FakeCi(None),
);
let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
assert_eq!(steps, vec![DriveStep::Complete]);
}
#[test]
fn drive_cursor_descends_past_a_running_plan_then_closes_it() {
let mut tree = cascade_tree(); tree.mark("plan-1", SubtaskStatus::Running, None); assert_eq!(
drive_cursor(&tree).map(|s| s.id.as_str()),
Some("t1"),
"descend past the Running plan-1 to the leftmost ready Task"
);
tree.mark("t1", SubtaskStatus::Done, None);
tree.mark("t2", SubtaskStatus::Done, None);
assert_eq!(
drive_cursor(&tree).map(|s| s.id.as_str()),
Some("plan-1"),
"a Running Plan with Done children is the cursor so drive closes it"
);
}
#[test]
fn drive_to_fixpoint_closes_a_bound_running_plan_and_its_tasks() {
let mut tree = cascade_tree();
tree.mark("plan-1", SubtaskStatus::Running, None); let (git, verify, forge, ci) = (
FakeGit(&["c1", "c2"]),
FakeVerify(true),
FakeForge(Some(true)),
FakeCi(None),
);
let steps = drive_to_fixpoint(&mut tree, &facts(&git, &verify, &forge, &ci));
assert_eq!(
steps,
vec![
DriveStep::Advanced { node: "t1".into() },
DriveStep::Advanced { node: "t2".into() },
DriveStep::Advanced {
node: "plan-1".into()
},
DriveStep::Advanced { node: "ph".into() },
DriveStep::Complete,
],
"the bound Running Plan and its Tasks close from git truth, unattended"
);
assert!(tree
.subtasks
.iter()
.all(|s| s.status == SubtaskStatus::Done));
}
}