ambient-ci 0.14.0

A continuous integration engine
Documentation
use std::{collections::HashMap, path::Path, process::Command};

use clingwrap::runner::{CommandError, CommandRunner};
use serde::{Deserialize, Serialize};

use crate::{
    action::{ActionError, Context},
    action_impl::ActionImpl,
    runlog::RunLogSource,
};

/// Execute a custom action.
///
/// Run the named executable from `.ambient` at the root of the source
/// tree. The arguments in `args` are used to set environment variables
/// and are also passed in via the standard input, both as JSON. Note that
/// there is no way for Ambient to check that the necessary arguments are
/// provided.
///
/// An `args` value of `foo` results in `AMBIENT_CI_foo` being set in the
/// environment.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Custom {
    /// Name of custom action executable in `.ambient`.
    pub name: String,

    /// Arguments to custom action.
    #[serde(default)]
    pub args: HashMap<String, serde_norway::Value>,
}

impl Custom {
    /// Create a new custom action.
    pub fn new(name: String, args: HashMap<String, serde_norway::Value>) -> Self {
        Self { name, args }
    }
}

impl ActionImpl for Custom {
    fn execute(&self, context: &mut Context) -> Result<(), ActionError> {
        let source = context.source_dir().to_path_buf();
        let exe = Path::new(".ambient").join(&self.name);
        let exe_exists = exe.exists();
        let json = serde_json::to_string(&self.args).map_err(CustomError::ArgsToJson)?;

        context.runlog().custom_action_starts(
            RunLogSource::Plan,
            source.clone(),
            self.clone(),
            exe.clone(),
            exe_exists,
        );

        let mut cmd = Command::new(exe);
        cmd.current_dir(&source);
        cmd.envs(context.env());
        for (key, value) in self.args.iter() {
            let key = format!("AMBIENT_CI_{key}");
            let value = serde_json::to_string(value).map_err(CustomError::ArgsToJson)?;
            cmd.env(key, value);
        }
        let mut runner = CommandRunner::new(cmd);
        runner.feed_stdin(json.as_bytes());
        runner.capture_stdout();
        runner.capture_stderr();

        let result = runner.execute();
        if let Ok(output) = result {
            // Output text to stdout for test suite. Use debug formatting to avoid
            // accidentally looking like a JSON line.
            println!("{:?}", String::from_utf8_lossy(&output.stdout));
            context
                .runlog()
                .custom_action_output(RunLogSource::Plan, output.stdout, output.stderr);
        } else if let Err(err) = result {
            if let CommandError::CommandFailed { output, .. } = &err {
                context.runlog().custom_action_output(
                    RunLogSource::Plan,
                    output.stdout.clone(),
                    output.stderr.clone(),
                );
            }
            Err(CustomError::Custom(self.name.to_string(), err))?;
        }

        Ok(())
    }
}

/// Errors from a custom action.
#[derive(Debug, thiserror::Error)]
pub enum CustomError {
    /// Can't run custom action.
    #[error("failed to run custom action {0:?}")]
    Custom(String, #[source] clingwrap::runner::CommandError),

    /// Can't convert custom action arguments to JSON.
    #[error("failed to convert custom action into JSON")]
    ArgsToJson(#[source] serde_json::Error),
}

impl From<CustomError> for ActionError {
    fn from(value: CustomError) -> Self {
        Self::Custom(value)
    }
}