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 /// When `true`, a non-zero exit code is a normal output instead of an
44 /// error: the step is `Completed` and its output carries the real code.
45 /// Timeout, spawn and input-preparation failures stay errors.
46 #[serde(default)]
47 pub exit_code_as_output: bool,
48 /// Optional step-level retry policy. When set, a transient failure retries
49 /// the step locally with exponential backoff before propagating the error.
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub retry: Option<RetryPolicy>,
52}
53
54impl ShellConfig {
55 /// Create a new shell config with the given command.
56 ///
57 /// # Examples
58 ///
59 /// ```
60 /// use ironflow_engine::config::ShellConfig;
61 ///
62 /// let config = ShellConfig::new("echo hello");
63 /// assert_eq!(config.command, "echo hello");
64 /// ```
65 pub fn new(command: &str) -> Self {
66 Self {
67 command: command.to_string(),
68 timeout_secs: None,
69 dir: None,
70 env: Vec::new(),
71 clean_env: false,
72 outputs: Vec::new(),
73 inputs: Vec::new(),
74 allow_failure: false,
75 exit_code_as_output: false,
76 retry: None,
77 }
78 }
79
80 /// Set the timeout in seconds.
81 pub fn timeout_secs(mut self, secs: u64) -> Self {
82 self.timeout_secs = Some(secs);
83 self
84 }
85
86 /// Set the working directory.
87 pub fn dir(mut self, dir: &str) -> Self {
88 self.dir = Some(dir.to_string());
89 self
90 }
91
92 /// Add an environment variable.
93 pub fn env(mut self, key: &str, value: &str) -> Self {
94 self.env.push((key.to_string(), value.to_string()));
95 self
96 }
97
98 /// Start with a clean environment (no inherited vars).
99 pub fn clean_env(mut self) -> Self {
100 self.clean_env = true;
101 self
102 }
103
104 /// Declare a file the step produces, typed from its name.
105 ///
106 /// `pattern` is a glob resolved against [`dir`](Self::dir). Every match is
107 /// stored as an artifact named after the file. When the step succeeds and
108 /// the pattern matches nothing, the step fails.
109 ///
110 /// # Examples
111 ///
112 /// ```
113 /// use ironflow_engine::config::ShellConfig;
114 ///
115 /// let config = ShellConfig::new("cargo build").output("target/*.log");
116 /// assert_eq!(config.outputs.len(), 1);
117 /// ```
118 pub fn output(mut self, pattern: &str) -> Self {
119 self.outputs.push(ArtifactOutput::new(pattern));
120 self
121 }
122
123 /// Declare a produced file with an explicit MIME type.
124 ///
125 /// # Examples
126 ///
127 /// ```
128 /// use ironflow_engine::config::ShellConfig;
129 ///
130 /// let config = ShellConfig::new("./gen").output_typed("data", "application/json");
131 /// assert_eq!(config.outputs[0].content_type.as_deref(), Some("application/json"));
132 /// ```
133 pub fn output_typed(mut self, pattern: &str, content_type: &str) -> Self {
134 self.outputs
135 .push(ArtifactOutput::typed(pattern, content_type));
136 self
137 }
138
139 /// Consume an artifact produced by an earlier step of the same run.
140 ///
141 /// The handle comes from the producing step, see [`ArtifactRef`]. The
142 /// artifact is written into the working directory under its own name
143 /// before the command runs. Use [`input_at`](Self::input_at) to choose
144 /// another path.
145 ///
146 /// # Examples
147 ///
148 /// ```no_run
149 /// use ironflow_engine::config::ShellConfig;
150 /// use ironflow_engine::context::WorkflowContext;
151 /// use ironflow_engine::error::EngineError;
152 ///
153 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
154 /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
155 /// let report = build.artifact("report.html")?;
156 /// let config = ShellConfig::new("./publish").input(&report);
157 /// assert_eq!(config.inputs[0].destination(), "report.html");
158 /// # Ok(())
159 /// # }
160 /// ```
161 pub fn input(mut self, artifact: &ArtifactRef) -> Self {
162 self.inputs.push(ArtifactInput::from(artifact));
163 self
164 }
165
166 /// Mark this step as allowed to fail without stopping the run.
167 ///
168 /// # Examples
169 ///
170 /// ```
171 /// use ironflow_engine::config::ShellConfig;
172 ///
173 /// let config = ShellConfig::new("cargo clippy").allow_failure();
174 /// assert!(config.allow_failure);
175 /// ```
176 pub fn allow_failure(mut self) -> Self {
177 self.allow_failure = true;
178 self
179 }
180
181 /// Treat a non-zero exit code as data instead of a failure.
182 ///
183 /// The step is `Completed`: `StepOutput::is_success()` is `false` and
184 /// `exit_code()` returns the real code, so the handler can branch on it
185 /// (a conflicting `git merge`, red tests). The run is not degraded and
186 /// [`allow_failure`](Self::allow_failure) is not triggered. A non-zero
187 /// exit is no longer an error, so a retry policy does not retry it.
188 /// Timeout, spawn failure and input-preparation failure remain errors and
189 /// follow `allow_failure`.
190 ///
191 /// # Examples
192 ///
193 /// ```
194 /// use ironflow_engine::config::ShellConfig;
195 ///
196 /// let config = ShellConfig::new("git merge feature").exit_code_as_output();
197 /// assert!(config.exit_code_as_output);
198 /// ```
199 pub fn exit_code_as_output(mut self) -> Self {
200 self.exit_code_as_output = true;
201 self
202 }
203
204 /// Set a step-level retry policy.
205 ///
206 /// # Examples
207 ///
208 /// ```
209 /// use ironflow_core::retry::RetryPolicy;
210 /// use ironflow_engine::config::ShellConfig;
211 ///
212 /// let config = ShellConfig::new("curl http://api")
213 /// .retry_policy(RetryPolicy::new(3));
214 /// assert!(config.retry.is_some());
215 /// ```
216 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
217 self.retry = Some(policy);
218 self
219 }
220
221 /// Consume an artifact and write it to an explicit path.
222 ///
223 /// # Examples
224 ///
225 /// ```no_run
226 /// use ironflow_engine::config::ShellConfig;
227 /// use ironflow_engine::context::WorkflowContext;
228 /// use ironflow_engine::error::EngineError;
229 ///
230 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
231 /// let build = ctx.shell("build", ShellConfig::new("./gen").output("report.html")).await?;
232 /// let report = build.artifact("report.html")?;
233 /// let config = ShellConfig::new("./publish").input_at(&report, "in/r.html");
234 /// assert_eq!(config.inputs[0].destination(), "in/r.html");
235 /// # Ok(())
236 /// # }
237 /// ```
238 pub fn input_at(mut self, artifact: &ArtifactRef, dest: &str) -> Self {
239 self.inputs.push(ArtifactInput::from(artifact).at(dest));
240 self
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn builder() {
250 let config = ShellConfig::new("cargo test")
251 .timeout_secs(60)
252 .dir("/app")
253 .env("RUST_LOG", "debug")
254 .clean_env();
255
256 assert_eq!(config.command, "cargo test");
257 assert_eq!(config.timeout_secs, Some(60));
258 assert_eq!(config.dir, Some("/app".to_string()));
259 assert_eq!(
260 config.env,
261 vec![("RUST_LOG".to_string(), "debug".to_string())]
262 );
263 assert!(config.clean_env);
264 }
265
266 #[test]
267 fn a_fresh_config_declares_no_artifact() {
268 let config = ShellConfig::new("echo hi");
269 assert!(config.outputs.is_empty());
270 assert!(config.inputs.is_empty());
271 }
272
273 #[test]
274 fn outputs_and_inputs_accumulate_in_declaration_order() {
275 let config = ShellConfig::new("build")
276 .output("a.txt")
277 .output_typed("b", "text/csv")
278 .input(&ArtifactRef::new("prev", "c.txt"))
279 .input_at(&ArtifactRef::new("prev", "d.txt"), "in/d.txt");
280
281 assert_eq!(config.outputs[0].pattern, "a.txt");
282 assert_eq!(config.outputs[1].content_type.as_deref(), Some("text/csv"));
283 assert_eq!(config.inputs[0], ArtifactInput::new("prev", "c.txt"));
284 assert_eq!(config.inputs[0].destination(), "c.txt");
285 assert_eq!(config.inputs[1].step, "prev");
286 assert_eq!(config.inputs[1].destination(), "in/d.txt");
287 }
288
289 #[test]
290 fn serde_omits_empty_artifact_declarations() {
291 let json = serde_json::to_string(&ShellConfig::new("echo hi")).expect("serialize");
292 assert!(!json.contains("outputs"));
293 assert!(!json.contains("inputs"));
294 }
295
296 #[test]
297 fn a_config_predating_artifacts_still_deserializes() {
298 let config: ShellConfig = serde_json::from_str(
299 r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false}"#,
300 )
301 .expect("deserialize");
302
303 assert!(config.outputs.is_empty());
304 assert!(config.inputs.is_empty());
305 }
306
307 #[test]
308 fn a_config_predating_retry_still_deserializes() {
309 let config: ShellConfig = serde_json::from_str(
310 r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
311 )
312 .expect("deserialize");
313
314 assert!(config.retry.is_none());
315 }
316
317 #[test]
318 fn a_config_predating_exit_code_as_output_still_deserializes() {
319 let config: ShellConfig = serde_json::from_str(
320 r#"{"command":"echo hi","timeout_secs":null,"dir":null,"env":[],"clean_env":false,"allow_failure":false}"#,
321 )
322 .expect("deserialize");
323
324 assert!(!config.exit_code_as_output);
325 }
326
327 #[test]
328 fn exit_code_as_output_roundtrip() {
329 assert!(!ShellConfig::new("x").exit_code_as_output);
330
331 let config = ShellConfig::new("git merge x").exit_code_as_output();
332 let json = serde_json::to_string(&config).expect("serialize");
333 let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
334 assert!(back.exit_code_as_output);
335 }
336
337 #[test]
338 fn retry_policy_roundtrip() {
339 use ironflow_core::retry::RetryPolicy;
340
341 let config = ShellConfig::new("curl http://api").retry_policy(RetryPolicy::new(3));
342 let json = serde_json::to_string(&config).expect("serialize");
343 let back: ShellConfig = serde_json::from_str(&json).expect("deserialize");
344 assert_eq!(back.retry.as_ref().unwrap().max_retries(), 3);
345 }
346}