use std::collections::HashMap;
use serde::{Deserialize, Serialize};
pub type MatrixParams = HashMap<String, String>;
pub const MAX_MATRIX_PARAMS: usize = 256;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatrixConfig {
pub params: Vec<MatrixParams>,
#[serde(default = "default_max_parallel")]
pub max_parallel: usize,
#[serde(default = "default_matrix_fail_strategy")]
pub fail_strategy: FailStrategy,
}
fn default_max_parallel() -> usize {
1
}
fn default_matrix_fail_strategy() -> FailStrategy {
FailStrategy::Continue
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowDefinition {
pub name: String,
pub project: String,
#[serde(default)]
pub schedule: Option<String>, pub repo_path: String,
#[serde(default = "default_base_branch")]
pub base_branch: String,
#[serde(default = "default_flow_timeout")]
pub timeout_secs: u64,
#[serde(default)]
pub steps: Vec<FlowStepDef>,
}
fn default_base_branch() -> String {
"main".to_string()
}
fn default_flow_timeout() -> u64 {
3600 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowStepDef {
pub name: String,
pub kind: StepKind,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub cli_tool: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub task: Option<String>,
#[serde(default)]
pub task_file: Option<String>,
#[serde(default)]
pub condition: Option<String>,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default)]
pub on_fail: FailStrategy,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub persona: Option<String>,
#[serde(default)]
pub matrix: Option<MatrixConfig>,
}
fn default_timeout() -> u64 {
600
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepKind {
Action,
Agent,
Gate,
Checkpoint,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailStrategy {
#[default]
Abort,
SkipFailed,
Continue,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_matrix_config_parse() {
let toml_str = r#"
name = "experiment"
project = "default"
repo_path = "/tmp/repo"
[[steps]]
name = "run-model"
kind = "agent"
cli_tool = "claude"
task = "run task with {{matrix.model}} using prompt {{matrix.prompt}}"
[steps.matrix]
max_parallel = 2
fail_strategy = "continue"
[[steps.matrix.params]]
model = "sonnet"
prompt = "caution-first"
[[steps.matrix.params]]
model = "haiku"
prompt = "fast"
"#;
let flow: FlowDefinition = toml::from_str(toml_str).unwrap();
assert_eq!(flow.steps.len(), 1);
let step = &flow.steps[0];
let matrix = step.matrix.as_ref().expect("matrix should be present");
assert_eq!(matrix.params.len(), 2);
assert_eq!(matrix.max_parallel, 2);
assert_eq!(matrix.fail_strategy, FailStrategy::Continue);
assert_eq!(matrix.params[0]["model"], "sonnet");
assert_eq!(matrix.params[0]["prompt"], "caution-first");
assert_eq!(matrix.params[1]["model"], "haiku");
assert_eq!(matrix.params[1]["prompt"], "fast");
}
#[test]
fn test_matrix_config_defaults() {
let toml_str = r#"
name = "test"
project = "default"
repo_path = "/tmp"
[[steps]]
name = "step"
kind = "action"
command = "echo {{matrix.val}}"
[[steps.matrix.params]]
val = "hello"
"#;
let flow: FlowDefinition = toml::from_str(toml_str).unwrap();
let matrix = flow.steps[0].matrix.as_ref().unwrap();
assert_eq!(matrix.max_parallel, 1); assert_eq!(matrix.fail_strategy, FailStrategy::Continue); }
#[test]
fn test_step_without_matrix_is_none() {
let toml_str = r#"
name = "test"
project = "default"
repo_path = "/tmp"
[[steps]]
name = "plain"
kind = "action"
command = "echo hello"
"#;
let flow: FlowDefinition = toml::from_str(toml_str).unwrap();
assert!(flow.steps[0].matrix.is_none());
}
#[test]
fn test_flow_config_parse_minimal() {
let toml_str = r#"
name = "test-flow"
project = "default"
repo_path = "/tmp/repo"
[[steps]]
name = "build"
kind = "action"
command = "cargo build"
"#;
let flow: FlowDefinition = toml::from_str(toml_str).unwrap();
assert_eq!(flow.name, "test-flow");
assert_eq!(flow.project, "default");
assert_eq!(flow.repo_path, "/tmp/repo");
assert_eq!(flow.base_branch, "main"); assert!(flow.schedule.is_none());
assert_eq!(flow.steps.len(), 1);
let step = &flow.steps[0];
assert_eq!(step.name, "build");
assert_eq!(step.kind, StepKind::Action);
assert_eq!(step.command, Some("cargo build".to_string()));
assert_eq!(step.timeout_secs, 600); assert_eq!(step.on_fail, FailStrategy::Abort); assert!(step.matrix.is_none());
}
#[test]
fn test_flow_config_parse_full() {
let toml_str = r#"
name = "compound-review-v2"
project = "terraphim"
schedule = "0 2 * * *"
repo_path = "/home/user/project"
base_branch = "develop"
[[steps]]
name = "gather-changes"
kind = "action"
command = "git diff main..HEAD"
timeout_secs = 300
on_fail = "abort"
[[steps]]
name = "analyze-architecture"
kind = "agent"
cli_tool = "claude"
model = "sonnet"
task = "Review the architecture changes"
task_file = "/prompts/arch.md"
timeout_secs = 900
on_fail = "skip_failed"
provider = "anthropic"
persona = "architect"
[[steps]]
name = "check-quality"
kind = "agent"
cli_tool = "opencode"
model = "k2p5"
task = "Check code quality"
timeout_secs = 600
on_fail = "continue"
provider = "kimi-for-coding"
[[steps]]
name = "gate-approval"
kind = "gate"
condition = "steps.analyze-architecture.exit_code == 0"
[[steps]]
name = "checkpoint-state"
kind = "checkpoint"
"#;
let flow: FlowDefinition = toml::from_str(toml_str).unwrap();
assert_eq!(flow.name, "compound-review-v2");
assert_eq!(flow.schedule, Some("0 2 * * *".to_string()));
assert_eq!(flow.repo_path, "/home/user/project");
assert_eq!(flow.base_branch, "develop");
assert_eq!(flow.steps.len(), 5);
let action_step = &flow.steps[0];
assert_eq!(action_step.name, "gather-changes");
assert_eq!(action_step.kind, StepKind::Action);
assert_eq!(action_step.command, Some("git diff main..HEAD".to_string()));
assert_eq!(action_step.timeout_secs, 300);
assert_eq!(action_step.on_fail, FailStrategy::Abort);
let agent_step = &flow.steps[1];
assert_eq!(agent_step.name, "analyze-architecture");
assert_eq!(agent_step.kind, StepKind::Agent);
assert_eq!(agent_step.cli_tool, Some("claude".to_string()));
assert_eq!(agent_step.model, Some("sonnet".to_string()));
assert_eq!(
agent_step.task,
Some("Review the architecture changes".to_string())
);
assert_eq!(agent_step.task_file, Some("/prompts/arch.md".to_string()));
assert_eq!(agent_step.timeout_secs, 900);
assert_eq!(agent_step.on_fail, FailStrategy::SkipFailed);
assert_eq!(agent_step.provider, Some("anthropic".to_string()));
assert_eq!(agent_step.persona, Some("architect".to_string()));
let agent_step2 = &flow.steps[2];
assert_eq!(agent_step2.name, "check-quality");
assert_eq!(agent_step2.kind, StepKind::Agent);
assert_eq!(agent_step2.on_fail, FailStrategy::Continue);
let gate_step = &flow.steps[3];
assert_eq!(gate_step.name, "gate-approval");
assert_eq!(gate_step.kind, StepKind::Gate);
assert_eq!(
gate_step.condition,
Some("steps.analyze-architecture.exit_code == 0".to_string())
);
let checkpoint_step = &flow.steps[4];
assert_eq!(checkpoint_step.name, "checkpoint-state");
assert_eq!(checkpoint_step.kind, StepKind::Checkpoint);
}
#[test]
fn test_step_kind_serde() {
assert_eq!(
serde_json::to_string(&StepKind::Action).unwrap(),
"\"action\""
);
assert_eq!(
serde_json::to_string(&StepKind::Agent).unwrap(),
"\"agent\""
);
assert_eq!(serde_json::to_string(&StepKind::Gate).unwrap(), "\"gate\"");
assert_eq!(
serde_json::to_string(&StepKind::Checkpoint).unwrap(),
"\"checkpoint\""
);
assert_eq!(
serde_json::from_str::<StepKind>("\"action\"").unwrap(),
StepKind::Action
);
assert_eq!(
serde_json::from_str::<StepKind>("\"agent\"").unwrap(),
StepKind::Agent
);
assert_eq!(
serde_json::from_str::<StepKind>("\"gate\"").unwrap(),
StepKind::Gate
);
assert_eq!(
serde_json::from_str::<StepKind>("\"checkpoint\"").unwrap(),
StepKind::Checkpoint
);
}
#[test]
fn test_fail_strategy_default() {
let strategy: FailStrategy = Default::default();
assert_eq!(strategy, FailStrategy::Abort);
let toml_str = r#"
name = "test"
kind = "action"
"#;
let step: FlowStepDef = toml::from_str(toml_str).unwrap();
assert_eq!(step.on_fail, FailStrategy::Abort);
}
#[test]
fn test_fail_strategy_variants() {
assert_eq!(
serde_json::to_string(&FailStrategy::Abort).unwrap(),
"\"abort\""
);
assert_eq!(
serde_json::to_string(&FailStrategy::SkipFailed).unwrap(),
"\"skip_failed\""
);
assert_eq!(
serde_json::to_string(&FailStrategy::Continue).unwrap(),
"\"continue\""
);
assert_eq!(
serde_json::from_str::<FailStrategy>("\"abort\"").unwrap(),
FailStrategy::Abort
);
assert_eq!(
serde_json::from_str::<FailStrategy>("\"skip_failed\"").unwrap(),
FailStrategy::SkipFailed
);
assert_eq!(
serde_json::from_str::<FailStrategy>("\"continue\"").unwrap(),
FailStrategy::Continue
);
}
}