use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CliRun {
pub exit_status: i32,
pub output: String,
pub duration_ms: u64,
}
impl CliRun {
#[must_use]
pub fn succeeded(&self) -> bool {
self.exit_status == 0
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CliFailure {
ExecutableNotFound {
executable: String,
},
SpawnFailed {
reason: String,
},
}
impl CliFailure {
#[must_use]
pub fn message(&self) -> String {
match self {
Self::ExecutableNotFound { executable } => {
format!("executable not found on PATH: {executable}")
}
Self::SpawnFailed { reason } => {
format!("command could not be spawned: {reason}")
}
}
}
}
#[derive(Clone, Debug)]
pub struct Shell {
path_override: Option<OsString>,
}
impl Shell {
#[must_use]
pub fn inherited() -> Self {
Self {
path_override: None,
}
}
pub fn with_path(path: impl Into<OsString>) -> Self {
Self {
path_override: Some(path.into()),
}
}
pub fn run(&self, executable: &str, args: &[&str], cwd: &str) -> Result<CliRun, CliFailure> {
if !Path::new(cwd).is_dir() {
return Err(CliFailure::SpawnFailed {
reason: format!("working directory does not exist: {cwd}"),
});
}
let resolved =
self.find_executable(executable)
.ok_or_else(|| CliFailure::ExecutableNotFound {
executable: executable.to_owned(),
})?;
let mut command = Command::new(resolved);
command.args(args).current_dir(cwd);
if let Some(path) = &self.path_override {
command.env("PATH", path);
}
let started = Instant::now();
let output = command.output().map_err(|error| CliFailure::SpawnFailed {
reason: error.to_string(),
})?;
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
combined.push_str(&String::from_utf8_lossy(&output.stderr));
Ok(CliRun {
exit_status: exit_code(output.status),
output: combined,
duration_ms,
})
}
fn find_executable(&self, executable: &str) -> Option<PathBuf> {
let search_path = match &self.path_override {
Some(path) => path.clone(),
None => std::env::var_os("PATH")?,
};
std::env::split_paths(&search_path)
.map(|directory| directory.join(executable))
.find(|candidate| is_executable_file(candidate))
}
}
#[cfg(unix)]
fn exit_code(status: std::process::ExitStatus) -> i32 {
use std::os::unix::process::ExitStatusExt;
match (status.code(), status.signal()) {
(Some(code), _) => code,
(None, Some(signal)) => 128 + signal,
(None, None) => -1,
}
}
#[cfg(not(unix))]
fn exit_code(status: std::process::ExitStatus) -> i32 {
status.code().unwrap_or(-1)
}
#[cfg(unix)]
fn is_executable_file(candidate: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
candidate
.metadata()
.map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable_file(candidate: &Path) -> bool {
candidate.is_file()
}