Skip to main content

ironflow_engine/config/
artifact.rs

1//! Artifact declarations carried by a step config.
2//!
3//! A step declares the files it *produces* ([`ArtifactOutput`]) and the ones it
4//! *consumes* ([`ArtifactInput`]). Both are serialized with the step input, so
5//! they survive a round-trip through the store and are visible on the dashboard.
6
7use serde::{Deserialize, Serialize};
8
9/// A file the step promises to produce.
10///
11/// `pattern` is a glob resolved against the step's working directory. When the
12/// step succeeds and the pattern matches nothing, the step fails with
13/// [`MissingArtifact`](crate::error::EngineError::MissingArtifact): a declared
14/// output that never appeared is a broken contract.
15///
16/// # Examples
17///
18/// ```
19/// use ironflow_engine::config::ArtifactOutput;
20///
21/// let output = ArtifactOutput::new("target/report.html");
22/// assert_eq!(output.pattern, "target/report.html");
23/// assert!(output.content_type.is_none());
24/// ```
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct ArtifactOutput {
27    /// Glob pattern, relative to the step's working directory.
28    pub pattern: String,
29    /// MIME type to record. Guessed from the file name when absent.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub content_type: Option<String>,
32}
33
34impl ArtifactOutput {
35    /// Declare an output whose MIME type is guessed from the file name.
36    ///
37    /// # Examples
38    ///
39    /// ```
40    /// use ironflow_engine::config::ArtifactOutput;
41    ///
42    /// let output = ArtifactOutput::new("dist/*.js");
43    /// assert_eq!(output.pattern, "dist/*.js");
44    /// ```
45    pub fn new(pattern: &str) -> Self {
46        Self {
47            pattern: pattern.to_string(),
48            content_type: None,
49        }
50    }
51
52    /// Declare an output with an explicit MIME type.
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use ironflow_engine::config::ArtifactOutput;
58    ///
59    /// let output = ArtifactOutput::typed("data", "application/json");
60    /// assert_eq!(output.content_type.as_deref(), Some("application/json"));
61    /// ```
62    pub fn typed(pattern: &str, content_type: &str) -> Self {
63        Self {
64            pattern: pattern.to_string(),
65            content_type: Some(content_type.to_string()),
66        }
67    }
68}
69
70/// An artifact the step wants placed in its working directory before it runs.
71///
72/// Resolved within the current run and attempt, among steps positioned strictly
73/// before the consumer. When several steps share `step`, the one closest to the
74/// consumer wins. No match fails the step with
75/// [`ArtifactNotFound`](crate::error::EngineError::ArtifactNotFound).
76///
77/// A sub-workflow never sees its parent's artifacts: pass what it needs through
78/// the payload instead.
79///
80/// # Examples
81///
82/// ```
83/// use ironflow_engine::config::ArtifactInput;
84///
85/// let input = ArtifactInput::new("build", "report.html");
86/// assert_eq!(input.destination(), "report.html");
87///
88/// let renamed = ArtifactInput::new("build", "report.html").at("inputs/report.html");
89/// assert_eq!(renamed.destination(), "inputs/report.html");
90/// ```
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ArtifactInput {
93    /// Name of the step that produced the artifact.
94    pub step: String,
95    /// Name of the artifact.
96    pub name: String,
97    /// Where to write it, relative to the working directory.
98    ///
99    /// Defaults to [`name`](ArtifactInput::name).
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub dest: Option<String>,
102}
103
104/// A handle on an artifact produced by an earlier step of the run.
105///
106/// Only the producing step hands one out: [`StepOutput::artifact`](crate::executor::StepOutput::artifact)
107/// for a file a shell step declared with [`ShellConfig::output`](super::ShellConfig::output),
108/// [`WorkflowContext::put_artifact`](crate::context::WorkflowContext::put_artifact)
109/// for bytes stored by hand. Pass it to [`ShellConfig::input`](super::ShellConfig::input)
110/// or [`WorkflowContext::get_artifact`](crate::context::WorkflowContext::get_artifact):
111/// the producer's name is never copied by hand.
112///
113/// # Examples
114///
115/// ```no_run
116/// use ironflow_engine::config::ShellConfig;
117/// use ironflow_engine::context::WorkflowContext;
118/// use ironflow_engine::error::EngineError;
119///
120/// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
121/// let build = ctx
122///     .shell("build", ShellConfig::new("./gen-report").output("target/report.html"))
123///     .await?;
124/// let report = build.artifact("report.html")?;
125/// assert_eq!(report.step(), "build");
126///
127/// ctx.shell("publish", ShellConfig::new("./publish report.html").input(&report))
128///     .await?;
129/// # Ok(())
130/// # }
131/// ```
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct ArtifactRef {
134    step: String,
135    name: String,
136}
137
138impl ArtifactRef {
139    /// A handle on `name` produced by the step called `step`.
140    pub(crate) fn new(step: &str, name: &str) -> Self {
141        Self {
142            step: step.to_string(),
143            name: name.to_string(),
144        }
145    }
146
147    /// Name of the step that produced the artifact.
148    pub fn step(&self) -> &str {
149        &self.step
150    }
151
152    /// Name of the artifact: the file name, without its directory.
153    pub fn name(&self) -> &str {
154        &self.name
155    }
156}
157
158impl From<&ArtifactRef> for ArtifactInput {
159    fn from(artifact: &ArtifactRef) -> Self {
160        Self::new(artifact.step(), artifact.name())
161    }
162}
163
164impl ArtifactInput {
165    /// Consume `name` as produced by the step called `step`.
166    ///
167    /// Workflow code passes an [`ArtifactRef`] to
168    /// [`ShellConfig::input`](super::ShellConfig::input) instead; this builds
169    /// the stored form directly.
170    pub fn new(step: &str, name: &str) -> Self {
171        Self {
172            step: step.to_string(),
173            name: name.to_string(),
174            dest: None,
175        }
176    }
177
178    /// Write the artifact to `dest` instead of its own name.
179    pub fn at(mut self, dest: &str) -> Self {
180        self.dest = Some(dest.to_string());
181        self
182    }
183
184    /// Path the artifact is written to, relative to the working directory.
185    pub fn destination(&self) -> &str {
186        self.dest.as_deref().unwrap_or(&self.name)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn output_defaults_to_a_guessed_type() {
196        assert!(ArtifactOutput::new("a.html").content_type.is_none());
197    }
198
199    #[test]
200    fn output_keeps_an_explicit_type() {
201        let output = ArtifactOutput::typed("a", "text/csv");
202        assert_eq!(output.content_type.as_deref(), Some("text/csv"));
203    }
204
205    #[test]
206    fn output_serde_omits_an_absent_content_type() {
207        let json = serde_json::to_string(&ArtifactOutput::new("a.html")).expect("serialize");
208        assert!(!json.contains("content_type"));
209    }
210
211    #[test]
212    fn output_serde_roundtrips() {
213        let output = ArtifactOutput::typed("a", "text/csv");
214        let json = serde_json::to_string(&output).expect("serialize");
215        let parsed: ArtifactOutput = serde_json::from_str(&json).expect("deserialize");
216        assert_eq!(parsed, output);
217    }
218
219    #[test]
220    fn input_destination_defaults_to_the_artifact_name() {
221        assert_eq!(ArtifactInput::new("build", "a.txt").destination(), "a.txt");
222    }
223
224    #[test]
225    fn input_destination_honours_an_override() {
226        assert_eq!(
227            ArtifactInput::new("build", "a.txt")
228                .at("in/a.txt")
229                .destination(),
230            "in/a.txt"
231        );
232    }
233
234    #[test]
235    fn input_serde_roundtrips() {
236        let input = ArtifactInput::new("build", "a.txt").at("in/a.txt");
237        let json = serde_json::to_string(&input).expect("serialize");
238        let parsed: ArtifactInput = serde_json::from_str(&json).expect("deserialize");
239        assert_eq!(parsed, input);
240    }
241
242    #[test]
243    fn a_handle_becomes_an_input_on_its_own_name() {
244        let input = ArtifactInput::from(&ArtifactRef::new("build", "report.html"));
245        assert_eq!(input, ArtifactInput::new("build", "report.html"));
246        assert_eq!(input.destination(), "report.html");
247    }
248
249    #[test]
250    fn input_deserializes_a_payload_without_dest() {
251        let parsed: ArtifactInput =
252            serde_json::from_str(r#"{"step":"build","name":"a.txt"}"#).expect("deserialize");
253        assert_eq!(parsed.destination(), "a.txt");
254    }
255}