use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanOutput {
pub meta: PlanMeta,
pub script: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanMeta {
pub phases: Vec<PhaseMeta>,
#[serde(default)]
pub reasoning: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseMeta {
pub label: String,
pub detail: String,
#[serde(default)]
pub agents: u32,
#[serde(default)]
pub depends_on: Vec<u32>,
}
#[derive(Debug, Clone)]
pub struct PlannedWorkflow {
pub phases: Vec<PhaseMeta>,
pub script: String,
pub reasoning: String,
}
impl From<PlanOutput> for PlannedWorkflow {
fn from(output: PlanOutput) -> Self {
Self {
phases: output.meta.phases,
script: output.script,
reasoning: output.meta.reasoning,
}
}
}
impl PlannedWorkflow {
pub fn from_script(script: String) -> Self {
Self {
phases: Vec::new(),
script,
reasoning: String::new(),
}
}
}
#[derive(Debug, Default)]
pub struct ValidationResult {
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
impl ValidationResult {
pub fn new() -> Self {
Self::default()
}
pub fn add_error(&mut self, msg: impl Into<String>) {
self.errors.push(msg.into());
}
pub fn add_warning(&mut self, msg: impl Into<String>) {
self.warnings.push(msg.into());
}
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
}
impl fmt::Display for ValidationResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.errors.is_empty() && self.warnings.is_empty() {
return write!(f, "valid");
}
if !self.errors.is_empty() {
write!(f, "errors: {}", self.errors.join("; "))?;
}
if !self.warnings.is_empty() {
if !self.errors.is_empty() {
write!(f, "; ")?;
}
write!(f, "warnings: {}", self.warnings.join("; "))?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub enum PlanningState {
Thinking,
Generating,
Validating,
Done(PlannedWorkflow),
Error(String),
}
impl fmt::Display for PlanningState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PlanningState::Thinking => write!(f, "thinking"),
PlanningState::Generating => write!(f, "generating"),
PlanningState::Validating => write!(f, "validating"),
PlanningState::Done(_) => write!(f, "done"),
PlanningState::Error(e) => write!(f, "error: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plan_output_deserialization() {
let json = r#"{
"meta": {
"phases": [
{ "label": "discovery", "detail": "find files", "agents": 1, "depends_on": [] },
{ "label": "analysis", "detail": "analyze", "agents": 3, "depends_on": [0] }
],
"reasoning": "standard pipeline"
},
"script": "phase(\"discovery\", 0)\nreport({ok=true})"
}"#;
let plan: PlanOutput = serde_json::from_str(json).unwrap();
assert_eq!(plan.meta.phases.len(), 2);
assert_eq!(plan.meta.phases[1].depends_on, vec![0]);
assert!(plan.script.contains("report("));
}
#[test]
fn test_phase_meta_defaults() {
let json = r#"{
"meta": { "phases": [{ "label": "x", "detail": "y" }], "reasoning": "" },
"script": "report({})"
}"#;
let plan: PlanOutput = serde_json::from_str(json).unwrap();
let phase = &plan.meta.phases[0];
assert_eq!(phase.agents, 0);
assert!(phase.depends_on.is_empty());
}
#[test]
fn test_planned_workflow_from_plan_output() {
let output = PlanOutput {
meta: PlanMeta {
phases: vec![PhaseMeta {
label: "test".into(),
detail: "test phase".into(),
agents: 2,
depends_on: vec![],
}],
reasoning: "test reasoning".into(),
},
script: "report({})".into(),
};
let workflow = PlannedWorkflow::from(output);
assert_eq!(workflow.phases.len(), 1);
assert_eq!(workflow.script, "report({})");
assert_eq!(workflow.reasoning, "test reasoning");
}
#[test]
fn test_planned_workflow_from_script() {
let workflow = PlannedWorkflow::from_script("report({ok=true})".into());
assert!(workflow.phases.is_empty());
assert_eq!(workflow.script, "report({ok=true})");
assert!(workflow.reasoning.is_empty());
}
#[test]
fn test_validation_result_valid() {
let result = ValidationResult::new();
assert!(result.is_valid());
assert!(!result.has_warnings());
}
#[test]
fn test_validation_result_errors() {
let mut result = ValidationResult::new();
result.add_error("lua syntax error");
assert!(!result.is_valid());
assert!(!result.has_warnings());
assert!(result.errors.contains(&"lua syntax error".to_string()));
}
#[test]
fn test_validation_result_warnings() {
let mut result = ValidationResult::new();
result.add_warning("meta phase 'x' not found in script");
assert!(result.is_valid());
assert!(result.has_warnings());
}
#[test]
fn test_validation_result_display() {
let mut result = ValidationResult::new();
result.add_error("syntax error");
result.add_warning("no phases");
assert_eq!(result.to_string(), "errors: syntax error; warnings: no phases");
}
#[test]
fn test_planning_state_display() {
assert_eq!(PlanningState::Thinking.to_string(), "thinking");
assert_eq!(PlanningState::Generating.to_string(), "generating");
assert_eq!(PlanningState::Validating.to_string(), "validating");
assert_eq!(PlanningState::Done(PlannedWorkflow::from_script("".into())).to_string(), "done");
assert_eq!(
PlanningState::Error("boom".into()).to_string(),
"error: boom"
);
}
#[test]
fn test_planning_state_done_variant() {
let workflow = PlannedWorkflow::from_script("test script".into());
let state = PlanningState::Done(workflow.clone());
assert_eq!(state.to_string(), "done");
}
#[test]
fn test_validation_result_errors_and_warnings() {
let mut result = ValidationResult::new();
result.add_error("error 1");
result.add_warning("warning 1");
result.add_error("error 2");
assert_eq!(result.errors.len(), 2);
assert_eq!(result.warnings.len(), 1);
assert!(!result.is_valid());
assert!(result.has_warnings());
}
}