use std::ffi::OsStr;
use std::path::Path;
use std::process::{Command, Output, Stdio};
use anyhow::{Context, Result, anyhow};
pub fn run(command: &str) -> Result<()> {
let status = shell_command(command)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.with_context(|| format!("Failed to execute command: {command}"))?;
if status.success() {
Ok(())
} else {
Err(anyhow!(
"Command failed with exit code {}: {}",
status.code().unwrap_or(-1),
command
))
}
}
pub fn output(command: &str) -> Result<String> {
let output = shell_command(command)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.with_context(|| format!("Failed to execute command: {command}"))?;
if output.status.success() {
String::from_utf8(output.stdout)
.with_context(|| format!("Command output was not valid UTF-8: {command}"))
.map(|s| s.trim_end().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow!(
"Command failed with exit code {}: {}\n{}",
output.status.code().unwrap_or(-1),
command,
stderr
))
}
}
#[must_use]
pub fn cmd<S: AsRef<OsStr>>(program: S) -> CommandBuilder {
CommandBuilder::new(program)
}
pub struct CommandBuilder {
command: Command,
}
pub struct PipeBuilder {
commands: Vec<(String, Vec<String>)>,
}
pub struct ChildProcess {
child: std::process::Child,
}
impl CommandBuilder {
fn new<S: AsRef<OsStr>>(program: S) -> Self {
Self {
command: Command::new(program),
}
}
#[must_use]
pub fn arg<S: AsRef<OsStr>>(mut self, arg: S) -> Self {
self.command.arg(arg);
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.command.args(args);
self
}
#[must_use]
pub fn dir<P: AsRef<Path>>(mut self, dir: P) -> Self {
self.command.current_dir(dir);
self
}
#[must_use]
pub fn env<K, V>(mut self, key: K, val: V) -> Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.command.env(key, val);
self
}
pub fn run(mut self) -> Result<()> {
let status = self
.command
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("Failed to execute command")?;
if status.success() {
Ok(())
} else {
Err(anyhow!(
"Command failed with exit code {}",
status.code().unwrap_or(-1)
))
}
}
pub fn output(mut self) -> Result<String> {
let output = self
.command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.context("Failed to execute command")?;
if output.status.success() {
String::from_utf8(output.stdout)
.context("Command output was not valid UTF-8")
.map(|s| s.trim_end().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow!(
"Command failed with exit code {}\n{}",
output.status.code().unwrap_or(-1),
stderr
))
}
}
pub fn output_raw(mut self) -> Result<Output> {
self.command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.context("Failed to execute command")
}
#[must_use]
pub fn pipe<S: AsRef<OsStr>>(self, program: S, args: &[&str]) -> PipeBuilder {
let first_program = self.command.get_program().to_string_lossy().to_string();
let first_args: Vec<String> = self
.command
.get_args()
.map(|s| s.to_string_lossy().to_string())
.collect();
let second_program = program.as_ref().to_string_lossy().to_string();
let second_args: Vec<String> = args.iter().map(std::string::ToString::to_string).collect();
PipeBuilder {
commands: vec![(first_program, first_args), (second_program, second_args)],
}
}
pub fn spawn(mut self) -> Result<ChildProcess> {
let child = self
.command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn command")?;
Ok(ChildProcess { child })
}
}
impl PipeBuilder {
#[must_use]
pub fn pipe<S: AsRef<OsStr>>(mut self, program: S, args: &[&str]) -> Self {
let program = program.as_ref().to_string_lossy().to_string();
let args: Vec<String> = args.iter().map(std::string::ToString::to_string).collect();
self.commands.push((program, args));
self
}
pub fn output(self) -> Result<String> {
if self.commands.is_empty() {
return Err(anyhow!("Cannot execute empty pipe chain"));
}
let mut prev_stdout: Option<std::process::ChildStdout> = None;
for (i, (program, args)) in self.commands.iter().enumerate() {
let mut command = Command::new(program);
command.args(args);
if let Some(stdout) = prev_stdout.take() {
command.stdin(Stdio::from(stdout));
} else {
command.stdin(Stdio::inherit());
}
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
if i < self.commands.len() - 1 {
let mut child = command
.spawn()
.with_context(|| format!("Failed to spawn: {program}"))?;
prev_stdout = child.stdout.take();
} else {
let output = command
.output()
.with_context(|| format!("Failed to execute: {program}"))?;
if output.status.success() {
return String::from_utf8(output.stdout)
.context("Command output was not valid UTF-8")
.map(|s| s.trim_end().to_string());
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("Pipe command failed: {program}\n{stderr}"));
}
}
Err(anyhow!("Pipe chain ended unexpectedly"))
}
pub fn run(self) -> Result<()> {
if self.commands.is_empty() {
return Err(anyhow!("Cannot execute empty pipe chain"));
}
let mut prev_stdout: Option<std::process::ChildStdout> = None;
for (i, (program, args)) in self.commands.iter().enumerate() {
let mut command = Command::new(program);
command.args(args);
if let Some(stdout) = prev_stdout.take() {
command.stdin(Stdio::from(stdout));
} else {
command.stdin(Stdio::inherit());
}
if i < self.commands.len() - 1 {
command.stdout(Stdio::piped());
let mut child = command
.spawn()
.with_context(|| format!("Failed to spawn: {program}"))?;
prev_stdout = child.stdout.take();
} else {
command.stdout(Stdio::inherit());
command.stderr(Stdio::inherit());
let status = command
.status()
.with_context(|| format!("Failed to execute: {program}"))?;
if !status.success() {
return Err(anyhow!(
"Pipe command failed with exit code {}: {}",
status.code().unwrap_or(-1),
program
));
}
}
}
Ok(())
}
}
impl ChildProcess {
pub fn wait(mut self) -> Result<()> {
let status = self
.child
.wait()
.context("Failed to wait for child process")?;
if status.success() {
Ok(())
} else {
Err(anyhow!(
"Child process exited with code {}",
status.code().unwrap_or(-1)
))
}
}
pub fn kill(&mut self) -> Result<()> {
self.child.kill().context("Failed to kill child process")
}
pub fn try_wait(&mut self) -> Result<Option<std::process::ExitStatus>> {
self.child
.try_wait()
.context("Failed to check child process status")
}
}
fn shell_command(command: &str) -> Command {
if cfg!(target_os = "windows") {
let mut cmd = Command::new("cmd");
cmd.args(["/C", command]);
cmd
} else {
let mut cmd = Command::new("sh");
cmd.args(["-c", command]);
cmd
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> TempDir {
TempDir::new().unwrap()
}
#[test]
fn run_executes_command() {
let result = if cfg!(target_os = "windows") {
run("echo hello")
} else {
run("true")
};
assert!(result.is_ok());
}
#[test]
fn run_fails_on_bad_command() {
let result = run("nonexistent_command_12345");
assert!(result.is_err());
}
#[test]
fn run_fails_on_nonzero_exit() {
let result = if cfg!(target_os = "windows") {
run("cmd /c exit 1")
} else {
run("false")
};
assert!(result.is_err());
}
#[test]
fn output_captures_stdout() {
let result = if cfg!(target_os = "windows") {
output("echo hello")
} else {
output("echo hello")
};
assert_eq!(result.unwrap(), "hello");
}
#[test]
fn output_trims_trailing_newlines() {
let result = if cfg!(target_os = "windows") {
output("echo test")
} else {
output("printf 'test\\n\\n'")
};
assert!(result.unwrap().contains("test"));
}
#[test]
fn cmd_builder_with_args() {
let result = if cfg!(target_os = "windows") {
cmd("cmd").args(["/C", "echo", "hello"]).output()
} else {
cmd("echo").arg("hello").output()
};
assert!(result.unwrap().contains("hello"));
}
#[test]
fn cmd_builder_with_dir() {
let dir = setup();
let result = if cfg!(target_os = "windows") {
cmd("cmd").args(["/C", "cd"]).dir(dir.path()).output()
} else {
cmd("pwd").dir(dir.path()).output()
};
let output = result.unwrap();
assert!(!output.is_empty());
}
#[test]
fn cmd_builder_with_env() {
let result = if cfg!(target_os = "windows") {
cmd("cmd")
.args(["/C", "echo", "%SCRIPTKIT_TEST_ENV%"])
.env("SCRIPTKIT_TEST_ENV", "test_value")
.output()
} else {
cmd("sh")
.args(["-c", "echo $SCRIPTKIT_TEST_ENV"])
.env("SCRIPTKIT_TEST_ENV", "test_value")
.output()
};
assert!(result.unwrap().contains("test_value"));
}
#[test]
fn cmd_builder_chains_correctly() {
let builder = cmd("program")
.arg("arg1")
.args(["arg2", "arg3"])
.dir(".")
.env("KEY", "VALUE");
drop(builder);
}
#[test]
#[cfg(unix)]
fn pipe_builder_works() {
let output = cmd("echo")
.arg("hello world")
.pipe("grep", &["hello"])
.output()
.unwrap();
assert!(output.contains("hello"));
}
#[test]
#[cfg(unix)]
fn pipe_builder_multiple_stages() {
let output = cmd("sh")
.args(["-c", "echo -e 'one\\ntwo\\nthree'"])
.pipe("grep", &["-v", "two"])
.pipe("wc", &["-l"])
.output()
.unwrap();
assert!(output.contains("2"));
}
#[test]
#[cfg(unix)]
fn spawn_creates_background_process() {
let mut child = cmd("sleep").arg("0.1").spawn().unwrap();
let _status = child.try_wait().unwrap();
let result = child.wait();
assert!(result.is_ok());
}
#[test]
#[cfg(unix)]
fn spawn_can_be_killed() {
use std::thread;
use std::time::Duration;
let mut child = cmd("sleep").arg("100").spawn().unwrap();
thread::sleep(Duration::from_millis(50));
assert!(child.try_wait().unwrap().is_none());
child.kill().unwrap();
thread::sleep(Duration::from_millis(50));
let _ = child.try_wait();
}
#[test]
#[cfg(windows)]
fn pipe_builder_works_windows() {
}
#[test]
#[cfg(windows)]
fn spawn_creates_background_process_windows() {
let mut child = cmd("timeout")
.args(["/t", "1", "/nobreak"])
.spawn()
.unwrap();
let _ = child.try_wait();
let _ = child.kill();
}
}