ironflow_engine/config/shell.rs
1//! [`ShellConfig`] — serializable configuration for a shell step.
2
3use ironflow_core::retry::RetryPolicy;
4use serde::{Deserialize, Serialize};
5
6use super::artifact::{ArtifactInput, ArtifactOutput, ArtifactRef};
7
8/// Serializable configuration for a shell step.
9///
10/// # Examples
11///
12/// ```
13/// use ironflow_engine::config::ShellConfig;
14///
15/// let config = ShellConfig::new("cargo build --release")
16/// .timeout_secs(300)
17/// .dir("/app")
18/// .output("target/report.html");
19/// ```
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ShellConfig {
22 /// The shell command to execute.
23 pub command: String,
24 /// Timeout in seconds (default: 300).
25 pub timeout_secs: Option<u64>,
26 /// Working directory.
27 pub dir: Option<String>,
28 /// Environment variables to set.
29 pub env: Vec<(String, String)>,
30 /// If true, start with a clean environment.
31 pub clean_env: bool,
32 /// Files the step promises to produce, collected once it finishes.
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
34 pub outputs: Vec<ArtifactOutput>,
35 /// Artifacts of earlier steps to place in the working directory first.
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub inputs: Vec<ArtifactInput>,
38 /// When `true`, a failure of this step does not fail the run. The step is
39 /// still marked `Failed` but execution continues and the run finishes with
40 /// [`RunStatus::Warning`] instead of `Failed`.
41 #[serde(default)]
42 pub allow_failure: bool,
43 /// Optional step-level retry policy. When set, a transient failure retries
44 /// the step locally with exponential backoff before propagating the error.
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub retry: Option<RetryPolicy>,
47}
48
49impl ShellConfig {
50 /// Create a new shell config with the given command.
51 ///
52 /// # Examples
53 ///
54 /// ```
55 /// use ironflow_engine::config::ShellConfig;
56 ///
57 /// let config = ShellConfig::new("echo hello");
58 /// assert_eq!(config.command, "echo hello");
59 /// ```
60 pub fn new(command: &str) -> Self {
61 Self {
62 command: command.to_string(),
63 timeout_secs: None,
64 dir: None,
65 env: Vec::new(),
66 clean_env: false,
67 outputs: Vec::new(),
68 inputs: Vec::new(),
69 allow_failure: false,
70 retry: None,
71 }
72 }
73
74 /// Set the timeout in seconds.
75 pub fn timeout_secs(mut self, secs: u64) -> Self {
76 self.timeout_secs = Some(secs);
77 self
78 }
79
80 /// Set the working directory.
81 pub fn dir(mut self, dir: &str) -> Self {
82 self.dir = Some(dir.to_string());
83 self
84 }
85
86 /// Add an environment variable.
87 pub fn env(mut self, key: &str, value: &str) -> Self {
88 self.env.push((key.to_string(), value.to_string()));
89 self
90 }
91
92 /// Start with a clean environment (no inherited vars).
93 pub fn clean_env(mut self) -> Self {
94 self.clean_env = true;
95 self
96 }
97
98 /// Declare a file the step produces, typed from its name.
99 ///
100 /// `pattern` is a glob resolved against [`dir`](Self::dir). Every match is
101 /// stored as an artifact named after the file. When the step succeeds and
102 /// the pattern matches nothing, the step fails.
103 ///
104 /// # Examples
105 ///
106 /// ```
107 /// use ironflow_engine::config::ShellConfig;
108 ///
109 /// let config = ShellConfig::new("cargo build").output("target/*.log");
110 /// assert_eq!(config.outputs.len(), 1);
111 /// ```
112 pub fn output(mut self, pattern: &str) -> Self {
113 self.outputs.push(ArtifactOutput::new(pattern));
114 self
115 }
116
117 /// Declare a produced file with an explicit MIME type.
118 ///
119 /// # Examples
120 ///
121 /// ```
122 /// use ironflow_engine::config::ShellConfig;
123 ///
124 /// let config = ShellConfig::new("./gen").output_typed("data", "application/json");
125 /// assert_eq!(config.outputs[0].content_type.as_deref(), Some("application/json"));
126 /// ```
127 pub fn output_typed(mut self, pattern: &str, content_type: &str) -> Self {
128 self.outputs
129 .push(ArtifactOutput::typed(pattern, content_type));
130 self
131 }
132
133 /// Consume an artifact produced by an earlier step of the same run.
134 ///
135 /// The handle comes from the producing step, see [`ArtifactRef`]. The
136 /// artifact is written into the working directory under its own name
137 /// before the command runs. Use [`input_at`](Self::input_at) to choose
138 /// another path.
139 ///
140 /// # Examples
141 ///
142 /// ```no_run
143 /// use ironflow_engine::config::ShellConfig;
144 /// use ironflow_engine::context::WorkflowContext;
145 /// use ironflow_engine::error::EngineError;
146 ///
147 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
148 /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
149 /// let report = build.artifact("report.html")?;
150 /// let config = ShellConfig::new("./publish").input(&report);
151 /// assert_eq!(config.inputs[0].destination(), "report.html");
152 /// # Ok(())
153 /// # }
154 /// ```
155 pub fn input(mut self, artifact: &ArtifactRef) -> Self {
156 self.inputs.push(ArtifactInput::from(artifact));
157 self
158 }
159
160 /// Mark this step as allowed to fail without stopping the run.
161 ///
162 /// # Examples
163 ///
164 /// ```
165 /// use ironflow_engine::config::ShellConfig;
166 ///
167 /// let config = ShellConfig::new("cargo clippy").allow_failure();
168 /// assert!(config.allow_failure);
169 /// ```
170 pub fn allow_failure(mut self) -> Self {
171 self.allow_failure = true;
172 self
173 }
174
175 /// Set a step-level retry policy.
176 ///
177 /// # Examples
178 ///
179 /// ```
180 /// use ironflow_core::retry::RetryPolicy;
181 /// use ironflow_engine::config::ShellConfig;
182 ///
183 /// let config = ShellConfig::new("curl http://api")
184 /// .retry_policy(RetryPolicy::new(3));
185 /// assert!(config.retry.is_some());
186 /// ```
187 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
188 self.retry = Some(policy);
189 self
190 }
191
192 /// Consume an artifact and write it to an explicit path.
193 ///
194 /// # Examples
195 ///
196 /// ```no_run
197 /// use ironflow_engine::config::ShellConfig;
198 /// use ironflow_engine::context::WorkflowContext;
199 /// use ironflow_engine::error::EngineError;
200 ///
201 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
202 /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
203 /// let report = build.artifact("report.html")?;
204 /// let config = ShellConfig::new("./publish").input_at(&report, "in/r.html");
205 /// assert_eq!(config.inputs[0].destination(), "in/r.html");
206 /// # Ok(())
207 /// # }
208 /// ```
209 pub fn input_at(mut self, artifact: &ArtifactRef, dest: &str) -> Self {
210 self.inputs.push(ArtifactInput::from(artifact).at(dest));
211 self
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn builder() {
221 let config = ShellConfig::new("cargo test")
222 .timeout_secs(60)
223 .dir("/app")
224 .env("RUST_LOG", "debug")
225 .clean_env();
226
227 assert_eq!(config.command, "cargo test");
228 assert_eq!(config.timeout_secs, Some(60));
229 assert_eq!(config.dir, Some("/app".to_string()));
230 assert_eq!(
231 config.env,
232 vec![("RUST_LOG".to_string(), "debug".to_string())]
233 );
234 assert!(config.clean_env);
235 }
236
237 #[test]
238 fn a_fresh_config_declares_no_artifact() {
239 let config = ShellConfig::new("echo hi");
240 assert!(config.outputs.is_empty());
241 assert!(config.inputs.is_empty());
242 }
243
244 #[test]
245 fn outputs_and_inputs_accumulate_in_declaration_order() {
246 let config = ShellConfig::new("build")
247 .output("a.txt")
248 .output_typed("b", "text/csv")
249 .input(&ArtifactRef::new("prev", "c.txt"))
250 .input_at(&ArtifactRef::new("prev", "d.txt"), "in/d.txt");
251
252 assert_eq!(config.outputs[0].pattern, "a.txt");
253 assert_eq!(config.outputs[1].content_type.as_deref(), Some("text/csv"));
254 assert_eq!(config.inputs[0], ArtifactInput::new("prev", "c.txt"));
255 assert_eq!(config.inputs[0].destination(), "c.txt");
256 assert_eq!(config.inputs[1].step, "prev");
257 assert_eq!(config.inputs[1].destination(), "in/d.txt");
258 }
259
260 #[test]
261 fn serde_omits_empty_artifact_declarations() {
262 let json = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
263 assert!(!json.contains("outputs"));
264 assert!(!json.contains("inputs"));
265 }
266
267 #[test]
268 fn a_config_predating_artifacts_still_deserializes() {
269 let config: ShellConfig = serde_json::from_str(
270 r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false}"#,
271 )
272 .expect("deserialize");
273
274 assert!(config.outputs.is_empty());
275 assert!(config.inputs.is_empty());
276 }
277
278 #[test]
279 fn a_config_predating_retry_still_deserializes() {
280 let config: ShellConfig = serde_json::from_str(
281 r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
282 )
283 .expect("deserialize");
284
285 assert!(config.retry.is_none());
286 }
287
288 #[test]
289 fn retry_policy_roundtrip() {
290 use ironflow_core::retry::RetryPolicy;
291
292 let config = ShellConfig::new("curl http://api").retry_policy(RetryPolicy::new(3));
293 let json = serde_json::to_string(&config).expect("serialize");
294 let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
295 assert_eq!(back.retry.as_ref().unwrap().max_retries(), 3);
296 }
297}