use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkflowPhase {
pub title: String,
#[serde(default)]
pub detail: String,
#[serde(default)]
pub model: String,
}
impl WorkflowPhase {
pub fn model_label(&self) -> &str {
if self.model.is_empty() {
"inherit"
} else {
&self.model
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Workflow {
pub id: String,
pub name: String,
pub path: String,
pub description: String,
#[serde(default)]
pub when_to_use: String,
#[serde(default)]
pub phases: Vec<WorkflowPhase>,
}
impl Workflow {
pub fn invocation(&self) -> String {
format!("/{}", self.name)
}
pub fn phase_count(&self) -> usize {
self.phases.len()
}
pub fn model_tiers(&self) -> Vec<&str> {
let mut seen: Vec<&str> = Vec::new();
for phase in &self.phases {
let label = phase.model_label();
if !seen.contains(&label) {
seen.push(label);
}
}
seen
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkflowCatalog {
pub version: String,
pub description: String,
pub workflows: Vec<Workflow>,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> WorkflowCatalog {
WorkflowCatalog {
version: "0.1.0".into(),
description: "generated".into(),
workflows: vec![Workflow {
id: "agentic-delegation".into(),
name: "agentic-delegation".into(),
path: ".claude/workflows/agentic-delegation.js".into(),
description: "Context7-grounded delegation".into(),
when_to_use: "Multi-step work".into(),
phases: vec![
WorkflowPhase {
title: "Recon".into(),
detail: "parallel sweeps".into(),
model: "haiku".into(),
},
WorkflowPhase {
title: "Spec".into(),
detail: "orchestrator tier".into(),
model: String::new(),
},
WorkflowPhase {
title: "Gate".into(),
detail: "gate suite".into(),
model: "haiku".into(),
},
],
}],
}
}
#[test]
fn invocation_prefixes_the_name() {
assert_eq!(sample().workflows[0].invocation(), "/agentic-delegation");
}
#[test]
fn phase_count_matches_declared_phases() {
assert_eq!(sample().workflows[0].phase_count(), 3);
}
#[test]
fn empty_model_renders_as_inherit_not_blank() {
let wf = sample();
assert_eq!(wf.workflows[0].phases[0].model_label(), "haiku");
assert_eq!(wf.workflows[0].phases[1].model_label(), "inherit");
}
#[test]
fn model_tiers_dedupe_in_phase_order() {
assert_eq!(
sample().workflows[0].model_tiers(),
vec!["haiku", "inherit"]
);
}
#[test]
fn round_trips_through_json() {
let original = sample();
let json = serde_json::to_string(&original).unwrap();
let parsed: WorkflowCatalog = serde_json::from_str(&json).unwrap();
assert_eq!(original, parsed);
}
#[test]
fn optional_fields_default_when_absent() {
let json = r#"{
"version": "0.1.0",
"description": "d",
"workflows": [{
"id": "w", "name": "w", "path": "p", "description": "x"
}]
}"#;
let parsed: WorkflowCatalog = serde_json::from_str(json).unwrap();
assert_eq!(parsed.workflows[0].when_to_use, "");
assert!(parsed.workflows[0].phases.is_empty());
assert_eq!(parsed.workflows[0].phase_count(), 0);
}
#[test]
fn unknown_field_is_rejected() {
let json = r#"{
"version": "0.1.0",
"description": "d",
"workflows": [{
"id": "w", "name": "w", "path": "p", "description": "x",
"surpriseKey": true
}]
}"#;
assert!(serde_json::from_str::<WorkflowCatalog>(json).is_err());
}
}