use std::{ fmt::Display, fs::File, os::unix::process::CommandExt, path::Path, process::{Command, ExitStatus, Output, Stdio}, }; use anyhow::{Context, Result, anyhow}; use crate::ctx::Ctx; // a program and its arguments: the only thing in ahab that knows what argv looks like #[derive(Default, Clone)] pub struct Argv(Vec); impl Display for Argv { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.quoted()) } } impl Argv { pub fn new(program: &str) -> Self { Self(vec![program.to_string()]) } pub fn arg(mut self, arg: impl AsRef) -> Self { self.0.push(arg.as_ref().to_string()); self } pub fn args(mut self, args: I) -> Self where I: IntoIterator, S: AsRef, { self.0 .extend(args.into_iter().map(|arg| arg.as_ref().to_string())); self } // a long option and the value it takes pub fn flag(self, name: &str, value: impl AsRef) -> Self { self.arg(name).arg(value) } pub fn program(&self) -> &str { self.0.first().map(String::as_str).unwrap_or_default() } pub fn words(&self) -> &[String] { &self.0 } // one word list for `sh -c`, quoted so a value with a space survives the shell pub fn quoted(&self) -> String { // a nul byte is the only thing shlex refuses, and no argv can hold one shlex::try_join(self.0.iter().map(String::as_str)).unwrap_or_default() } pub fn run(&self, ctx: &Ctx) -> Result<()> { if self.skipped(ctx) { return Ok(()); } let status = self.command(ctx)?.spawn()?.wait()?; self.check(status) } // reading changes nothing, so a dry run answers the question for real pub fn capture(&self, ctx: &Ctx) -> Result { let out = self.command(ctx)?.output()?; if !out.status.success() { // output() holds stderr back, so the command's own complaint has to be // passed on here or it is lost let stderr = String::from_utf8_lossy(&out.stderr); return Err(match stderr.trim() { "" => self.failure(out.status), reason => anyhow!("`{self}` failed: {reason}"), }); } Ok(String::from_utf8(out.stdout)?) } // replaces this process, so the command's exit code and signals become ours pub fn replace(&self, ctx: &Ctx) -> Result<()> { if self.skipped(ctx) { return Ok(()); } let error = self.command(ctx)?.exec(); Err(error).with_context(|| format!("running `{self}`")) } pub fn stream_to(&self, ctx: &Ctx, out: Stdio) -> Result<()> { if self.skipped(ctx) { return Ok(()); } let status = self.command(ctx)?.stdout(out).spawn()?.wait()?; self.check(status) } // the status rather than an error, for callers with something better to say pub fn status(&self, ctx: &Ctx) -> Result { Ok(self.command(ctx)?.spawn()?.wait()?) } pub fn stdin_from(&self, ctx: &Ctx, input: &Path) -> Result { let stdin = self.opened(input)?; Ok(self.command(ctx)?.stdin(stdin).spawn()?.wait()?) } // both streams held back, for callers that read the command's complaints pub fn stdin_from_captured(&self, ctx: &Ctx, input: &Path) -> Result { let stdin = self.opened(input)?; Ok(self.command(ctx)?.stdin(stdin).output()?) } // whether it succeeded, without failure being an error pub fn quietly_succeeds(&self, ctx: &Ctx) -> Result { Ok(self .command(ctx)? .stdout(Stdio::null()) .spawn()? .wait()? .success()) } fn command(&self, ctx: &Ctx) -> Result { if ctx.verbose { eprintln!("running `{self}`"); } let (program, rest) = self.0.split_first().context("empty command")?; let mut command = Command::new(program); command.args(rest); Ok(command) } fn skipped(&self, ctx: &Ctx) -> bool { if ctx.dry_run { eprintln!("would run `{self}`"); return true; } false } fn opened(&self, path: &Path) -> Result { let file = File::open(path).with_context(|| format!("opening {}", path.display()))?; Ok(Stdio::from(file)) } fn check(&self, status: ExitStatus) -> Result<()> { if status.success() { return Ok(()); } Err(self.failure(status)) } fn failure(&self, status: ExitStatus) -> anyhow::Error { match status.code() { Some(code) => anyhow!("`{self}` exited with {code}"), None => anyhow!("`{self}` was killed by a signal"), } } } #[cfg(test)] mod tests { use super::Argv; #[test] fn plain_words_are_left_as_they_are() { let argv = Argv::new("pg_dump") .flag("--username", "myproject") .arg("--data-only") .arg("myproject_db"); assert_eq!( argv.quoted(), "pg_dump --username myproject --data-only myproject_db" ); } #[test] fn anything_a_shell_would_read_as_more_than_one_word_is_quoted() { assert_eq!(Argv::new("psql").arg("my db").quoted(), "psql 'my db'"); assert_eq!(Argv::new("sh").arg("a | b").quoted(), "sh 'a | b'"); assert_eq!(Argv::new("echo").arg("").quoted(), "echo ''"); assert_eq!(Argv::new("echo").arg("it's").quoted(), r#"echo "it's""#); } }