diff --git a/Cargo.lock b/Cargo.lock index 94070ebc..bca2031c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1112,6 +1112,7 @@ dependencies = [ "axum", "backon", "base64", + "bstr", "bytes", "chrono", "clap", @@ -1161,7 +1162,6 @@ dependencies = [ "url", "uuid", "webbrowser", - "which 6.0.3", "wiremock", ] @@ -1257,6 +1257,7 @@ version = "0.0.0-dev" dependencies = [ "anyhow", "async-trait", + "bstr", "chrono", "derive_more", "include_dir", @@ -1266,11 +1267,12 @@ dependencies = [ "serde", "serde_json", "tempfile", + "thiserror 2.0.18", "tokio", "tracing", "unicode-segmentation", "unicode-width", - "which 7.0.3", + "which", ] [[package]] @@ -1295,6 +1297,7 @@ dependencies = [ "anyhow", "async-trait", "derive_more", + "hm-common", "include_dir", "rstest", "serde", @@ -1302,7 +1305,6 @@ dependencies = [ "tempfile", "tokio", "tracing", - "which 7.0.3", ] [[package]] @@ -1438,15 +1440,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" version = "1.4.0" @@ -1961,12 +1954,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2735,19 +2722,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -2757,7 +2731,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -3256,7 +3230,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -3266,7 +3240,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -3992,18 +3966,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "which" -version = "6.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" -dependencies = [ - "either", - "home", - "rustix 0.38.44", - "winsafe", -] - [[package]] name = "which" version = "7.0.3" @@ -4012,7 +3974,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix 1.1.4", + "rustix", "winsafe", ] @@ -4402,7 +4364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix", ] [[package]] diff --git a/crates/hm-common/Cargo.toml b/crates/hm-common/Cargo.toml index 2d2e0bc0..618b1f57 100644 --- a/crates/hm-common/Cargo.toml +++ b/crates/hm-common/Cargo.toml @@ -11,6 +11,9 @@ categories = ["command-line-utilities"] [lib] path = "src/lib.rs" +[features] +app-runtime = [] + [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } @@ -18,6 +21,8 @@ chrono = { workspace = true } derive_more = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } +bstr = "1" include_dir = "0.7" num-traits = { workspace = true } tempfile = "3" @@ -30,7 +35,6 @@ which = "7" [dev-dependencies] tempfile = "3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -which = "7" rstest = { workspace = true } proptest = { workspace = true } diff --git a/crates/hm-common/src/app_runtime.rs b/crates/hm-common/src/app_runtime.rs new file mode 100644 index 00000000..8844ff4f --- /dev/null +++ b/crates/hm-common/src/app_runtime.rs @@ -0,0 +1,143 @@ +//! Application runtime context. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::git::Git; +use crate::process::{ExecutableNotFound, pathbin}; +use crate::python::Python; + +/// Failure to initialize the [`AppRuntime`]. +#[derive(Debug, thiserror::Error)] +pub enum InitError { + /// A required executable was missing from `PATH`. + #[error(transparent)] + Executables(#[from] ExecutableNotFound), + /// The current directory could not be read. + #[error("reading the current directory")] + Cwd(#[source] std::io::Error), +} + +// TODO: This is a process-wide singleton (a global `OnceLock`), which is +// convenient but couples every caller to hidden global state. Consider +// threading an explicit `AppRuntime` handle through the application instead, +// so dependencies are visible in signatures and tests can inject their own. +/// Process-wide runtime context, resolved once at startup. +/// +/// Install it with [`init`](Self::init), then read it from anywhere through the +/// associated accessors — no threading required. +#[derive(Debug)] +pub struct AppRuntime { + git: PathBuf, + python3: PathBuf, + cwd: PathBuf, +} + +static RUNTIME: OnceLock = OnceLock::new(); + +impl AppRuntime { + /// Resolve the runtime and install it as the process-wide singleton. + /// + /// Call once, early in `main`, before any accessor. A later call is ignored. + /// + /// # Errors + /// [`InitError`] if a required executable is missing from `PATH` or the + /// current directory cannot be read. + pub fn init() -> Result<(), InitError> { + let runtime = Self::resolve()?; + let _ = RUNTIME.set(runtime); + Ok(()) + } + + fn resolve() -> Result { + Ok(Self { + git: pathbin("git")?, + python3: pathbin("python3")?, + cwd: std::env::current_dir().map_err(InitError::Cwd)?, + }) + } + + #[allow( + clippy::expect_used, + reason = "accessing the runtime before init is a startup bug, not a runtime error" + )] + fn get() -> &'static Self { + RUNTIME + .get() + .expect("AppRuntime::init must be called before the runtime is accessed") + } + + /// The absolute working directory captured at initialization. + /// + /// # Panics + /// If [`init`](Self::init) has not been called. + #[must_use] + pub fn cwd() -> &'static Path { + &Self::get().cwd + } + + /// The system `git`, bound to a [`Git`] handle. + /// + /// # Panics + /// If [`init`](Self::init) has not been called. + #[must_use] + pub fn git() -> Git<'static> { + Git::new(&Self::get().git) + } + + /// The system `python3`, bound to a [`Python`] handle. + /// + /// # Panics + /// If [`init`](Self::init) has not been called. + #[must_use] + pub fn python() -> Python<'static> { + Python::new(&Self::get().python3) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::print_stderr, + reason = "test setup, assertions, and skip diagnostics" +)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + fn resolve_reports_toolchain_availability() { + assert_eq!( + AppRuntime::resolve().is_ok(), + pathbin("python3").is_ok() && pathbin("git").is_ok() + ); + } + + #[rstest] + fn resolve_captures_an_absolute_cwd() { + let Ok(runtime) = AppRuntime::resolve() else { + eprintln!("skipping: toolchain unavailable"); + return; + }; + assert!(runtime.cwd.is_absolute(), "cwd was {:?}", runtime.cwd); + } + + #[rstest] + fn init_installs_a_globally_accessible_runtime() { + if AppRuntime::resolve().is_err() { + eprintln!("skipping: toolchain unavailable"); + return; + } + AppRuntime::init().unwrap(); + assert!(AppRuntime::cwd().is_absolute()); + // git() binds the resolved git; a fresh temp dir is not a repo. + let dir = tempfile::tempdir().unwrap(); + assert!(AppRuntime::git().repo(dir.path()).is_err()); + } + + #[rstest] + fn init_error_wraps_a_missing_executable() { + let err: InitError = pathbin("hm-common-no-such-binary-xyz").unwrap_err().into(); + assert!(matches!(err, InitError::Executables(_))); + } +} diff --git a/crates/hm-common/src/git.rs b/crates/hm-common/src/git.rs new file mode 100644 index 00000000..d4f021e4 --- /dev/null +++ b/crates/hm-common/src/git.rs @@ -0,0 +1,275 @@ +//! Running git. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use bstr::{BStr, BString, ByteSlice}; + +use crate::process::{CapturedStreams as _, CommandExt as _}; + +/// A path that is not a git repository. +#[derive(Debug, thiserror::Error)] +#[error("`{path}` is not a git repository")] +pub struct InvalidRepoError { + path: PathBuf, +} + +/// A resolved `git` executable. +#[derive(Debug, Clone, Copy)] +pub struct Git<'bin> { + bin: &'bin Path, +} + +impl<'bin> Git<'bin> { + /// Wrap a `git` executable, typically via `AppRuntime::git()`. + #[must_use] + pub const fn new(bin: &'bin Path) -> Self { + Self { bin } + } + + /// Bind to the git work tree at `repo`. + /// + /// # Errors + /// [`InvalidRepoError`] if `repo` is not inside a git work tree. + #[tracing::instrument(skip(self))] + pub fn repo<'g>(&'g self, repo: &'g Path) -> Result, InvalidRepoError> { + let bound = GitRepo { git: self, repo }; + if bound.run(&["rev-parse", "--git-dir"]).is_some() { + Ok(bound) + } else { + Err(InvalidRepoError { + path: repo.to_path_buf(), + }) + } + } +} + +/// A git work tree, bound to a [`Git`] and a repository path. +#[derive(Debug, Clone, Copy)] +pub struct GitRepo<'g, 'bin> { + git: &'g Git<'bin>, + repo: &'g Path, +} + +impl<'g, 'bin> GitRepo<'g, 'bin> { + /// Run `git -C `, returning trimmed stdout on a zero exit. + /// `None` on spawn failure, a non-zero exit, or empty output. + fn run(&self, args: &[&str]) -> Option { + let mut cmd = Command::new(self.git.bin); + cmd.arg("-C").arg(self.repo).args(args); + let out = cmd.captured().ok()?.success().ok()?; + let trimmed = out.stdout().trim(); + (!trimmed.is_empty()).then(|| BString::from(trimmed)) + } + + /// The checked-out branch (`HEAD` when detached). `None` if git fails. + #[tracing::instrument(skip(self))] + pub fn current_branch(&self) -> Option> { + let name = self.run(&["rev-parse", "--abbrev-ref", "HEAD"])?; + Some(GitBranch { repo: self, name }) + } + + /// The named remote, or `None` when it is not configured. + #[tracing::instrument(skip(self))] + pub fn remote(&self, name: &str) -> Option> { + let url = self.run(&["config", "--get", &format!("remote.{name}.url")])?; + Some(GitRemote { + repo: self, + name: name.to_owned(), + url, + }) + } +} + +/// A branch in a [`GitRepo`]. +#[derive(Debug, Clone)] +pub struct GitBranch<'r, 'g, 'bin> { + repo: &'r GitRepo<'g, 'bin>, + name: BString, +} + +impl<'r, 'g, 'bin> GitBranch<'r, 'g, 'bin> { + /// The branch name (e.g. `main`, or `HEAD` when detached). + #[must_use] + pub fn name(&self) -> &BStr { + self.name.as_bstr() + } + + /// The commit the branch points at, as a hex object id. `None` if git fails. + #[tracing::instrument(skip(self))] + pub fn head_commit(&self) -> Option { + let name = self.name.to_str().ok()?; + self.repo.run(&["rev-parse", name]) + } +} + +/// A remote of a [`GitRepo`]. +#[derive(Debug, Clone)] +pub struct GitRemote<'r, 'g, 'bin> { + repo: &'r GitRepo<'g, 'bin>, + name: String, + url: BString, +} + +impl<'r, 'g, 'bin> GitRemote<'r, 'g, 'bin> { + /// The remote's name (e.g. `origin`). + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// The remote's configured URL. + #[must_use] + pub fn url(&self) -> &BStr { + self.url.as_bstr() + } + + /// `owner/repo` parsed from the remote URL, mirroring the backend's + /// `Harmont.Pipelines.RepoName`. `None` when fewer than two path segments + /// remain. + #[must_use] + pub fn gh_repo_name(&self) -> Option { + parse_gh_repo_name(self.url.as_bstr()) + } + + /// The remote's default branch, from its `HEAD` symbolic ref. `None` when + /// `/HEAD` is unset (common on fresh clones). + #[tracing::instrument(skip(self))] + pub fn default_branch(&self) -> Option> { + let line = self + .repo + .run(&["symbolic-ref", &format!("refs/remotes/{}/HEAD", self.name)])?; + let name = parse_default_branch(line.as_bstr(), &self.name)?; + Some(GitBranch { + repo: self.repo, + name, + }) + } +} + +/// Extract `owner/repo` from a remote URL: drop scheme/host and a trailing +/// `.git`, then take the last two non-empty path segments. +fn parse_gh_repo_name(url: &BStr) -> Option { + let url = url.to_str().ok()?.trim(); + let path = if let Some((_, rest)) = url.split_once("://") { + rest.split_once('/').map_or(rest, |(_, p)| p) + } else if url.contains('@') && url.contains(':') { + let after_at = url.split_once('@').map_or(url, |(_, r)| r); + after_at.split_once(':').map_or(after_at, |(_, p)| p) + } else { + url.split_once('/').map_or(url, |(_, p)| p) + }; + let path = path.trim_end_matches('/'); + let path = path.strip_suffix(".git").unwrap_or(path); + let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + if segs.len() < 2 { + return None; + } + Some(BString::from(segs[segs.len() - 2..].join("/"))) +} + +/// Extract the branch name from a `symbolic-ref refs/remotes//HEAD` +/// result (e.g. `refs/remotes/origin/main` → `main`). +fn parse_default_branch(line: &BStr, remote: &str) -> Option { + let line = line.to_str().ok()?.trim(); + let branch = line.strip_prefix(&format!("refs/remotes/{remote}/"))?; + (!branch.is_empty()).then(|| BString::from(branch)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "test setup and assertions")] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::https("https://github.com/acme/web.git", Some("acme/web"))] + #[case::https_no_suffix("https://github.com/acme/web", Some("acme/web"))] + #[case::scp("git@github.com:acme/web.git", Some("acme/web"))] + #[case::ssh("ssh://git@github.com/acme/web", Some("acme/web"))] + #[case::trailing_slash("https://github.com/acme/web/", Some("acme/web"))] + #[case::nested("https://gitlab.com/group/sub/web.git", Some("sub/web"))] + #[case::deep_path("https://example.com/a/b/c/repo", Some("c/repo"))] + #[case::too_short("https://github.com/web", None)] + #[case::empty("", None)] + #[case::not_a_url("not-a-url", None)] + fn parses_gh_repo_name(#[case] url: &str, #[case] expected: Option<&str>) { + let got = parse_gh_repo_name(url.into()); + assert_eq!(got, expected.map(BString::from)); + } + + #[rstest] + #[case::main("refs/remotes/origin/main", "origin", Some("main"))] + #[case::trailing_newline("refs/remotes/origin/main\n", "origin", Some("main"))] + #[case::slash("refs/remotes/origin/feature/x", "origin", Some("feature/x"))] + #[case::other_remote("refs/remotes/upstream/dev", "upstream", Some("dev"))] + #[case::wrong_prefix("refs/heads/main", "origin", None)] + #[case::trailing_slash("refs/remotes/origin/", "origin", None)] + #[case::empty("", "origin", None)] + fn parses_default_branch(#[case] line: &str, #[case] remote: &str, #[case] expected: Option<&str>) { + let got = parse_default_branch(line.into(), remote); + assert_eq!(got, expected.map(BString::from)); + } + + /// A throwaway git repo with one commit and an `origin` remote. + fn temp_repo() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let git = |args: &[&str]| { + let ok = Command::new("git") + .arg("-C") + .arg(dir.path()) + .args(args) + .captured() + .unwrap() + .success(); + assert!(ok.is_ok(), "git {args:?} failed"); + }; + git(&["init", "-q", "-b", "main"]); + git(&["config", "user.email", "t@t.dev"]); + git(&["config", "user.name", "t"]); + git(&["commit", "-q", "--allow-empty", "-m", "init"]); + git(&["remote", "add", "origin", "git@github.com:acme/web.git"]); + dir + } + + #[rstest] + fn repo_rejects_a_non_repo_dir() { + let dir = tempfile::tempdir().unwrap(); + let git = Git::new(Path::new("git")); + assert!(git.repo(dir.path()).is_err()); + } + + #[rstest] + fn reads_branch_commit_and_remote() { + let dir = temp_repo(); + let git = Git::new(Path::new("git")); + let repo = git.repo(dir.path()).unwrap(); + + let branch = repo.current_branch().unwrap(); + assert_eq!(branch.name(), "main"); + assert_eq!(branch.head_commit().unwrap().len(), 40); + + let remote = repo.remote("origin").unwrap(); + assert_eq!(remote.name(), "origin"); + assert_eq!(remote.gh_repo_name(), Some(BString::from("acme/web"))); + } + + #[rstest] + fn default_branch_reads_origin_head() { + let dir = temp_repo(); + let git = Git::new(Path::new("git")); + let repo = git.repo(dir.path()).unwrap(); + // Point origin/HEAD at a fabricated remote-tracking ref. + Command::new("git") + .arg("-C") + .arg(dir.path()) + .args(["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]) + .captured() + .unwrap() + .success() + .unwrap(); + + let branch = repo.remote("origin").unwrap().default_branch().unwrap(); + assert_eq!(branch.name(), "main"); + } +} diff --git a/crates/hm-common/src/lib.rs b/crates/hm-common/src/lib.rs index 93372701..b0c5fb15 100644 --- a/crates/hm-common/src/lib.rs +++ b/crates/hm-common/src/lib.rs @@ -1,6 +1,11 @@ //! Harmont common utilities shared across the `hm` workspace. +#[cfg(feature = "app-runtime")] +pub mod app_runtime; pub mod format; pub mod fs; +pub mod git; +pub mod process; +pub mod python; pub mod string; pub mod time; diff --git a/crates/hm-common/src/process/capture.rs b/crates/hm-common/src/process/capture.rs new file mode 100644 index 00000000..abf0f496 --- /dev/null +++ b/crates/hm-common/src/process/capture.rs @@ -0,0 +1,292 @@ +//! Spawn a command and capture its output, with a typed success/failure split. + +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). +#[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`. + 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. +#[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 +} + +/// Accessors over a captured process's output, implemented by [`CapturedOk`] and +/// [`CapturedError`]. +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. + fn stdout_str(&self) -> Result<&str, Utf8Error> { + std::str::from_utf8(self.stdout()) + } + /// stderr as UTF-8, borrowed. + fn stderr_str(&self) -> Result<&str, Utf8Error> { + std::str::from_utf8(self.stderr()) + } + + /// stdout as an owned UTF-8 `String`. + fn stdout_string(&self) -> Result { + self.stdout_str().map(str::to_owned) + } + /// stderr as an owned UTF-8 `String`. + 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. + fn captured(&mut self) -> io::Result; +} + +impl CommandExt for std::process::Command { + #[tracing::instrument(skip(self), fields(program = %self.get_program().to_string_lossy()))] + 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. + fn captured(&mut self) -> impl Future>; +} + +impl AsyncCommandExt for tokio::process::Command { + #[tracing::instrument(skip(self), fields(program = %self.as_std().get_program().to_string_lossy()))] + 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