abbaye/builders/
script.rs1use std::path::{Path, PathBuf};
2use std::process::Stdio;
3
4use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender};
5use crate::utils::expand_variables;
6use miette::{IntoDiagnostic, Result, miette};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use tokio::io::{AsyncBufReadExt, BufReader};
10use tokio::process::Command;
11
12#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
14pub struct ScriptBuilderConfig {
15 pub script: Vec<String>,
33
34 pub outputs: Vec<ScriptBuilderOutput>,
41}
42
43#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]
47#[serde(untagged)]
48pub enum ScriptBuilderOutput {
49 PathWithName { path: PathBuf, name: String },
50 Path(PathBuf),
51}
52
53pub struct ScriptBuilder;
55
56impl Builder for ScriptBuilder {
57 type ConfigType = ScriptBuilderConfig;
58
59 async fn build(
60 &self,
61 config: Self::ConfigType,
62 version: &str,
63 log: LogSender,
64 ) -> Result<Vec<ArtifactPath>> {
65 for line in &config.script {
66 let mut child = Command::new("sh")
67 .args(["-c", line])
68 .env("ABBAYE_BUILDING_VERSION", version)
69 .stdout(Stdio::piped())
70 .stderr(Stdio::piped())
71 .spawn()
72 .into_diagnostic()?;
73
74 let stdout = child.stdout.take().expect("stdout piped");
76 let stderr = child.stderr.take().expect("stderr piped");
77 let log_out = log.clone();
78 let log_err = log.clone();
79 let stdout_task = tokio::spawn(async move {
80 let mut lines = BufReader::new(stdout).lines();
81 while let Ok(Some(l)) = lines.next_line().await {
82 let _ = log_out.send(LogEvent::Line(l));
83 }
84 });
85 let stderr_task = tokio::spawn(async move {
86 let mut lines = BufReader::new(stderr).lines();
87 while let Ok(Some(l)) = lines.next_line().await {
88 let _ = log_err.send(LogEvent::Line(l));
89 }
90 });
91
92 let status = child.wait().await.into_diagnostic()?;
93 let _ = tokio::join!(stdout_task, stderr_task);
95
96 if !status.success() {
97 return Err(miette!(
98 "script command failed (exit {}):\n {}",
99 status.code().unwrap_or(-1),
100 line
101 ));
102 }
103 }
104
105 let mut artifacts = Vec::new();
107 for output in &config.outputs {
108 let vars = vec![("ABBAYE_BUILDING_VERSION", version)];
109
110 let (pattern, name_override) = match output {
111 ScriptBuilderOutput::Path(p) => (expand_variables(p, vars), None),
112 ScriptBuilderOutput::PathWithName { path: p, name } => {
113 (expand_variables(p, vars), Some(name.clone()))
114 }
115 };
116
117 let pattern_str = pattern.to_string_lossy().to_string();
118
119 if pattern_str.contains(['*', '?', '[']) {
121 let glob = globset::Glob::new(&pattern_str)
123 .into_diagnostic()?
124 .compile_matcher();
125
126 let mut matched = false;
127
128 let walk_root = glob_walk_root(&pattern);
134 for entry in walkdir::WalkDir::new(walk_root)
135 .into_iter()
136 .filter_map(|e| e.ok())
137 {
138 let path = entry.path();
139 if glob.is_match(path) {
140 matched = true;
141 let name = name_override.clone().unwrap_or_else(|| {
142 path.file_name()
143 .unwrap_or_default()
144 .to_string_lossy()
145 .into_owned()
146 });
147 artifacts.push(ArtifactPath {
148 path: path.to_path_buf(),
149 name,
150 hash: None,
151 category: None,
152 group_name: None,
153 group_comment: None,
154 });
155 }
156 }
157
158 if !matched {
159 return Err(miette!("no files matched output pattern: {}", pattern_str));
160 }
161 } else {
162 if !pattern.exists() {
164 return Err(miette!(
165 "declared script output does not exist: {}",
166 pattern.display()
167 ));
168 }
169 let name = name_override.unwrap_or_else(|| {
170 pattern
171 .file_name()
172 .unwrap_or_default()
173 .to_string_lossy()
174 .into_owned()
175 });
176 artifacts.push(ArtifactPath {
177 path: pattern.clone(),
178 name,
179 hash: None,
180 category: None,
181 group_name: None,
182 group_comment: None,
183 });
184 }
185 }
186 Ok(artifacts)
187 }
188}
189
190fn glob_walk_root(pattern: &Path) -> &Path {
199 let mut current = pattern;
200 loop {
201 let s = current.to_string_lossy();
202 if s.is_empty() {
203 return Path::new(".");
204 }
205 if !s.contains('*') && !s.contains('?') && !s.contains('[') {
206 return current;
207 }
208 match current.parent() {
209 Some(parent) => current = parent,
210 None => return Path::new("."),
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn test_glob_walk_root_simple() {
221 assert_eq!(
222 glob_walk_root(Path::new("target/**/*.txt")),
223 Path::new("target")
224 );
225 }
226
227 #[test]
228 fn test_glob_walk_root_nested() {
229 assert_eq!(
230 glob_walk_root(Path::new("target/release/foo-*")),
231 Path::new("target/release")
232 );
233 }
234
235 #[test]
236 fn test_glob_walk_root_no_glob() {
237 assert_eq!(
238 glob_walk_root(Path::new("README.md")),
239 Path::new("README.md")
240 );
241 }
242
243 #[test]
244 fn test_glob_walk_root_root_glob() {
245 assert_eq!(glob_walk_root(Path::new("*.tar.gz")), Path::new("."));
246 }
247
248 #[test]
249 fn test_glob_walk_root_deep_glob() {
250 assert_eq!(
251 glob_walk_root(Path::new("foo/bar/*/baz/**/*.txt")),
252 Path::new("foo/bar")
253 );
254 }
255}