ironflow_engine/executor/
step_artifacts.rs1use glob::{MatchOptions, Pattern};
4use uuid::Uuid;
5
6use crate::config::{ArtifactOutput, ArtifactRef};
7use crate::error::EngineError;
8
9use super::StepOutput;
10
11#[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 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 pub fn step_name(&self) -> &str {
50 &self.step_name
51 }
52
53 pub fn step_id(&self) -> Option<Uuid> {
55 self.step_id
56 }
57
58 pub fn declared(&self) -> &[String] {
60 &self.declared
61 }
62}
63
64impl StepOutput {
65 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
113fn 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}