release-tool 0.4.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::{Command, Stdio};

#[derive(Clone, Eq, PartialEq)]
pub struct CommandRequest {
    pub program: String,
    pub arguments: Vec<String>,
    pub current_dir: PathBuf,
    pub environment: BTreeMap<String, String>,
}

impl std::fmt::Debug for CommandRequest {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let redacted_environment: BTreeMap<_, _> = self
            .environment
            .keys()
            .map(|key| (key, "[REDACTED]"))
            .collect();
        formatter
            .debug_struct("CommandRequest")
            .field("program", &self.program)
            .field("arguments", &self.arguments)
            .field("current_dir", &self.current_dir)
            .field("environment", &redacted_environment)
            .finish()
    }
}

impl CommandRequest {
    pub fn new(
        program: impl Into<String>,
        arguments: impl IntoIterator<Item = impl Into<String>>,
        current_dir: impl Into<PathBuf>,
    ) -> Self {
        Self {
            program: program.into(),
            arguments: arguments.into_iter().map(Into::into).collect(),
            current_dir: current_dir.into(),
            environment: BTreeMap::new(),
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CommandResult {
    pub status: i32,
    pub stdout: String,
    pub stderr: String,
}

impl CommandResult {
    pub fn redact(mut self, secrets: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
        for secret in secrets {
            let secret = secret.as_ref();
            if secret.is_empty() {
                continue;
            }
            self.stdout = self.stdout.replace(secret, "[REDACTED]");
            self.stderr = self.stderr.replace(secret, "[REDACTED]");
        }
        self
    }

    pub fn require_success(self, description: &str) -> Result<Self> {
        if self.status == 0 {
            return Ok(self);
        }
        let detail = self.stderr.trim();
        if detail.is_empty() {
            bail!("{description} failed with exit status {}", self.status);
        }
        bail!(
            "{description} failed with exit status {}: {detail}",
            self.status
        )
    }
}

pub trait CommandRunner: Send + Sync {
    fn execute(&self, request: &CommandRequest) -> Result<CommandResult>;

    /// Runs a project-owned command with native stdout/stderr when the adapter supports it.
    /// Custom adapters retain capture semantics until they explicitly opt into streaming.
    fn execute_inheriting_output(&self, request: &CommandRequest) -> Result<CommandResult> {
        self.execute(request)
    }
}

#[derive(Clone, Copy, Debug, Default)]
pub struct SystemCommandRunner;

impl CommandRunner for SystemCommandRunner {
    fn execute(&self, request: &CommandRequest) -> Result<CommandResult> {
        let output = command(request)
            .output()
            .with_context(|| format!("failed to execute `{}`", request.program))?;
        Ok(CommandResult {
            status: output.status.code().unwrap_or(1),
            stdout: String::from_utf8(output.stdout)
                .context("command stdout is not valid UTF-8")?,
            stderr: String::from_utf8(output.stderr)
                .context("command stderr is not valid UTF-8")?,
        })
    }

    fn execute_inheriting_output(&self, request: &CommandRequest) -> Result<CommandResult> {
        let status = command(request)
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .status()
            .with_context(|| format!("failed to execute `{}`", request.program))?;
        Ok(CommandResult {
            status: status.code().unwrap_or(1),
            stdout: String::new(),
            stderr: String::new(),
        })
    }
}

fn command(request: &CommandRequest) -> Command {
    let mut command = Command::new(&request.program);
    command
        .args(&request.arguments)
        .current_dir(&request.current_dir)
        .envs(&request.environment)
        .stdin(Stdio::null());
    command
}