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
104impl ArtifactInput {
105 /// Consume `name` as produced by the step called `step`.
106 pub fn new(step: &str, name: &str) -> Self {
107 Self {
108 step: step.to_string(),
109 name: name.to_string(),
110 dest: None,
111 }
112 }
113
114 /// Write the artifact to `dest` instead of its own name.
115 pub fn at(mut self, dest: &str) -> Self {
116 self.dest = Some(dest.to_string());
117 self
118 }
119
120 /// Path the artifact is written to, relative to the working directory.
121 pub fn destination(&self) -> &str {
122 self.dest.as_deref().unwrap_or(&self.name)
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn output_defaults_to_a_guessed_type() {
132 assert!(ArtifactOutput::new("a.html").content_type.is_none());
133 }
134
135 #[test]
136 fn output_keeps_an_explicit_type() {
137 let output = ArtifactOutput::typed("a", "text/csv");
138 assert_eq!(output.content_type.as_deref(), Some("text/csv"));
139 }
140
141 #[test]
142 fn output_serde_omits_an_absent_content_type() {
143 let json = serde_json::to_string(&ArtifactOutput::new("a.html")).expect("serialize");
144 assert!(!json.contains("content_type"));
145 }
146
147 #[test]
148 fn output_serde_roundtrips() {
149 let output = ArtifactOutput::typed("a", "text/csv");
150 let json = serde_json::to_string(&output).expect("serialize");
151 let parsed: ArtifactOutput = serde_json::from_str(&json).expect("deserialize");
152 assert_eq!(parsed, output);
153 }
154
155 #[test]
156 fn input_destination_defaults_to_the_artifact_name() {
157 assert_eq!(ArtifactInput::new("build", "a.txt").destination(), "a.txt");
158 }
159
160 #[test]
161 fn input_destination_honours_an_override() {
162 assert_eq!(
163 ArtifactInput::new("build", "a.txt")
164 .at("in/a.txt")
165 .destination(),
166 "in/a.txt"
167 );
168 }
169
170 #[test]
171 fn input_serde_roundtrips() {
172 let input = ArtifactInput::new("build", "a.txt").at("in/a.txt");
173 let json = serde_json::to_string(&input).expect("serialize");
174 let parsed: ArtifactInput = serde_json::from_str(&json).expect("deserialize");
175 assert_eq!(parsed, input);
176 }
177
178 #[test]
179 fn input_deserializes_a_payload_without_dest() {
180 let parsed: ArtifactInput =
181 serde_json::from_str(r#"{"step":"build","name":"a.txt"}"#).expect("deserialize");
182 assert_eq!(parsed.destination(), "a.txt");
183 }
184}