Skip to main content

abbaye/builders/
script.rs

1use 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/// Configuration for [`ScriptBuilder`].
13#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
14pub struct ScriptBuilderConfig {
15    /// Shell commands to execute in order. Each line is passed to `sh -c`,
16    /// so any POSIX shell syntax is supported.
17    ///
18    /// The environment variable `ABBAYE_BUILDING_VERSION` is set to the version
19    /// being built. (e.g. the git tag `v0.1.0` or whatever. Not Abbaye's own version)
20    ///
21    /// The build fails immediately if any command exits with a non-zero status.
22    ///
23    /// ```toml
24    /// [[builders]]
25    /// type = "script"
26    /// script = [
27    ///   "make release",
28    ///   "strip target/mybin",
29    /// ]
30    /// outputs = ["target/mybin"]
31    /// ```
32    pub script: Vec<String>,
33
34    /// Paths of the files or directories produced by the script that should be
35    /// treated as release artifacts (copied to `dist/` and listed on the
36    /// release page). Each path is resolved relative to the working directory
37    /// in which `abbaye` is run.
38    ///
39    /// If a listed path is missing, the build fails.
40    pub outputs: Vec<ScriptBuilderOutput>,
41}
42
43/// Lets the user specify a path for a script output, optionally with a custom name.
44/// I need to check but it should enable the user to specify a directory as output.
45/// If that's a good idea is an other question.
46#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]
47#[serde(untagged)]
48pub enum ScriptBuilderOutput {
49    PathWithName { path: PathBuf, name: String },
50    Path(PathBuf),
51}
52
53/// Executes a script and treats the listed output paths as release artifacts.
54pub 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            // Merge stdout and stderr into the log channel concurrently.
75            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            // Drain I/O tasks before checking the exit code.
94            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        // Collect every declared output path as an artifact.
106        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            // Check if pattern contains glob characters
120            if pattern_str.contains(['*', '?', '[']) {
121                // Compile the glob pattern
122                let glob = globset::Glob::new(&pattern_str)
123                    .into_diagnostic()?
124                    .compile_matcher();
125
126                let mut matched = false;
127
128                // Walk the directory to find matching paths.
129                // Extract the longest non-glob prefix to use as the walk root,
130                // so patterns like `target/**/*.txt` work correctly (the naive
131                // `.parent()` on `target/**/*.txt` yields `target/**` which is
132                // not a real directory).
133                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                // Regular path (non-glob)
163                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
190/// Given a glob pattern, walk up the path tree until we find a component
191/// that contains no glob metacharacters (`*`, `?`, `[`).  The result is
192/// a concrete directory (or `.`) suitable as a `WalkDir` root.
193///
194/// For example:
195/// - `target/**/*.txt`            → `target/`
196/// - `target/release/foo-*`      → `target/release/`
197/// - `*.tar.gz`                   → `.`
198fn 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}