use std::ffi::{OsStr, OsString};
use std::fmt::{self, Write as FmtWrite};
use std::io::Write as IoWrite;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
#[cfg(feature = "spinners")]
use std::sync::Arc;
#[cfg(feature = "spinners")]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(feature = "spinners")]
use std::thread::JoinHandle;
#[cfg(feature = "spinners")]
use std::time::Duration;
#[cfg(feature = "spinners")]
use spinners::Spinners;
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
#[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> {
self.run_inner(cmd, None)
}
pub fn run_with_spinner(self, cmd: &Cmd, message: &str) -> Result<(), ExecError> {
self.run_inner(cmd, Some(message))
}
fn run_inner(self, cmd: &Cmd, spinner_message: Option<&str>) -> Result<(), ExecError> {
let pretty = pretty_cmd(&cmd.program, &cmd.args, cmd.cwd.as_deref());
if self.dry_run {
if self.verbose >= 3 {
eprintln!("{pretty}");
}
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);
}
if self.verbose >= 3 {
reset_terminal_colors();
eprintln!("{pretty}");
c.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = c.status()?;
if !status.success() {
return Err(ExecError::Failed {
cmd: pretty,
status,
});
}
return Ok(());
}
if self.verbose == 2 {
c.stdin(Stdio::inherit())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut spinner = start_spinner(self.verbose, self.dry_run, spinner_message);
let output = c.output();
if let Some(ref mut sp) = spinner {
stop_spinner(sp, spinner_message);
}
let output = output?;
if !output.status.success() {
let mut err = StandardStream::stderr(ColorChoice::Auto);
if write_red_line(&mut err, &pretty).is_err() {
drop(err);
}
if !output.stdout.is_empty() {
let mut out = std::io::stdout();
if write_raw_bytes(&mut out, &output.stdout).is_err() {
}
}
if !output.stderr.is_empty() {
let mut err_stream = std::io::stderr();
if write_raw_bytes(&mut err_stream, &output.stderr).is_err() {
}
}
return Err(ExecError::Failed {
cmd: pretty,
status: output.status,
});
}
return Ok(());
}
c.stdin(Stdio::inherit())
.stdout(Stdio::null())
.stderr(Stdio::null());
let status = c.status()?;
if !status.success() {
return Err(ExecError::Failed {
cmd: pretty,
status,
});
}
Ok(())
}
}
fn reset_terminal_colors() {
let mut out = StandardStream::stdout(ColorChoice::Auto);
if out.reset().is_err() {
}
let mut err = StandardStream::stderr(ColorChoice::Auto);
if err.reset().is_err() {
}
let mut raw_out = std::io::stdout();
if raw_out.write_all(b"\x1b[0m").is_err() {
}
if raw_out.flush().is_err() {
}
let mut raw_err = std::io::stderr();
if raw_err.write_all(b"\x1b[0m").is_err() {
}
if raw_err.flush().is_err() {
}
}
#[cfg(feature = "spinners")]
struct SpinnerHandle {
stop: Arc<AtomicBool>,
join: Option<JoinHandle<()>>,
message: String,
}
#[cfg(not(feature = "spinners"))]
type SpinnerHandle = ();
#[cfg(feature = "spinners")]
fn start_spinner(verbose: u8, dry_run: bool, message: Option<&str>) -> Option<SpinnerHandle> {
if dry_run || verbose != 2 {
return None;
}
let msg = message.map_or_else(String::new, |m| format!("kaze: {m}"));
if msg.is_empty() {
return None;
}
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = Arc::clone(&stop);
let msg_thread = msg.clone();
let join = std::thread::spawn(move || {
let spinner = Spinners::SimpleDots;
let _ = spinner;
let interval_ms: u64 = 400;
let frames = [". ", ".. ", "...", " "];
let mut frames = frames.iter().cycle();
let mut out = StandardStream::stdout(ColorChoice::Auto);
let mut spec = ColorSpec::new();
spec.set_fg(Some(Color::Green));
while !stop_thread.load(Ordering::Relaxed) {
let frame = frames.next().unwrap_or(&"");
if out.set_color(&spec).is_ok() {
if write!(out, "\r{msg_thread} {frame}").is_err() {
break;
}
if out.reset().is_err() {
break;
}
if out.flush().is_err() {
break;
}
}
std::thread::sleep(Duration::from_millis(interval_ms));
}
});
Some(SpinnerHandle {
stop,
join: Some(join),
message: msg,
})
}
#[cfg(not(feature = "spinners"))]
const fn start_spinner(_: u8, _: bool, _: Option<&str>) -> Option<SpinnerHandle> {
None
}
#[cfg(feature = "spinners")]
fn stop_spinner(spinner: &mut SpinnerHandle, _message: Option<&str>) {
spinner.stop.store(true, Ordering::Relaxed);
if let Some(join) = spinner.join.take() {
if join.join().is_err() {
}
}
let mut out = StandardStream::stdout(ColorChoice::Auto);
if write!(out, "\r\x1b[2K").is_err() {
return;
}
let mut spec = ColorSpec::new();
spec.set_fg(Some(Color::Green));
if out.set_color(&spec).is_ok() {
if writeln!(out, "{}", spinner.message).is_err() {
}
if out.reset().is_err() {
}
} else if writeln!(out, "{}", spinner.message).is_err() {
}
}
#[cfg(not(feature = "spinners"))]
const fn stop_spinner(_: &mut SpinnerHandle, _: Option<&str>) {}
fn write_red_line(out: &mut dyn WriteColor, msg: &str) -> std::io::Result<()> {
let mut spec = ColorSpec::new();
spec.set_fg(Some(Color::Red));
out.set_color(&spec)?;
writeln!(out, "{msg}")?;
out.reset()?;
Ok(())
}
fn write_raw_bytes(out: &mut dyn IoWrite, bytes: &[u8]) -> std::io::Result<()> {
out.write_all(bytes)?;
out.flush()?;
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"));
}
#[test]
fn run_verbose_three_succeeds() {
let cmd = Cmd::new("sh").args(["-c", "true"]);
let runner = Runner {
verbose: 3,
dry_run: false,
};
runner.run(&cmd).expect("run ok");
}
#[test]
fn run_verbose_three_fails() {
let cmd = Cmd::new("sh").args(["-c", "exit 1"]);
let runner = Runner {
verbose: 3,
dry_run: false,
};
let err = runner.run(&cmd).expect_err("run fails");
assert!(matches!(err, ExecError::Failed { .. }));
}
#[test]
fn run_verbose_three_dry_run_no_spawn() {
let cmd = Cmd::new("definitely-does-not-exist");
let runner = Runner {
verbose: 3,
dry_run: true,
};
runner.run(&cmd).expect("dry run ok");
}
#[test]
fn run_verbose_two_reports_failure_with_output() {
let cmd = Cmd::new("sh").args(["-c", "echo out; echo err 1>&2; exit 2"]);
let runner = Runner {
verbose: 2,
dry_run: false,
};
let err = runner.run(&cmd).expect_err("run fails");
assert!(matches!(err, ExecError::Failed { .. }));
}
#[test]
fn run_with_spinner_succeeds() {
let cmd = Cmd::new("sh").args(["-c", "true"]);
let runner = Runner {
verbose: 2,
dry_run: false,
};
runner.run_with_spinner(&cmd, "Doing work").expect("run ok");
}
#[test]
fn reset_terminal_colors_best_effort() {
reset_terminal_colors();
}
#[cfg(feature = "spinners")]
#[test]
fn spinner_none_when_dry_run_or_not_verbose() {
assert!(start_spinner(2, true, Some("x")).is_none());
assert!(start_spinner(1, false, Some("x")).is_none());
}
#[cfg(feature = "spinners")]
#[test]
fn spinner_starts_and_stops() {
let mut spinner = start_spinner(2, false, Some("Working")).expect("spinner started");
stop_spinner(&mut spinner, Some("Working"));
}
#[test]
fn write_raw_bytes_preserves_content() {
let mut buf = Vec::new();
write_raw_bytes(&mut buf, b"oops").expect("write bytes");
assert_eq!(buf, b"oops");
}
}