use std::path::{Path, PathBuf};
use crate::error::PipelineError;
#[must_use]
pub struct CommandBuilder {
program: String,
args: Vec<String>,
working_dir: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct CommandResult {
pub success: bool,
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
}
impl CommandBuilder {
pub fn new(program: &str) -> Self {
Self {
program: String::from(program),
args: Vec::new(),
working_dir: None,
}
}
pub fn args(mut self, args: &[&str]) -> Self {
self.args.extend(args.iter().map(|s| String::from(*s)));
self
}
pub fn working_dir(mut self, path: &Path) -> Self {
self.working_dir = Some(path.to_path_buf());
self
}
pub async fn execute(self) -> Result<CommandResult, PipelineError> {
let mut cmd = tokio::process::Command::new(&self.program);
let _ = cmd.args(&self.args);
if let Some(dir) = &self.working_dir {
let _ = cmd.current_dir(dir);
}
let output = cmd.output().await?;
let exit_code = output.status.code();
Ok(CommandResult {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code,
})
}
}
impl CommandResult {
pub fn require_success(&self) -> Result<&Self, PipelineError> {
if self.success {
Ok(self)
} else {
let code_str = self
.exit_code
.map_or_else(|| String::from("unknown"), |c| c.to_string());
Err(PipelineError::Command(format!(
"failed (exit code {code_str}): {}",
self.stderr.trim()
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn echo_command_succeeds() {
let result = CommandBuilder::new("echo")
.args(&["hello", "world"])
.execute()
.await;
assert!(result.is_ok(), "echo should not fail to spawn");
let cmd_result = result.unwrap_or_else(|_| CommandResult {
success: false,
stdout: String::new(),
stderr: String::new(),
exit_code: None,
});
assert!(cmd_result.success, "echo should succeed");
assert_eq!(
cmd_result.stdout.trim(),
"hello world",
"echo output should match"
);
assert_eq!(cmd_result.exit_code, Some(0), "exit code should be 0");
}
#[tokio::test]
async fn false_command_fails() {
let result = CommandBuilder::new("false").execute().await;
assert!(result.is_ok(), "false should not fail to spawn");
let cmd_result = result.unwrap_or_else(|_| CommandResult {
success: true,
stdout: String::new(),
stderr: String::new(),
exit_code: None,
});
assert!(!cmd_result.success, "false should report failure");
}
#[test]
fn require_success_on_success() {
let result = CommandResult {
success: true,
stdout: String::from("ok"),
stderr: String::new(),
exit_code: Some(0),
};
assert!(
result.require_success().is_ok(),
"should return Ok for successful command"
);
}
#[test]
fn require_success_on_failure() {
let result = CommandResult {
success: false,
stdout: String::new(),
stderr: String::from("something went wrong"),
exit_code: Some(1),
};
let err = result.require_success();
assert!(err.is_err(), "should return Err for failed command");
}
}