vfa_tui/models/
workflow.rs1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(deny_unknown_fields)]
14pub struct WorkflowPhase {
15 pub title: String,
16 #[serde(default)]
18 pub detail: String,
19 #[serde(default)]
23 pub model: String,
24}
25
26impl WorkflowPhase {
27 pub fn model_label(&self) -> &str {
32 if self.model.is_empty() {
33 "inherit"
34 } else {
35 &self.model
36 }
37 }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(deny_unknown_fields)]
43pub struct Workflow {
44 pub id: String,
45 pub name: String,
46 pub path: String,
48 pub description: String,
49 #[serde(default)]
51 pub when_to_use: String,
52 #[serde(default)]
53 pub phases: Vec<WorkflowPhase>,
54}
55
56impl Workflow {
57 pub fn invocation(&self) -> String {
59 format!("/{}", self.name)
60 }
61
62 pub fn phase_count(&self) -> usize {
64 self.phases.len()
65 }
66
67 pub fn model_tiers(&self) -> Vec<&str> {
72 let mut seen: Vec<&str> = Vec::new();
73 for phase in &self.phases {
74 let label = phase.model_label();
75 if !seen.contains(&label) {
76 seen.push(label);
77 }
78 }
79 seen
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[serde(deny_unknown_fields)]
86pub struct WorkflowCatalog {
87 pub version: String,
88 pub description: String,
89 pub workflows: Vec<Workflow>,
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 fn sample() -> WorkflowCatalog {
97 WorkflowCatalog {
98 version: "0.1.0".into(),
99 description: "generated".into(),
100 workflows: vec![Workflow {
101 id: "agentic-delegation".into(),
102 name: "agentic-delegation".into(),
103 path: ".claude/workflows/agentic-delegation.js".into(),
104 description: "Context7-grounded delegation".into(),
105 when_to_use: "Multi-step work".into(),
106 phases: vec![
107 WorkflowPhase {
108 title: "Recon".into(),
109 detail: "parallel sweeps".into(),
110 model: "haiku".into(),
111 },
112 WorkflowPhase {
113 title: "Spec".into(),
114 detail: "orchestrator tier".into(),
115 model: String::new(),
116 },
117 WorkflowPhase {
118 title: "Gate".into(),
119 detail: "gate suite".into(),
120 model: "haiku".into(),
121 },
122 ],
123 }],
124 }
125 }
126
127 #[test]
128 fn invocation_prefixes_the_name() {
129 assert_eq!(sample().workflows[0].invocation(), "/agentic-delegation");
130 }
131
132 #[test]
133 fn phase_count_matches_declared_phases() {
134 assert_eq!(sample().workflows[0].phase_count(), 3);
135 }
136
137 #[test]
138 fn empty_model_renders_as_inherit_not_blank() {
139 let wf = sample();
140 assert_eq!(wf.workflows[0].phases[0].model_label(), "haiku");
141 assert_eq!(wf.workflows[0].phases[1].model_label(), "inherit");
142 }
143
144 #[test]
145 fn model_tiers_dedupe_in_phase_order() {
146 assert_eq!(
147 sample().workflows[0].model_tiers(),
148 vec!["haiku", "inherit"]
149 );
150 }
151
152 #[test]
153 fn round_trips_through_json() {
154 let original = sample();
155 let json = serde_json::to_string(&original).unwrap();
156 let parsed: WorkflowCatalog = serde_json::from_str(&json).unwrap();
157 assert_eq!(original, parsed);
158 }
159
160 #[test]
161 fn optional_fields_default_when_absent() {
162 let json = r#"{
163 "version": "0.1.0",
164 "description": "d",
165 "workflows": [{
166 "id": "w", "name": "w", "path": "p", "description": "x"
167 }]
168 }"#;
169 let parsed: WorkflowCatalog = serde_json::from_str(json).unwrap();
170 assert_eq!(parsed.workflows[0].when_to_use, "");
171 assert!(parsed.workflows[0].phases.is_empty());
172 assert_eq!(parsed.workflows[0].phase_count(), 0);
173 }
174
175 #[test]
176 fn unknown_field_is_rejected() {
177 let json = r#"{
180 "version": "0.1.0",
181 "description": "d",
182 "workflows": [{
183 "id": "w", "name": "w", "path": "p", "description": "x",
184 "surpriseKey": true
185 }]
186 }"#;
187 assert!(serde_json::from_str::<WorkflowCatalog>(json).is_err());
188 }
189}