Abbaye

at e1dfdc3

use std::path::PathBuf;
use std::process::Stdio;

use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender};
use crate::utils::expand_variables;
use miette::{IntoDiagnostic, Result, miette};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;

/// Configuration for [`ScriptBuilder`].
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ScriptBuilderConfig {
    /// Shell commands to execute in order. Each line is passed to `sh -c`,
    /// so any POSIX shell syntax is supported.
    ///
    /// The environment variable `ABBAYE_BUILDING_VERSION` is set to the version
    /// being built. (e.g. the git tag `v0.1.0` or whatever. Not Abbaye's own version)
    ///
    /// The build fails immediately if any command exits with a non-zero status.
    ///
    /// ```toml
    /// [[builders]]
    /// type = "script"
    /// script = [
    ///   "make release",
    ///   "strip target/mybin",
    /// ]
    /// outputs = ["target/mybin"]
    /// ```
    pub script: Vec<String>,

    /// Paths of the files or directories produced by the script that should be
    /// treated as release artifacts (copied to `dist/` and listed on the
    /// release page). Each path is resolved relative to the working directory
    /// in which `abbaye` is run.
    ///
    /// If a listed path is missing, the build fails.
    pub outputs: Vec<ScriptBuilderOutput>,
}

/// Lets the user specify a path for a script output, optionally with a custom name.
/// I need to check but it should enable the user to specify a directory as output.
/// If that's a good idea is an other question.
#[derive(Debug, Deserialize, Serialize, Clone, JsonSchema)]
#[serde(untagged)]
pub enum ScriptBuilderOutput {
    PathWithName { path: PathBuf, name: String },
    Path(PathBuf),
}

/// Executes a script and treats the listed output paths as release artifacts.
pub struct ScriptBuilder;

impl Builder for ScriptBuilder {
    type ConfigType = ScriptBuilderConfig;

    async fn build(
        &self,
        config: Self::ConfigType,
        version: &str,
        log: LogSender,
    ) -> Result<Vec<ArtifactPath>> {
        for line in &config.script {
            let mut child = Command::new("sh")
                .args(["-c", line])
                .env("ABBAYE_BUILDING_VERSION", version)
                .stdout(Stdio::piped())
                .stderr(Stdio::piped())
                .spawn()
                .into_diagnostic()?;

            // Merge stdout and stderr into the log channel concurrently.
            let stdout = child.stdout.take().expect("stdout piped");
            let stderr = child.stderr.take().expect("stderr piped");
            let log_out = log.clone();
            let log_err = log.clone();
            let stdout_task = tokio::spawn(async move {
                let mut lines = BufReader::new(stdout).lines();
                while let Ok(Some(l)) = lines.next_line().await {
                    let _ = log_out.send(LogEvent::Line(l));
                }
            });
            let stderr_task = tokio::spawn(async move {
                let mut lines = BufReader::new(stderr).lines();
                while let Ok(Some(l)) = lines.next_line().await {
                    let _ = log_err.send(LogEvent::Line(l));
                }
            });

            let status = child.wait().await.into_diagnostic()?;
            // Drain I/O tasks before checking the exit code.
            let _ = tokio::join!(stdout_task, stderr_task);

            if !status.success() {
                return Err(miette!(
                    "script command failed (exit {}):\n  {}",
                    status.code().unwrap_or(-1),
                    line
                ));
            }
        }

        // Collect every declared output path as an artifact.
        let mut artifacts = Vec::new();
        for output in &config.outputs {
            let vars = vec![("ABBAYE_BUILDING_VERSION", version)];

            let (pattern, name_override) = match output {
                ScriptBuilderOutput::Path(p) => (expand_variables(p, vars), None),
                ScriptBuilderOutput::PathWithName { path: p, name } => {
                    (expand_variables(p, vars), Some(name.clone()))
                }
            };

            let pattern_str = pattern.to_string_lossy().to_string();

            // Check if pattern contains glob characters
            if pattern_str.contains(['*', '?', '[']) {
                // Compile the glob pattern
                let glob = globset::Glob::new(&pattern_str)
                    .into_diagnostic()?
                    .compile_matcher();

                let mut matched = false;

                // Walk the directory to find matching paths
                let parent = pattern
                    .parent()
                    .unwrap_or_else(|| std::path::Path::new("."));
                for entry in walkdir::WalkDir::new(parent)
                    .into_iter()
                    .filter_map(|e| e.ok())
                {
                    let path = entry.path();
                    if glob.is_match(path) {
                        matched = true;
                        let name = name_override.clone().unwrap_or_else(|| {
                            path.file_name()
                                .unwrap_or_default()
                                .to_string_lossy()
                                .into_owned()
                        });
                        artifacts.push(ArtifactPath {
                            path: path.to_path_buf(),
                            name,
                            hash: None,
                        });
                    }
                }

                if !matched {
                    return Err(miette!("no files matched output pattern: {}", pattern_str));
                }
            } else {
                // Regular path (non-glob)
                if !pattern.exists() {
                    return Err(miette!(
                        "declared script output does not exist: {}",
                        pattern.display()
                    ));
                }
                let name = name_override.unwrap_or_else(|| {
                    pattern
                        .file_name()
                        .unwrap_or_default()
                        .to_string_lossy()
                        .into_owned()
                });
                artifacts.push(ArtifactPath {
                    path: pattern.clone(),
                    name,
                    hash: None,
                });
            }
        }
        Ok(artifacts)
    }
}