Running commands
Command is the entry point of the runner layer: a builder describing what
to run and how, plus a family of consuming verbs that decide what you get
back. Every one-shot verb spawns the child into a fresh, private kill-on-drop
process group, so an early return, panic, or dropped
future can never leak a process tree.
- Program, arguments, working directory
- Resolving a locally-installed tool:
prefer_local - Environment
- Standard input
- Output handling
- Timeouts and retries
- Privileges and spawn flags
- Consuming verbs
- Results and errors
Program, arguments, working directory
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("git") .arg("log") // one at a time… .args(["--oneline", "-n", "10"]) // …or in bulk .current_dir("/path/to/repo") // run there .run() .await?; Ok(()) }
Arguments are passed as an array — there is no shell between you and the
child, so there is no quoting, no word-splitting, and no injection surface.
(When you actually want a | b | c, use a pipeline, which
connects the stages in-process instead of invoking a shell.)
The program name reaches the OS verbatim — two deliberate non-goals
(conveniences some libraries layer on, e.g. duct): a bare name is resolved
on PATH by the OS, never rewritten to ./name; and current_dir does not
re-anchor a relative program path against the new directory — whether
Command::new("./tool").current_dir(dir) resolves tool relative to dir
is the platform's behavior (Unix: yes; Windows: the parent's directory may
win). Pass absolute program paths when combining the two.
For quick one-liners the free functions skip the builder:
#[tokio::main] async fn main() -> processkit::Result<()> { let version = processkit::run("cargo", ["--version"]).await?; // trimmed stdout, success required let result = processkit::output_string("git", ["status", "-s"]).await?; // full ProcessResult Ok(()) }
Resolving a locally-installed tool: prefer_local
prefer_local adds a directory to check before the system PATH when
resolving a bare-name program for this one run — for a project's own
node_modules/.bin, a target/debug build, or a vendored toolchain, without
hand-rolling a PATH override:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("eslint") .prefer_local("./node_modules/.bin") .arg("src/") .output_string() .await?; Ok(()) }
Resolution order. Repeated calls accumulate, in priority order: the
directory from the first call is probed first, then the second, and so on,
with the system PATH tried last as the final fallback:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { Command::new("tool") .prefer_local("./vendor/bin") // checked first .prefer_local("./target/debug") // checked second .run() // then the system PATH .await?; Ok(()) }
Resolution reuses the exact same PATHEXT-aware lookup as the PATH search
(the same internal probe_dir helper — not a separate implementation), so a
.exe/.cmd/.bat on Windows is found under a prefer_local directory
exactly as it would be on PATH.
Only a bare name is affected. If the program passed to Command::new is
a path — absolute, or relative with a separator ("./tool", "../bin/x") —
prefer_local has no effect at all: the existing contract that such a program
is never looked up on PATH (or here) is unchanged.
Interaction with PATH/inherit_env/env. prefer_local only changes
where the parent looks to resolve the program for this one launch. It does
not rewrite or extend the PATH the child sees in its own environment —
that is governed entirely by env/inherit_env/env_clear, as usual. When
the program is found under a prefer_local directory, the child is simply
spawned via that resolved absolute path instead of the bare name; a
grandchild the program itself spawns does not inherit this reach — only the
one program named in this Command benefits.
Interaction with current_dir. A relative prefer_local directory (as in
the examples above) is probed against the process's actual current
directory, never against whatever is set via current_dir on the same
Command. The resolved match is then always turned into an absolute path
before being handed to the OS, so it can't later be reinterpreted against the
child's working directory once current_dir is set — unlike a relative-path
program passed straight to Command::new, which is subject to that
footgun (see Program, arguments, working directory
above).
Diagnostics. If resolution fails everywhere, Error::NotFound's
searched field includes the prefer_local directories too — first, in
priority order, ahead of the PATH directories — so the diagnostic never
hides that they were checked.
Preflight: resolve a program without running it
Sometimes you want to know whether an external tool is available before you
run it — a doctor check at startup, a friendly "is git installed?" error
up front — with no side effects. resolve_program locates a command's
program and returns its absolute path without spawning anything:
use processkit::Command; fn main() -> processkit::Result<()> { // `which` is the crate-level shortcut for a bare tool. let git = processkit::which("git")?; // Ok(/usr/bin/git) or Err(NotFound) println!("git lives at {}", git.display()); // On a builder it honors that command's own `prefer_local` and env, so it // resolves exactly what a real run of that command would launch. let eslint = Command::new("eslint") .prefer_local("./node_modules/.bin") .resolve_program()?; println!("eslint lives at {}", eslint.display()); Ok(()) }
No divergence from a real run. Resolution reuses the crate's own
launch-path logic — the same PATH/PATHEXT/execute-bit resolution and
prefer_local handling a spawn performs, not a second copy — so a
resolve_program hit is exactly the executable a run would launch, and a miss
is exactly the Error::NotFound (with the same searched diagnostic and
is_not_found() classification) a run would raise. A command that relocates the
child's PATH (env/env_remove of PATH, env_clear, inherit_env) is
resolved against that effective child PATH, so preflight still matches the
spawn.
It is a synchronous, cheap filesystem probe (a few stats) — no async
runtime is required, and no process is ever started. Contrast probe(), which
runs the tool to read its exit code; resolve_program only locates it.
fn main() { match processkit::which("definitely-not-installed") { Ok(path) => println!("found: {}", path.display()), Err(e) if e.is_not_found() => eprintln!("tool not installed"), Err(e) => eprintln!("resolution error: {e}"), } }
For a tool wrapped behind a CliClient, CliClient::resolve_program() does the
same for the client's program, honoring its env defaults.
Environment
Four builders compose, applied in a fixed order at spawn:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { Command::new("worker") .env("RUST_LOG", "debug") // set one variable .env_remove("GIT_DIR") // unset one inherited variable .run().await?; // Allow-list mode: clear everything, copy only the named parent variables. Command::new("sandboxed-tool") .inherit_env(["PATH", "HOME", "LANG"]) .env("MODE", "ci") // explicit env/env_remove still apply on top .run().await?; // Scorched earth: the child starts with an empty environment. Command::new("hermetic-tool").env_clear().run().await?; Ok(()) }
inherit_env is the sandboxing middle ground: it implies env_clear, then
copies the listed variables from the parent at each spawn (so a retry sees
fresh values), and repeated calls accumulate names. A name the parent doesn't
have is skipped, not set to empty.
Standard input
By default stdin is closed at spawn — the child reads EOF immediately and
can never hang waiting for input. Everything else is opt-in via
stdin(Stdin::…):
| Source | Reusable on re-run? | Use for |
|---|---|---|
Stdin::empty() | — | The default, explicit |
Stdin::from_string("…") | ✅ | Text payloads |
Stdin::from_bytes(vec![…]) | ✅ | Binary payloads |
Stdin::from_iter_lines(["a", "b"]) | ✅ | Anything iterable; each item is written \n-terminated |
Stdin::from_file(path) | ✅ (re-opened per run) | Large inputs streamed from disk |
Stdin::from_reader(reader) | ❌ one-shot | Any AsyncRead — a socket, a decompressor, … |
Stdin::from_lines(stream) | ❌ one-shot | Any Stream<Item = String> — a channel, a tail, … |
use processkit::{Command, Stdin}; #[tokio::main] async fn main() -> processkit::Result<()> { let sorted = Command::new("sort") .stdin(Stdin::from_iter_lines(["banana", "apple", "cherry"])) .run() .await?; assert_eq!(sorted, "apple\nbanana\ncherry"); Ok(()) }
The payload is written on a background task (so a large input can't deadlock
against the child's output) and the pipe is dropped afterwards to signal EOF.
The two one-shot sources are consumed by their first run: a retried or
cloned command reusing them fails loud the second time — re-running a
consumed from_reader/from_lines source is an Error::Io (InvalidInput)
at launch (D10), not a silent empty stdin. Prefer the reusable sources when
a command may run more than once.
For conversational, request/response stdin — write a line, read the answer,
repeat — use keep_stdin_open() and the streaming API instead: see
Streaming & interactive I/O.
Inheriting the parent's stdin: inherit_stdin()
inherit_stdin() hands the child the parent's own standard input — it reads
directly from whatever this process's stdin is (a terminal, a file, a pipe)
rather than from a crate-managed pipe. It is the stdin counterpart of
stdout(StdioMode::Inherit) / stderr(StdioMode::Inherit): the child shares
the parent stream instead of the crate mediating it.
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // `git commit` opens $EDITOR on the parent's terminal; the child talks to the // real tty directly. stdout/stderr are still captured as usual. Command::new("git").arg("commit").inherit_stdin().run().await?; Ok(()) }
Reach for it when a child must talk to the real terminal — git commit opening
$EDITOR, a tool prompting for a password or a yes/no — or to forward the
parent's piped stdin straight through. Until a pseudo-terminal exists (a future
direction, not yet provided) this covers the common non-tty-negotiating
interactive cases without the crate having to pump bytes. Because the child reads
the parent's stdin directly, the crate neither feeds nor captures that input, and
take_stdin() returns None (as for a non-keep_stdin_open run). Capturing and
streaming the child's output is unaffected.
Why a dedicated verb rather than a Stdin::Inherit source or a mode enum.
For stdout/stderr the three StdioMode variants map cleanly onto one setter, but
stdin's "piped" case is not modeless — it needs a payload (which source? what
bytes?), already expressed by stdin(Stdin::…), and its "null" case is
Stdin::empty(). Folding inheritance into that same stdin(Stdin) field would
make "inherit and a source" collapse to silent last-write-wins, impossible to
flag. A separate inherit_stdin() keeps the two intents in distinct fields so an
incompatible pairing is a detectable, rejectable error instead.
Accordingly, inherit_stdin() is mutually exclusive with either way the crate
would otherwise drive stdin — a configured stdin(Stdin::…) source (including an
explicit Stdin::empty()) or keep_stdin_open()'s interactive pipe. Setting
inherit_stdin() together with one of those is a contradiction (feed the child a
source and let it read the terminal?), so it is refused at the launch boundary
with a typed Error::Io (InvalidInput) — the same failure mode as re-running a
consumed one-shot source — rather than silently letting one win. Drop the other
stdin knob to resolve it. The refusal is enforced on the same launch seam the
hermetic test doubles route through, so a ScriptedRunner rejects the conflict
exactly as a live run does.
Output handling
Encodings
Output is decoded line by line, UTF-8 by default (invalid bytes become
U+FFFD, never an error). Legacy-encoding tools can override per stream:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("legacy-tool") .encoding(encoding_rs::SHIFT_JIS) // both streams… // .stdout_encoding(…) / .stderr_encoding(…) // …or each its own .output_string() .await?; Ok(()) }
(processkit::prelude::Encoding re-exports encoding_rs::Encoding, so any of its
encodings works — the single-byte and ASCII-compatible multibyte ones
(WINDOWS_1252, GBK, SHIFT_JIS, …) and the non-ASCII-compatible ones
(UTF_16LE/UTF_16BE): output is fed through one persistent decoder and split
on decoded newlines, so a 0x0A byte inside a UTF-16 code unit is not mistaken
for a line break. A leading byte-order mark of the chosen encoding is stripped
once at the stream start.)
Buffer policies — bounding memory on chatty children
Captured lines are held in memory; a multi-gigabyte log would normally grow
the buffer to match. output_buffer bounds retention (the pipe is always
fully drained, so the child never blocks):
use processkit::{Command, OutputBufferPolicy, OverflowMode}; #[tokio::main] async fn main() -> processkit::Result<()> { let tail = Command::new("verbose-build") .output_buffer(OutputBufferPolicy::bounded(1_000)) // keep the newest 1000 lines .output_string() .await?; // …or keep the head instead of the tail: let head_policy = OutputBufferPolicy::bounded(1_000).with_overflow(OverflowMode::DropNewest); Ok(()) }
DropOldest (the default) keeps a rolling tail; DropNewest freezes the
head. bounded(0) retains nothing — useful when a line handler (below) is the
real consumer. Under a line cap, dropped or not, every line still feeds
the handlers and the line counters.
The line cap alone does not bound memory — one enormous newline-free "line"
(base64 -w0) is held whole. Add with_max_bytes to cap the retained bytes
too (either ceiling, or both); the byte cap also bounds the pump's in-flight
assembly buffer, so a never-terminated flood can't exhaust memory. One
consequence: a line whose own length exceeds the byte cap can't be assembled, so
it is dropped whole — counted, but not delivered to a per-line handler or
stdout_tee (don't set a byte cap if a tee must see arbitrarily long lines):
#![allow(unused)] fn main() { use processkit::{Command, OutputBufferPolicy}; let policy = OutputBufferPolicy::unbounded().with_max_bytes(8 << 20); // 8 MiB ring let strict = OutputBufferPolicy::fail_loud(10_000).with_max_bytes(8 << 20); // error on either }
fail_loud makes the ceiling error instead of dropping: the run fails with
Error::OutputTooLarge once the cumulative output (lines or bytes) crosses the
cap — even when a streaming consumer is draining lines as they arrive. It bounds
memory, not wall-time, so pair it with timeout against a flooding child.
Even under a drop policy (DropOldest/DropNewest), the checking verbs that
hand back stdout as if complete — run, parse, try_parse — refuse
silently-truncated output (B12): if the policy dropped lines they fail with
Error::OutputTooLarge rather than feed a parser a truncated tail. The lenient
capture verbs (output_string / output_bytes) are unaffected — they return
the partial result with truncated() set for you to inspect.
Line handlers — tee output as it arrives
on_stdout_line / on_stderr_line run a callback on each decoded line in
addition to capture or streaming — logging, progress bars, metrics:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("cargo") .args(["build", "--release"]) .on_stderr_line(|line| eprintln!("[build] {line}")) .output_string() .await?; Ok(()) }
The handler runs on the read pump — keep it cheap. The contract is forgiving and precisely specified:
- A panicking handler does not poison the run. The panic is caught, the
handler is disabled for the rest of the run (surfaced as a
tracingwarn when that feature is on), and pumping continues — the final result still carries every line. You can safely re-export this callback seam to your own users without auditing their closures. - Ordering: invocations are FIFO within a stream; there is no ordering between stdout and stderr handlers (two independent pumps). On the consuming verbs, all handler calls happen-before the awaited future resolves — finalize a progress bar the moment the call returns. (One documented exception: a leaked pipe held open past the child's death is cut off after a bounded teardown grace.)
- Handlers are hermetically testable:
ScriptedRunnerreplays canned output through them — see Testing → scripting replies.
For a ready-made tee to an async sink — a file, socket, or any
[tokio::io::AsyncWrite] — reach for stdout_tee / stderr_tee instead of
hand-writing a handler. Each decoded line is written to the sink (plus a \n)
as it is produced, awaited on the pump so a slow sink applies backpressure
(the pump slows, the pipe fills, the child blocks) rather than blocking the
runtime; a write error disables the tee with a tracing warn instead of being
swallowed. It runs independently of on_stdout_line — set both and both
fire per line.
Timeouts and retries
use processkit::{Command, Error}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("flaky-network-tool") .timeout(Duration::from_secs(30)) // kill the tree at the deadline .retry(3, Duration::from_millis(200), |e| { // up to 3 attempts total matches!(e, Error::Timeout { .. }) // …but only retry timeouts }) .run() .await?; Ok(()) }
timeoutkills the whole process tree at the deadline. On the capturing verbs the expiry is captured (ProcessResult::timed_out), on the success-checking verbs it raisesError::Timeout— the full decision table lives in Timeouts, retries & cancellation.retryapplies to the success-checking verbs only —run,run_unit,exit_code,probe,checked,parse, andtry_parse(seven in all; each runs through the retry loop). The classifier sees the typed error and decides. The non-erroringoutput_string/output_bytespaths never retry, and neither doesfirst_line(its stream search is single-attempt).
Privileges and spawn flags
Spawn-time controls for sandboxing and service launch:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // Unix: drop privileges (uid + gid + supplementary groups) and detach. Command::new("worker") .gid(1000) // applied before uid (a gid change needs privilege) .groups([1000]) // replace the inherited (often root's) supplementary groups .uid(1000) // dropped last .setsid() // new session: survives the controlling terminal .run().await?; // Windows: no console window flashing up from a GUI app. Command::new("helper").create_no_window().run().await?; // Hardening: take the direct child down even if THIS process is SIGKILLed // (Drop never runs). Windows has this for free; Linux arms PDEATHSIG. Command::new("worker").kill_on_parent_death().start().await?; Ok(()) }
uid / gid / groups / setsid are POSIX-only — on Windows the run
fails with Error::Unsupported rather than silently skipping a privilege drop.
A correct drop sets all three of uid/gid/groups: dropping the uid alone
leaves the child holding the parent's (often root's) supplementary groups.
create_no_window is a harmless no-op outside Windows.
kill_on_parent_death is best-effort by design: guaranteed on Windows
(regardless of the knob), direct-child-only on Linux, unavailable on
macOS/BSD — the graceful-exit guarantee via Drop holds everywhere either
way. Containment is preserved in every combination; the platform fine print
(the Linux cgroup × uid interaction, setsid × process-group coordination,
the pdeathsig thread caveat) is collected in
Platform support.
Scheduling: CPU priority and umask
Two more spawn-time knobs, reusing the same seams as the builders above —
Unix pre_exec, Windows creation_flags — for background/batch children
that shouldn't starve the foreground, and for controlling the permissions of
files a child creates:
use processkit::{Command, Priority}; #[tokio::main] async fn main() -> processkit::Result<()> { // Run at a lower CPU-scheduling priority — supported on BOTH platforms. Command::new("batch-job") .priority(Priority::BelowNormal) .run().await?; // Unix only: files this child creates get 0644/0755 instead of 0666/0777. Command::new("worker").umask(0o022).run().await?; Ok(()) }
priority maps onto nice/setpriority on Unix and a priority class on
Windows (Idle/BelowNormal/Normal/AboveNormal/High); unlike the
privilege builders, every variant is supported on both platforms, so this
knob never yields Error::Unsupported. One caveat: lowering nice below its
inherited value on Unix — raising priority via Priority::AboveNormal/High,
or even requesting Priority::Normal under a positively-niced parent (e.g. a
niced CI/batch launcher) — needs CAP_SYS_NICE/root; without it the OS
rejects the change and the spawn fails loud (Error::Spawn), never silently
downgrading to a lower priority.
umask is Unix-only — like setsid/groups, requesting it on Windows fails
with Error::Unsupported rather than being silently ignored.
Interactive auth / TTY. processkit wires pipes, not a pseudo-terminal,
so a tool that demands a tty — an ssh/sudo password prompt, some
credential helpers — won't get one (PTY support is not implemented; the
trade-off is recorded in decisions/permissions-privileges-pty-network.md). Drive
such tools non-interactively instead: key-based auth, ssh -o BatchMode=yes, GIT_SSH_COMMAND / GIT_TERMINAL_PROMPT=0, or feed a known
answer over interactive stdin. Conversational
tools that read stdin without needing a tty already work today via
keep_stdin_open + stdout_lines.
Consuming verbs
| Verb | Returns | Non-zero exit | Timeout | Use when |
|---|---|---|---|---|
output_string() | ProcessResult<String> | captured | captured (timed_out) | You want to inspect the outcome yourself |
output_bytes() | ProcessResult<Vec<u8>> | captured | captured | Binary stdout (images, archives, …) |
run() | trimmed stdout String | Error::Exit | Error::Timeout | "Give me the answer or fail" |
exit_code() | i32 | the code, Ok | Error::Timeout | The code is the answer |
probe() | bool | 0→true, 1→false, else Error::Exit | Error::Timeout | Predicate commands: git diff --quiet, grep -q |
first_line(pred) | Option<String> | — (stream-based) | Error::Timeout | Grab one matching line, kill the rest |
start() | live RunningProcess | — | bounds the stream | Streaming, interactive I/O, probes |
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // probe(): the exit code as a boolean. let clean = Command::new("git").args(["diff", "--quiet"]).probe().await?; // first_line(): stop as soon as the interesting line appears. let first_match = Command::new("git") .args(["log", "--oneline"]) .first_line(|l| l.contains("fix:")) .await?; Ok(()) }
first_line returns Ok(None) when stdout closes without a match, and kills
the (private-group) child once it has its answer — you never wait out a long
log for one line. A cancel_on token that fires
while the search is still running surfaces as Error::Cancelled, so a readiness
probe with a shutdown token can't misread token-driven teardown as "the line
never appeared" — while a run that genuinely ends with no match still reports
Ok(None), even if the token happens to fire an instant later.
Results and errors
The capturing verbs hand back a ProcessResult:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("git").args(["merge", "feature"]).output_string().await?; result.code(); // Option<i32> — None = killed (timeout/signal), no code result.signal(); // Option<i32> — the signal number (Unix), else None result.is_success(); // code in ok_codes (default {0}) result.timed_out(); // the run's own deadline expired result.outcome(); // the explicit three-way enum behind the accessors above result.stdout(); // &str (or &[u8] from output_bytes) result.stderr(); // &str result.combined(); // stdout + stderr concatenated result.diagnostic(); // stderr if non-empty, else stdout — the human-facing line // (git/jj put "CONFLICT …" on stdout!) // Opt into erroring whenever you're ready: let ok = result.ensure_success()?; // Exit / Timeout / Signalled (signal-kill) as typed errors Ok(()) }
When the three-way distinction matters, match on Outcome instead of
mentally decoding the code()/timed_out() pair:
use processkit::Outcome; #[tokio::main] async fn main() -> processkit::Result<()> { let result = processkit::Command::new("git").args(["merge", "feature"]).output_string().await?; match result.outcome() { Outcome::Exited(0) => println!("clean"), Outcome::Exited(code) => println!("failed with {code}"), Outcome::Signalled(signal) => println!("killed by signal {signal:?}"), Outcome::TimedOut => println!("hit its deadline"), _ => {} // non_exhaustive: future dispositions } Ok(()) }
For a single query you usually don't need the match (and its
#[non_exhaustive] wildcard): Outcome carries the same code() /
signal() / timed_out() accessors as ProcessResult, so a bare Outcome
(from RunningProcess::wait or Finished::outcome) answers directly —
outcome.code(), outcome.signal(), outcome.timed_out(). There is no
Outcome::is_success (success is ok_codes-aware — use
ProcessResult::is_success).
The error enum is structured and #[non_exhaustive]:
| Variant | Meaning |
|---|---|
Error::Spawn { program, source } | The program was located but the OS couldn't start it (permissions, a bad working directory, a Windows .cmd/.bat needing cmd.exe, …) — not is_not_found() |
Error::NotFound { program, searched } | The program couldn't be located (the single "not found" representation — is_not_found() is true); searched is Some(dirs) for a bare-name PATH lookup, None otherwise |
Error::Exit { program, code, stdout, stderr, stdout_bytes } | Non-zero exit, both streams attached in full (the Display message is bounded, but the fields carry the complete captured text for classification); stdout_bytes is Some(exact bytes) for a checking verb built over output_bytes, None on the text path — read via Error::stdout_bytes() (the variant is #[non_exhaustive]) |
Error::Signalled { program, signal, stdout, stderr, stdout_bytes } | The process was killed by a signal (no exit code); signal carries the number on Unix, None elsewhere; the partial streams captured before the kill are attached (reach them via diagnostic()); stdout_bytes as above |
Error::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes } | A fail_loud buffer's line or byte ceiling was exceeded |
Error::Timeout { program, timeout, stdout, stderr, stdout_bytes } | The run's own deadline killed it; whatever the run captured before the kill is attached — a hung tool's last stderr line tails the Display and is reachable via diagnostic(); stdout_bytes as above |
Error::NotReady { program, timeout } | A readiness probe gave up |
Error::Parse { program, message } | A try_parse parser (on Command, ProcessRunnerExt, CliClient, or Pipeline) rejected the output (the Display/Debug of message is bounded to a 200-byte preview; the field carries the full text) |
Error::Stdin { program, source } | Feeding the child's stdin failed for a non-broken-pipe reason on an otherwise-successful run (a louder failure — exit/signal/timeout — wins instead); a routine broken pipe never surfaces |
Error::CassetteMiss { program } | (record feature) a cassette replay found no matching recording (stale/incomplete cassette) — kept distinct from a missing program, so is_not_found() is false |
Error::Unsupported { operation } | The platform can't do what was asked (and silently skipping would be wrong) |
Error::Cancelled { program } | the run's token was cancelled |
Error::ResourceLimit { kind, reason, detail } | (limits feature) a requested cap couldn't be enforced — kind (LimitKind::Memory/Processes/Cpu) says which limit, reason (LimitReason::Invalid/Unsupported/Unenforceable) says why, without parsing detail's English text; read via Error::limit_kind()/limit_reason() (the variant is #[non_exhaustive]) |
Error::Io(source) | A low-level IO error from the crate's own machinery (driving a child, group control, cassette files) — never an arbitrary foreign io::Error (no blanket From, D13) |
Error::diagnostic() returns the most useful human-facing line out of a
failure that captured output — Exit, and (D12) Timeout / Signalled (the
partial streams of a hung-then-killed or crashed tool). Each of those variants'
one-line Display also appends a bounded excerpt of that diagnostic (the last
non-empty line, capped at 200 bytes), so a bare eprintln!("{e}") reads
`git` exited with code 2: fatal: boom — actionable in a log line without
dumping multi-KiB streams into it.
Next: Streaming & interactive I/O · Timeouts, retries & cancellation · Process groups