Skip to main content

vfa_tui/models/
workflow.rs

1//! Workflow catalog entries (`catalog/workflows.json`).
2//!
3//! Workflow scripts live in `.claude/workflows/` as executable JavaScript whose bodies
4//! call runtime globals that only exist inside the workflow engine. The TUI never parses
5//! those scripts: `scripts/generate-workflow-catalog.mjs` extracts each script's `meta`
6//! block into generated catalog JSON, and this module deserializes that — the same
7//! read-first contract every other catalog model follows.
8
9use serde::{Deserialize, Serialize};
10
11/// One phase of a workflow, as declared in the script's `meta.phases`.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(deny_unknown_fields)]
14pub struct WorkflowPhase {
15    pub title: String,
16    /// Short description of what the phase does. Empty when the script omits it.
17    #[serde(default)]
18    pub detail: String,
19    /// Model tier override for the phase (e.g. `haiku`). Empty means the phase
20    /// inherits the session model — which for the spec phase is deliberate, since
21    /// planning must not be downgraded.
22    #[serde(default)]
23    pub model: String,
24}
25
26impl WorkflowPhase {
27    /// Display label for the phase's model tier.
28    ///
29    /// An empty `model` is not missing data — it means the phase inherits the session
30    /// model, so it renders as `inherit` rather than as a blank cell.
31    pub fn model_label(&self) -> &str {
32        if self.model.is_empty() {
33            "inherit"
34        } else {
35            &self.model
36        }
37    }
38}
39
40/// A workflow discovered in `.claude/workflows/`.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(deny_unknown_fields)]
43pub struct Workflow {
44    pub id: String,
45    pub name: String,
46    /// Repo-relative path to the workflow script.
47    pub path: String,
48    pub description: String,
49    /// Guidance on when to reach for this workflow. Empty when the script omits it.
50    #[serde(default)]
51    pub when_to_use: String,
52    #[serde(default)]
53    pub phases: Vec<WorkflowPhase>,
54}
55
56impl Workflow {
57    /// The invocation a user types to run this workflow.
58    pub fn invocation(&self) -> String {
59        format!("/{}", self.name)
60    }
61
62    /// Number of declared phases.
63    pub fn phase_count(&self) -> usize {
64        self.phases.len()
65    }
66
67    /// Distinct model tiers this workflow spans, in phase order, first occurrence kept.
68    ///
69    /// Useful as a one-line cost signal in a list view: a workflow that is entirely
70    /// `haiku` reads very differently from one that reaches the session model.
71    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/// The `catalog/workflows.json` document.
84#[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        // deny_unknown_fields is the contract that makes a generator change that adds a
178        // key fail loudly here rather than being silently dropped from the display.
179        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}