ironflow_engine/workflow.rs
1//! Workflow definition and builder.
2//!
3//! A [`WorkflowDef`] is an immutable, serializable description of a workflow —
4//! a named sequence of steps. Use the [`Workflow`] builder to create one.
5//!
6//! # Examples
7//!
8//! ```
9//! use ironflow_engine::config::{ShellConfig, AgentStepConfig};
10//! use ironflow_engine::workflow::Workflow;
11//!
12//! let workflow = Workflow::new("deploy")
13//! .shell("build", ShellConfig::new("cargo build --release"))
14//! .shell("test", ShellConfig::new("cargo test"))
15//! .agent("review", AgentStepConfig::new("Review the diff"))
16//! .build()
17//! .expect("valid workflow");
18//!
19//! assert_eq!(workflow.name, "deploy");
20//! assert_eq!(workflow.steps.len(), 3);
21//! ```
22
23use serde::{Deserialize, Serialize};
24
25use crate::config::{AgentStepConfig, HttpConfig, ShellConfig, StepConfig, WorkflowStepConfig};
26use crate::error::EngineError;
27
28// ---------------------------------------------------------------------------
29// WorkflowDef (immutable definition)
30// ---------------------------------------------------------------------------
31
32/// An immutable workflow definition: a named sequence of steps.
33///
34/// Created via the [`Workflow`] builder. Can be serialized for storage.
35///
36/// # Examples
37///
38/// ```
39/// use ironflow_engine::workflow::Workflow;
40/// use ironflow_engine::config::ShellConfig;
41///
42/// let def = Workflow::new("ci").shell("test", ShellConfig::new("cargo test")).build().unwrap();
43/// assert_eq!(def.name, "ci");
44/// ```
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct WorkflowDef {
47 /// Workflow name (used as the key for lookups and display).
48 pub name: String,
49 /// Ordered list of step definitions.
50 pub steps: Vec<StepDef>,
51}
52
53/// A single step within a workflow definition.
54///
55/// Holds the step name and its serializable configuration.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct StepDef {
58 /// Human-readable step name.
59 pub name: String,
60 /// The operation configuration for this step.
61 pub config: StepConfig,
62}
63
64// ---------------------------------------------------------------------------
65// Workflow builder
66// ---------------------------------------------------------------------------
67
68/// Builder for creating a [`WorkflowDef`].
69///
70/// # Examples
71///
72/// ```
73/// use ironflow_engine::config::ShellConfig;
74/// use ironflow_engine::workflow::Workflow;
75///
76/// let def = Workflow::new("deploy")
77/// .shell("build", ShellConfig::new("cargo build"))
78/// .build()
79/// .expect("valid workflow");
80/// ```
81#[must_use = "a Workflow builder does nothing until .build() is called"]
82pub struct Workflow {
83 name: String,
84 steps: Vec<StepDef>,
85}
86
87impl Workflow {
88 /// Start building a new workflow with the given name.
89 ///
90 /// # Examples
91 ///
92 /// ```
93 /// use ironflow_engine::workflow::Workflow;
94 ///
95 /// let builder = Workflow::new("my-pipeline");
96 /// ```
97 pub fn new(name: &str) -> Self {
98 Self {
99 name: name.to_string(),
100 steps: Vec::new(),
101 }
102 }
103
104 /// Add a shell step.
105 ///
106 /// # Examples
107 ///
108 /// ```
109 /// use ironflow_engine::config::ShellConfig;
110 /// use ironflow_engine::workflow::Workflow;
111 ///
112 /// let builder = Workflow::new("ci")
113 /// .shell("test", ShellConfig::new("cargo test"));
114 /// ```
115 pub fn shell(mut self, name: &str, config: ShellConfig) -> Self {
116 self.steps.push(StepDef {
117 name: name.to_string(),
118 config: StepConfig::Shell(config),
119 });
120 self
121 }
122
123 /// Add an HTTP step.
124 ///
125 /// # Examples
126 ///
127 /// ```
128 /// use ironflow_engine::config::HttpConfig;
129 /// use ironflow_engine::workflow::Workflow;
130 ///
131 /// let builder = Workflow::new("notify")
132 /// .http("webhook", HttpConfig::post("https://hooks.example.com/notify"));
133 /// ```
134 pub fn http(mut self, name: &str, config: HttpConfig) -> Self {
135 self.steps.push(StepDef {
136 name: name.to_string(),
137 config: StepConfig::Http(config),
138 });
139 self
140 }
141
142 /// Add an agent step.
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// use ironflow_engine::config::AgentStepConfig;
148 /// use ironflow_engine::workflow::Workflow;
149 ///
150 /// let builder = Workflow::new("review")
151 /// .agent("review-code", AgentStepConfig::new("Review this PR"));
152 /// ```
153 pub fn agent(mut self, name: &str, config: AgentStepConfig) -> Self {
154 self.steps.push(StepDef {
155 name: name.to_string(),
156 config: StepConfig::Agent(config),
157 });
158 self
159 }
160
161 /// Add a sub-workflow step.
162 ///
163 /// # Examples
164 ///
165 /// ```
166 /// use ironflow_engine::config::WorkflowStepConfig;
167 /// use ironflow_engine::workflow::Workflow;
168 /// use ironflow_engine::config::ShellConfig;
169 /// use serde_json::json;
170 ///
171 /// let builder = Workflow::new("pipeline")
172 /// .shell("lint", ShellConfig::new("cargo clippy"))
173 /// .workflow("run-tests", WorkflowStepConfig::new("ci-test", json!({})));
174 /// ```
175 pub fn workflow(mut self, name: &str, config: WorkflowStepConfig) -> Self {
176 self.steps.push(StepDef {
177 name: name.to_string(),
178 config: StepConfig::Workflow(config),
179 });
180 self
181 }
182
183 /// Add a step with an arbitrary [`StepConfig`].
184 ///
185 /// Use this when you have a pre-built `StepConfig` or need to add
186 /// steps programmatically.
187 pub fn step(mut self, name: &str, config: StepConfig) -> Self {
188 self.steps.push(StepDef {
189 name: name.to_string(),
190 config,
191 });
192 self
193 }
194
195 /// Consume the builder and produce an immutable [`WorkflowDef`].
196 ///
197 /// # Errors
198 ///
199 /// Returns [`EngineError::StepConfig`] if the name is empty/whitespace
200 /// or no steps were added.
201 ///
202 /// # Examples
203 ///
204 /// ```
205 /// use ironflow_engine::config::ShellConfig;
206 /// use ironflow_engine::workflow::Workflow;
207 ///
208 /// let def = Workflow::new("ci")
209 /// .shell("test", ShellConfig::new("cargo test"))
210 /// .build()
211 /// .expect("valid workflow");
212 /// assert_eq!(def.steps.len(), 1);
213 /// ```
214 pub fn build(self) -> Result<WorkflowDef, EngineError> {
215 if self.name.trim().is_empty() {
216 return Err(EngineError::StepConfig(
217 "workflow name must not be empty".into(),
218 ));
219 }
220 if self.steps.is_empty() {
221 return Err(EngineError::StepConfig(
222 "workflow must have at least one step".into(),
223 ));
224 }
225
226 Ok(WorkflowDef {
227 name: self.name,
228 steps: self.steps,
229 })
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn build_simple_workflow() {
239 let def = Workflow::new("deploy")
240 .shell("build", ShellConfig::new("cargo build"))
241 .shell("test", ShellConfig::new("cargo test"))
242 .build()
243 .unwrap();
244
245 assert_eq!(def.name, "deploy");
246 assert_eq!(def.steps.len(), 2);
247 assert_eq!(def.steps[0].name, "build");
248 assert_eq!(def.steps[1].name, "test");
249 }
250
251 #[test]
252 fn build_mixed_step_types() {
253 let def = Workflow::new("pipeline")
254 .shell("build", ShellConfig::new("cargo build"))
255 .http("notify", HttpConfig::post("http://hooks.example.com"))
256 .agent("review", AgentStepConfig::new("Review code"))
257 .build()
258 .unwrap();
259
260 assert_eq!(def.steps.len(), 3);
261 assert!(matches!(def.steps[0].config, StepConfig::Shell(_)));
262 assert!(matches!(def.steps[1].config, StepConfig::Http(_)));
263 assert!(matches!(def.steps[2].config, StepConfig::Agent(_)));
264 }
265
266 #[test]
267 fn build_returns_error_on_empty_name() {
268 let result = Workflow::new(" ")
269 .shell("step", ShellConfig::new("echo"))
270 .build();
271 assert!(result.is_err());
272 }
273
274 #[test]
275 fn build_returns_error_on_no_steps() {
276 let result = Workflow::new("empty").build();
277 assert!(result.is_err());
278 }
279
280 #[test]
281 fn workflow_def_serde_roundtrip() {
282 let def = Workflow::new("test")
283 .shell("s1", ShellConfig::new("echo hello"))
284 .build()
285 .unwrap();
286
287 let json = serde_json::to_string(&def).expect("serialize");
288 let back: WorkflowDef = serde_json::from_str(&json).expect("deserialize");
289 assert_eq!(back.name, "test");
290 assert_eq!(back.steps.len(), 1);
291 }
292
293 #[test]
294 fn step_method_with_step_config() {
295 let config = StepConfig::Shell(ShellConfig::new("echo test"));
296 let def = Workflow::new("test")
297 .step("generic", config)
298 .build()
299 .unwrap();
300 assert_eq!(def.steps.len(), 1);
301 }
302}