use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::types::{DeterminismMode, Phase};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plan {
pub plan: PlanHeader,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanHeader {
pub version: String,
pub plan_id: Uuid,
pub goal: String,
pub created_at: String,
pub created_by: String,
pub budget_estimate_usd: f64,
#[serde(default)]
pub determinism: DeterminismMode,
#[serde(default)]
pub content_sha256: String,
#[serde(default)]
pub signature: Option<String>,
#[serde(default = "default_max_escalations")]
pub max_escalations: u32,
#[serde(default)]
pub steps: Vec<Step>,
}
fn default_max_escalations() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Step {
pub step_id: String,
pub description: String,
pub agent_hint: String,
pub phases: Vec<Phase>,
pub verify_command: String,
#[serde(default)]
pub depends_on: Vec<String>,
#[serde(default)]
pub budget_estimate_usd: Option<f64>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub skill_ref: Option<String>,
#[serde(default)]
pub determinism: Option<DeterminismMode>,
#[serde(default)]
pub input: Option<toml::Table>,
#[serde(default)]
pub judge: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanValidationError {
UnknownDependency { step_id: String, referenced: String },
CycleDetected { cycle: Vec<String> },
EmptyPhases { step_id: String },
MissingVerifyCommand { step_id: String },
PhasesOutOfOrder { step_id: String, phases: Vec<Phase> },
MissingContentHash,
}
impl std::fmt::Display for PlanValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlanValidationError::UnknownDependency {
step_id,
referenced,
} => write!(
f,
"step '{}' depends on unknown step '{}'",
step_id, referenced
),
PlanValidationError::CycleDetected { cycle } => {
write!(f, "dependency cycle detected: {}", cycle.join(" → "))
}
PlanValidationError::EmptyPhases { step_id } => {
write!(f, "step '{}' has no phases", step_id)
}
PlanValidationError::MissingVerifyCommand { step_id } => {
write!(f, "step '{}' has empty verify_command", step_id)
}
PlanValidationError::PhasesOutOfOrder { step_id, phases } => {
write!(
f,
"step '{}' phases not in SDLC order: {:?}",
step_id, phases
)
}
PlanValidationError::MissingContentHash => {
write!(
f,
"content_sha256 is empty — call compute_content_sha256() first"
)
}
}
}
}
impl Plan {
pub fn validate(&self) -> Vec<PlanValidationError> {
let mut errors = Vec::new();
let step_ids: std::collections::HashSet<&str> =
self.plan.steps.iter().map(|s| s.step_id.as_str()).collect();
for step in &self.plan.steps {
for dep in &step.depends_on {
if !step_ids.contains(dep.as_str()) {
errors.push(PlanValidationError::UnknownDependency {
step_id: step.step_id.clone(),
referenced: dep.clone(),
});
}
}
}
if let Some(cycle) = detect_cycle(&self.plan.steps) {
errors.push(PlanValidationError::CycleDetected { cycle });
}
for step in &self.plan.steps {
if step.phases.is_empty() {
errors.push(PlanValidationError::EmptyPhases {
step_id: step.step_id.clone(),
});
}
}
for step in &self.plan.steps {
if step.verify_command.trim().is_empty() {
errors.push(PlanValidationError::MissingVerifyCommand {
step_id: step.step_id.clone(),
});
}
}
for step in &self.plan.steps {
let mut prev_idx: Option<u8> = None;
for phase in &step.phases {
let idx = phase.sdlc_index();
if let Some(p) = prev_idx
&& idx <= p
{
errors.push(PlanValidationError::PhasesOutOfOrder {
step_id: step.step_id.clone(),
phases: step.phases.clone(),
});
break;
}
prev_idx = Some(idx);
}
}
if self.plan.content_sha256.is_empty() {
errors.push(PlanValidationError::MissingContentHash);
}
errors
}
pub fn compute_content_sha256(&self) -> String {
use sha2::{Digest, Sha256};
let canonical = self.canonical_toml_bytes();
hex::encode(Sha256::digest(&canonical))
}
fn canonical_toml_bytes(&self) -> Vec<u8> {
let clean = CleanPlan {
plan: CleanPlanHeader {
version: self.plan.version.clone(),
plan_id: self.plan.plan_id,
goal: self.plan.goal.clone(),
created_at: self.plan.created_at.clone(),
created_by: self.plan.created_by.clone(),
budget_estimate_usd: self.plan.budget_estimate_usd,
determinism: self.plan.determinism,
max_escalations: self.plan.max_escalations,
steps: self
.plan
.steps
.iter()
.map(|s| CleanStep {
step_id: s.step_id.clone(),
description: s.description.clone(),
agent_hint: s.agent_hint.clone(),
phases: s.phases.clone(),
verify_command: s.verify_command.clone(),
depends_on: s.depends_on.clone(),
budget_estimate_usd: s.budget_estimate_usd,
timeout_secs: s.timeout_secs,
skill_ref: s.skill_ref.clone(),
determinism: s.determinism,
input: s.input.clone(),
})
.collect(),
},
};
toml::to_string(&clean).unwrap().into_bytes()
}
}
#[derive(Debug, Clone, Serialize)]
struct CleanPlan {
plan: CleanPlanHeader,
}
#[derive(Debug, Clone, Serialize)]
struct CleanPlanHeader {
version: String,
plan_id: Uuid,
goal: String,
created_at: String,
created_by: String,
budget_estimate_usd: f64,
determinism: DeterminismMode,
max_escalations: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
steps: Vec<CleanStep>,
}
#[derive(Debug, Clone, Serialize)]
struct CleanStep {
step_id: String,
description: String,
agent_hint: String,
phases: Vec<Phase>,
verify_command: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
depends_on: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
budget_estimate_usd: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
skill_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
determinism: Option<DeterminismMode>,
#[serde(skip_serializing_if = "Option::is_none")]
input: Option<toml::Table>,
}
fn detect_cycle(steps: &[Step]) -> Option<Vec<String>> {
use std::collections::{HashMap, VecDeque};
let step_ids: Vec<&str> = steps.iter().map(|s| s.step_id.as_str()).collect();
let id_to_idx: HashMap<&str, usize> = step_ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
let n = steps.len();
let mut in_degree = vec![0u32; n];
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
for (i, step) in steps.iter().enumerate() {
for dep in &step.depends_on {
if let Some(&j) = id_to_idx.get(dep.as_str()) {
adj[j].push(i);
in_degree[i] += 1;
}
}
}
let mut queue: VecDeque<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
let mut sorted = Vec::new();
while let Some(u) = queue.pop_front() {
sorted.push(u);
for &v in &adj[u] {
in_degree[v] -= 1;
if in_degree[v] == 0 {
queue.push_back(v);
}
}
}
if sorted.len() < n {
let cycle_nodes: Vec<String> = (0..n)
.filter(|i| in_degree[*i] > 0)
.map(|i| step_ids[i].to_string())
.collect();
Some(cycle_nodes)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coordination::types::*;
use uuid::Uuid;
#[test]
fn test_parse_minimal_plan() {
let toml = r#"
[plan]
version = "0"
plan_id = "550e8400-e29b-41d4-a716-446655440000"
goal = "Add Stripe webhook handler"
created_at = "2026-05-24T12:00:00Z"
created_by = "agent:commander-planner"
budget_estimate_usd = 2.50
determinism = "best-effort"
content_sha256 = "abc123"
[[plan.steps]]
step_id = "step_001"
description = "Implement webhook signature validator"
agent_hint = "code-review"
phases = ["plan", "design", "implement", "test", "verify"]
verify_command = "cargo test --lib webhook_validator"
depends_on = []
[[plan.steps]]
step_id = "step_002"
description = "Deploy to staging"
agent_hint = "generic"
phases = ["plan", "implement", "verify"]
verify_command = "curl -fsS https://staging.example.com/health"
depends_on = ["step_001"]
"#;
let plan: Plan = toml::from_str(toml).expect("parse valid plan");
assert_eq!(plan.plan.version, "0");
assert_eq!(
plan.plan.plan_id,
"550e8400-e29b-41d4-a716-446655440000"
.parse::<Uuid>()
.unwrap()
);
assert_eq!(plan.plan.goal, "Add Stripe webhook handler");
assert_eq!(plan.plan.determinism, DeterminismMode::BestEffort);
assert_eq!(plan.plan.steps.len(), 2);
assert_eq!(plan.plan.steps[0].step_id, "step_001");
assert_eq!(plan.plan.steps[0].agent_hint, "code-review");
assert_eq!(
plan.plan.steps[0].phases,
vec![
Phase::Plan,
Phase::Design,
Phase::Implement,
Phase::Test,
Phase::Verify,
]
);
assert!(plan.plan.steps[0].depends_on.is_empty());
assert_eq!(plan.plan.steps[1].depends_on, vec!["step_001".to_string()]);
}
#[test]
fn test_parse_defaults() {
let toml = r#"
[plan]
version = "0"
plan_id = "550e8400-e29b-41d4-a716-446655440000"
goal = "test"
created_at = "2026-05-24T12:00:00Z"
created_by = "agent:test"
budget_estimate_usd = 0.0
determinism = "best-effort"
content_sha256 = "abc"
[[plan.steps]]
step_id = "s1"
description = "test step"
agent_hint = "generic"
phases = ["verify"]
verify_command = "true"
depends_on = []
"#;
let plan: Plan = toml::from_str(toml).expect("parse defaults");
assert!(plan.plan.signature.is_none());
assert!(plan.plan.steps[0].skill_ref.is_none());
assert_eq!(plan.plan.steps[0].budget_estimate_usd, None);
assert_eq!(plan.plan.steps[0].timeout_secs, None);
assert_eq!(plan.plan.determinism, DeterminismMode::BestEffort);
}
#[test]
fn test_reject_unknown_phase() {
let toml = r#"
[plan]
version = "0"
plan_id = "550e8400-e29b-41d4-a716-446655440000"
goal = "test"
created_at = "2026-05-24T12:00:00Z"
created_by = "agent:test"
budget_estimate_usd = 0.0
determinism = "best-effort"
content_sha256 = "abc"
[[plan.steps]]
step_id = "s1"
description = "bad phase"
agent_hint = "generic"
phases = ["unknown_phase"]
verify_command = "true"
depends_on = []
"#;
let result = toml::from_str::<Plan>(toml);
assert!(result.is_err(), "unknown phase must reject");
}
#[test]
fn test_reject_missing_verify_command() {
let toml = r#"
[plan]
version = "0"
plan_id = "550e8400-e29b-41d4-a716-446655440000"
goal = "test"
created_at = "2026-05-24T12:00:00Z"
created_by = "agent:test"
budget_estimate_usd = 0.0
determinism = "best-effort"
content_sha256 = "abc"
[[plan.steps]]
step_id = "s1"
description = "no verify"
agent_hint = "generic"
phases = ["plan"]
verify_command = ""
depends_on = []
"#;
let plan = toml::from_str::<Plan>(toml).expect("parse ok");
let errors = plan.validate();
assert!(
!errors.is_empty(),
"empty verify_command must fail validation"
);
}
#[test]
fn test_reject_missing_phases() {
let toml = r#"
[plan]
version = "0"
plan_id = "550e8400-e29b-41d4-a716-446655440000"
goal = "test"
created_at = "2026-05-24T12:00:00Z"
created_by = "agent:test"
budget_estimate_usd = 0.0
determinism = "best-effort"
content_sha256 = "abc"
[[plan.steps]]
step_id = "s1"
description = "no phases"
agent_hint = "generic"
phases = []
verify_command = "true"
depends_on = []
"#;
let plan = toml::from_str::<Plan>(toml).expect("parse ok");
let errors = plan.validate();
assert!(!errors.is_empty(), "empty phases must fail validation");
}
#[test]
fn test_reject_dependency_cycle() {
let toml = r#"
[plan]
version = "0"
plan_id = "550e8400-e29b-41d4-a716-446655440000"
goal = "test"
created_at = "2026-05-24T12:00:00Z"
created_by = "agent:test"
budget_estimate_usd = 0.0
determinism = "best-effort"
content_sha256 = "abc"
[[plan.steps]]
step_id = "s1"
description = "step 1"
agent_hint = "generic"
phases = ["verify"]
verify_command = "true"
depends_on = ["s2"]
[[plan.steps]]
step_id = "s2"
description = "step 2"
agent_hint = "generic"
phases = ["verify"]
verify_command = "true"
depends_on = ["s1"]
"#;
let plan = toml::from_str::<Plan>(toml).expect("parse ok");
let errors = plan.validate();
assert!(!errors.is_empty(), "cycle must fail validation");
let has_cycle = errors
.iter()
.any(|e| matches!(e, PlanValidationError::CycleDetected { .. }));
assert!(has_cycle, "error must mention cycle, got: {:?}", errors);
}
#[test]
fn test_validate_valid_plan() {
let toml = r#"
[plan]
version = "0"
plan_id = "550e8400-e29b-41d4-a716-446655440000"
goal = "valid plan"
created_at = "2026-05-24T12:00:00Z"
created_by = "agent:test"
budget_estimate_usd = 0.0
determinism = "best-effort"
content_sha256 = "abc123"
[[plan.steps]]
step_id = "build"
description = "build"
agent_hint = "generic"
phases = ["plan", "implement", "verify"]
verify_command = "cargo build"
depends_on = []
[[plan.steps]]
step_id = "test"
description = "test"
agent_hint = "code-review"
phases = ["test", "verify"]
verify_command = "cargo test"
depends_on = ["build"]
"#;
let plan: Plan = toml::from_str(toml).expect("parse valid plan");
let errors = plan.validate();
assert!(
errors.is_empty(),
"validation must pass for valid plan, got: {:?}",
errors
);
}
#[test]
fn test_validate_unknown_dependency() {
let toml = r#"
[plan]
version = "0"
plan_id = "550e8400-e29b-41d4-a716-446655440000"
goal = "test"
created_at = "2026-05-24T12:00:00Z"
created_by = "agent:test"
budget_estimate_usd = 0.0
determinism = "best-effort"
content_sha256 = "abc"
[[plan.steps]]
step_id = "s1"
description = "step 1"
agent_hint = "generic"
phases = ["verify"]
verify_command = "true"
depends_on = ["nonexistent_step"]
"#;
let plan = toml::from_str::<Plan>(toml).expect("parse ok");
let errors = plan.validate();
assert!(!errors.is_empty(), "unknown dep must fail validation");
}
#[test]
fn test_content_sha256_computation() {
let toml = r#"
[plan]
version = "0"
plan_id = "550e8400-e29b-41d4-a716-446655440000"
goal = "test"
created_at = "2026-05-24T12:00:00Z"
created_by = "agent:test"
budget_estimate_usd = 0.0
determinism = "best-effort"
[[plan.steps]]
step_id = "s1"
description = "step 1"
agent_hint = "generic"
phases = ["verify"]
verify_command = "true"
depends_on = []
"#;
let plan = toml::from_str::<Plan>(toml).expect("parse ok");
let hash = plan.compute_content_sha256();
assert_eq!(hash.len(), 64);
let hash2 = plan.compute_content_sha256();
assert_eq!(hash, hash2);
}
}