From 1cac743952f9c51852b7892e33c98e65cda36b77 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 25 Jul 2026 13:58:34 -0700 Subject: [PATCH 01/18] feat(hm-common): add process capture helper Add hm_common::process: a `.captured()` extension on std and tokio Command that spawns, captures stdout/stderr + exit status, and returns a typed Captured. `Captured::success()` splits a zero exit (CapturedOk) from a failure (CapturedError, a std::error::Error whose Display names the program, status, and a trimmed stderr snippet). Output is read via the sealed CapturedStreams trait: byte-first `stdout()`, strict `stdout_str`/`stdout_string`, panicking `_unwrap` variants, and lossy `stdout_lossy` (stderr mirrors). Covered by rstest cases + a tokio test. Replace the four hand-rolled spawn/status/utf8 blocks: - hm-dsl-engine python_engine: async `.captured().await?.success()?`, dropping the manual Stdio piping and the bail! error construction. - hm run git_metadata / git_remote_url / git_default_branch: one `git_at()` builder + `.captured()`, collapsing the duplicated Command/output/filter/from_utf8_lossy chains. --- Cargo.lock | 2 + crates/hm-common/Cargo.toml | 1 + crates/hm-common/src/lib.rs | 1 + crates/hm-common/src/process.rs | 335 ++++++++++++++++++++++ crates/hm-dsl-engine/Cargo.toml | 1 + crates/hm-dsl-engine/src/python_engine.rs | 23 +- crates/hm/src/commands/run/mod.rs | 48 ++-- 7 files changed, 373 insertions(+), 38 deletions(-) create mode 100644 crates/hm-common/src/process.rs diff --git a/Cargo.lock b/Cargo.lock index bceb2f8b..7090bb32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1264,6 +1264,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "thiserror 2.0.18", "tokio", "tracing", "which 7.0.3", @@ -1291,6 +1292,7 @@ dependencies = [ "anyhow", "async-trait", "derive_more", + "hm-common", "include_dir", "rstest", "serde", diff --git a/crates/hm-common/Cargo.toml b/crates/hm-common/Cargo.toml index 0e6cd49a..1a571cd2 100644 --- a/crates/hm-common/Cargo.toml +++ b/crates/hm-common/Cargo.toml @@ -17,6 +17,7 @@ async-trait = { workspace = true } derive_more = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } include_dir = "0.7" tempfile = "3" tokio = { version = "1", features = ["process", "fs"] } diff --git a/crates/hm-common/src/lib.rs b/crates/hm-common/src/lib.rs index df5bdfbd..a3115457 100644 --- a/crates/hm-common/src/lib.rs +++ b/crates/hm-common/src/lib.rs @@ -2,3 +2,4 @@ pub mod format; pub mod fs; +pub mod process; diff --git a/crates/hm-common/src/process.rs b/crates/hm-common/src/process.rs new file mode 100644 index 00000000..889912c0 --- /dev/null +++ b/crates/hm-common/src/process.rs @@ -0,0 +1,335 @@ +//! Subprocess execution: spawn a command, capture its output, and inspect the +//! result with a typed success/failure split and byte-first accessors. +//! +//! [`CommandExt`] and [`AsyncCommandExt`] add `.captured()` to +//! [`std::process::Command`] and [`tokio::process::Command`]. It returns a +//! [`Captured`]; call [`Captured::success`] to separate a zero exit from a +//! failure, then read output through the [`CapturedStreams`] accessors. +//! +//! ```no_run +//! use hm_common::process::{CommandExt, CapturedStreams}; +//! +//! let out = std::process::Command::new("git") +//! .args(["rev-parse", "HEAD"]) +//! .captured()? // io::Error if git can't be spawned +//! .success()?; // CapturedError if git exits non-zero +//! let sha = out.stdout_string()?; +//! # Ok::<(), Box>(()) +//! ``` + +use std::borrow::Cow; +use std::future::Future; +use std::io; +use std::process::ExitStatus; +use std::str::Utf8Error; + +mod sealed { + pub trait Sealed {} + impl Sealed for std::process::Command {} + impl Sealed for tokio::process::Command {} + impl Sealed for super::CapturedOk {} + impl Sealed for super::CapturedError {} +} + +/// A finished process: its captured stdout/stderr and exit status. +/// +/// The output bytes are only readable after resolving the exit status with +/// [`success`](Captured::success) — success and failure both expose the +/// [`CapturedStreams`] accessors, so the check can't be skipped by accident. +#[derive(Debug, Clone)] +pub struct Captured { + program: String, + stdout: Vec, + stderr: Vec, + status: ExitStatus, +} + +impl Captured { + /// Split on the exit status: `Ok` if the process exited 0, else `Err`. + /// + /// # Errors + /// Returns [`CapturedError`] when the process exited non-zero (or was + /// killed by a signal). + pub fn success(self) -> Result { + if self.status.success() { + Ok(CapturedOk(self)) + } else { + Err(CapturedError(self)) + } + } + + /// The raw exit status. + #[must_use] + pub const fn status(&self) -> ExitStatus { + self.status + } + + /// The exit code, or `None` if the process was killed by a signal. + #[must_use] + pub fn code(&self) -> Option { + self.status.code() + } +} + +/// A process that exited successfully (status 0). Read output via +/// [`CapturedStreams`]. +#[derive(Debug, Clone)] +pub struct CapturedOk(Captured); + +/// A process that exited non-zero. +/// +/// Implements [`std::error::Error`]; its `Display` names the program, the exit +/// status, and a trimmed snippet of stderr. Output is still readable via +/// [`CapturedStreams`]. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{}", render_capture_error(.0))] +pub struct CapturedError(Captured); + +fn describe_status(status: ExitStatus) -> String { + status.code().map_or_else( + || "terminated by signal".to_string(), + |code| format!("exited with status {code}"), + ) +} + +fn render_capture_error(c: &Captured) -> String { + use std::fmt::Write as _; + + let mut msg = format!("`{}` {}", c.program, describe_status(c.status)); + let stderr = String::from_utf8_lossy(&c.stderr); + let stderr = stderr.trim(); + if !stderr.is_empty() { + const MAX: usize = 2000; + let snippet: String = stderr.chars().take(MAX).collect(); + let ellipsis = if snippet.len() < stderr.len() { "…" } else { "" }; + let _ = write!(msg, ": {snippet}{ellipsis}"); + } + msg +} + +/// Byte-first accessors over a captured process's output, implemented by +/// [`CapturedOk`] and [`CapturedError`]. Sealed; not implementable downstream. +pub trait CapturedStreams: sealed::Sealed { + /// Raw stdout bytes. + fn stdout(&self) -> &[u8]; + /// Raw stderr bytes. + fn stderr(&self) -> &[u8]; + /// The exit status. + fn status(&self) -> ExitStatus; + + /// The exit code, or `None` if killed by a signal. + fn code(&self) -> Option { + self.status().code() + } + + /// stdout as UTF-8, borrowed. + /// + /// # Errors + /// Returns [`Utf8Error`] if stdout is not valid UTF-8. + fn stdout_str(&self) -> Result<&str, Utf8Error> { + std::str::from_utf8(self.stdout()) + } + /// stderr as UTF-8, borrowed. + /// + /// # Errors + /// Returns [`Utf8Error`] if stderr is not valid UTF-8. + fn stderr_str(&self) -> Result<&str, Utf8Error> { + std::str::from_utf8(self.stderr()) + } + + /// stdout as an owned UTF-8 `String`. + /// + /// # Errors + /// Returns [`Utf8Error`] if stdout is not valid UTF-8. + fn stdout_string(&self) -> Result { + self.stdout_str().map(str::to_owned) + } + /// stderr as an owned UTF-8 `String`. + /// + /// # Errors + /// Returns [`Utf8Error`] if stderr is not valid UTF-8. + fn stderr_string(&self) -> Result { + self.stderr_str().map(str::to_owned) + } + + /// stdout as `&str`, **panicking** if not valid UTF-8. + #[allow(clippy::expect_used, reason = "callers opt into panic-on-invalid by calling _unwrap")] + fn stdout_str_unwrap(&self) -> &str { + self.stdout_str().expect("stdout was not valid UTF-8") + } + /// stderr as `&str`, **panicking** if not valid UTF-8. + #[allow(clippy::expect_used, reason = "callers opt into panic-on-invalid by calling _unwrap")] + fn stderr_str_unwrap(&self) -> &str { + self.stderr_str().expect("stderr was not valid UTF-8") + } + + /// stdout as an owned `String`, **panicking** if not valid UTF-8. + #[allow(clippy::expect_used, reason = "callers opt into panic-on-invalid by calling _unwrap")] + fn stdout_string_unwrap(&self) -> String { + self.stdout_string().expect("stdout was not valid UTF-8") + } + /// stderr as an owned `String`, **panicking** if not valid UTF-8. + #[allow(clippy::expect_used, reason = "callers opt into panic-on-invalid by calling _unwrap")] + fn stderr_string_unwrap(&self) -> String { + self.stderr_string().expect("stderr was not valid UTF-8") + } + + /// stdout decoded lossily (invalid sequences become U+FFFD). Never fails. + fn stdout_lossy(&self) -> Cow<'_, str> { + String::from_utf8_lossy(self.stdout()) + } + /// stderr decoded lossily (invalid sequences become U+FFFD). Never fails. + fn stderr_lossy(&self) -> Cow<'_, str> { + String::from_utf8_lossy(self.stderr()) + } +} + +impl CapturedStreams for CapturedOk { + fn stdout(&self) -> &[u8] { + &self.0.stdout + } + fn stderr(&self) -> &[u8] { + &self.0.stderr + } + fn status(&self) -> ExitStatus { + self.0.status + } +} + +impl CapturedStreams for CapturedError { + fn stdout(&self) -> &[u8] { + &self.0.stdout + } + fn stderr(&self) -> &[u8] { + &self.0.stderr + } + fn status(&self) -> ExitStatus { + self.0.status + } +} + +/// Adds [`captured`](CommandExt::captured) to [`std::process::Command`]. +pub trait CommandExt: sealed::Sealed { + /// Spawn, wait for completion, and capture stdout/stderr and exit status. + /// + /// # Errors + /// Returns the [`io::Error`] from spawning (e.g. the program is not found). + fn captured(&mut self) -> io::Result; +} + +impl CommandExt for std::process::Command { + fn captured(&mut self) -> io::Result { + let program = self.get_program().to_string_lossy().into_owned(); + let out = self.output()?; + Ok(Captured { + program, + stdout: out.stdout, + stderr: out.stderr, + status: out.status, + }) + } +} + +/// Adds [`captured`](AsyncCommandExt::captured) to [`tokio::process::Command`]. +pub trait AsyncCommandExt: sealed::Sealed { + /// Spawn, await completion, and capture stdout/stderr and exit status. + /// + /// # Errors + /// Returns the [`io::Error`] from spawning (e.g. the program is not found). + fn captured(&mut self) -> impl Future>; +} + +impl AsyncCommandExt for tokio::process::Command { + async fn captured(&mut self) -> io::Result { + let program = self.as_std().get_program().to_string_lossy().into_owned(); + let out = self.output().await?; + Ok(Captured { + program, + stdout: out.stdout, + stderr: out.stderr, + status: out.status, + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "test setup and assertions")] +mod tests { + use super::*; + use rstest::rstest; + + /// `sh -c