use std::ffi::{OsStr, OsString};
use std::fmt::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
#[derive(Debug, Clone)]
pub struct Cmd {
pub program: OsString,
pub args: Vec<OsString>,
pub cwd: Option<PathBuf>,
pub env: Vec<(OsString, OsString)>,
}
impl Cmd {
pub fn new(program: impl Into<OsString>) -> Self {
Self {
program: program.into(),
args: vec![],
cwd: None,
env: vec![],
}
}
#[must_use]
pub fn arg(mut self, a: impl Into<OsString>) -> Self {
self.args.push(a.into());
self
}
#[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
}
#[must_use]
pub fn cwd(mut self, p: impl Into<PathBuf>) -> Self {
self.cwd = Some(p.into());
self
}
#[must_use]
pub fn env(mut self, k: impl Into<OsString>, v: impl Into<OsString>) -> Self {
self.env.push((k.into(), v.into()));
self
}
}
#[derive(Debug)]
pub enum ExecError {
Io(std::io::Error),
Failed {
cmd: String,
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)
}
}
#[derive(Debug, Clone, Copy)]
pub struct Runner {
pub verbose: u8,
pub dry_run: bool,
}
impl Runner {
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(())
}
}
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
}
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"));
}
}