nishikaze 0.1.0

Zephyr build system companion.
Documentation
//! Execution helpers for running external commands.

use std::ffi::{OsStr, OsString};
use std::fmt::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};

/// Command specification to execute.
#[derive(Debug, Clone)]
pub struct Cmd {
    /// Program to execute.
    pub program: OsString,
    /// Program arguments.
    pub args: Vec<OsString>,
    /// Optional working directory.
    pub cwd: Option<PathBuf>,
    /// Environment overrides.
    pub env: Vec<(OsString, OsString)>,
}

impl Cmd {
    /**
     * Creates a new command builder.
     *
     * # Returns
     * Constructed `Cmd` with program name set.
     */
    pub fn new(program: impl Into<OsString>) -> Self {
        Self {
            program: program.into(),
            args: vec![],
            cwd: None,
            env: vec![],
        }
    }

    /**
     * Appends a single argument.
     *
     * # Returns
     * Updated `Cmd`.
     */
    #[must_use]
    pub fn arg(mut self, a: impl Into<OsString>) -> Self {
        self.args.push(a.into());
        self
    }

    /**
     * Appends multiple arguments.
     *
     * # Returns
     * Updated `Cmd`.
     */
    #[must_use]
    pub fn args<I, S>(mut self, it: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<OsString>,
    {
        self.args.extend(it.into_iter().map(Into::into));
        self
    }

    /**
     * Sets the working directory.
     *
     * # Returns
     * Updated `Cmd`.
     */
    #[must_use]
    pub fn cwd(mut self, p: impl Into<PathBuf>) -> Self {
        self.cwd = Some(p.into());
        self
    }

    /**
     * Adds an environment override.
     *
     * # Returns
     * Updated `Cmd`.
     */
    #[must_use]
    pub fn env(mut self, k: impl Into<OsString>, v: impl Into<OsString>) -> Self {
        self.env.push((k.into(), v.into()));
        self
    }
}

/// Execution errors from running a command.
#[derive(Debug)]
pub enum ExecError {
    /// Process spawn failed.
    Io(std::io::Error),
    /// Process exited with failure status.
    Failed {
        /// Rendered command line for diagnostics.
        cmd: String,
        /// Exit status returned by the process.
        status: ExitStatus,
    },
}

impl fmt::Display for ExecError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match *self {
            Self::Io(ref e) => write!(f, "process spawn failed: {e}"),
            Self::Failed {
                ref cmd,
                ref status,
            } => write!(f, "command failed: {cmd} - {status}"),
        }
    }
}

impl From<std::io::Error> for ExecError {
    fn from(value: std::io::Error) -> Self {
        Self::Io(value)
    }
}

/// Command runner configuration.
#[derive(Debug, Clone, Copy)]
pub struct Runner {
    /// Verbosity level.
    pub verbose: u8,
    /// Whether to skip execution and only print the command.
    pub dry_run: bool,
}

impl Runner {
    /**
     * Runs a command using the configured runner behavior.
     *
     * # Errors
     * Returns `ExecError::Io` if the process cannot be spawned and
     * `ExecError::Failed` if the command exits unsuccessfully.
     */
    pub fn run(self, cmd: &Cmd) -> Result<(), ExecError> {
        let pretty = pretty_cmd(&cmd.program, &cmd.args, cmd.cwd.as_deref());
        if self.verbose > 0 || self.dry_run {
            eprintln!("{pretty}");
        }

        if self.dry_run {
            return Ok(());
        }

        let mut c = Command::new(&cmd.program);
        c.args(&cmd.args);

        if let Some(cwd) = cmd.cwd.as_deref() {
            c.current_dir(cwd);
        }

        for pair in &cmd.env {
            c.env(&pair.0, &pair.1);
        }

        c.stdin(Stdio::inherit())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit());

        let status = c.status()?;
        if !status.success() {
            return Err(ExecError::Failed {
                cmd: pretty,
                status,
            });
        }

        Ok(())
    }
}

/**
 * Formats a command for logging, including a `(cd ...)` wrapper when a
 * working directory is supplied.
 *
 * # Arguments
 * - `program`: Executable name.
 * - `args`: Argument list to render.
 * - `cwd`: Optional working directory to include in the output.
 *
 * # Returns
 * Rendered command string suitable for logs.
 */
fn pretty_cmd(program: &OsStr, args: &[OsString], cwd: Option<&Path>) -> String {
    let mut s = String::new();
    if let Some(cwd) = cwd {
        let _e = write!(s, "(cd {} && ", cwd.display());
    }

    s.push_str(&shellish(program));

    for a in args {
        s.push(' ');
        s.push_str(&shellish(a));
    }

    if cwd.is_some() {
        s.push(')');
    }

    s
}

/**
 * Returns a shell-friendly representation, quoting whitespace to preserve
 * argument boundaries.
 *
 * # Arguments
 * - `s`: Argument to render.
 *
 * # Returns
 * Shell-friendly string with whitespace preserved.
 */
fn shellish(s: &OsStr) -> String {
    let t = s.to_string_lossy();
    if t.chars().any(char::is_whitespace) {
        format!("{t:?}")
    } else {
        t.into_owned()
    }
}

#[cfg(test)]
mod tests {
    use std::os::unix::process::ExitStatusExt;
    use std::path::PathBuf;

    use super::*;

    #[test]
    fn dry_run_does_not_execute() {
        let cmd = Cmd::new("definitely-does-not-exist").arg("--help");
        let runner = Runner {
            verbose: 1,
            dry_run: true,
        };
        runner.run(&cmd).expect("dry run should succeed");
    }

    #[test]
    fn run_executes_when_not_dry_run() {
        let cmd = Cmd::new("definitely-does-not-exist").arg("--help");
        let runner = Runner {
            verbose: 0,
            dry_run: false,
        };
        let err = runner.run(&cmd).expect_err("spawn fails");
        assert!(matches!(err, ExecError::Io(_)));
    }

    #[test]
    fn cmd_builder_sets_fields() {
        let cmd = Cmd::new("tool")
            .arg("a")
            .args(["b", "c"])
            .cwd("dir")
            .env("KEY", "VALUE");
        assert_eq!(cmd.program, OsString::from("tool"));
        assert_eq!(
            cmd.args,
            vec![
                OsString::from("a"),
                OsString::from("b"),
                OsString::from("c")
            ]
        );
        assert_eq!(cmd.cwd, Some(PathBuf::from("dir")));
        assert_eq!(
            cmd.env,
            vec![(OsString::from("KEY"), OsString::from("VALUE"))]
        );
    }

    #[test]
    fn pretty_cmd_includes_cwd_and_quotes_whitespace() {
        let cwd = PathBuf::from("/tmp/my dir");
        let cmd = Cmd::new("tool").arg("arg one").arg("arg2").cwd(&cwd);
        let out = pretty_cmd(&cmd.program, &cmd.args, cmd.cwd.as_deref());
        assert!(out.starts_with("(cd "));
        assert!(out.contains("/tmp/my dir"));
        assert!(out.contains("\"arg one\""));
        assert!(out.ends_with(')'));
    }

    #[test]
    fn exec_error_display_formats_messages() {
        let err = ExecError::Failed {
            cmd: "tool --bad".to_owned(),
            status: std::process::ExitStatus::from_raw(1 << 8),
        };
        let msg = err.to_string();
        assert!(msg.contains("command failed:"));
        assert!(msg.contains("tool --bad"));
    }
}