use serde::{Deserialize, Serialize};
use crate::role_profile::{ScopeKeyword, ScopeSpec};
use crate::{Caveats, CountBound, Scope};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Plan {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default)]
pub aggregation: Aggregation,
#[serde(default, rename = "subtask")]
pub subtasks: Vec<Subtask>,
}
impl Plan {
pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
toml::from_str(s)
}
pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
toml::to_string_pretty(self)
}
#[must_use]
pub fn subtask(&self, id: &str) -> Option<&Subtask> {
self.subtasks.iter().find(|s| s.id == id)
}
#[must_use]
pub fn roots(&self) -> Vec<&Subtask> {
self.subtasks
.iter()
.filter(|s| s.parent.is_none())
.collect()
}
#[must_use]
pub fn children(&self, id: &str) -> Vec<&Subtask> {
self.subtasks
.iter()
.filter(|s| s.parent.as_deref() == Some(id))
.collect()
}
#[must_use]
pub fn leaves(&self) -> Vec<&Subtask> {
let parented: std::collections::HashSet<&str> = self
.subtasks
.iter()
.filter_map(|s| s.parent.as_deref())
.collect();
self.subtasks
.iter()
.filter(|s| !parented.contains(s.id.as_str()))
.collect()
}
#[must_use]
pub fn next_ready_leaf(&self) -> Option<&Subtask> {
self.leaves().into_iter().find(|s| {
s.status == SubtaskStatus::Pending
&& s.deps
.iter()
.all(|d| matches!(self.subtask(d).map(|t| t.status), Some(SubtaskStatus::Done)))
})
}
#[must_use]
pub fn next_dispatch(&self, parent: &Caveats) -> Option<(String, CrewTask)> {
self.next_ready_leaf()
.map(|s| (s.id.clone(), s.to_crew_task(parent)))
}
pub fn mark(&mut self, id: &str, status: SubtaskStatus, result: Option<String>) {
if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
s.status = status;
if result.is_some() {
s.result = result;
}
}
}
pub fn set_instruction(&mut self, id: &str, instruction: &str) {
if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
s.instruction = instruction.to_string();
}
}
pub fn clear_context(&mut self, id: &str) {
if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
s.context.clear();
}
}
pub fn set_artifact_commit(&mut self, id: &str, commit: &str, branch: Option<&str>) {
if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
let existing = s.artifact_ref.take().unwrap_or_default();
s.artifact_ref = Some(ArtifactRef {
commit: Some(commit.to_string()),
branch: branch.map(str::to_string).or(existing.branch),
..existing
});
}
}
pub fn set_artifact_issue(&mut self, id: &str, issue: u64) {
if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
let existing = s.artifact_ref.take().unwrap_or_default();
s.artifact_ref = Some(ArtifactRef {
issue: Some(issue),
..existing
});
}
}
#[must_use]
pub fn next_uncaptured_task_under(&self, plan_id: &str) -> Option<&Subtask> {
if self.subtask(plan_id)?.kind != NodeKind::Plan {
return None;
}
self.subtasks.iter().find(|s| {
s.kind == NodeKind::Task
&& s.status == SubtaskStatus::Pending
&& s.artifact_ref
.as_ref()
.and_then(|a| a.commit.as_deref())
.is_none()
&& self.path_to(&s.id).iter().any(|p| p == plan_id)
})
}
#[must_use]
pub fn is_complete(&self) -> bool {
self.leaves()
.iter()
.all(|s| s.status == SubtaskStatus::Done)
}
#[must_use]
pub fn next_ready_node(&self) -> Option<&Subtask> {
self.subtasks.iter().find(|s| {
s.status == SubtaskStatus::Pending
&& s.deps
.iter()
.all(|d| matches!(self.subtask(d).map(|t| t.status), Some(SubtaskStatus::Done)))
&& self
.children(&s.id)
.iter()
.all(|c| c.status == SubtaskStatus::Done)
})
}
#[must_use]
pub fn subtree_complete(&self, id: &str) -> bool {
match self.subtask(id) {
None => false,
Some(node) => {
node.status == SubtaskStatus::Done
&& self
.children(id)
.iter()
.all(|c| self.subtree_complete(&c.id))
}
}
}
#[must_use]
pub fn path_to(&self, id: &str) -> Vec<String> {
let mut chain = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut cur = self.subtask(id);
while let Some(node) = cur {
if !seen.insert(node.id.as_str()) {
break; }
chain.push(node.id.clone());
cur = node.parent.as_deref().and_then(|p| self.subtask(p));
}
chain.reverse();
chain
}
#[must_use]
pub fn nodes_of_kind(&self, kind: NodeKind) -> Vec<&Subtask> {
self.subtasks.iter().filter(|s| s.kind == kind).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Subtask {
pub id: String,
pub instruction: String,
#[serde(default)]
pub deps: Vec<String>,
#[serde(default)]
pub parallel_ok: bool,
#[serde(default)]
pub context: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify: Option<String>,
#[serde(default)]
pub status: SubtaskStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(default)]
pub kind: NodeKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_ref: Option<ArtifactRef>,
#[serde(default)]
pub caveat_policy: CaveatPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrewTask {
pub goal: String,
pub caveats: Caveats,
pub context: Vec<String>,
pub verify: Option<String>,
}
impl Subtask {
#[must_use]
pub fn node(
id: impl Into<String>,
instruction: impl Into<String>,
kind: NodeKind,
parent: Option<String>,
) -> Self {
Self {
id: id.into(),
instruction: instruction.into(),
deps: Vec::new(),
parallel_ok: false,
context: Vec::new(),
verify: None,
status: SubtaskStatus::default(),
result: None,
parent,
kind,
conversation_id: None,
artifact_ref: None,
caveat_policy: CaveatPolicy::default(),
}
}
#[must_use]
pub fn to_crew_task(&self, parent: &Caveats) -> CrewTask {
CrewTask {
goal: self.instruction.clone(),
caveats: parent.meet(&self.caveat_policy.to_caveats()),
context: self.context.clone(),
verify: self.verify.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Aggregation {
#[default]
Concat,
LastWins,
Reduce,
Custom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SubtaskStatus {
#[default]
Pending,
Running,
Done,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum NodeKind {
Roadmap,
Phase,
Plan,
#[default]
Task,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ArtifactRef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pr: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issue: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaveatPolicy {
#[serde(default = "denied_axis")]
pub fs_read: ScopeSpec,
#[serde(default = "denied_axis")]
pub fs_write: ScopeSpec,
#[serde(default = "denied_axis")]
pub exec: ScopeSpec,
#[serde(default = "denied_axis")]
pub net: ScopeSpec,
#[serde(default)]
pub max_calls: Option<u64>,
}
fn denied_axis() -> ScopeSpec {
ScopeSpec::Keyword(ScopeKeyword::None)
}
impl Default for CaveatPolicy {
fn default() -> Self {
Self {
fs_read: denied_axis(),
fs_write: denied_axis(),
exec: denied_axis(),
net: denied_axis(),
max_calls: None,
}
}
}
impl CaveatPolicy {
#[must_use]
pub fn to_caveats(&self) -> Caveats {
Caveats {
fs_read: self.fs_read.to_scope(),
fs_write: self.fs_write.to_scope(),
exec: self.exec.to_scope(),
net: self.net.to_scope(),
max_calls: match self.max_calls {
Some(n) => CountBound::AtMost(n),
None => CountBound::Unlimited,
},
valid_for_generation: Scope::All,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const DENIED: ScopeSpec = ScopeSpec::Keyword(ScopeKeyword::None);
#[test]
fn bare_subtask_fragment_parses_without_a_goal() {
let toml = r#"
[[subtask]]
id = "s1"
instruction = "do the first thing"
[[subtask]]
id = "s2"
instruction = "do the second thing"
deps = ["s1"]
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert!(plan.goal.is_none());
assert_eq!(plan.subtasks.len(), 2);
assert_eq!(plan.subtasks[1].deps, vec!["s1".to_string()]);
}
#[test]
fn omitted_caveat_policy_denies_every_axis() {
let toml = r#"
[[subtask]]
id = "s1"
instruction = "untrusted, model-proposed"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let pol = &plan.subtasks[0].caveat_policy;
assert_eq!(pol.fs_read, DENIED);
assert_eq!(pol.fs_write, DENIED);
assert_eq!(pol.exec, DENIED);
assert_eq!(pol.net, DENIED);
assert_eq!(*pol, CaveatPolicy::default());
}
#[test]
fn default_policy_lowers_to_a_fully_denied_caveats() {
let cav = CaveatPolicy::default().to_caveats();
assert_eq!(cav.fs_read, Scope::none());
assert_eq!(cav.fs_write, Scope::none());
assert_eq!(cav.exec, Scope::none());
assert_eq!(cav.net, Scope::none());
}
#[test]
fn explicit_policy_is_honored_and_lowers_correctly() {
let toml = r#"
[[subtask]]
id = "s1"
instruction = "scoped"
[subtask.caveat_policy]
fs_read = "all"
fs_write = ["src/", "Cargo.toml"]
exec = ["cargo"]
net = "none"
max_calls = 40
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let cav = plan.subtasks[0].caveat_policy.to_caveats();
assert_eq!(cav.fs_read, Scope::All);
assert_eq!(
cav.fs_write,
Scope::only(["src/".to_string(), "Cargo.toml".to_string()])
);
assert_eq!(cav.exec, Scope::only(["cargo".to_string()]));
assert_eq!(cav.net, Scope::none());
assert_eq!(cav.max_calls, CountBound::AtMost(40));
}
#[test]
fn status_defaults_to_pending() {
let toml = r#"
[[subtask]]
id = "s1"
instruction = "x"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert_eq!(plan.subtasks[0].status, SubtaskStatus::Pending);
assert!(plan.subtasks[0].result.is_none());
}
#[test]
fn plan_round_trips_through_toml() {
let plan = Plan {
goal: Some("ship the thing".to_string()),
aggregation: Aggregation::Concat,
subtasks: vec![Subtask {
id: "s1".to_string(),
instruction: "write the module".to_string(),
deps: vec![],
parallel_ok: true,
context: vec!["src/lib.rs".to_string()],
verify: Some("cargo test -p x".to_string()),
caveat_policy: CaveatPolicy {
fs_write: ScopeSpec::Items(vec!["src/".to_string()]),
..CaveatPolicy::default()
},
status: SubtaskStatus::Done,
result: Some("done".to_string()),
parent: Some("epic".to_string()),
kind: NodeKind::Task,
conversation_id: None,
artifact_ref: None,
}],
};
let text = plan.to_toml_string().unwrap();
let back = Plan::from_toml_str(&text).unwrap();
assert_eq!(back, plan);
}
#[test]
fn full_plan_with_aggregation_parses() {
let toml = r#"
goal = "refactor the parser"
aggregation = "lastwins"
[[subtask]]
id = "s1"
instruction = "x"
status = "running"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert_eq!(plan.goal.as_deref(), Some("refactor the parser"));
assert_eq!(plan.aggregation, Aggregation::LastWins);
assert_eq!(plan.subtasks[0].status, SubtaskStatus::Running);
}
#[test]
fn unknown_field_is_rejected() {
let toml = r#"
[[subtask]]
id = "s1"
instruction = "x"
bogus_field = "should fail"
"#;
assert!(Plan::from_toml_str(toml).is_err());
}
#[test]
fn empty_input_is_an_empty_plan() {
let plan = Plan::from_toml_str("").unwrap();
assert!(plan.goal.is_none());
assert!(plan.subtasks.is_empty());
assert_eq!(plan.aggregation, Aggregation::Concat);
}
#[test]
fn parent_pointers_build_a_tree() {
let toml = r#"
[[subtask]]
id = "epic"
instruction = "the big task"
[[subtask]]
id = "a"
instruction = "sub-task a"
parent = "epic"
[[subtask]]
id = "b"
instruction = "sub-task b"
parent = "epic"
[[subtask]]
id = "solo"
instruction = "a top-level leaf"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let ids = |v: Vec<&Subtask>| v.iter().map(|s| s.id.clone()).collect::<Vec<_>>();
assert_eq!(ids(plan.roots()), vec!["epic", "solo"]);
assert_eq!(ids(plan.children("epic")), vec!["a", "b"]);
assert_eq!(ids(plan.leaves()), vec!["a", "b", "solo"]);
assert_eq!(plan.subtask("a").unwrap().parent.as_deref(), Some("epic"));
}
#[test]
fn flat_plan_has_every_subtask_as_a_leaf() {
let plan = Plan::from_toml_str(
"[[subtask]]\nid=\"s1\"\ninstruction=\"x\"\n[[subtask]]\nid=\"s2\"\ninstruction=\"y\"\n",
)
.unwrap();
assert_eq!(plan.leaves().len(), 2);
assert_eq!(plan.roots().len(), 2);
}
#[test]
fn next_ready_leaf_is_the_execution_cursor() {
let toml = r#"
[[subtask]]
id = "epic"
instruction = "branch"
[[subtask]]
id = "a"
instruction = "first leaf"
parent = "epic"
status = "done"
[[subtask]]
id = "b"
instruction = "second leaf"
parent = "epic"
deps = ["a"]
[[subtask]]
id = "c"
instruction = "third leaf"
parent = "epic"
deps = ["b"]
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert_eq!(plan.next_ready_leaf().expect("b ready").id, "b");
}
#[test]
fn next_ready_leaf_none_when_all_done_or_blocked() {
let toml = r#"
[[subtask]]
id = "a"
instruction = "done"
status = "done"
[[subtask]]
id = "b"
instruction = "blocked"
deps = ["never_exists"]
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert!(plan.next_ready_leaf().is_none());
}
#[test]
fn parent_defaults_none_and_fragment_stays_valid() {
let frag = Plan::from_toml_str(
"[[subtask]]\nid=\"leaf\"\ninstruction=\"x\"\nparent=\"outside_the_slice\"\n",
)
.unwrap();
assert_eq!(
frag.subtasks[0].parent.as_deref(),
Some("outside_the_slice")
);
let root = Plan::from_toml_str("[[subtask]]\nid=\"r\"\ninstruction=\"y\"\n").unwrap();
assert!(root.subtasks[0].parent.is_none());
assert_eq!(root.roots().len(), 1);
}
#[test]
fn to_crew_task_projects_goal_context_and_attenuated_caveats() {
let toml = r#"
[[subtask]]
id = "leaf"
instruction = "write the module"
context = ["src/lib.rs"]
[subtask.caveat_policy]
fs_write = ["src/"]
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let task = plan.subtasks[0].to_crew_task(&Caveats::top());
assert_eq!(task.goal, "write the module");
assert_eq!(task.context, vec!["src/lib.rs".to_string()]);
assert_eq!(task.caveats.fs_write, Scope::only(["src/".to_string()]));
assert_eq!(task.caveats.fs_read, Scope::none());
assert_eq!(task.caveats.exec, Scope::none());
assert_eq!(task.caveats.net, Scope::none());
}
#[test]
fn to_crew_task_never_widens_past_the_parent() {
let toml = r#"
[[subtask]]
id = "leaf"
instruction = "x"
[subtask.caveat_policy]
fs_read = "all"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let parent = Caveats {
fs_read: Scope::only(["a/".to_string()]),
..Caveats::top()
};
let task = plan.subtasks[0].to_crew_task(&parent);
assert_eq!(task.caveats.fs_read, Scope::only(["a/".to_string()]));
}
#[test]
fn plan_is_a_drivable_execution_state_machine() {
let toml = r#"
[[subtask]]
id = "epic"
instruction = "branch"
[[subtask]]
id = "a"
instruction = "step a"
parent = "epic"
[[subtask]]
id = "b"
instruction = "step b"
parent = "epic"
deps = ["a"]
[[subtask]]
id = "c"
instruction = "step c"
parent = "epic"
deps = ["b"]
"#;
let mut plan = Plan::from_toml_str(toml).unwrap();
let top = Caveats::top();
let mut order = Vec::new();
while let Some((id, task)) = plan.next_dispatch(&top) {
assert!(!task.goal.is_empty());
plan.mark(&id, SubtaskStatus::Running, None);
plan.mark(&id, SubtaskStatus::Done, Some(format!("ran {id}")));
order.push(id);
}
assert_eq!(order, vec!["a", "b", "c"]);
assert!(plan.is_complete());
assert_eq!(plan.subtask("a").unwrap().result.as_deref(), Some("ran a"));
}
#[test]
fn a_failed_leaf_blocks_its_dependents_and_stops_the_run() {
let toml = r#"
[[subtask]]
id = "a"
instruction = "x"
[[subtask]]
id = "b"
instruction = "y"
deps = ["a"]
"#;
let mut plan = Plan::from_toml_str(toml).unwrap();
let top = Caveats::top();
let (id, _task) = plan.next_dispatch(&top).expect("a is ready");
assert_eq!(id, "a");
plan.mark(&id, SubtaskStatus::Failed, Some("boom".into()));
assert!(plan.next_dispatch(&top).is_none());
assert!(!plan.is_complete());
}
#[test]
fn mark_is_a_noop_for_an_absent_id_and_empty_plan_is_complete() {
let mut plan = Plan::from_toml_str("[[subtask]]\nid=\"a\"\ninstruction=\"x\"\n").unwrap();
plan.mark("nope", SubtaskStatus::Done, Some("ignored".into()));
assert_eq!(plan.subtask("a").unwrap().status, SubtaskStatus::Pending);
assert!(Plan::from_toml_str("").unwrap().is_complete());
}
#[test]
fn node_kind_defaults_to_task_on_a_legacy_plan() {
let toml = r#"
[[subtask]]
id = "a"
instruction = "x"
[[subtask]]
id = "b"
instruction = "y"
deps = ["a"]
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert!(plan.subtasks.iter().all(|s| s.kind == NodeKind::Task));
assert_eq!(NodeKind::default(), NodeKind::Task);
}
#[test]
fn node_kind_and_artifact_ref_round_trip_through_toml() {
let toml = r#"
[[subtask]]
id = "plan-1"
instruction = "implement the parser"
kind = "plan"
conversation_id = "1720000000000-abc"
[[subtask]]
id = "task-1"
instruction = "commit the AST"
kind = "task"
parent = "plan-1"
[subtask.artifact_ref]
branch = "feat/parser"
commit = "deadbeef"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let p = plan.subtask("plan-1").unwrap();
assert_eq!(p.kind, NodeKind::Plan);
assert_eq!(p.conversation_id.as_deref(), Some("1720000000000-abc"));
let t = plan.subtask("task-1").unwrap();
assert_eq!(t.kind, NodeKind::Task);
assert_eq!(
t.artifact_ref.as_ref().unwrap().commit.as_deref(),
Some("deadbeef")
);
let back = Plan::from_toml_str(&plan.to_toml_string().unwrap()).unwrap();
assert_eq!(back, plan);
}
#[test]
fn next_ready_node_generalizes_the_cursor_to_branches() {
let toml = r#"
[[subtask]]
id = "phase-1"
instruction = "phase"
kind = "phase"
[[subtask]]
id = "t1"
instruction = "task 1"
kind = "task"
parent = "phase-1"
[[subtask]]
id = "t2"
instruction = "task 2"
kind = "task"
parent = "phase-1"
deps = ["t1"]
"#;
let mut plan = Plan::from_toml_str(toml).unwrap();
assert_eq!(plan.next_ready_node().map(|s| s.id.as_str()), Some("t1"));
plan.mark("t1", SubtaskStatus::Done, None);
assert_eq!(plan.next_ready_node().map(|s| s.id.as_str()), Some("t2"));
plan.mark("t2", SubtaskStatus::Done, None);
assert_eq!(
plan.next_ready_node().map(|s| s.id.as_str()),
Some("phase-1")
);
plan.mark("phase-1", SubtaskStatus::Done, None);
assert_eq!(plan.next_ready_node(), None);
}
#[test]
fn subtree_complete_tracks_the_whole_subtree() {
let toml = r#"
[[subtask]]
id = "p"
instruction = "parent"
kind = "phase"
[[subtask]]
id = "c1"
instruction = "child 1"
parent = "p"
[[subtask]]
id = "c2"
instruction = "child 2"
parent = "p"
"#;
let mut plan = Plan::from_toml_str(toml).unwrap();
assert!(!plan.subtree_complete("p"));
assert!(!plan.subtree_complete("absent"));
plan.mark("c1", SubtaskStatus::Done, None);
plan.mark("c2", SubtaskStatus::Done, None);
assert!(!plan.subtree_complete("p"));
plan.mark("p", SubtaskStatus::Done, None);
assert!(plan.subtree_complete("p"));
assert!(plan.subtree_complete("c1"));
}
#[test]
fn path_to_returns_the_root_to_node_breadcrumb() {
let toml = r#"
[[subtask]]
id = "road"
instruction = "roadmap"
kind = "roadmap"
[[subtask]]
id = "ph"
instruction = "phase"
kind = "phase"
parent = "road"
[[subtask]]
id = "pl"
instruction = "plan"
kind = "plan"
parent = "ph"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert_eq!(plan.path_to("pl"), ["road", "ph", "pl"]);
assert_eq!(plan.path_to("road"), ["road"]);
assert!(plan.path_to("absent").is_empty());
}
#[test]
fn nodes_of_kind_filters_by_kind() {
let toml = r#"
[[subtask]]
id = "road"
instruction = "roadmap"
kind = "roadmap"
[[subtask]]
id = "pl1"
instruction = "plan 1"
kind = "plan"
parent = "road"
[[subtask]]
id = "pl2"
instruction = "plan 2"
kind = "plan"
parent = "road"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let plans: Vec<_> = plan
.nodes_of_kind(NodeKind::Plan)
.iter()
.map(|s| s.id.clone())
.collect();
assert_eq!(plans, ["pl1", "pl2"]);
assert_eq!(plan.nodes_of_kind(NodeKind::Roadmap).len(), 1);
assert!(plan.nodes_of_kind(NodeKind::Task).is_empty());
}
#[test]
fn set_artifact_commit_binds_the_commit_preserving_pr_and_branch() {
let toml = r#"
[[subtask]]
id = "t1"
instruction = "task 1"
kind = "task"
[subtask.artifact_ref]
pr = 42
branch = "feat/x"
[[subtask]]
id = "t2"
instruction = "task 2"
kind = "task"
"#;
let mut plan = Plan::from_toml_str(toml).unwrap();
plan.set_artifact_commit("t1", "b56fefadeadbeef", None);
let a = plan.subtask("t1").unwrap().artifact_ref.as_ref().unwrap();
assert_eq!(a.commit.as_deref(), Some("b56fefadeadbeef"));
assert_eq!(a.branch.as_deref(), Some("feat/x"), "existing branch kept");
assert_eq!(a.pr, Some(42), "existing pr preserved");
plan.set_artifact_commit("t1", "cafef00d", Some("main"));
let a = plan.subtask("t1").unwrap().artifact_ref.as_ref().unwrap();
assert_eq!(a.commit.as_deref(), Some("cafef00d"));
assert_eq!(a.branch.as_deref(), Some("main"));
assert_eq!(a.pr, Some(42));
plan.set_artifact_commit("t2", "abc123", Some("main"));
let a = plan.subtask("t2").unwrap().artifact_ref.as_ref().unwrap();
assert_eq!(a.commit.as_deref(), Some("abc123"));
assert_eq!(a.branch.as_deref(), Some("main"));
assert_eq!(a.pr, None);
plan.set_artifact_commit("nope", "x", None);
assert!(plan.subtask("nope").is_none());
}
#[test]
fn set_artifact_issue_binds_the_issue_preserving_other_refs() {
let toml = r#"
[[subtask]]
id = "t1"
instruction = "task 1"
kind = "task"
[subtask.artifact_ref]
pr = 42
branch = "feat/x"
"#;
let mut plan = Plan::from_toml_str(toml).unwrap();
plan.set_artifact_issue("t1", 1083);
let a = plan.subtask("t1").unwrap().artifact_ref.as_ref().unwrap();
assert_eq!(a.issue, Some(1083));
assert_eq!(a.pr, Some(42), "existing pr preserved");
assert_eq!(a.branch.as_deref(), Some("feat/x"), "branch preserved");
let text = plan.to_toml_string().unwrap();
assert!(text.contains("issue = 1083"), "{text}");
let back = Plan::from_toml_str(&text).unwrap();
assert_eq!(
back.subtask("t1")
.unwrap()
.artifact_ref
.as_ref()
.unwrap()
.issue,
Some(1083)
);
plan.set_artifact_issue("nope", 1);
assert!(plan.subtask("nope").is_none());
}
#[test]
fn next_uncaptured_task_under_walks_the_plan_in_order() {
let toml = r#"
[[subtask]]
id = "ph"
instruction = "phase"
kind = "phase"
[[subtask]]
id = "pl"
instruction = "plan"
kind = "plan"
parent = "ph"
[[subtask]]
id = "t1"
instruction = "task 1"
kind = "task"
parent = "pl"
[[subtask]]
id = "t2"
instruction = "task 2"
kind = "task"
parent = "pl"
"#;
let mut plan = Plan::from_toml_str(toml).unwrap();
let id = |o: Option<&Subtask>| o.map(|s| s.id.clone());
assert_eq!(id(plan.next_uncaptured_task_under("pl")), Some("t1".into()));
plan.set_artifact_commit("t1", "abc", None);
assert_eq!(id(plan.next_uncaptured_task_under("pl")), Some("t2".into()));
plan.set_artifact_commit("t2", "def", None);
assert_eq!(plan.next_uncaptured_task_under("pl"), None);
assert!(plan.next_uncaptured_task_under("ph").is_none());
assert!(plan.next_uncaptured_task_under("t1").is_none());
assert!(plan.next_uncaptured_task_under("absent").is_none());
}
}