1use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use super::types::{DeterminismMode, Phase};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Plan {
11 pub plan: PlanHeader,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PlanHeader {
17 pub version: String,
19 pub plan_id: Uuid,
21 pub goal: String,
23 pub created_at: String,
25 pub created_by: String,
27 pub budget_estimate_usd: f64,
29 #[serde(default)]
31 pub determinism: DeterminismMode,
32 #[serde(default)]
34 pub content_sha256: String,
35 #[serde(default)]
37 pub signature: Option<String>,
38 #[serde(default = "default_max_escalations")]
40 pub max_escalations: u32,
41 #[serde(default)]
43 pub steps: Vec<Step>,
44}
45
46fn default_max_escalations() -> u32 {
47 3
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Step {
53 pub step_id: String,
55 pub description: String,
57 pub agent_hint: String,
59 pub phases: Vec<Phase>,
61 pub verify_command: String,
63 #[serde(default)]
65 pub depends_on: Vec<String>,
66
67 #[serde(default)]
70 pub budget_estimate_usd: Option<f64>,
71 #[serde(default)]
73 pub timeout_secs: Option<u64>,
74 #[serde(default)]
76 pub skill_ref: Option<String>,
77 #[serde(default)]
79 pub determinism: Option<DeterminismMode>,
80 #[serde(default)]
82 pub input: Option<toml::Table>,
83 #[serde(default)]
85 pub judge: bool,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum PlanValidationError {
91 UnknownDependency { step_id: String, referenced: String },
92 CycleDetected { cycle: Vec<String> },
93 EmptyPhases { step_id: String },
94 MissingVerifyCommand { step_id: String },
95 PhasesOutOfOrder { step_id: String, phases: Vec<Phase> },
96 MissingContentHash,
97}
98
99impl std::fmt::Display for PlanValidationError {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 match self {
102 PlanValidationError::UnknownDependency {
103 step_id,
104 referenced,
105 } => write!(
106 f,
107 "step '{}' depends on unknown step '{}'",
108 step_id, referenced
109 ),
110 PlanValidationError::CycleDetected { cycle } => {
111 write!(f, "dependency cycle detected: {}", cycle.join(" → "))
112 }
113 PlanValidationError::EmptyPhases { step_id } => {
114 write!(f, "step '{}' has no phases", step_id)
115 }
116 PlanValidationError::MissingVerifyCommand { step_id } => {
117 write!(f, "step '{}' has empty verify_command", step_id)
118 }
119 PlanValidationError::PhasesOutOfOrder { step_id, phases } => {
120 write!(
121 f,
122 "step '{}' phases not in SDLC order: {:?}",
123 step_id, phases
124 )
125 }
126 PlanValidationError::MissingContentHash => {
127 write!(
128 f,
129 "content_sha256 is empty — call compute_content_sha256() first"
130 )
131 }
132 }
133 }
134}
135
136impl Plan {
137 pub fn validate(&self) -> Vec<PlanValidationError> {
139 let mut errors = Vec::new();
140 let step_ids: std::collections::HashSet<&str> =
141 self.plan.steps.iter().map(|s| s.step_id.as_str()).collect();
142
143 for step in &self.plan.steps {
145 for dep in &step.depends_on {
146 if !step_ids.contains(dep.as_str()) {
147 errors.push(PlanValidationError::UnknownDependency {
148 step_id: step.step_id.clone(),
149 referenced: dep.clone(),
150 });
151 }
152 }
153 }
154 if let Some(cycle) = detect_cycle(&self.plan.steps) {
155 errors.push(PlanValidationError::CycleDetected { cycle });
156 }
157
158 for step in &self.plan.steps {
160 if step.phases.is_empty() {
161 errors.push(PlanValidationError::EmptyPhases {
162 step_id: step.step_id.clone(),
163 });
164 }
165 }
166
167 for step in &self.plan.steps {
169 if step.verify_command.trim().is_empty() {
170 errors.push(PlanValidationError::MissingVerifyCommand {
171 step_id: step.step_id.clone(),
172 });
173 }
174 }
175
176 for step in &self.plan.steps {
178 let mut prev_idx: Option<u8> = None;
179 for phase in &step.phases {
180 let idx = phase.sdlc_index();
181 if let Some(p) = prev_idx
182 && idx <= p
183 {
184 errors.push(PlanValidationError::PhasesOutOfOrder {
185 step_id: step.step_id.clone(),
186 phases: step.phases.clone(),
187 });
188 break;
189 }
190 prev_idx = Some(idx);
191 }
192 }
193
194 if self.plan.content_sha256.is_empty() {
196 errors.push(PlanValidationError::MissingContentHash);
197 }
198
199 errors
200 }
201
202 pub fn compute_content_sha256(&self) -> String {
204 use sha2::{Digest, Sha256};
205 let canonical = self.canonical_toml_bytes();
206 hex::encode(Sha256::digest(&canonical))
207 }
208
209 fn canonical_toml_bytes(&self) -> Vec<u8> {
212 let clean = CleanPlan {
213 plan: CleanPlanHeader {
214 version: self.plan.version.clone(),
215 plan_id: self.plan.plan_id,
216 goal: self.plan.goal.clone(),
217 created_at: self.plan.created_at.clone(),
218 created_by: self.plan.created_by.clone(),
219 budget_estimate_usd: self.plan.budget_estimate_usd,
220 determinism: self.plan.determinism,
221 max_escalations: self.plan.max_escalations,
222 steps: self
223 .plan
224 .steps
225 .iter()
226 .map(|s| CleanStep {
227 step_id: s.step_id.clone(),
228 description: s.description.clone(),
229 agent_hint: s.agent_hint.clone(),
230 phases: s.phases.clone(),
231 verify_command: s.verify_command.clone(),
232 depends_on: s.depends_on.clone(),
233 budget_estimate_usd: s.budget_estimate_usd,
234 timeout_secs: s.timeout_secs,
235 skill_ref: s.skill_ref.clone(),
236 determinism: s.determinism,
237 input: s.input.clone(),
238 })
239 .collect(),
240 },
241 };
242 toml::to_string(&clean).unwrap().into_bytes()
243 }
244}
245
246#[derive(Debug, Clone, Serialize)]
249struct CleanPlan {
250 plan: CleanPlanHeader,
251}
252
253#[derive(Debug, Clone, Serialize)]
254struct CleanPlanHeader {
255 version: String,
256 plan_id: Uuid,
257 goal: String,
258 created_at: String,
259 created_by: String,
260 budget_estimate_usd: f64,
261 determinism: DeterminismMode,
262 max_escalations: u32,
263 #[serde(default, skip_serializing_if = "Vec::is_empty")]
264 steps: Vec<CleanStep>,
265}
266
267#[derive(Debug, Clone, Serialize)]
268struct CleanStep {
269 step_id: String,
270 description: String,
271 agent_hint: String,
272 phases: Vec<Phase>,
273 verify_command: String,
274 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 depends_on: Vec<String>,
276 #[serde(skip_serializing_if = "Option::is_none")]
277 budget_estimate_usd: Option<f64>,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 timeout_secs: Option<u64>,
280 #[serde(skip_serializing_if = "Option::is_none")]
281 skill_ref: Option<String>,
282 #[serde(skip_serializing_if = "Option::is_none")]
283 determinism: Option<DeterminismMode>,
284 #[serde(skip_serializing_if = "Option::is_none")]
285 input: Option<toml::Table>,
286}
287
288fn detect_cycle(steps: &[Step]) -> Option<Vec<String>> {
291 use std::collections::{HashMap, VecDeque};
292
293 let step_ids: Vec<&str> = steps.iter().map(|s| s.step_id.as_str()).collect();
294 let id_to_idx: HashMap<&str, usize> = step_ids
295 .iter()
296 .enumerate()
297 .map(|(i, &id)| (id, i))
298 .collect();
299
300 let n = steps.len();
301 let mut in_degree = vec![0u32; n];
302 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
303
304 for (i, step) in steps.iter().enumerate() {
305 for dep in &step.depends_on {
306 if let Some(&j) = id_to_idx.get(dep.as_str()) {
307 adj[j].push(i);
308 in_degree[i] += 1;
309 }
310 }
311 }
312
313 let mut queue: VecDeque<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
314 let mut sorted = Vec::new();
315
316 while let Some(u) = queue.pop_front() {
317 sorted.push(u);
318 for &v in &adj[u] {
319 in_degree[v] -= 1;
320 if in_degree[v] == 0 {
321 queue.push_back(v);
322 }
323 }
324 }
325
326 if sorted.len() < n {
327 let cycle_nodes: Vec<String> = (0..n)
328 .filter(|i| in_degree[*i] > 0)
329 .map(|i| step_ids[i].to_string())
330 .collect();
331 Some(cycle_nodes)
332 } else {
333 None
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::coordination::types::*;
341 use uuid::Uuid;
342
343 #[test]
344 fn test_parse_minimal_plan() {
345 let toml = r#"
346[plan]
347version = "0"
348plan_id = "550e8400-e29b-41d4-a716-446655440000"
349goal = "Add Stripe webhook handler"
350created_at = "2026-05-24T12:00:00Z"
351created_by = "agent:commander-planner"
352budget_estimate_usd = 2.50
353determinism = "best-effort"
354content_sha256 = "abc123"
355
356[[plan.steps]]
357step_id = "step_001"
358description = "Implement webhook signature validator"
359agent_hint = "code-review"
360phases = ["plan", "design", "implement", "test", "verify"]
361verify_command = "cargo test --lib webhook_validator"
362depends_on = []
363
364[[plan.steps]]
365step_id = "step_002"
366description = "Deploy to staging"
367agent_hint = "generic"
368phases = ["plan", "implement", "verify"]
369verify_command = "curl -fsS https://staging.example.com/health"
370depends_on = ["step_001"]
371"#;
372 let plan: Plan = toml::from_str(toml).expect("parse valid plan");
373 assert_eq!(plan.plan.version, "0");
374 assert_eq!(
375 plan.plan.plan_id,
376 "550e8400-e29b-41d4-a716-446655440000"
377 .parse::<Uuid>()
378 .unwrap()
379 );
380 assert_eq!(plan.plan.goal, "Add Stripe webhook handler");
381 assert_eq!(plan.plan.determinism, DeterminismMode::BestEffort);
382 assert_eq!(plan.plan.steps.len(), 2);
383 assert_eq!(plan.plan.steps[0].step_id, "step_001");
384 assert_eq!(plan.plan.steps[0].agent_hint, "code-review");
385 assert_eq!(
386 plan.plan.steps[0].phases,
387 vec![
388 Phase::Plan,
389 Phase::Design,
390 Phase::Implement,
391 Phase::Test,
392 Phase::Verify,
393 ]
394 );
395 assert!(plan.plan.steps[0].depends_on.is_empty());
396 assert_eq!(plan.plan.steps[1].depends_on, vec!["step_001".to_string()]);
397 }
398
399 #[test]
400 fn test_parse_defaults() {
401 let toml = r#"
402[plan]
403version = "0"
404plan_id = "550e8400-e29b-41d4-a716-446655440000"
405goal = "test"
406created_at = "2026-05-24T12:00:00Z"
407created_by = "agent:test"
408budget_estimate_usd = 0.0
409determinism = "best-effort"
410content_sha256 = "abc"
411
412[[plan.steps]]
413step_id = "s1"
414description = "test step"
415agent_hint = "generic"
416phases = ["verify"]
417verify_command = "true"
418depends_on = []
419"#;
420 let plan: Plan = toml::from_str(toml).expect("parse defaults");
421 assert!(plan.plan.signature.is_none());
422 assert!(plan.plan.steps[0].skill_ref.is_none());
423 assert_eq!(plan.plan.steps[0].budget_estimate_usd, None);
424 assert_eq!(plan.plan.steps[0].timeout_secs, None);
425 assert_eq!(plan.plan.determinism, DeterminismMode::BestEffort);
426 }
427
428 #[test]
429 fn test_reject_unknown_phase() {
430 let toml = r#"
431[plan]
432version = "0"
433plan_id = "550e8400-e29b-41d4-a716-446655440000"
434goal = "test"
435created_at = "2026-05-24T12:00:00Z"
436created_by = "agent:test"
437budget_estimate_usd = 0.0
438determinism = "best-effort"
439content_sha256 = "abc"
440
441[[plan.steps]]
442step_id = "s1"
443description = "bad phase"
444agent_hint = "generic"
445phases = ["unknown_phase"]
446verify_command = "true"
447depends_on = []
448"#;
449 let result = toml::from_str::<Plan>(toml);
450 assert!(result.is_err(), "unknown phase must reject");
451 }
452
453 #[test]
454 fn test_reject_missing_verify_command() {
455 let toml = r#"
456[plan]
457version = "0"
458plan_id = "550e8400-e29b-41d4-a716-446655440000"
459goal = "test"
460created_at = "2026-05-24T12:00:00Z"
461created_by = "agent:test"
462budget_estimate_usd = 0.0
463determinism = "best-effort"
464content_sha256 = "abc"
465
466[[plan.steps]]
467step_id = "s1"
468description = "no verify"
469agent_hint = "generic"
470phases = ["plan"]
471verify_command = ""
472depends_on = []
473"#;
474 let plan = toml::from_str::<Plan>(toml).expect("parse ok");
475 let errors = plan.validate();
476 assert!(
477 !errors.is_empty(),
478 "empty verify_command must fail validation"
479 );
480 }
481
482 #[test]
483 fn test_reject_missing_phases() {
484 let toml = r#"
485[plan]
486version = "0"
487plan_id = "550e8400-e29b-41d4-a716-446655440000"
488goal = "test"
489created_at = "2026-05-24T12:00:00Z"
490created_by = "agent:test"
491budget_estimate_usd = 0.0
492determinism = "best-effort"
493content_sha256 = "abc"
494
495[[plan.steps]]
496step_id = "s1"
497description = "no phases"
498agent_hint = "generic"
499phases = []
500verify_command = "true"
501depends_on = []
502"#;
503 let plan = toml::from_str::<Plan>(toml).expect("parse ok");
504 let errors = plan.validate();
505 assert!(!errors.is_empty(), "empty phases must fail validation");
506 }
507
508 #[test]
509 fn test_reject_dependency_cycle() {
510 let toml = r#"
511[plan]
512version = "0"
513plan_id = "550e8400-e29b-41d4-a716-446655440000"
514goal = "test"
515created_at = "2026-05-24T12:00:00Z"
516created_by = "agent:test"
517budget_estimate_usd = 0.0
518determinism = "best-effort"
519content_sha256 = "abc"
520
521[[plan.steps]]
522step_id = "s1"
523description = "step 1"
524agent_hint = "generic"
525phases = ["verify"]
526verify_command = "true"
527depends_on = ["s2"]
528
529[[plan.steps]]
530step_id = "s2"
531description = "step 2"
532agent_hint = "generic"
533phases = ["verify"]
534verify_command = "true"
535depends_on = ["s1"]
536"#;
537 let plan = toml::from_str::<Plan>(toml).expect("parse ok");
538 let errors = plan.validate();
539 assert!(!errors.is_empty(), "cycle must fail validation");
540 let has_cycle = errors
541 .iter()
542 .any(|e| matches!(e, PlanValidationError::CycleDetected { .. }));
543 assert!(has_cycle, "error must mention cycle, got: {:?}", errors);
544 }
545
546 #[test]
547 fn test_validate_valid_plan() {
548 let toml = r#"
549[plan]
550version = "0"
551plan_id = "550e8400-e29b-41d4-a716-446655440000"
552goal = "valid plan"
553created_at = "2026-05-24T12:00:00Z"
554created_by = "agent:test"
555budget_estimate_usd = 0.0
556determinism = "best-effort"
557content_sha256 = "abc123"
558
559[[plan.steps]]
560step_id = "build"
561description = "build"
562agent_hint = "generic"
563phases = ["plan", "implement", "verify"]
564verify_command = "cargo build"
565depends_on = []
566
567[[plan.steps]]
568step_id = "test"
569description = "test"
570agent_hint = "code-review"
571phases = ["test", "verify"]
572verify_command = "cargo test"
573depends_on = ["build"]
574"#;
575 let plan: Plan = toml::from_str(toml).expect("parse valid plan");
576 let errors = plan.validate();
577 assert!(
578 errors.is_empty(),
579 "validation must pass for valid plan, got: {:?}",
580 errors
581 );
582 }
583
584 #[test]
585 fn test_validate_unknown_dependency() {
586 let toml = r#"
587[plan]
588version = "0"
589plan_id = "550e8400-e29b-41d4-a716-446655440000"
590goal = "test"
591created_at = "2026-05-24T12:00:00Z"
592created_by = "agent:test"
593budget_estimate_usd = 0.0
594determinism = "best-effort"
595content_sha256 = "abc"
596
597[[plan.steps]]
598step_id = "s1"
599description = "step 1"
600agent_hint = "generic"
601phases = ["verify"]
602verify_command = "true"
603depends_on = ["nonexistent_step"]
604"#;
605 let plan = toml::from_str::<Plan>(toml).expect("parse ok");
606 let errors = plan.validate();
607 assert!(!errors.is_empty(), "unknown dep must fail validation");
608 }
609
610 #[test]
611 fn test_content_sha256_computation() {
612 let toml = r#"
613[plan]
614version = "0"
615plan_id = "550e8400-e29b-41d4-a716-446655440000"
616goal = "test"
617created_at = "2026-05-24T12:00:00Z"
618created_by = "agent:test"
619budget_estimate_usd = 0.0
620determinism = "best-effort"
621
622[[plan.steps]]
623step_id = "s1"
624description = "step 1"
625agent_hint = "generic"
626phases = ["verify"]
627verify_command = "true"
628depends_on = []
629"#;
630 let plan = toml::from_str::<Plan>(toml).expect("parse ok");
631 let hash = plan.compute_content_sha256();
632 assert_eq!(hash.len(), 64);
633 let hash2 = plan.compute_content_sha256();
634 assert_eq!(hash, hash2);
635 }
636}