Skip to main content

ironflow_engine/executor/
step_artifacts.rs

1//! [`StepArtifacts`] -- the artifacts a step can hand out as handles.
2
3use glob::{MatchOptions, Pattern};
4use uuid::Uuid;
5
6use crate::config::{ArtifactOutput, ArtifactRef};
7use crate::error::EngineError;
8
9use super::StepOutput;
10
11/// Which artifacts a step can hand out as [`ArtifactRef`] handles.
12///
13/// The workflow context fills it once the step has run: the step name, its
14/// record when one exists (not while planning), and the file-name part of the
15/// outputs it declared. A [`StepOutput`] built anywhere else carries the empty
16/// default and hands out nothing.
17///
18/// # Examples
19///
20/// ```
21/// use ironflow_engine::executor::StepArtifacts;
22///
23/// let artifacts = StepArtifacts::default();
24/// assert_eq!(artifacts.step_name(), "");
25/// assert!(artifacts.declared().is_empty());
26/// ```
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct StepArtifacts {
29    step_name: String,
30    step_id: Option<Uuid>,
31    declared: Vec<String>,
32}
33
34impl StepArtifacts {
35    /// Artifacts of the step `step_name`, recorded as `step_id`, which
36    /// declared `outputs`.
37    pub(crate) fn new(step_name: &str, step_id: Option<Uuid>, outputs: &[ArtifactOutput]) -> Self {
38        Self {
39            step_name: step_name.to_string(),
40            step_id,
41            declared: outputs
42                .iter()
43                .map(|output| file_name_part(&output.pattern).to_string())
44                .collect(),
45        }
46    }
47
48    /// Name of the step, empty when the output does not come from a step.
49    pub fn step_name(&self) -> &str {
50        &self.step_name
51    }
52
53    /// Record of the step, `None` while planning.
54    pub fn step_id(&self) -> Option<Uuid> {
55        self.step_id
56    }
57
58    /// File-name patterns of the outputs the step declared.
59    pub fn declared(&self) -> &[String] {
60        &self.declared
61    }
62}
63
64impl StepOutput {
65    /// Handle on an artifact this step declared, to feed a later step.
66    ///
67    /// `name` is the file name the artifact is stored under, without its
68    /// directory. It must match the file-name part of one of the step's
69    /// [`output`](crate::config::ShellConfig::output) patterns, so a typo fails
70    /// here rather than in the step that consumes it.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`EngineError::ArtifactNotDeclared`] when no declared output
75    /// covers `name`.
76    ///
77    /// # Examples
78    ///
79    /// ```no_run
80    /// use ironflow_engine::config::ShellConfig;
81    /// use ironflow_engine::context::WorkflowContext;
82    /// use ironflow_engine::error::EngineError;
83    ///
84    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
85    /// let build = ctx
86    ///     .shell("build", ShellConfig::new("cargo build").output("target/*.log"))
87    ///     .await?;
88    /// let log = build.artifact("build.log")?;
89    /// assert!(build.artifact("build.txt").is_err());
90    ///
91    /// ctx.shell("archive", ShellConfig::new("gzip build.log").input(&log)).await?;
92    /// # Ok(())
93    /// # }
94    /// ```
95    pub fn artifact(&self, name: &str) -> Result<ArtifactRef, EngineError> {
96        let options = MatchOptions {
97            require_literal_separator: true,
98            ..MatchOptions::new()
99        };
100        let declared = self.artifacts.declared.iter().any(|pattern| {
101            Pattern::new(pattern).is_ok_and(|pattern| pattern.matches_with(name, options))
102        });
103        if !declared {
104            return Err(EngineError::ArtifactNotDeclared {
105                step: self.artifacts.step_name.clone(),
106                name: name.to_string(),
107            });
108        }
109        Ok(ArtifactRef::new(&self.artifacts.step_name, name))
110    }
111}
112
113/// The part of an output pattern that names the file: artifacts are stored
114/// under the file name alone.
115fn file_name_part(pattern: &str) -> &str {
116    pattern
117        .rsplit_once('/')
118        .map_or(pattern, |(_, file_name)| file_name)
119}
120
121#[cfg(test)]
122mod tests {
123    use rust_decimal::Decimal;
124    use serde_json::Value;
125
126    use super::*;
127
128    fn output_declaring(patterns: &[&str]) -> StepOutput {
129        let outputs: Vec<ArtifactOutput> =
130            patterns.iter().map(|p| ArtifactOutput::new(p)).collect();
131        StepOutput {
132            output: Value::Null,
133            duration_ms: 0,
134            cost_usd: Decimal::ZERO,
135            input_tokens: None,
136            cache_read_input_tokens: None,
137            cache_creation_input_tokens: None,
138            output_tokens: None,
139            model: None,
140            debug_messages: None,
141            artifacts: StepArtifacts::new("build", None, &outputs),
142        }
143    }
144
145    #[test]
146    fn a_literal_declaration_hands_out_its_file_name() {
147        let output = output_declaring(&["target/report.html"]);
148        let handle = output.artifact("report.html").expect("declared");
149        assert_eq!(handle, ArtifactRef::new("build", "report.html"));
150    }
151
152    #[test]
153    fn a_glob_declaration_hands_out_every_matching_name() {
154        let output = output_declaring(&["logs/*.log", "report.html"]);
155        assert!(output.artifact("build.log").is_ok());
156        assert!(output.artifact("report.html").is_ok());
157        assert!(output.artifact("build.txt").is_err());
158    }
159
160    #[test]
161    fn a_name_with_a_directory_never_matches() {
162        let output = output_declaring(&["*.log"]);
163        assert!(matches!(
164            output.artifact("nested/build.log"),
165            Err(EngineError::ArtifactNotDeclared { .. })
166        ));
167    }
168
169    #[test]
170    fn an_undeclared_name_reports_the_step() {
171        let err = output_declaring(&["report.html"])
172            .artifact("report.htm")
173            .expect_err("typo");
174        assert!(matches!(
175            err,
176            EngineError::ArtifactNotDeclared { ref step, ref name }
177                if step == "build" && name == "report.htm"
178        ));
179    }
180
181    #[test]
182    fn an_output_built_outside_a_step_hands_out_nothing() {
183        let mut output = output_declaring(&[]);
184        output.artifacts = StepArtifacts::default();
185        assert!(output.artifact("anything").is_err());
186    }
187
188    #[test]
189    fn unicode_file_names_match() {
190        let output = output_declaring(&["out/*.html"]);
191        assert!(output.artifact("rapport-été.html").is_ok());
192    }
193
194    #[test]
195    fn file_name_part_drops_the_directories() {
196        assert_eq!(file_name_part("target/release/app"), "app");
197        assert_eq!(file_name_part("report.html"), "report.html");
198        assert_eq!(file_name_part("**/*.log"), "*.log");
199    }
200}