Skip to main content

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}