xbp 10.15.4

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use anyhow::Context;
use std::process::Output;
use std::process::Stdio;
use tokio::process::Command;
use tracing::debug;

use crate::sdk::{command_debug_log, command_failure_message, decode_output};

/// Normalized command execution result.
#[derive(Debug)]
pub struct CommandOutcome {
    pub output: Output,
    pub stdout: String,
    pub stderr: String,
}

impl CommandOutcome {
    pub fn is_success(&self) -> bool {
        self.output.status.success()
    }
}

/// Thin async runner with optional debug tracing.
#[derive(Clone, Debug)]
pub struct CommandRunner {
    debug: bool,
}

impl CommandRunner {
    pub fn new(debug: bool) -> Self {
        Self { debug }
    }

    pub async fn run(&self, program: &str, args: &[&str]) -> anyhow::Result<CommandOutcome> {
        let output = Command::new(program)
            .args(args)
            .output()
            .await
            .with_context(|| format!("Failed to execute {} {}", program, args.join(" ")))?;

        command_debug_log(self.debug, program, args, &output, |msg| debug!("{}", msg));

        let stdout = decode_output(&output.stdout);
        let stderr = decode_output(&output.stderr);

        Ok(CommandOutcome {
            output,
            stdout,
            stderr,
        })
    }

    pub async fn run_checked(
        &self,
        program: &str,
        args: &[&str],
        hint: Option<&str>,
    ) -> anyhow::Result<CommandOutcome> {
        let outcome = self.run(program, args).await?;
        if !outcome.output.status.success() {
            let message = command_failure_message(program, args, &outcome.output, hint);
            return Err(anyhow::anyhow!(message));
        }
        Ok(outcome)
    }

    pub async fn run_with_stdio(
        &self,
        program: &str,
        args: &[&str],
    ) -> anyhow::Result<std::process::ExitStatus> {
        let status = Command::new(program)
            .args(args)
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()
            .await
            .with_context(|| format!("Failed to execute {} {}", program, args.join(" ")))?;

        if self.debug {
            debug!(
                "{} {} -> status={}",
                program,
                args.join(" "),
                status
                    .code()
                    .map(|c| c.to_string())
                    .unwrap_or_else(|| "SIG".into())
            );
        }
        Ok(status)
    }

    pub async fn run_builder<F>(
        &self,
        program: &str,
        args: &[&str],
        configure: F,
    ) -> anyhow::Result<CommandOutcome>
    where
        F: FnOnce(&mut Command),
    {
        let mut cmd = Command::new(program);
        cmd.args(args);
        configure(&mut cmd);

        let output = cmd
            .output()
            .await
            .with_context(|| format!("Failed to execute {} {}", program, args.join(" ")))?;

        command_debug_log(self.debug, program, args, &output, |msg| debug!("{}", msg));

        let stdout = decode_output(&output.stdout);
        let stderr = decode_output(&output.stderr);

        Ok(CommandOutcome {
            output,
            stdout,
            stderr,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn command_runner_success() {
        let runner = CommandRunner::new(true);
        #[cfg(windows)]
        let outcome = runner
            .run_checked("cmd", &["/C", "echo hello"], None)
            .await
            .unwrap();

        #[cfg(not(windows))]
        let outcome = runner
            .run_checked("sh", &["-c", "echo hello"], None)
            .await
            .unwrap();

        assert!(outcome.is_success());
        assert_eq!(outcome.stdout, "hello");
    }

    #[tokio::test]
    async fn command_runner_failure_returns_error() {
        let runner = CommandRunner::new(false);
        #[cfg(windows)]
        let err = runner
            .run_checked("cmd", &["/C", "exit /b 1"], Some("intentional failure"))
            .await
            .unwrap_err();

        #[cfg(not(windows))]
        let err = runner
            .run_checked("sh", &["-c", "false"], Some("intentional failure"))
            .await
            .unwrap_err();

        assert!(err.to_string().contains("failed with status"));
    }
}