From 70f0db3bdf7d28e604d5a3de701c200958918eb1 Mon Sep 17 00:00:00 2001 From: Techcable Date: Mon, 20 Jul 2026 22:08:50 -0700 Subject: [PATCH] BREAKING: Split processkit::ErrorKind from processkit::Error The Error enum was over 100 bytes, bloating every Result and triggering clippy lints result_large_err and large_enum_variant. It's now an opaque struct wrapping Box, taking up only 8 bytes. Matching is now performed using the `err.kind()` instead of directly on `err`. The is_* classifiers are now available on both Error and ErrorKind. Fixes #21 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 + README.md | 22 +- docs/README.md | 2 +- docs/commands.md | 64 +- docs/containers.md | 6 +- docs/cookbook.md | 24 +- docs/errors.md | 46 +- docs/pipelines.md | 16 +- docs/platform-support.md | 6 +- docs/process-groups.md | 4 +- docs/streaming.md | 8 +- docs/supervision.md | 2 +- docs/testing.md | 22 +- docs/timeouts-and-cancellation.md | 34 +- docs/upgrading.md | 6 +- src/buffer.rs | 8 +- src/cassette.rs | 340 ++++++---- src/client.rs | 88 +-- src/command.rs | 177 ++--- src/digest.rs | 2 +- src/doubles.rs | 233 ++++--- src/error.rs | 925 ++++++++++++++++----------- src/group.rs | 111 ++-- src/lib.rs | 66 +- src/limits.rs | 8 +- src/pipeline.rs | 133 ++-- src/priority.rs | 8 +- src/pump.rs | 18 +- src/result.rs | 104 +-- src/runner.rs | 221 ++++--- src/running/mod.rs | 160 ++--- src/running/probes.rs | 25 +- src/running/stream.rs | 21 +- src/signal.rs | 2 +- src/stdin.rs | 2 +- src/supervisor.rs | 80 ++- tests/integration/batch.rs | 9 +- tests/integration/cancellation.rs | 35 +- tests/integration/capture.rs | 131 ++-- tests/integration/env_privileges.rs | 4 +- tests/integration/limits.rs | 12 +- tests/integration/pipeline.rs | 29 +- tests/integration/process_control.rs | 14 +- tests/integration/readiness.rs | 20 +- tests/integration/shutdown.rs | 7 +- tests/integration/stdin_inherit.rs | 20 +- tests/integration/streaming.rs | 7 +- tests/stress/main.rs | 13 +- 48 files changed, 1851 insertions(+), 1450 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b748ee5..313504a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,12 @@ to a dated version section. ### Changed +- **Breaking:** `Error` is now an opaque struct wrapping `Box`. + `size_of::()` had grown to over 100 bytes, bloating every `Result` + and triggering clippy lints (see [issue #21](https://github.com/ZelAnton/ProcessKit-rs/issues/21)). + All previous `Error` variants are available in the new, `#[non_exhaustive]` + `ErrorKind` accessible through `err.kind()` or `err.into_kind()`. + The `is_*` classifiers are available on both `Error` and `ErrorKind`. - Release publishing now uses crates.io Trusted Publishing — a short-lived token minted over GitHub OIDC per run — instead of a stored long-lived `CRATES_IO_TOKEN` secret diff --git a/README.md b/README.md index 500eebe..60d0a7c 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ rule allows only for the real hierarchy root — *not* a cgroup-namespace root ( ordinary container EBUSYs too), and *not* under a systemd session/scope/service. The crate does not migrate your process to work around it, so in practice limits apply only at a minimal non-systemd init. When a requested limit can't be enforced, `with_options` -returns `Error::ResourceLimit` instead of a silently-unbounded group — an unapplied cap +returns `ErrorKind::ResourceLimit` instead of a silently-unbounded group — an unapplied cap is no protection. *Deeper: [Process groups → resource limits](docs/process-groups.md).* @@ -278,7 +278,7 @@ async fn main() -> processkit::Result<()> { ``` Signals are POSIX-only: on Windows just `Signal::Kill` is deliverable (it maps to -the Job Object terminate) and anything else returns `Error::Unsupported`. +the Job Object terminate) and anything else returns `ErrorKind::Unsupported`. `Signal::Kill` always takes the same whole-tree hard-kill path as `kill_all()`. Suspend/resume work everywhere a container exists — one `cgroup.freeze` write covering the subtree on Linux, `SIGSTOP`/`SIGCONT` on @@ -474,7 +474,7 @@ async fn health_check() -> bool { A probe that doesn't pass within its deadline — or that can no longer pass (the child exits; for `wait_for_line`, its stdout closes) — fails with -`Error::NotReady` (distinct from `Error::Timeout`, which is the run's own +`ErrorKind::NotReady` (distinct from `ErrorKind::Timeout`, which is the run's own `Command::timeout`) and **does not kill the child**: the caller decides what happens next. `wait_for_line` consumes stdout up to the match (continue with `finish`); `wait_for_port` / `wait_for` background-drain and @@ -552,7 +552,7 @@ async fn main() -> processkit::Result<()> { `inherit_env` clears the environment and copies only the named parent vars (explicit `env`/`env_remove` still apply on top); it works everywhere. `uid` / `gid` (group id is set before user id) and `setsid` are POSIX-only — on other -targets the run fails with `Error::Unsupported` rather than silently skipping +targets the run fails with `ErrorKind::Unsupported` rather than silently skipping a privilege drop. One Linux caveat: under the **cgroup** mechanism the child joins its cgroup after the uid has already dropped, and the auto-created cgroup isn't writable by the target user — the spawn fails with a permission @@ -572,7 +572,7 @@ macOS/BSD have no equivalent (documented no-op). Hand a command a [`CancellationToken`] (re-exported from `tokio-util`); cancelling the token -kills the process tree, and every consuming path reports `Error::Cancelled`: +kills the process tree, and every consuming path reports `ErrorKind::Cancelled`: ```rust,no_run use processkit::{CancellationToken, Command}; @@ -591,7 +591,10 @@ async fn main() -> processkit::Result<()> { // elsewhere — a shutdown signal, a sibling failure, a UI button: token.cancel(); - assert!(matches!(job.await.unwrap(), Err(processkit::Error::Cancelled { .. }))); + assert!(matches!( + job.await.unwrap().map_err(processkit::Error::into_kind), + Err(processkit::ErrorKind::Cancelled { .. }) + )); Ok(()) } ``` @@ -654,7 +657,7 @@ async fn main() -> processkit::Result<()> { > (own-group handle) or the direct child (shared-group handle) is killed, the > pipes close, and the stream ends. A `cancel_on` token ends > the stream the same way, and the following `finish` reports -> `Error::Cancelled`. For an ad-hoc bound, wrapping the loop in +> `ErrorKind::Cancelled`. For an ad-hoc bound, wrapping the loop in > [`tokio::time::timeout`] and dropping the handle (which kills the tree) still > works. @@ -669,7 +672,8 @@ use processkit::Command; // `ProcessStdin`'s writer methods return `std::io::Result` (idiomatic for a // writer), so this example uses `Box` to mix them with the -// crate's `Result`; in a `processkit::Result` function, `.map_err(processkit::Error::Io)?`. +// crate's `Result`; in a `processkit::Result` function, +// `.map_err(processkit::ErrorKind::Io)?`. #[tokio::main] async fn main() -> Result<(), Box> { // `bc` evaluates each stdin line and prints the result on stdout. @@ -784,7 +788,7 @@ file is written `0600`. When one invocation was recorded several times, replay serves the entries in capture order and then repeats the last one — a recorded sequence of changing outputs replays faithfully, while retry/probe loops keep getting a stable final answer. An invocation absent from -the cassette is a strict `Error::CassetteMiss` (distinct from a missing program, +the cassette is a strict `ErrorKind::CassetteMiss` (distinct from a missing program, so `is_not_found()` is `false`; replay never spawns a surprise subprocess), and the file carries a format `version` so future readers fail loudly instead of misreading old fixtures. diff --git a/docs/README.md b/docs/README.md index 6343268..b93da5a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,7 +60,7 @@ guarantee is unconditional in every configuration. |---|---|---|---| | `stats` | off | `ProcessGroup::stats` / `sample_stats`, `RunningProcess::cpu_time` / `peak_memory_bytes` / `profile` | `windows-sys/ProcessStatus` (Windows) | | `process-control` | **on** | `Signal`, `ProcessGroup::{signal, suspend, resume, members, adopt}` | — | -| `limits` | off | Whole-tree resource caps on `ProcessGroupOptions` (`max_memory` / `max_processes` / `cpu_quota`), `Error::ResourceLimit`; implies `stats` | — | +| `limits` | off | Whole-tree resource caps on `ProcessGroupOptions` (`max_memory` / `max_processes` / `cpu_quota`), `ErrorKind::ResourceLimit`; implies `stats` | — | | `mock` | off | A `mockall`-generated `MockRunner` for expectation-style tests (test-only; its `expect_*` surface is **semver-exempt**, tracking `mockall` — prefer `ScriptedRunner`/`RecordingRunner` for a stable double) | `mockall` | | `tracing` | off | Events on the `processkit` target: spawn/exit, timeout & cancel firing, group teardown, retries, supervisor storms, teardown anomalies (never argv/env) | `tracing` | | `record` | off | `RecordReplayRunner` JSON cassettes over the runner seam | `serde`, `serde_json` | diff --git a/docs/commands.md b/docs/commands.md index 81bddaa..3dcb9f2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -128,7 +128,7 @@ child's working directory once `current_dir` is set — unlike a relative-path footgun (see [Program, arguments, working directory](#program-arguments-working-directory) above). -**Diagnostics.** If resolution fails everywhere, `Error::NotFound`'s +**Diagnostics.** If resolution fails everywhere, `ErrorKind::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. @@ -162,7 +162,7 @@ fn main() -> processkit::Result<()> { 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 exactly the `ErrorKind::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 @@ -250,7 +250,7 @@ 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`) +consumed `from_reader`/`from_lines` source is an `ErrorKind::Io` (`InvalidInput`) at launch (D10), not a silent empty stdin. Prefer the reusable sources when a command may run more than once. @@ -301,7 +301,7 @@ would otherwise drive stdin — a configured `stdin(Stdin::…)` source (includi 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 +with a typed `ErrorKind::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 @@ -415,14 +415,14 @@ let strict = OutputBufferPolicy::fail_loud(10_000).with_max_bytes(8 << 20); // e ``` `fail_loud` makes the ceiling **error** instead of dropping: the run fails with -`Error::OutputTooLarge` once the cumulative output (lines *or* bytes) crosses the +`ErrorKind::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 +`ErrorKind::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. @@ -475,7 +475,7 @@ fire per line. ## Timeouts and retries ```rust,no_run -use processkit::{Command, Error}; +use processkit::{Command, ErrorKind}; use std::time::Duration; #[tokio::main] @@ -483,7 +483,7 @@ 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 + matches!(e.kind(), ErrorKind::Timeout { .. }) // …but only retry timeouts }) .run() .await?; @@ -493,7 +493,7 @@ async fn main() -> processkit::Result<()> { - **`timeout`** kills the whole process tree at the deadline. On the capturing verbs the expiry is *captured* (`ProcessResult::timed_out`), on the - success-checking verbs it *raises* `Error::Timeout` — the full decision + success-checking verbs it *raises* `ErrorKind::Timeout` — the full decision table lives in [Timeouts, retries & cancellation](timeouts-and-cancellation.md). - **`retry`** applies to the success-checking verbs only — `run`, `run_unit`, `exit_code`, `probe`, `checked`, `parse`, and `try_parse` (seven in all; each @@ -529,7 +529,7 @@ async fn main() -> processkit::Result<()> { ``` `uid` / `gid` / `groups` / `setsid` are POSIX-only — on Windows the run -fails with `Error::Unsupported` rather than silently skipping a privilege drop. +fails with `ErrorKind::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. @@ -584,15 +584,15 @@ async fn main() -> processkit::Result<()> { `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 +knob never yields `ErrorKind::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 `nice`d CI/batch launcher) — needs `CAP_SYS_NICE`/root; without it the OS -rejects the change and the spawn fails loud (`Error::Spawn`), never silently +rejects the change and the spawn fails loud (`ErrorKind::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. +with `ErrorKind::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 @@ -610,10 +610,10 @@ tools that read stdin without needing a tty already work today via |---|---|---|---|---| | `output_string()` | `ProcessResult` | captured | captured (`timed_out`) | You want to inspect the outcome yourself | | `output_bytes()` | `ProcessResult>` | 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` | — (stream-based) | `Error::Timeout` | Grab one matching line, kill the rest | +| `run()` | trimmed stdout `String` | `ErrorKind::Exit` | `ErrorKind::Timeout` | "Give me the answer or fail" | +| `exit_code()` | `i32` | the code, `Ok` | `ErrorKind::Timeout` | The code *is* the answer | +| `probe()` | `bool` | `0`→`true`, `1`→`false`, else `ErrorKind::Exit` | `ErrorKind::Timeout` | Predicate commands: `git diff --quiet`, `grep -q` | +| `first_line(pred)` | `Option` | — (stream-based) | `ErrorKind::Timeout` | Grab one matching line, kill the rest | | `start()` | live `RunningProcess` | — | bounds the stream | [Streaming, interactive I/O, probes](streaming.md) | ```rust,no_run @@ -636,7 +636,7 @@ async fn main() -> processkit::Result<()> { `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`](timeouts-and-cancellation.md) token that fires -while the search is still running surfaces as `Error::Cancelled`, so a readiness +while the search is still running surfaces as `ErrorKind::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. @@ -701,20 +701,20 @@ 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](streaming.md#readiness-probes) 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) | +| `ErrorKind::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()` | +| `ErrorKind::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 | +| `ErrorKind::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]`) | +| `ErrorKind::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 | +| `ErrorKind::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes }` | A `fail_loud` buffer's line or byte ceiling was exceeded | +| `ErrorKind::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 | +| `ErrorKind::NotReady { program, timeout }` | A [readiness probe](streaming.md#readiness-probes) gave up | +| `ErrorKind::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) | +| `ErrorKind::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 | +| `ErrorKind::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` | +| `ErrorKind::Unsupported { operation }` | The platform can't do what was asked (and silently skipping would be wrong) | +| `ErrorKind::Cancelled { program }` | the run's token was cancelled | +| `ErrorKind::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]`) | +| `ErrorKind::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 diff --git a/docs/containers.md b/docs/containers.md index 269b768..5e993e3 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -243,14 +243,14 @@ essentially never meets, privileged or not (see Confirmed by actually requesting a limit from inside a container: ```text -plain docker run: Error::ResourceLimit { kind: Memory, reason: Unenforceable, +plain docker run: ErrorKind::ResourceLimit { kind: Memory, reason: Unenforceable, detail: "…Read-only file system…" } -docker run --privileged: Error::ResourceLimit { kind: Memory, reason: Unenforceable, +docker run --privileged: ErrorKind::ResourceLimit { kind: Memory, reason: Unenforceable, detail: "…cgroup v2's 'no internal processes' rule…Resource busy…" } ``` Both fail the *same* way the crate documents for any non-delegated host — -`Error::ResourceLimit` with `reason: LimitReason::Unenforceable` — never a +`ErrorKind::ResourceLimit` with `reason: LimitReason::Unenforceable` — never a silently-unbounded group (see [Errors → `ResourceLimit`](errors.md) and [Platform support → containment mechanisms](platform-support.md#containment-mechanisms) diff --git a/docs/cookbook.md b/docs/cookbook.md index cc7f7b5..10940f1 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -132,7 +132,7 @@ async fn main() -> processkit::Result<()> { At the deadline the whole tree is killed. On the capture verbs the timeout is *captured* (`timed_out()`, partial output kept); on the success-checking verbs -(`run`, `exit_code`) it surfaces as `Error::Timeout`. +(`run`, `exit_code`) it surfaces as `ErrorKind::Timeout`. ## Let a tool clean up on timeout @@ -204,7 +204,7 @@ fn main() { friendly up-front error. Resolution reuses the crate's own launch-path logic (the same PATH/PATHEXT/execute-bit and `prefer_local` handling a real run uses), so a hit is exactly what would be launched and a miss is exactly the -`Error::NotFound` (`is_not_found()`) a run would raise. It is synchronous — no +`ErrorKind::NotFound` (`is_not_found()`) a run would raise. It is synchronous — no tokio runtime needed. A wrapped tool's client offers the same via `CliClient::resolve_program()`. @@ -233,7 +233,7 @@ async fn main() -> Result<(), Box> { ``` One-shot sources (`from_reader`/`from_lines`) feed a single run; re-running the -same `Command` afterwards **fails loud** (an `Error::Io` at launch, D10) instead +same `Command` afterwards **fails loud** (an `ErrorKind::Io` at launch, D10) instead of silently seeing empty stdin. For a conversation, see the next recipe but one. *Fine print: [Running commands → standard input](commands.md).* @@ -289,7 +289,7 @@ async fn main() -> Result<(), Box> { `keep_stdin_open()` hands you an async writer instead of closing stdin at spawn; interleave writes with reads for request/response tools. Its writer methods return `std::io::Result` (idiomatic for a writer) — convert with -`.map_err(processkit::Error::Io)?` in a `processkit::Result` function, or use +`.map_err(processkit::ErrorKind::Io)?` in a `processkit::Result` function, or use `Box`. *Fine print: [Streaming & interactive I/O → interactive stdin](streaming.md).* @@ -357,7 +357,7 @@ async fn main() -> processkit::Result<()> { } ``` -A probe that can't succeed fails fast with `Error::NotReady` and never kills +A probe that can't succeed fails fast with `ErrorKind::NotReady` and never kills the child — you decide what happens next. No more `sleep(2)` and hoping. *Fine print: [Streaming & interactive I/O → readiness probes](streaming.md).* @@ -436,7 +436,7 @@ async fn main() -> processkit::Result<()> { } ``` -Unenforceable limits are a hard `Error::ResourceLimit`, never a silently +Unenforceable limits are a hard `ErrorKind::ResourceLimit`, never a silently unbounded group. On Unix, add `.uid(…)`/`.gid(…)` to drop privileges (note the cgroup-mechanism caveat in the guide). @@ -480,7 +480,7 @@ being the same case. ```rust,no_run use processkit::Command; -use processkit::Error; +use processkit::ErrorKind; use std::time::Duration; #[tokio::main] @@ -489,7 +489,7 @@ async fn main() -> processkit::Result<()> { .args(["fetch", "--quiet"]) .timeout(Duration::from_secs(10)) .retry(3, Duration::from_millis(200), |e| { - matches!(e, Error::Timeout { .. }) + matches!(e.kind(), ErrorKind::Timeout { .. }) || e.diagnostic().is_some_and(|m| m.contains("Could not resolve host")) }) .run() @@ -520,8 +520,8 @@ async fn main() -> processkit::Result<()> { }); // On Ctrl-C / shutdown signal / sibling failure: - token.cancel(); // kills the tree; the run resolves to Error::Cancelled - let outcome = job.await; // Err(Error::Cancelled { .. }) inside + token.cancel(); // kills the tree; the run resolves to ErrorKind::Cancelled + let outcome = job.await; // Err(ErrorKind::Cancelled { .. }) inside Ok(()) } ``` @@ -569,7 +569,7 @@ cgroup); elsewhere you still get process counts. ## Contain a process you didn't spawn ```rust,no_run -use processkit::{Error, ProcessGroup}; +use processkit::{ErrorKind, ProcessGroup}; fn main() -> processkit::Result<()> { // `tokio`/`std` calls return `io::Error`, which the crate does NOT auto-convert @@ -578,7 +578,7 @@ fn main() -> processkit::Result<()> { // your own code so both error types `?` freely. let child = tokio::process::Command::new("legacy-launcher") .spawn() - .map_err(Error::Io)?; + .map_err(ErrorKind::Io)?; let group = ProcessGroup::new()?; // `adopt` is part of `process-control` (default-on) group.adopt(&child)?; // from now on the group's teardown covers it diff --git a/docs/errors.md b/docs/errors.md index 8466a5d..c774fd7 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -19,27 +19,27 @@ which variant fires from where, how to classify it, and what to do about it. | Variant | Where it comes from | Recommended reaction | |---|---|---| -| `Error::Spawn { program, source }` | The program was located but the OS refused to start it — permission denied, a bad working directory, a Windows `.cmd`/`.bat` needing `cmd.exe`, `ETXTBSY`, … | Inspect `source`; `is_permission_denied()` for an ACL/executable-bit problem, `is_transient()` for a bare-retry-clears-it condition. Not `is_not_found()` — the program *was* found. | -| `Error::NotFound { program, searched }` | The program could not be located at all — not installed, not on `PATH`, or a path that doesn't resolve | `is_not_found()` is `true`; surface a "is it installed?" hint. `searched` (`Some(dirs)` for a bare-name `PATH` lookup, `None` otherwise) is for a diagnostic only — never log it, it echoes the `PATH` value. | -| `Error::CassetteMiss { program }` (`record` feature) | A cassette replay found no recording matching the invocation — a stale or incomplete cassette, not a missing program | Fix or re-record the cassette. **Not** `is_not_found()` — do not let an "optional dependency" wrapper swallow this as "tool not installed". | -| `Error::Exit { program, code, stdout, stderr, stdout_bytes }` | The process ran to completion but exited non-zero | Branch on `code()`; `diagnostic()` for the best one-line human message (stderr, else stdout — `git`/`jj` put decisive text on stdout). | -| `Error::Timeout { program, timeout, stdout, stderr, stdout_bytes }` | `Command::timeout` elapsed and the tree was killed, on a **checking** verb | `is_timeout()`. Whatever was captured before the kill is attached — `diagnostic()` often explains the hang. Consider composing into a retry classifier: `e.is_timeout() \|\| e.is_transient()`. | -| `Error::NotReady { program, timeout }` | A [readiness probe](streaming.md#readiness-probes) (`wait_for_line` / `wait_for_port` / `wait_for`) did not pass within its own deadline | Not a run failure — the child is still running (a probe deadline never kills it). Decide whether to keep waiting, `shutdown()` the handle, or surface the failure. | -| `Error::Parse { program, message }` | The run succeeded but `try_parse` (or a caller's own parser feeding `Error::parse`) could not make sense of the output | `message` is caller-built and carries the parse failure; bounded in `Display`/`Debug`, full text on the field. | -| `Error::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes }` | A `fail_loud` capture ceiling (`OutputBufferPolicy::max_lines`/`max_bytes`) was exceeded; the run itself may have succeeded | Raise the ceiling, switch to a lossy/streaming policy, or treat as a genuine failure — the pipe was fully drained either way, so the child never blocked. | -| `Error::ResourceLimit { kind, reason, detail }` (`limits` feature) | A requested cap on `ProcessGroupOptions` couldn't be enforced — no whole-tree container on this platform, or the OS rejected it | Read `limit_kind()` / `limit_reason()` rather than parsing `detail`; an unenforced limit is no protection, so treat this as a hard stop, not a warning. | -| `Error::Unsupported { operation }` | An operation isn't supported by the active containment mechanism on this platform (e.g. any `Signal` but `Kill` on Windows Job Objects) | Branch on platform ahead of time (see [Platform support](platform-support.md)), or catch and degrade. | -| `Error::Cancelled { program }` | The run's `CancellationToken` fired and its tree was killed | `is_cancelled()`. This is an *abandonment*, not a failure to diagnose — the caller already knows why. Never retried (see [Errors and retries](#errors-and-retries)); terminal under a `Supervisor` too. | -| `Error::Signalled { program, signal, stdout, stderr, stdout_bytes }` | The process was killed by a signal (**Unix only**; a `ScriptedRunner`/cassette replay can also report `Signalled(None)`) | `is_signalled()`. No exit code to check — always a failure. `diagnostic()` surfaces whatever was captured before the crash. | -| `Error::Stdin { program, source }` | Feeding the child's stdin failed for a reason other than a routine broken pipe, on an **otherwise-successful** run | A diagnostic of a silently-truncated input the child may have already acted on. The io-level classifiers (`is_transient`, `is_not_found`, `is_permission_denied`) deliberately return `false` here — the run already succeeded, so a blanket retry would just re-run a command that worked. | -| `Error::Io(source)` | A low-level IO error from the crate's own machinery — driving a child, controlling a process group, reading/writing a cassette file | Never an arbitrary foreign `io::Error` (there is deliberately no blanket `From`); every `Io` here was raised at a known site inside the crate. | +| `ErrorKind::Spawn { program, source }` | The program was located but the OS refused to start it — permission denied, a bad working directory, a Windows `.cmd`/`.bat` needing `cmd.exe`, `ETXTBSY`, … | Inspect `source`; `is_permission_denied()` for an ACL/executable-bit problem, `is_transient()` for a bare-retry-clears-it condition. Not `is_not_found()` — the program *was* found. | +| `ErrorKind::NotFound { program, searched }` | The program could not be located at all — not installed, not on `PATH`, or a path that doesn't resolve | `is_not_found()` is `true`; surface a "is it installed?" hint. `searched` (`Some(dirs)` for a bare-name `PATH` lookup, `None` otherwise) is for a diagnostic only — never log it, it echoes the `PATH` value. | +| `ErrorKind::CassetteMiss { program }` (`record` feature) | A cassette replay found no recording matching the invocation — a stale or incomplete cassette, not a missing program | Fix or re-record the cassette. **Not** `is_not_found()` — do not let an "optional dependency" wrapper swallow this as "tool not installed". | +| `ErrorKind::Exit { program, code, stdout, stderr, stdout_bytes }` | The process ran to completion but exited non-zero | Branch on `code()`; `diagnostic()` for the best one-line human message (stderr, else stdout — `git`/`jj` put decisive text on stdout). | +| `ErrorKind::Timeout { program, timeout, stdout, stderr, stdout_bytes }` | `Command::timeout` elapsed and the tree was killed, on a **checking** verb | `is_timeout()`. Whatever was captured before the kill is attached — `diagnostic()` often explains the hang. Consider composing into a retry classifier: `e.is_timeout() \|\| e.is_transient()`. | +| `ErrorKind::NotReady { program, timeout }` | A [readiness probe](streaming.md#readiness-probes) (`wait_for_line` / `wait_for_port` / `wait_for`) did not pass within its own deadline | Not a run failure — the child is still running (a probe deadline never kills it). Decide whether to keep waiting, `shutdown()` the handle, or surface the failure. | +| `ErrorKind::Parse { program, message }` | The run succeeded but `try_parse` (or a caller's own parser feeding `Error::parse`) could not make sense of the output | `message` is caller-built and carries the parse failure; bounded in `Display`/`Debug`, full text on the field. | +| `ErrorKind::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes }` | A `fail_loud` capture ceiling (`OutputBufferPolicy::max_lines`/`max_bytes`) was exceeded; the run itself may have succeeded | Raise the ceiling, switch to a lossy/streaming policy, or treat as a genuine failure — the pipe was fully drained either way, so the child never blocked. | +| `ErrorKind::ResourceLimit { kind, reason, detail }` (`limits` feature) | A requested cap on `ProcessGroupOptions` couldn't be enforced — no whole-tree container on this platform, or the OS rejected it | Read `limit_kind()` / `limit_reason()` rather than parsing `detail`; an unenforced limit is no protection, so treat this as a hard stop, not a warning. | +| `ErrorKind::Unsupported { operation }` | An operation isn't supported by the active containment mechanism on this platform (e.g. any `Signal` but `Kill` on Windows Job Objects) | Branch on platform ahead of time (see [Platform support](platform-support.md)), or catch and degrade. | +| `ErrorKind::Cancelled { program }` | The run's `CancellationToken` fired and its tree was killed | `is_cancelled()`. This is an *abandonment*, not a failure to diagnose — the caller already knows why. Never retried (see [Errors and retries](#errors-and-retries)); terminal under a `Supervisor` too. | +| `ErrorKind::Signalled { program, signal, stdout, stderr, stdout_bytes }` | The process was killed by a signal (**Unix only**; a `ScriptedRunner`/cassette replay can also report `Signalled(None)`) | `is_signalled()`. No exit code to check — always a failure. `diagnostic()` surfaces whatever was captured before the crash. | +| `ErrorKind::Stdin { program, source }` | Feeding the child's stdin failed for a reason other than a routine broken pipe, on an **otherwise-successful** run | A diagnostic of a silently-truncated input the child may have already acted on. The io-level classifiers (`is_transient`, `is_not_found`, `is_permission_denied`) deliberately return `false` here — the run already succeeded, so a blanket retry would just re-run a command that worked. | +| `ErrorKind::Io(source)` | A low-level IO error from the crate's own machinery — driving a child, controlling a process group, reading/writing a cassette file | Never an arbitrary foreign `io::Error` (there is deliberately no blanket `From`); every `Io` here was raised at a known site inside the crate. | ## Variants that look alike but aren't - **`Timeout` is *captured*, `Cancelled` is *always an error*.** `output_string`/ `output_bytes` return `Ok` with `timed_out() == true` on a deadline — the caller decides whether that counts as failure. A cancellation, by contrast, - reports `Err(Error::Cancelled)` on **every** consuming path, streaming + reports `Err(ErrorKind::Cancelled)` on **every** consuming path, streaming included, because it's a deliberate caller action, not run data. When a run both hits its deadline and gets cancelled, cancellation wins (checked first). See [Precedence and interactions](timeouts-and-cancellation.md#precedence-and-interactions). @@ -87,17 +87,17 @@ change, but it also means every downstream `match` **must** carry a catch-all arm. ```rust,no_run -use processkit::{Command, Error}; +use processkit::{Command, ErrorKind}; #[tokio::main] async fn main() -> processkit::Result<()> { let err = Command::new("maybe-missing-tool").run().await.unwrap_err(); - match err { - Error::NotFound { .. } => eprintln!("is it installed?"), - Error::Timeout { .. } => eprintln!("hit its deadline"), - Error::Cancelled { .. } => { /* caller-initiated, nothing to log */ } - Error::Exit { code, .. } => eprintln!("exited with {code}"), + match err.kind() { + ErrorKind::NotFound { .. } => eprintln!("is it installed?"), + ErrorKind::Timeout { .. } => eprintln!("hit its deadline"), + ErrorKind::Cancelled { .. } => { /* caller-initiated, nothing to log */ } + ErrorKind::Exit { code, .. } => eprintln!("exited with {code}"), // #[non_exhaustive]: a future variant (or a today's variant behind a // feature this build doesn't enable, e.g. ResourceLimit) falls here. other => eprintln!("run failed: {other}"), @@ -135,7 +135,7 @@ async fn main() -> processkit::Result<()> { } ``` -`Error::Cancelled` is **never** retried, whatever the classifier says — the +`ErrorKind::Cancelled` is **never** retried, whatever the classifier says — the token stays cancelled forever, so another attempt could only fail the same way. See [Retries](timeouts-and-cancellation.md#retries) for the full ground rules (stdin re-use, which verbs retry at all). @@ -171,7 +171,7 @@ async fn main() -> processkit::Result<()> { `GiveUpAttempt::Failed(&Error)` is the spawn/IO path (no `ProcessResult` was ever produced); `GiveUpAttempt::Crashed(&ProcessResult)` is a completed-but-failing run. A recognized-permanent failure reports -`StopReason::GaveUp` instead of restarting forever. `Error::Cancelled` is +`StopReason::GaveUp` instead of restarting forever. `ErrorKind::Cancelled` is terminal here too — supervision returns `Err(Cancelled)` instead of restarting into a still-cancelled token, `give_up_when` or not. diff --git a/docs/pipelines.md b/docs/pipelines.md index dd345d5..db99d2d 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -48,12 +48,12 @@ The verbs mirror `Command`'s, each operating on the pipefail outcome: |---|---|---| | `output_string()` | `ProcessResult` | …reported in the result (code/stderr/program of the first unclean stage) | | `output_bytes()` | `ProcessResult>` | …same, with the last stage's stdout captured raw (binary pipes) | -| `run()` | trimmed final stdout | …raised as that stage's `Error::Exit`; fails loud on a truncated capture | -| `checked()` | full `ProcessResult` | …raised as `Error::Exit` (untrimmed stdout) | -| `run_unit()` | `()` | …raised as `Error::Exit` (output discarded) | -| `exit_code()` | `i32` | …its attributed code (no code → `Error::Timeout`/`Signalled`) | +| `run()` | trimmed final stdout | …raised as that stage's `ErrorKind::Exit`; fails loud on a truncated capture | +| `checked()` | full `ProcessResult` | …raised as `ErrorKind::Exit` (untrimmed stdout) | +| `run_unit()` | `()` | …raised as `ErrorKind::Exit` (output discarded) | +| `exit_code()` | `i32` | …its attributed code (no code → `ErrorKind::Timeout`/`Signalled`) | | `probe()` | `bool` | `0` → `true`, `1` → `false`, else `Err` | -| `parse(\|s\| …)` / `try_parse(\|s\| …)` | `T` | …raised as `Error::Exit`; fails loud on a truncated capture | +| `parse(\|s\| …)` / `try_parse(\|s\| …)` | `T` | …raised as `ErrorKind::Exit`; fails loud on a truncated capture | `Err` from `output_string` itself means a stage couldn't be *started or driven* at all (spawn failure, broken plumbing) — never a mere non-zero exit. @@ -226,13 +226,13 @@ async fn main() -> processkit::Result<()> { own sub-group, grandchildren of a forking `sh -c …` included, not just its direct child. Every stage is evaluated by the same pipefail rule (D14): a stage that hit its own deadline — inner *or* last — surfaces on `run()` as - that stage's `Error::Timeout`, reporting **that stage's own deadline** (not + that stage's `ErrorKind::Timeout`, reporting **that stage's own deadline** (not the chain's, and never `0ns`). Cancellation has two forms. **`Pipeline::cancel_on(token)`** is the chain-level control: the token **gap-fills** into every stage that doesn't already carry its own `Command::cancel_on` (an explicit per-stage token is left intact), so firing -it tears the whole chain down and the run resolves to `Error::Cancelled`. (A +it tears the whole chain down and the run resolves to `ErrorKind::Cancelled`. (A `cancel_on` token on an individual stage `Command` also cancels that stage and errors the pipeline, but the pipeline-level builder is the clearer authority.) See @@ -306,7 +306,7 @@ the session regardless of which stage is slow. A `Pipeline` is `Clone` and re-runnable — stages are re-cloned per run. The one caveat is inherited from `Command`: a **one-shot** stdin source on the first stage (`Stdin::from_reader` / `from_lines`) is consumed by the first run; -re-running then **fails loud** (an `Error::Io` at launch, D10) rather than +re-running then **fails loud** (an `ErrorKind::Io` at launch, D10) rather than silently feeding empty stdin. Use the reusable sources (`from_string` / `from_bytes` / `from_iter_lines` / `from_file`) when a chain runs more than once. diff --git a/docs/platform-support.md b/docs/platform-support.md index 4090963..d907cb9 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -8,7 +8,7 @@ like wasm. Building for such a target fails at compile time (a `compile_error!` guard, or earlier in tokio's own dependencies). Within the supported set, it treats platform support as first-class: every capability is either fully implemented, *honestly partial* (documented and typed), or refused with -`Error::Unsupported` — never silently skipped. This page collects all the +`ErrorKind::Unsupported` — never silently skipped. This page collects all the matrices and fine print in one place. - [CI coverage](#ci-coverage) @@ -81,7 +81,7 @@ session/scope/service. The crate does not migrate your process into a sub-cgroup to work around it, so in practice limits apply only at a minimal non-systemd init sitting at the real root. Without a usable cgroup it quietly falls back to `ProcessGroup` — unless you requested [resource limits](#capability-matrices), which fail fast -instead (`Error::ResourceLimit`), because an unapplied cap is no protection. The +instead (`ErrorKind::ResourceLimit`), because an unapplied cap is no protection. The error's `reason` distinguishes the two ways this happens: `LimitReason::Unsupported` when no cgroup v2 is mounted at all (or on macOS/BSD, which has no whole-tree container of any kind), `LimitReason::Unenforceable` when cgroup v2 exists but this @@ -205,7 +205,7 @@ surfaces as `Exited(-1073741510)` (`STATUS_CONTROL_C_EXIT` as a signed `i32`). The crate reports the platform truth rather than fabricating a `Signalled` from an NTSTATUS code (that mapping would be a lossy guess). When you need to *know* the run was killed, use a `ProcessGroup` deadline or a cancellation token (which -surface as `TimedOut` / `Error::Cancelled` on every platform). `Outcome::Signalled` +surface as `TimedOut` / `ErrorKind::Cancelled` on every platform). `Outcome::Signalled` is therefore Unix-only. **Linux cgroup delegation.** Creating the per-group cgroup needs write access diff --git a/docs/process-groups.md b/docs/process-groups.md index 14bc961..cd81ecf 100644 --- a/docs/process-groups.md +++ b/docs/process-groups.md @@ -202,7 +202,7 @@ async fn main() -> processkit::Result<()> { | Platform | Deliverable signals | |---|---| | Linux (cgroup or pgroup), macOS/BSD | Any — `Term`, `Kill`, `Int`, `Hup`, `Quit`, `Usr1`, `Usr2`, `Other(n)` | -| Windows | `Kill` (Job Object terminate); `Int`/`Term` as a best-effort soft close (`CTRL_BREAK` to console leaders + `WM_CLOSE` to windowed members) — `Error::Unsupported` only when neither exists; every other signal → `Error::Unsupported` | +| Windows | `Kill` (Job Object terminate); `Int`/`Term` as a best-effort soft close (`CTRL_BREAK` to console leaders + `WM_CLOSE` to windowed members) — `ErrorKind::Unsupported` only when neither exists; every other signal → `ErrorKind::Unsupported` | `Signal::Kill` always takes the same *atomic* whole-tree kill path as `kill_all` (`cgroup.kill` / `killpg` / job terminate), so it cannot miss @@ -361,7 +361,7 @@ async fn main() -> processkit::Result<()> { `cpu_quota` is a fraction of a **single** core (`2.0` = two cores). Limits need a real container; when a requested cap can't be enforced — no Job Object/cgroup, or a Linux cgroup whose controllers can't be enabled — -`with_options` returns `Error::ResourceLimit { kind, reason, detail }` instead +`with_options` returns `ErrorKind::ResourceLimit { kind, reason, detail }` instead of handing back a silently-unbounded group: `kind` names the limit (`max_memory`/`max_processes`/`cpu_quota`), `reason` says whether the value was simply invalid, the platform has no whole-tree mechanism at all diff --git a/docs/streaming.md b/docs/streaming.md index 6fe68fe..2f57cfd 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -96,7 +96,7 @@ Things to know: pipes close, and the stream ends — a streamed run can't hang past its deadline. A `cancel_on` token ends it the same way; the following - `finish` then reports `Error::Cancelled`. Details in + `finish` then reports `ErrorKind::Cancelled`. Details in [Timeouts & cancellation](timeouts-and-cancellation.md). - Line counters tick live: `run.stdout_line_count()` / `stderr_line_count()` are cheap progress gauges even while you stream. @@ -169,7 +169,7 @@ use processkit::prelude::StreamExt; use processkit::{Command, Finished, Outcome}; // `ProcessStdin`'s writer methods return `std::io::Result`; `Box` -// mixes them with the crate's `Result` (or `.map_err(processkit::Error::Io)?`). +// mixes them with the crate's `Result` (or `.map_err(processkit::ErrorKind::Io)?`). #[tokio::main] async fn main() -> Result<(), Box> { // `bc` evaluates each stdin line and prints the result. @@ -250,8 +250,8 @@ async fn main() -> processkit::Result<()> { Probe semantics, deliberately uniform: -- A probe that can't pass within its deadline fails with **`Error::NotReady`** - — distinct from `Error::Timeout`, which is the run's own deadline. +- A probe that can't pass within its deadline fails with **`ErrorKind::NotReady`** + — distinct from `ErrorKind::Timeout`, which is the run's own deadline. - A probe also fails *fast* once readiness can no longer happen: the child exits, or (for `wait_for_line`) its stdout closes — no waiting out a 30s deadline on a dead server. diff --git a/docs/supervision.md b/docs/supervision.md index 536a1ce..8946f30 100644 --- a/docs/supervision.md +++ b/docs/supervision.md @@ -396,7 +396,7 @@ the error itself as `run()`'s `Err`. A [cancelled](timeouts-and-cancellation.md#cancellation) incarnation is **terminal**: `run()` returns -`Err(Error::Cancelled)` immediately. The token never un-cancels, so a restart +`Err(ErrorKind::Cancelled)` immediately. The token never un-cancels, so a restart could only produce another instantly-cancelled run — the supervisor refuses the futile loop. diff --git a/docs/testing.md b/docs/testing.md index d6af1a6..19870fa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -54,7 +54,7 @@ scripted handle whose canned lines flow through the same pump machinery a real child uses — `stdout_lines`, `wait_for_line`, and `finish` behave identically, with no subprocess (see [Scripted streaming](#scripted-streaming) below). An `output_string`-only custom -runner keeps compiling: `start` is defaulted to `Error::Unsupported`. +runner keeps compiling: `start` is defaulted to `ErrorKind::Unsupported`. ```rust,no_run use processkit::{Command, ProcessRunner, ProcessRunnerExt, Result}; @@ -108,7 +108,7 @@ The pieces: with stderr. **`Reply::lines(["a", "b"])`** — exit 0 with the lines joined (and streamed one by one on a scripted [`start`](#scripted-streaming)). **`Reply::timeout()`** — a timed-out run (the checking helpers raise - `Error::Timeout` from it, carrying the command's own configured deadline). On a + `ErrorKind::Timeout` from it, carrying the command's own configured deadline). On a scripted [`start`](#scripted-streaming) it resolves *immediately* as timed-out; to exercise a real deadline race, use `Reply::pending()` + a `Command::timeout`. **`.with_stdout(text)`** — attach stdout to any of them (e.g. the @@ -117,7 +117,7 @@ The pieces: - **`Reply::pending()`** — parks the call until the command's cancellation token (per-command `cancel_on` or the client-level [`default_cancel_on`](timeouts-and-cancellation.md#client-level-default)) - fires, resolving with `Error::Cancelled` — so a test can prove an + fires, resolving with `ErrorKind::Cancelled` — so a test can prove an orchestration *actually cancels* a blocked call, not just that it formats a canned error — or until the command's `timeout` deadline elapses, resolving timed-out (`Outcome::TimedOut`) on the bulk verbs and `start` alike, like a @@ -129,7 +129,7 @@ The pieces: but not `git foobar` (and not `rm foo`). Use [`on_sequence`](https://docs.rs/processkit) to serve an ordered sequence of replies (each once, then the last repeats) for a fail-then-succeed scenario. -- **No match and no fallback is a loud error** (`Error::Spawn`, not-found) — +- **No match and no fallback is a loud error** (`ErrorKind::Spawn`, not-found) — an unexpected invocation can't slip through a test silently. - Bulk runs also **replay the canned lines through the command's `on_stdout_line`/`on_stderr_line` handlers**, so a wrapper's @@ -329,12 +329,12 @@ Semantics worth knowing before you commit a cassette: | Match key | program + args + a stdin **source digest** (hashed, never persisted: in-memory bytes hash their content, a `from_file` source hashes its path) — no stdin (absent or `Stdin::empty()`) keys distinctly; lossy UTF-8 on the text parts. **`cwd` is not part of the key by default** — a cassette recorded from one absolute working directory still replays when the same invocation runs from another (a dev box vs. a CI workspace); `cwd` is still stored on the entry, verbatim, for visibility. Opt in to a stricter key with `match_on_cwd` / `match_on_env` (below) | | Environment | **values never reach the file** — only sorted variable names, so *env* secrets can't leak through a committed fixture. Env is **not matched by default**, so irrelevant env differences can't cause spurious misses. Opt in with `match_on_env(["NAME", …])` to also key on selected variables' *values* — still via a **digest**, so raw values remain off-disk (see [Opt-in stricter matching](#opt-in-stricter-matching-cwd--selected-env-values)) | | Duplicates of one key | replay in capture order, then the **last entry repeats** — a recorded sequence (`git rev-parse HEAD` before/after a commit) replays faithfully, while retry/probe loops keep getting a stable final answer | -| Miss | strict `Error::CassetteMiss` (distinct from a missing program — `is_not_found()` is `false`) — replay never spawns a surprise subprocess; a stale cassette fails loudly | -| Timeouts | a recorded timed-out run replays as one, surfacing `Error::Timeout` with the *replaying* command's deadline | -| Format | pretty-printed JSON with a `version` field; unknown versions / corrupt files / an entry with a contradictory outcome / a file over 64 MiB are `Error::Io(InvalidData)`, a missing file keeps `NotFound` | -| Err results | recorded and replayed faithfully (`Error::Spawn`/`NotFound`/`Stdin`/`OutputTooLarge`/`Unsupported`/`Io` — with its `ErrorKind` preserved by name — plus an `Other` fallback); replaying such an entry surfaces the reconstructed error instead of `Error::CassetteMiss`. `Error::Cancelled` is the one exception — never recorded, since replay short-circuits on the replaying command's own token first | +| Miss | strict `ErrorKind::CassetteMiss` (distinct from a missing program — `is_not_found()` is `false`) — replay never spawns a surprise subprocess; a stale cassette fails loudly | +| Timeouts | a recorded timed-out run replays as one, surfacing `ErrorKind::Timeout` with the *replaying* command's deadline | +| Format | pretty-printed JSON with a `version` field; unknown versions / corrupt files / an entry with a contradictory outcome / a file over 64 MiB are `ErrorKind::Io(InvalidData)`, a missing file keeps `NotFound` | +| Err results | recorded and replayed faithfully (`ErrorKind::Spawn`/`NotFound`/`Stdin`/`OutputTooLarge`/`Unsupported`/`Io` — with its `ErrorKind` preserved by name — plus an `Other` fallback); replaying such an entry surfaces the reconstructed error instead of `ErrorKind::CassetteMiss`. `ErrorKind::Cancelled` is the one exception — never recorded, since replay short-circuits on the replaying command's own token first | | Verbs (`output_string` + `start`) | a cassette is **verb-agnostic**: record through either and replay through either. Replaying `start` hands back a scripted `RunningProcess` whose recorded lines flow through the command's real pumps (`stdout_lines` / `wait_for_line` / `finish`), no subprocess. *Recording* a `start` captures the run whole (the child runs to completion before the handle returns), so an **interactive** run fed stdin mid-stream can't be recorded that way — bound it with `Command::timeout` or script it with `ScriptedRunner` | -| `output_bytes` | **unsupported** (`Error::Unsupported`) in both modes — a lossy-UTF-8 text fixture can't reproduce exact raw bytes; capture bytes from a real or scripted runner | +| `output_bytes` | **unsupported** (`ErrorKind::Unsupported`) in both modes — a lossy-UTF-8 text fixture can't reproduce exact raw bytes; capture bytes from a real or scripted runner | Only env **values** are redacted. `program`, `args`, `cwd`, `stdout`, and `stderr` are stored **verbatim** and can carry secrets (a `--password=…` flag, a @@ -439,7 +439,7 @@ impl Git { } /// Branch list, parsed — the parser is fallible and returns the crate's - /// `Result`, typically an `Error::Parse` naming the program. + /// `Result`, typically an `ErrorKind::Parse` naming the program. pub async fn branches(&self, repo: &Path) -> Result> { self.core .try_parse( @@ -472,7 +472,7 @@ The generated type is `Git` with `Git::new()`, it as `self.core` from the wrapper's own methods) whose helpers speak the crate-wide verb vocabulary: `run` (trimmed stdout), `output_string` (full result), `run_unit` (success only), `exit_code`, `probe`, plus `parse` -(infallible) and `try_parse` (fallible → `Error::Parse`). +(infallible) and `try_parse` (fallible → `ErrorKind::Parse`). And the payoff — the wrapper tests hermetically with any double: diff --git a/docs/timeouts-and-cancellation.md b/docs/timeouts-and-cancellation.md index aa240ca..bd50cf4 100644 --- a/docs/timeouts-and-cancellation.md +++ b/docs/timeouts-and-cancellation.md @@ -44,7 +44,7 @@ async fn main() -> processkit::Result<()> { .run() .await .unwrap_err(); - assert!(matches!(err, processkit::Error::Timeout { .. })); + assert!(matches!(err.kind(), processkit::ErrorKind::Timeout { .. })); Ok(()) } ``` @@ -54,17 +54,17 @@ Where each verb lands: | Verb | Deadline expiry becomes | |---|---| | `output_string()` / `output_bytes()` | `Ok` result with `timed_out() == true`, `code() == None`, partial output kept | -| `run()` / `exit_code()` / `probe()` / `checked()` | `Error::Timeout { program, timeout, stdout, stderr }` — the partial output captured before the kill is attached (`err.diagnostic()` surfaces a hung tool's last words) | -| `first_line(pred)` | `Error::Timeout` (the line never arrived in time) | +| `run()` / `exit_code()` / `probe()` / `checked()` | `ErrorKind::Timeout { program, timeout, stdout, stderr }` — the partial output captured before the kill is attached (`err.diagnostic()` surfaces a hung tool's last words) | +| `first_line(pred)` | `ErrorKind::Timeout` (the line never arrived in time) | | `start()` + streaming | the stream **ends** at the deadline (tree killed, pipes closed); `finish` then reports the kill (`outcome == Outcome::TimedOut`) | -| `ensure_success()` on a captured result | `Error::Timeout`, checked *before* the exit code | +| `ensure_success()` on a captured result | `ErrorKind::Timeout`, checked *before* the exit code | | [`Pipeline`](pipelines.md#timeouts) | chain deadline → `timed_out` result; per-stage deadlines fold into pipefail | Two distinct deadline families to keep apart: - `Command::timeout` — the run's own contract, this section. - The [readiness probes](streaming.md#readiness-probes)' `within` parameter — - gives `Error::NotReady` and **never kills the child**. + gives `ErrorKind::NotReady` and **never kills the child**. ### Graceful timeout @@ -117,7 +117,7 @@ teardown timing. only while the classifier accepts the error: ```rust,no_run -use processkit::{Command, Error}; +use processkit::{Command, ErrorKind}; use std::time::Duration; #[tokio::main] @@ -127,8 +127,8 @@ async fn main() -> processkit::Result<()> { .timeout(Duration::from_secs(10)) .retry(3, Duration::from_millis(250), |e| { // transient: network timeouts and curl's "couldn't connect" (7) - matches!(e, Error::Timeout { .. }) - || matches!(e, Error::Exit { code: 7, .. }) + matches!(e.kind(), ErrorKind::Timeout { .. }) + || matches!(e.kind(), ErrorKind::Exit { code: 7, .. }) }) .run() .await?; @@ -150,7 +150,7 @@ Ground rules: as-is, since a second attempt could only replay empty stdin. Use a reusable stdin source if a stdin-bearing command must retry. (A one-shot source re-run *outside* the retry loop — a `Supervisor` incarnation, a pipeline re-run — - instead fails loud with an `Error::Io` (`InvalidInput`) at launch.) + instead fails loud with an `ErrorKind::Io` (`InvalidInput`) at launch.) - A `Cancelled` error is **never retried**, classifier or not — the token stays cancelled forever, so another attempt could only fail the same way. @@ -162,7 +162,7 @@ backoff shape, different loop condition. Hand any command a `CancellationToken` (re-exported at the crate root); cancelling the token kills the run's tree and makes every consuming path -report `Error::Cancelled`: +report `ErrorKind::Cancelled`: ```rust,no_run use processkit::{CancellationToken, Command}; @@ -183,8 +183,8 @@ async fn main() -> processkit::Result<()> { shutdown.cancel(); assert!(matches!( - job.await.unwrap(), - Err(processkit::Error::Cancelled { .. }) + job.await.unwrap().map_err(processkit::Error::into_kind), + Err(processkit::ErrorKind::Cancelled { .. }) )); Ok(()) } @@ -194,15 +194,15 @@ The contract, path by path: | Situation | Behavior | |---|---| -| Cancel during `run` / `output_string` / `output_bytes` / `wait` / `profile` / `exit_code` / `probe` | tree killed, `Error::Cancelled { program }` | -| Cancel during streaming (`stdout_lines`) | the stream **ends**; the following `finish` reports `Error::Cancelled` | +| Cancel during `run` / `output_string` / `output_bytes` / `wait` / `profile` / `exit_code` / `probe` | tree killed, `ErrorKind::Cancelled { program }` | +| Cancel during streaming (`stdout_lines`) | the stream **ends**; the following `finish` reports `ErrorKind::Cancelled` | | Token already cancelled before the run | short-circuits **before spawning** — no process is ever created | | Cancel on a shared-`ProcessGroup` handle | kills the child itself, leaves the group's siblings alone (same scope as a timeout) | | A `Pipeline` stage's token cancels | that stage dies; the cancellation errors the whole pipeline and the private group reaps the other stages | | Under `retry` | terminal — never retried, whatever the classifier says | | Under a [`Supervisor`](supervision.md) | terminal — supervision returns `Err(Cancelled)` instead of restarting into a still-cancelled token | | `wait_any` mid-run | surfaces `Err(Cancelled)` — each racer's wait path resolves to `Cancelled` when its token fires, the same as a bulk verb (a *pre-cancelled* token still hits the pre-spawn short-circuit) | -| `first_line` mid-run | surfaces `Error::Cancelled` once the token fires — a cancelled stream that closes without a match is reported as cancellation, not `Ok(None)` | +| `first_line` mid-run | surfaces `ErrorKind::Cancelled` once the token fires — a cancelled stream that closes without a match is reported as cancellation, not `Ok(None)` | ### Client-level default @@ -217,7 +217,7 @@ use processkit::{CancellationToken, CliClient}; let token = CancellationToken::new(); let gh = CliClient::new("gh").default_cancel_on(token.child_token()); // ... controller cancels `token` → every in-flight command of THIS client -// dies (whole tree), surfacing Error::Cancelled to the awaiting call. +// dies (whole tree), surfacing ErrorKind::Cancelled to the awaiting call. ``` Clients are cheap — scope cancellation by building **one client per @@ -254,7 +254,7 @@ async fn main() -> processkit::Result<()> { .run() .await .unwrap_err(); - assert!(matches!(err, processkit::Error::Cancelled { .. })); + assert!(matches!(err.kind(), processkit::ErrorKind::Cancelled { .. })); Ok(()) } ``` diff --git a/docs/upgrading.md b/docs/upgrading.md index daa6fd8..c334428 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -70,10 +70,10 @@ After — add `..` to the pattern (or, better, use the existing accessors instea of destructuring at all): ```rust,no_run -use processkit::Error; +use processkit::{Error, ErrorKind}; fn handle(err: Error) { -match &err { - Error::Exit { program, code, stdout, stderr, .. } => { let _ = (program, code, stdout, stderr); } +match err.kind() { + ErrorKind::Exit { program, code, stdout, stderr, .. } => { let _ = (program, code, stdout, stderr); } _ => {} } diff --git a/src/buffer.rs b/src/buffer.rs index ba5e68a..463b94c 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -92,7 +92,7 @@ pub enum OverflowMode { /// "Head" semantics: keep what is already buffered and discard new lines. DropNewest, /// Fail-loud ceiling: once the buffer is full, the run errors with - /// [`Error::OutputTooLarge`](crate::Error::OutputTooLarge) rather than + /// [`ErrorKind::OutputTooLarge`](crate::ErrorKind::OutputTooLarge) rather than /// silently dropping lines. The pipe is still drained (so the child never /// blocks); excess lines are counted but not retained. /// @@ -132,7 +132,7 @@ pub enum OverflowMode { /// *unbounded* buffer (no `max_lines` and no `max_bytes`) this mode is a /// misconfiguration — a fail-loud ceiling with no ceiling — so it is treated /// as **zero-tolerance**: the run errors on *any* line-pumped output - /// (`Error::OutputTooLarge`), rather than silently retaining everything. Use + /// (`ErrorKind::OutputTooLarge`), rather than silently retaining everything. Use /// `fail_loud(n)` when you want a real cap. /// /// **Counts the total, not the backlog.** The ceiling fires on the @@ -237,7 +237,7 @@ impl OutputBufferPolicy { /// Retain at most `max_lines` and error when full — a fail-loud ceiling. /// /// Equivalent to `bounded(max_lines).with_overflow(OverflowMode::Error)`. - /// The run errors with [`Error::OutputTooLarge`](crate::Error::OutputTooLarge) + /// The run errors with [`ErrorKind::OutputTooLarge`](crate::ErrorKind::OutputTooLarge) /// once this limit is reached; excess lines are counted but not retained. pub fn fail_loud(max_lines: usize) -> Self { Self { @@ -306,7 +306,7 @@ impl Default for OutputBufferPolicy { /// - No cap (`None`): retain everything (the default — byte-for-byte the old /// behavior). /// - [`OverflowMode::Error`]: flag overflow and stop retaining past the cap; the -/// caller drains the pipe to EOF and then raises `Error::OutputTooLarge`. +/// caller drains the pipe to EOF and then raises `ErrorKind::OutputTooLarge`. /// - [`OverflowMode::DropNewest`]: keep the first `cap` bytes (head), flag /// truncation. /// - [`OverflowMode::DropOldest`]: keep roughly the last `cap` bytes (tail). The diff --git a/src/cassette.rs b/src/cassette.rs index 383bc72..f425255 100644 --- a/src/cassette.rs +++ b/src/cassette.rs @@ -35,7 +35,7 @@ use serde::{Deserialize, Serialize}; use crate::command::Command; use crate::doubles::Invocation; -use crate::error::{Error, Result}; +use crate::error::{Error, ErrorKind, Result}; use crate::result::{Outcome, ProcessResult}; use crate::runner::{JobRunner, ProcessRunner}; @@ -183,18 +183,18 @@ struct Entry { /// invocation raises the *same error* the recording run did, instead of the /// call silently falling through to a plain [`Entry`] read (or, before this /// existed, missing the cassette entirely with a misleading -/// [`Error::CassetteMiss`]). +/// [`ErrorKind::CassetteMiss`]). /// /// Deliberately **not** every [`Error`] variant: only the ones the raw /// [`ProcessRunner::output_string`]/[`ProcessRunner::start`] seam can actually /// return in record mode land here. A variant produced by a *checking* verb /// layered over an otherwise-successful [`ProcessResult`] — -/// [`Exit`](Error::Exit), [`Timeout`](Error::Timeout), -/// [`Signalled`](Error::Signalled) — is already reproduced through the +/// [`Exit`](ErrorKind::Exit), [`Timeout`](ErrorKind::Timeout), +/// [`Signalled`](ErrorKind::Signalled) — is already reproduced through the /// existing `code`/`timed_out`/`signal` fields and never needs this; only a /// call that returned no [`ProcessResult`] at all does. /// -/// [`Cancelled`](Error::Cancelled) is deliberately **excluded** (never +/// [`Cancelled`](ErrorKind::Cancelled) is deliberately **excluded** (never /// recorded, like the pre-this-task "record nothing" behavior): replay /// already short-circuits a *replaying* command's own cancelled token before /// ever consulting the cassette (mirroring the live runner's pre-spawn @@ -205,11 +205,11 @@ struct Entry { /// [`Error`] addition, or one not listed above) still lands here via /// [`Other`](CassetteError::Other), carrying its `Display` message — so an /// `Err` is always reproduced as *some* error, never silently dropped back to -/// a [`Error::CassetteMiss`]. +/// a [`ErrorKind::CassetteMiss`]. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind")] enum CassetteError { - /// [`Error::Spawn`]: the child could not be started. + /// [`ErrorKind::Spawn`]: the child could not be started. Spawn { /// The OS error's [`std::io::ErrorKind`], named — see /// [`io_kind_name`]/[`io_kind_from_name`]. @@ -217,15 +217,15 @@ enum CassetteError { /// The OS error's `Display` text. message: String, }, - /// [`Error::NotFound`]: the program could not be located. + /// [`ErrorKind::NotFound`]: the program could not be located. NotFound { /// The `PATH` directories searched, joined — see - /// [`Error::NotFound`]'s `searched` field. Never logged elsewhere; + /// [`ErrorKind::NotFound`]'s `searched` field. Never logged elsewhere; /// stored here exactly like the rest of a cassette (verbatim, /// reviewed before committing). searched: Option, }, - /// [`Error::Stdin`]: feeding the child's stdin failed for a reason other + /// [`ErrorKind::Stdin`]: feeding the child's stdin failed for a reason other /// than a routine broken pipe. Stdin { /// See [`Spawn`](CassetteError::Spawn)'s `os_kind`. @@ -233,20 +233,20 @@ enum CassetteError { /// The OS error's `Display` text. message: String, }, - /// [`Error::OutputTooLarge`]: the captured output exceeded its ceiling. + /// [`ErrorKind::OutputTooLarge`]: the captured output exceeded its ceiling. OutputTooLarge { max_lines: Option, max_bytes: Option, total_lines: usize, total_bytes: usize, }, - /// [`Error::Unsupported`]: the operation is not supported on this + /// [`ErrorKind::Unsupported`]: the operation is not supported on this /// platform/mechanism. Unsupported { /// A short description of the unsupported operation. operation: String, }, - /// [`Error::Io`]: a low-level IO error from the crate's own machinery. + /// [`ErrorKind::Io`]: a low-level IO error from the crate's own machinery. Io { /// See [`Spawn`](CassetteError::Spawn)'s `os_kind`. os_kind: String, @@ -254,7 +254,7 @@ enum CassetteError { message: String, }, /// Any other `Error` variant, kept only as a `Display` message. Replays - /// as [`Error::Io`] with [`std::io::ErrorKind::Other`] — not the original + /// as [`ErrorKind::Io`] with [`std::io::ErrorKind::Other`] — not the original /// variant, but still a loud, informative `Err` rather than a silent /// cassette miss. Other { @@ -265,22 +265,22 @@ enum CassetteError { impl CassetteError { /// Capture the inner runner's `Err` for the cassette, or `None` for - /// [`Error::Cancelled`] (see the type doc — deliberately never recorded). + /// [`ErrorKind::Cancelled`] (see the type doc — deliberately never recorded). fn from_error(err: &Error) -> Option { - Some(match err { - Error::Cancelled { .. } => return None, - Error::Spawn { source, .. } => CassetteError::Spawn { + Some(match err.kind() { + ErrorKind::Cancelled { .. } => return None, + ErrorKind::Spawn { source, .. } => CassetteError::Spawn { os_kind: io_kind_name(source.kind()).to_owned(), message: source.to_string(), }, - Error::NotFound { searched, .. } => CassetteError::NotFound { + ErrorKind::NotFound { searched, .. } => CassetteError::NotFound { searched: searched.clone(), }, - Error::Stdin { source, .. } => CassetteError::Stdin { + ErrorKind::Stdin { source, .. } => CassetteError::Stdin { os_kind: io_kind_name(source.kind()).to_owned(), message: source.to_string(), }, - Error::OutputTooLarge { + ErrorKind::OutputTooLarge { max_lines, max_bytes, total_lines, @@ -292,10 +292,10 @@ impl CassetteError { total_lines: *total_lines, total_bytes: *total_bytes, }, - Error::Unsupported { operation } => CassetteError::Unsupported { + ErrorKind::Unsupported { operation } => CassetteError::Unsupported { operation: operation.clone(), }, - Error::Io(source) => CassetteError::Io { + ErrorKind::Io(source) => CassetteError::Io { os_kind: io_kind_name(source.kind()).to_owned(), message: source.to_string(), }, @@ -309,15 +309,15 @@ impl CassetteError { /// `program` (the replaying command's own [`Command::program_name`]). fn to_error(&self, program: &str) -> Error { match self { - CassetteError::Spawn { os_kind, message } => Error::Spawn { + CassetteError::Spawn { os_kind, message } => ErrorKind::Spawn { program: program.to_owned(), source: std::io::Error::new(io_kind_from_name(os_kind), message.clone()), }, - CassetteError::NotFound { searched } => Error::NotFound { + CassetteError::NotFound { searched } => ErrorKind::NotFound { program: program.to_owned(), searched: searched.clone(), }, - CassetteError::Stdin { os_kind, message } => Error::Stdin { + CassetteError::Stdin { os_kind, message } => ErrorKind::Stdin { program: program.to_owned(), source: std::io::Error::new(io_kind_from_name(os_kind), message.clone()), }, @@ -326,22 +326,25 @@ impl CassetteError { max_bytes, total_lines, total_bytes, - } => Error::OutputTooLarge { + } => ErrorKind::OutputTooLarge { program: program.to_owned(), max_lines: *max_lines, max_bytes: *max_bytes, total_lines: *total_lines, total_bytes: *total_bytes, }, - CassetteError::Unsupported { operation } => Error::Unsupported { + CassetteError::Unsupported { operation } => ErrorKind::Unsupported { operation: operation.clone(), }, - CassetteError::Io { os_kind, message } => Error::Io(std::io::Error::new( + CassetteError::Io { os_kind, message } => ErrorKind::Io(std::io::Error::new( io_kind_from_name(os_kind), message.clone(), )), - CassetteError::Other { message } => Error::Io(std::io::Error::other(message.clone())), + CassetteError::Other { message } => { + ErrorKind::Io(std::io::Error::other(message.clone())) + } } + .into() } } @@ -646,7 +649,7 @@ fn lock_sibling(path: &Path) -> std::path::PathBuf { /// The `Err` raised when another writer is saving the same cassette right now. /// Its [`WouldBlock`](std::io::ErrorKind::WouldBlock) kind makes the wrapping -/// [`Error::Io`] satisfy [`Error::is_transient`](crate::Error::is_transient) — so +/// [`ErrorKind::Io`] satisfy [`Error::is_transient`](crate::Error::is_transient) — so /// the loser can simply retry once the winner's save completes, and the last /// confirmed-good cassette is preserved rather than silently overwritten. fn concurrent_save_conflict() -> std::io::Error { @@ -943,11 +946,12 @@ fn reject_unrecordable_stdin(command: &Command) -> Result<()> { .effective_stdin_source() .is_some_and(|s| s.is_one_shot()) { - return Err(Error::Unsupported { + return Err(ErrorKind::Unsupported { operation: "cassette record/replay with one-shot streaming stdin \ (from_reader/from_lines); use from_bytes/from_string/from_file" .to_string(), - }); + } + .into()); } Ok(()) } @@ -1033,7 +1037,7 @@ enum Mode { /// failure, a missing program, … — is recorded too (as a discriminant + payload; /// an internal, non-public schema detail — not every `Error` variant is /// representable, an unmodeled one falls back to its `Display` text), -/// **except** [`Error::Cancelled`], which is never recorded (a caller-driven +/// **except** [`ErrorKind::Cancelled`], which is never recorded (a caller-driven /// cancellation isn't a fact about the invocation to replay). Either way the /// real error still propagates to the record-mode caller unchanged. /// @@ -1072,14 +1076,14 @@ enum Mode { /// values — a broader always-on env-keying alternative was considered and /// deferred.) /// - **Duplicates** replay in capture order, then the last entry repeats. -/// - **A miss is [`Error::CassetteMiss`]** (not `is_not_found()`): never a +/// - **A miss is [`ErrorKind::CassetteMiss`]** (not `is_not_found()`): never a /// surprise subprocess. A **recorded `Err`** replays as that same `Error` /// rather than a result — this is a genuine behavioral difference from /// cassettes written before this existed, where such an invocation instead /// missed the cassette entirely. /// - The replayed result carries the *replaying* command's /// [`timeout`](Command::timeout), so a recorded timed-out run surfaces as -/// [`Error::Timeout`](crate::Error::Timeout) with the real deadline. +/// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) with the real deadline. /// - Covers the **text and streaming verbs**: `output_string` replays the /// captured result, and [`start`](crate::ProcessRunner::start) replays the /// recorded output through a scripted [`RunningProcess`](crate::RunningProcess) @@ -1093,7 +1097,7 @@ enum Mode { /// never comes; bound it with a [`Command::timeout`](crate::Command::timeout), or /// script it with a [`ScriptedRunner`](crate::testing::ScriptedRunner) instead). /// - **The runner's `output_bytes` verb is unsupported** -/// ([`Error::Unsupported`](crate::Error::Unsupported)) in both modes: a cassette +/// ([`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported)) in both modes: a cassette /// stores lossy-UTF-8 text and cannot reproduce the exact raw bytes that verb /// promises — capture bytes from a real or scripted runner. (This guards the /// convenient default route, which would otherwise re-encode the recorded text @@ -1207,7 +1211,7 @@ impl RecordReplayRunner { /// `O_EXCL` temp, so writers never stomp one temp or delete another's (a /// stale temp from a crashed writer is a harmless orphan). The lock is taken /// **non-blocking**: if another writer holds it at that instant, the save is - /// refused with a *transient* [`Error::Io`] + /// refused with a *transient* [`ErrorKind::Io`] /// ([`WouldBlock`](std::io::ErrorKind::WouldBlock), so /// [`is_transient`](crate::Error::is_transient) is `true` — retry once the /// other save completes) rather than silently overwriting it. This trades the @@ -1217,7 +1221,7 @@ impl RecordReplayRunner { /// /// # Errors /// - /// [`Error::Io`] if the recorded entries cannot be serialized to JSON; if + /// [`ErrorKind::Io`] if the recorded entries cannot be serialized to JSON; if /// another writer holds the save lock (a transient /// [`WouldBlock`](std::io::ErrorKind::WouldBlock) conflict — see /// *Concurrency*); or if writing, renaming, or fsync'ing the cassette (or its @@ -1251,8 +1255,8 @@ impl RecordReplayRunner { entries: entries.clone(), }; let json = serde_json::to_string_pretty(&cassette) - .map_err(|e| Error::Io(std::io::Error::from(e)))?; - write_cassette(path, &json).map_err(Error::Io)?; + .map_err(|e| ErrorKind::Io(std::io::Error::from(e)))?; + write_cassette(path, &json).map_err(ErrorKind::Io)?; dirty.store(false, Ordering::SeqCst); Ok(()) } @@ -1275,26 +1279,27 @@ fn validate_entry_outcome(entry: &Entry) -> Result<()> { + usize::from(entry.signal.is_some()); if entry.error.is_some() { if indicators > 0 { - return Err(Error::Io(std::io::Error::new( + return Err(ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidData, format!( "cassette entry for `{}` carries both a recorded `error` and an outcome \ indicator (`code`/`timed_out`/`signal`) — at most one may be set", entry.program ), - ))); + )) + .into()); } return Ok(()); } if indicators > 1 { - return Err(Error::Io(std::io::Error::new( + return Err(ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidData, format!( "cassette entry for `{}` has a contradictory outcome: at most one of \ `code` (exited), `timed_out`, or `signal` (signalled) may be set — found {indicators}", entry.program ), - ))); + )).into()); } Ok(()) } @@ -1305,7 +1310,7 @@ impl RecordReplayRunner { /// /// # Errors /// - /// Always [`Error::Io`]: a missing file keeps its `NotFound` kind; a corrupt + /// Always [`ErrorKind::Io`]: a missing file keeps its `NotFound` kind; a corrupt /// file, a contradictory entry, an unknown format `version`, or a cassette /// over the 64 MiB size limit is `InvalidData`. pub fn replay(path: impl AsRef) -> Result { @@ -1314,15 +1319,16 @@ impl RecordReplayRunner { if let Ok(meta) = std::fs::metadata(path) && meta.len() > MAX_CASSETTE_BYTES { - return Err(Error::Io(std::io::Error::new( + return Err(ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidData, format!( "cassette is {} bytes, over the {MAX_CASSETTE_BYTES}-byte limit", meta.len() ), - ))); + )) + .into()); } - let text = std::fs::read_to_string(path).map_err(Error::Io)?; + let text = std::fs::read_to_string(path).map_err(ErrorKind::Io)?; Self::from_cassette_text(&text) } @@ -1383,7 +1389,7 @@ pub fn fuzz_cassette_replay(text: &str, calls: &[(String, Vec)]) { .await .expect_err("a recorded error must replay as an error"); assert!( - !matches!(err, Error::CassetteMiss { .. }), + !matches!(err.kind(), ErrorKind::CassetteMiss { .. }), "a matched recorded error must not become a cassette miss" ); } @@ -1397,8 +1403,12 @@ pub fn fuzz_cassette_replay(text: &str, calls: &[(String, Vec)]) { assert_eq!(actual.stderr(), expected_result.stderr()); assert_eq!(actual.outcome(), expected_result.outcome()); } - None => match replayer.output_string(&command).await { - Err(Error::CassetteMiss { .. }) => {} + None => match replayer + .output_string(&command) + .await + .map_err(Error::into_kind) + { + Err(ErrorKind::CassetteMiss { .. }) => {} other => { panic!("an unmatched invocation must be a CassetteMiss, got {other:?}") } @@ -1419,18 +1429,18 @@ fn replay_slots_from_text(text: &str) -> Result> { version: u32, } let header: CassetteHeader = - serde_json::from_str(text).map_err(|e| Error::Io(std::io::Error::from(e)))?; + serde_json::from_str(text).map_err(|e| ErrorKind::Io(std::io::Error::from(e)))?; if header.version > CASSETTE_VERSION { - return Err(Error::Io(std::io::Error::new( + return Err(ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidData, format!( "cassette version {} is not supported (this build reads up to version {CASSETTE_VERSION})", header.version ), - ))); + )).into()); } let cassette: Cassette = - serde_json::from_str(text).map_err(|e| Error::Io(std::io::Error::from(e)))?; + serde_json::from_str(text).map_err(|e| ErrorKind::Io(std::io::Error::from(e)))?; let mut slots: HashMap = HashMap::new(); for entry in cassette.entries { validate_entry_outcome(&entry)?; @@ -1500,9 +1510,10 @@ impl ProcessRunner for RecordReplayRunner { if let Some(token) = command.cancel_token() && token.is_cancelled() { - return Err(Error::Cancelled { + return Err(ErrorKind::Cancelled { program: command.program_name(), - }); + } + .into()); } // A capture verb on `stdout(Inherit/Null)` has nothing to read — // the real runner and the scripted double both reject it, and the @@ -1524,9 +1535,10 @@ impl ProcessRunner for RecordReplayRunner { { Some(slot) => slot, None => { - return Err(Error::CassetteMiss { + return Err(ErrorKind::CassetteMiss { program: command.program_name(), - }); + } + .into()); } }; slot.play().clone() @@ -1549,11 +1561,12 @@ impl ProcessRunner for RecordReplayRunner { /// bytes through the defaulted `start` path; capture bytes from a real or /// scripted runner instead. async fn output_bytes(&self, _command: &Command) -> Result>> { - Err(Error::Unsupported { + Err(ErrorKind::Unsupported { operation: "output_bytes on a cassette (a lossy-UTF-8 text fixture cannot \ reproduce exact bytes; capture them from a real or scripted runner)" .to_string(), - }) + } + .into()) } async fn start(&self, command: &Command) -> Result { @@ -1634,9 +1647,10 @@ impl ProcessRunner for RecordReplayRunner { if let Some(token) = command.cancel_token() && token.is_cancelled() { - return Err(Error::Cancelled { + return Err(ErrorKind::Cancelled { program: command.program_name(), - }); + } + .into()); } let invocation = Invocation::from_command(command); let stdin_digest = stdin_digest_of(command); @@ -1646,9 +1660,10 @@ impl ProcessRunner for RecordReplayRunner { { Some(slot) => slot, None => { - return Err(Error::CassetteMiss { + return Err(ErrorKind::CassetteMiss { program: command.program_name(), - }); + } + .into()); } }; slot.play().clone() @@ -1966,7 +1981,7 @@ mod tests { .await .expect_err("output_bytes must be unsupported in record mode"); assert!( - matches!(rec_err, Error::Unsupported { .. }), + matches!(rec_err.kind(), ErrorKind::Unsupported { .. }), "got {rec_err:?}" ); // The rejected call recorded nothing; a real entry is still needed to load. @@ -1982,7 +1997,7 @@ mod tests { .await .expect_err("output_bytes must be unsupported in replay mode"); assert!( - matches!(rep_err, Error::Unsupported { .. }), + matches!(rep_err.kind(), ErrorKind::Unsupported { .. }), "got {rep_err:?}" ); } @@ -2029,7 +2044,7 @@ mod tests { let err = RecordReplayRunner::replay(&path) .expect_err("a contradictory outcome must be rejected"); assert!( - matches!(&err, Error::Io(e) if e.kind() == std::io::ErrorKind::InvalidData), + matches!(&err.kind(), ErrorKind::Io(e) if e.kind() == std::io::ErrorKind::InvalidData), "got {err:?}" ); } @@ -2049,9 +2064,9 @@ mod tests { .output_string(&Command::new("tool").arg("--other")) .await .expect_err("an unrecorded invocation must not be served"); - match &err { - Error::CassetteMiss { program } => assert_eq!(program, "tool"), - other => panic!("expected Error::CassetteMiss, got {other:?}"), + match &err.kind() { + ErrorKind::CassetteMiss { program } => assert_eq!(program, "tool"), + other => panic!("expected ErrorKind::CassetteMiss, got {other:?}"), } // A stale cassette is NOT mistaken for a missing program. assert!( @@ -2130,7 +2145,10 @@ mod tests { .output_string(&Command::new("tool").stdin(crate::Stdin::from_reader(&b"payload"[..]))) .await .expect_err("record must reject a one-shot streaming stdin"); - assert!(matches!(err, Error::Unsupported { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::Unsupported { .. }), + "got {err:?}" + ); // Record a plain entry so the cassette loads, then prove replay rejects // a streaming stdin too. @@ -2144,7 +2162,10 @@ mod tests { .output_string(&Command::new("tool").stdin(crate::Stdin::from_reader(&b"payload"[..]))) .await .expect_err("replay must reject a one-shot streaming stdin"); - assert!(matches!(err, Error::Unsupported { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::Unsupported { .. }), + "got {err:?}" + ); } #[test] @@ -2204,7 +2225,10 @@ mod tests { .output_string(&Command::new("tool")) .await .expect_err("a no-stdin call must not match a stdin-recorded entry"); - assert!(matches!(err, Error::CassetteMiss { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::CassetteMiss { .. }), + "got {err:?}" + ); } #[tokio::test] @@ -2229,9 +2253,9 @@ mod tests { ) .await .expect_err("run() raises the captured timeout"); - match err { - Error::Timeout { timeout, .. } => assert_eq!(timeout, Duration::from_secs(7)), - other => panic!("expected Error::Timeout, got {other:?}"), + match err.kind() { + ErrorKind::Timeout { timeout, .. } => assert_eq!(*timeout, Duration::from_secs(7)), + other => panic!("expected ErrorKind::Timeout, got {other:?}"), } } @@ -2297,20 +2321,20 @@ mod tests { #[tokio::test] async fn load_errors_are_typed_io() { let (_dir, path) = temp_cassette(); - match RecordReplayRunner::replay(&path) { - Err(Error::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound), + match RecordReplayRunner::replay(&path).map_err(Error::into_kind) { + Err(ErrorKind::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound), other => panic!("expected Io(NotFound), got {other:?}"), } std::fs::write(&path, "{ not json").unwrap(); - match RecordReplayRunner::replay(&path) { - Err(Error::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidData), + match RecordReplayRunner::replay(&path).map_err(Error::into_kind) { + Err(ErrorKind::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidData), other => panic!("expected Io(InvalidData), got {other:?}"), } std::fs::write(&path, r#"{ "version": 99, "entries": [] }"#).unwrap(); - match RecordReplayRunner::replay(&path) { - Err(Error::Io(e)) => { + match RecordReplayRunner::replay(&path).map_err(Error::into_kind) { + Err(ErrorKind::Io(e)) => { assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); assert!(e.to_string().contains("version 99"), "got: {e}"); } @@ -2331,8 +2355,8 @@ mod tests { r#"{ "version": 99, "entries": [ { "program": "x", "args": [], "code": "not-a-number" } ] }"#, ) .unwrap(); - match RecordReplayRunner::replay(&path) { - Err(Error::Io(e)) => { + match RecordReplayRunner::replay(&path).map_err(Error::into_kind) { + Err(ErrorKind::Io(e)) => { assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); assert!( e.to_string().contains("version 99"), @@ -2366,8 +2390,8 @@ mod tests { .output_string(&cmd) .await .expect_err("a non-piped stdout must error, even against a matching entry"); - match err { - Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), + match err.kind() { + ErrorKind::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), other => panic!("expected Io(InvalidInput), got {other:?}"), } } @@ -2449,12 +2473,18 @@ mod tests { .output_string(&Command::new("other").arg("build").current_dir("dir-b")) .await .expect_err("a different program is still a miss"); - assert!(matches!(err, Error::CassetteMiss { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::CassetteMiss { .. }), + "got {err:?}" + ); let err = replayer .output_string(&Command::new("tool").arg("test").current_dir("dir-b")) .await .expect_err("different args are still a miss"); - assert!(matches!(err, Error::CassetteMiss { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::CassetteMiss { .. }), + "got {err:?}" + ); } #[cfg(unix)] @@ -2636,7 +2666,7 @@ mod tests { .await .expect_err("the refused save's run must be absent"); assert!( - matches!(miss, Error::CassetteMiss { .. }), + matches!(miss.kind(), ErrorKind::CassetteMiss { .. }), "the clobbering entry must be absent (a miss), got {miss:?}" ); } @@ -2697,7 +2727,7 @@ mod tests { .save() .expect_err("renaming the temp over a directory must fail, not silently succeed"); assert!( - matches!(err, Error::Io(_)), + matches!(err.kind(), ErrorKind::Io(_)), "a write/rename failure is an Io error, got {err:?}" ); // The recorder is still dirty; dropping it re-runs the best-effort flush, @@ -2782,7 +2812,10 @@ mod tests { .run(&Command::new("tool")) .await .expect_err("run must reject a truncated replay"); - assert!(matches!(err, Error::OutputTooLarge { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::OutputTooLarge { .. }), + "got {err:?}" + ); } #[tokio::test] @@ -2841,7 +2874,10 @@ mod tests { .output_string(&Command::new("tool").arg("--version").cancel_on(token)) .await .expect_err("a pre-cancelled token must short-circuit replay"); - assert!(matches!(err, Error::Cancelled { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::Cancelled { .. }), + "got {err:?}" + ); } #[tokio::test] @@ -2863,7 +2899,10 @@ mod tests { .start(&Command::new("tool").arg("--version").cancel_on(token)) .await .expect_err("a pre-cancelled token must short-circuit start replay"); - assert!(matches!(err, Error::Cancelled { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::Cancelled { .. }), + "got {err:?}" + ); } /// An inner runner whose `output_string` always fails with a fixed `Error` @@ -2881,11 +2920,14 @@ mod tests { async fn record_of_a_not_found_err_replays_the_same_not_found_error() { // T-018: a record-mode call that returns `Err` must no longer vanish // into thin air — replaying the same invocation must reproduce the - // recorded `Error::NotFound`, not miss the cassette. + // recorded `ErrorKind::NotFound`, not miss the cassette. let (_dir, path) = temp_cassette(); - let inner = FailingInner(|program| Error::NotFound { - program: program.to_owned(), - searched: Some("/usr/bin:/bin".to_owned()), + let inner = FailingInner(|program| { + ErrorKind::NotFound { + program: program.to_owned(), + searched: Some("/usr/bin:/bin".to_owned()), + } + .into() }); let recorder = RecordReplayRunner::record(&path, inner); let record_err = recorder @@ -2893,7 +2935,7 @@ mod tests { .await .expect_err("the inner runner's Err must still reach the record-mode caller"); assert!( - matches!(&record_err, Error::NotFound { .. }), + matches!(&record_err.kind(), ErrorKind::NotFound { .. }), "got {record_err:?}" ); recorder.save().expect("save"); @@ -2903,12 +2945,12 @@ mod tests { .output_string(&Command::new("ghost")) .await .expect_err("replay must reproduce the recorded NotFound, not CassetteMiss"); - match &replay_err { - Error::NotFound { program, searched } => { + match &replay_err.kind() { + ErrorKind::NotFound { program, searched } => { assert_eq!(program, "ghost"); assert_eq!(searched.as_deref(), Some("/usr/bin:/bin")); } - other => panic!("expected Error::NotFound, got {other:?}"), + other => panic!("expected ErrorKind::NotFound, got {other:?}"), } assert!( replay_err.is_not_found(), @@ -2918,9 +2960,12 @@ mod tests { // The same round trip through `start`, which shares the recording and // replay machinery with `output_string`. let (_dir2, path2) = temp_cassette(); - let inner2 = FailingInner(|program| Error::NotFound { - program: program.to_owned(), - searched: None, + let inner2 = FailingInner(|program| { + ErrorKind::NotFound { + program: program.to_owned(), + searched: None, + } + .into() }); let recorder2 = RecordReplayRunner::record(&path2, inner2); let _ = recorder2 @@ -2933,7 +2978,10 @@ mod tests { .start(&Command::new("ghost")) .await .expect_err("start replay must reproduce the recorded NotFound"); - assert!(matches!(err2, Error::NotFound { .. }), "got {err2:?}"); + assert!( + matches!(err2.kind(), ErrorKind::NotFound { .. }), + "got {err2:?}" + ); } #[tokio::test] @@ -2942,9 +2990,12 @@ mod tests { // classifiers (`is_permission_denied`) to still work after replay, not // just the message text. let (_dir, path) = temp_cassette(); - let inner = FailingInner(|program| Error::Spawn { - program: program.to_owned(), - source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), + let inner = FailingInner(|program| { + ErrorKind::Spawn { + program: program.to_owned(), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), + } + .into() }); let recorder = RecordReplayRunner::record(&path, inner); let _ = recorder @@ -2958,7 +3009,7 @@ mod tests { .output_string(&Command::new("tool")) .await .expect_err("replay reproduces the recorded Spawn error"); - assert!(matches!(err, Error::Spawn { .. }), "got {err:?}"); + assert!(matches!(err.kind(), ErrorKind::Spawn { .. }), "got {err:?}"); assert!( err.is_permission_denied(), "the recorded os error kind must survive replay: {err:?}" @@ -2967,21 +3018,24 @@ mod tests { #[tokio::test] async fn a_cancelled_record_mode_call_is_never_persisted_to_the_cassette() { - // Error::Cancelled is caller-state, not a fact about the invocation — + // ErrorKind::Cancelled is caller-state, not a fact about the invocation — // recording it would make an unrelated future replay wrongly cancelled. // It must simply not appear in the cassette (like the old "record // nothing for an Err" behavior), so a later real recording of the same // invocation can still be captured normally. let (_dir, path) = temp_cassette(); - let inner = FailingInner(|program| Error::Cancelled { - program: program.to_owned(), + let inner = FailingInner(|program| { + ErrorKind::Cancelled { + program: program.to_owned(), + } + .into() }); let recorder = RecordReplayRunner::record(&path, inner); let err = recorder .output_string(&Command::new("tool")) .await .expect_err("the cancelled call still surfaces its real error to the caller"); - assert!(matches!(err, Error::Cancelled { .. })); + assert!(matches!(err.kind(), ErrorKind::Cancelled { .. })); recorder.save().expect("save"); let replayer = RecordReplayRunner::replay(&path).expect("load"); @@ -2990,7 +3044,7 @@ mod tests { .await .expect_err("a cassette with no recorded entry for this invocation must miss"); assert!( - matches!(err, Error::CassetteMiss { .. }), + matches!(err.kind(), ErrorKind::CassetteMiss { .. }), "Cancelled must never be persisted, got {err:?}" ); } @@ -3019,16 +3073,19 @@ mod tests { #[tokio::test] async fn unmodeled_error_variant_falls_back_to_other_rather_than_dropping_silently() { - // `Error::Parse` has no dedicated `CassetteError` arm — `from_error`'s + // `ErrorKind::Parse` has no dedicated `CassetteError` arm — `from_error`'s // catch-all `other =>` routes it into `CassetteError::Other`, and - // `to_error` reconstructs that as `Error::Io(ErrorKind::Other)` carrying + // `to_error` reconstructs that as `ErrorKind::Io(ErrorKind::Other)` carrying // the original `Display` text, never the original variant. This is the // lossy safety-net path: it must still land as *some* Err, never a // silent CassetteMiss. let (_dir, path) = temp_cassette(); - let inner = FailingInner(|program| Error::Parse { - program: program.to_owned(), - message: "unexpected token at line 3".to_owned(), + let inner = FailingInner(|program| { + ErrorKind::Parse { + program: program.to_owned(), + message: "unexpected token at line 3".to_owned(), + } + .into() }); let recorder = RecordReplayRunner::record(&path, inner); let record_err = recorder @@ -3043,24 +3100,27 @@ mod tests { .output_string(&Command::new("tool")) .await .expect_err("replay must reproduce the Other fallback, not miss the cassette"); - match err { - Error::Io(source) => { + match err.kind() { + ErrorKind::Io(source) => { assert_eq!(source.kind(), std::io::ErrorKind::Other, "got {source:?}"); assert_eq!(source.to_string(), expected_message); } - other => panic!("expected Error::Io(ErrorKind::Other), got {other:?}"), + other => panic!("expected ErrorKind::Io(ErrorKind::Other), got {other:?}"), } } #[tokio::test] async fn modeled_unsupported_error_round_trips_exactly_through_replay() { - // Unlike `Parse` above, `Error::Unsupported` has an explicit + // Unlike `Parse` above, `ErrorKind::Unsupported` has an explicit // `CassetteError::Unsupported` arm in `from_error`/`to_error`, so it // must survive record/replay with full fidelity (variant and fields), // not just as a lossy `Other` message. let (_dir, path) = temp_cassette(); - let inner = FailingInner(|_program| Error::Unsupported { - operation: "signal(Hup)".to_owned(), + let inner = FailingInner(|_program| { + ErrorKind::Unsupported { + operation: "signal(Hup)".to_owned(), + } + .into() }); let recorder = RecordReplayRunner::record(&path, inner); let _ = recorder @@ -3074,9 +3134,9 @@ mod tests { .output_string(&Command::new("tool")) .await .expect_err("replay must reproduce Unsupported, not miss the cassette"); - match err { - Error::Unsupported { operation } => assert_eq!(operation, "signal(Hup)"), - other => panic!("expected Error::Unsupported, got {other:?}"), + match err.kind() { + ErrorKind::Unsupported { operation } => assert_eq!(operation, "signal(Hup)"), + other => panic!("expected ErrorKind::Unsupported, got {other:?}"), } } @@ -3135,7 +3195,10 @@ mod tests { .output_string(&Command::new("tool").env("MODE", "slow")) .await .expect_err("a differing selected env value must miss under the policy"); - assert!(matches!(err, Error::CassetteMiss { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::CassetteMiss { .. }), + "got {err:?}" + ); let still_hit = replayer .run( @@ -3238,7 +3301,10 @@ mod tests { .output_string(&Command::new("tool")) // FLAG untouched .await .expect_err("an untouched selected var must not match a set-var recording"); - assert!(matches!(err, Error::CassetteMiss { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::CassetteMiss { .. }), + "got {err:?}" + ); } #[tokio::test] @@ -3270,7 +3336,10 @@ mod tests { .output_string(&Command::new("tool").current_dir("/work/b")) .await .expect_err("a differing cwd must miss under match_on_cwd"); - assert!(matches!(err, Error::CassetteMiss { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::CassetteMiss { .. }), + "got {err:?}" + ); } #[tokio::test] @@ -3294,7 +3363,10 @@ mod tests { .output_string(&Command::new("tool").env("MODE", "a")) .await .expect_err("a policy-keyed entry must not match a no-policy replay"); - assert!(matches!(err, Error::CassetteMiss { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::CassetteMiss { .. }), + "got {err:?}" + ); } #[tokio::test] diff --git a/src/client.rs b/src/client.rs index 2e2667c..dd943d9 100644 --- a/src/client.rs +++ b/src/client.rs @@ -273,7 +273,7 @@ impl CliClient { /// Cancel every command this client builds when `token` fires: each built /// command gets [`cancel_on(token.clone())`](Command::cancel_on), so /// cancelling the token kills every in-flight run of **this client** (the - /// whole tree, surfacing [`Error::Cancelled`](crate::Error::Cancelled) on + /// whole tree, surfacing [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled) on /// the awaiting call — same semantics as the per-command builder). /// /// **Precedence:** a per-command [`Command::cancel_on`] chained on a built @@ -380,7 +380,7 @@ impl CliClient { /// /// # Errors /// - /// [`Error::NotFound`](crate::Error::NotFound) when the program can't be + /// [`ErrorKind::NotFound`](crate::ErrorKind::NotFound) when the program can't be /// located — see [`Command::resolve_program`] for the full contract /// (`searched` diagnostic, [`is_not_found`](crate::Error::is_not_found) /// classification). @@ -435,12 +435,12 @@ impl CliClient { /// # Errors /// /// The same surface as [`Command::run`](crate::Command::run): a launch - /// failure ([`Error::NotFound`] / [`Error::Spawn`] / [`Error::Unsupported`] / - /// [`Error::Io`], and — via the client's runner — [`Error::Cancelled`] on a - /// pre-cancelled token), a non-accepted exit ([`Error::Exit`]), - /// [`Error::Signalled`], [`Error::Timeout`], [`Error::Cancelled`], - /// [`Error::OutputTooLarge`] (a fail-loud buffer truncated the presented - /// stdout), or [`Error::Stdin`]. + /// failure ([`ErrorKind::NotFound`] / [`ErrorKind::Spawn`] / [`ErrorKind::Unsupported`] / + /// [`ErrorKind::Io`], and — via the client's runner — [`ErrorKind::Cancelled`] on a + /// pre-cancelled token), a non-accepted exit ([`ErrorKind::Exit`]), + /// [`ErrorKind::Signalled`], [`ErrorKind::Timeout`], [`ErrorKind::Cancelled`], + /// [`ErrorKind::OutputTooLarge`] (a fail-loud buffer truncated the presented + /// stdout), or [`ErrorKind::Stdin`]. pub async fn run(&self, call: impl IntoCommand) -> Result { self.runner.run(&call.into_command(self)).await } @@ -453,10 +453,10 @@ impl CliClient { /// # Errors /// /// The same surface as [`Command::checked`](crate::Command::checked): the - /// launch failures, plus [`Error::Exit`] / [`Error::Signalled`] / - /// [`Error::Timeout`] / [`Error::Cancelled`] / [`Error::Stdin`]. Being the + /// launch failures, plus [`ErrorKind::Exit`] / [`ErrorKind::Signalled`] / + /// [`ErrorKind::Timeout`] / [`ErrorKind::Cancelled`] / [`ErrorKind::Stdin`]. Being the /// lenient building block, it does not fail loud on a bounded-buffer - /// truncation, so it never returns [`Error::OutputTooLarge`]. + /// truncation, so it never returns [`ErrorKind::OutputTooLarge`]. pub async fn checked(&self, call: impl IntoCommand) -> Result> { self.runner.checked(&call.into_command(self)).await } @@ -470,8 +470,8 @@ impl CliClient { /// [`Command::output_string`](crate::Command::output_string): a non-zero /// exit, a timeout, and a signal-kill are *captured* in the returned /// [`ProcessResult`], not raised; beyond the launch failures, only - /// [`Error::Cancelled`], [`Error::OutputTooLarge`] (a fail-loud overflow), - /// [`Error::Stdin`], and [`Error::Io`] surface. + /// [`ErrorKind::Cancelled`], [`ErrorKind::OutputTooLarge`] (a fail-loud overflow), + /// [`ErrorKind::Stdin`], and [`ErrorKind::Io`] surface. pub async fn output_string(&self, call: impl IntoCommand) -> Result> { self.runner.output_string(&call.into_command(self)).await } @@ -483,10 +483,10 @@ impl CliClient { /// # Errors /// /// Identical to [`output_string`](Self::output_string), except a fail-loud - /// [`Error::OutputTooLarge`] applies to the raw stdout *byte* ceiling. Note + /// [`ErrorKind::OutputTooLarge`] applies to the raw stdout *byte* ceiling. Note /// that a runner that only implements /// [`output_string`](crate::ProcessRunner::output_string) surfaces - /// [`Error::Unsupported`] here (byte capture routes through + /// [`ErrorKind::Unsupported`] here (byte capture routes through /// [`start`](crate::ProcessRunner::start)). pub async fn output_bytes(&self, call: impl IntoCommand) -> Result>> { self.runner.output_bytes(&call.into_command(self)).await @@ -499,8 +499,8 @@ impl CliClient { /// # Errors /// /// The same surface as [`checked`](Self::checked) (launch failures plus - /// [`Error::Exit`] / [`Error::Signalled`] / [`Error::Timeout`] / - /// [`Error::Cancelled`] / [`Error::Stdin`]); only the output is discarded. + /// [`ErrorKind::Exit`] / [`ErrorKind::Signalled`] / [`ErrorKind::Timeout`] / + /// [`ErrorKind::Cancelled`] / [`ErrorKind::Stdin`]); only the output is discarded. pub async fn run_unit(&self, call: impl IntoCommand) -> Result<()> { self.runner.run_unit(&call.into_command(self)).await } @@ -512,7 +512,7 @@ impl CliClient { /// # Errors /// /// The launch failures, plus — when the run produced no code — - /// [`Error::Timeout`], [`Error::Signalled`], or [`Error::Cancelled`]. A + /// [`ErrorKind::Timeout`], [`ErrorKind::Signalled`], or [`ErrorKind::Cancelled`]. A /// non-zero exit is returned as the code, not raised. pub async fn exit_code(&self, call: impl IntoCommand) -> Result { self.runner.exit_code(&call.into_command(self)).await @@ -526,9 +526,9 @@ impl CliClient { /// /// # Errors /// - /// Any exit code other than `0`/`1` becomes [`Error::Exit`], and — atop the - /// launch failures — a run with no code errors as [`Error::Timeout`], - /// [`Error::Signalled`], or [`Error::Cancelled`]. + /// Any exit code other than `0`/`1` becomes [`ErrorKind::Exit`], and — atop the + /// launch failures — a run with no code errors as [`ErrorKind::Timeout`], + /// [`ErrorKind::Signalled`], or [`ErrorKind::Cancelled`]. pub async fn probe(&self, call: impl IntoCommand) -> Result { self.runner.probe(&call.into_command(self)).await } @@ -540,10 +540,10 @@ impl CliClient { /// /// # Errors /// - /// The launch failures, plus [`Error::Timeout`] when a command + /// The launch failures, plus [`ErrorKind::Timeout`] when a command /// [`timeout`](crate::Command::timeout) is set and its deadline elapses - /// mid-stream (tearing the process down), [`Error::Cancelled`], or - /// [`Error::Io`] while streaming. A stream that ends with no match is + /// mid-stream (tearing the process down), [`ErrorKind::Cancelled`], or + /// [`ErrorKind::Io`] while streaming. A stream that ends with no match is /// `Ok(None)`, not an error. pub async fn first_line( &self, @@ -566,8 +566,8 @@ impl CliClient { /// # Errors /// /// The success-checking surface of [`run`](Self::run) (launch failures plus - /// [`Error::Exit`] / [`Error::Signalled`] / [`Error::Timeout`] / - /// [`Error::Cancelled`] / [`Error::Stdin`]), plus [`Error::OutputTooLarge`] + /// [`ErrorKind::Exit`] / [`ErrorKind::Signalled`] / [`ErrorKind::Timeout`] / + /// [`ErrorKind::Cancelled`] / [`ErrorKind::Stdin`]), plus [`ErrorKind::OutputTooLarge`] /// when a fail-loud buffer truncated the stdout the parser would see. The /// `parse` closure is infallible, so it adds no error. pub async fn parse(&self, call: impl IntoCommand, parse: F) -> Result @@ -580,7 +580,7 @@ impl CliClient { /// Run (errors on a non-zero exit) and feed stdout to a *fallible* `parse` — /// the shape of JSON deserialization, where a parse failure becomes - /// [`Error::Parse`](crate::Error::Parse). Fails loud on a bounded-buffer + /// [`ErrorKind::Parse`](crate::ErrorKind::Parse). Fails loud on a bounded-buffer /// truncation. Delegates to /// [`ProcessRunnerExt::try_parse`](crate::ProcessRunnerExt::try_parse). /// @@ -588,7 +588,7 @@ impl CliClient { /// /// Everything [`parse`](Self::parse) can return, plus whatever the fallible /// `parse` closure yields on malformed output — typically - /// [`Error::Parse`](crate::Error::Parse). + /// [`ErrorKind::Parse`](crate::ErrorKind::Parse). pub async fn try_parse(&self, call: impl IntoCommand, parse: F) -> Result where T: Send, @@ -722,7 +722,8 @@ mod tests { use std::time::Duration; use super::*; - use crate::Error; + + use crate::error::ErrorKind; use crate::testing::{RecordingRunner, Reply, ScriptedRunner}; #[test] @@ -816,14 +817,13 @@ mod tests { ); let err = client .try_parse::(client.command(["x"]), |s| { - s.trim().parse::().map_err(|e| Error::Parse { - program: "gh".into(), - message: e.to_string(), - }) + s.trim() + .parse::() + .map_err(|e| Error::parse("gh", e.to_string())) }) .await .unwrap_err(); - assert!(matches!(err, Error::Parse { .. }), "got {err:?}"); + assert!(matches!(err.kind(), ErrorKind::Parse { .. }), "got {err:?}"); } #[tokio::test] @@ -904,8 +904,9 @@ mod tests { client .exit_code(client.command(["auth", "status"])) .await - .unwrap_err(), - Error::Timeout { .. } + .unwrap_err() + .kind(), + ErrorKind::Timeout { .. } )); } @@ -1178,7 +1179,10 @@ mod tests { .await .expect("the explicit token must resolve the call") .expect_err("explicit token cancels"); - assert!(matches!(err, Error::Cancelled { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::Cancelled { .. }), + "got {err:?}" + ); } #[tokio::test(start_paused = true)] @@ -1202,9 +1206,10 @@ mod tests { match tokio::time::timeout(Duration::from_secs(3600), call) .await .expect("the cancelled token must resolve the call") + .map_err(Error::into_kind) { - Err(Error::Cancelled { program }) => assert_eq!(program, "gh"), - other => panic!("expected Error::Cancelled, got {other:?}"), + Err(ErrorKind::Cancelled { program }) => assert_eq!(program, "gh"), + other => panic!("expected ErrorKind::Cancelled, got {other:?}"), } assert_eq!(rec.only_call().args_str(), ["run", "watch", "123"]); } @@ -1235,9 +1240,10 @@ mod tests { match tokio::time::timeout(Duration::from_secs(3600), call) .await .expect("cancelling the shared token must resolve the clone's call") + .map_err(Error::into_kind) { - Err(Error::Cancelled { program }) => assert_eq!(program, "gh"), - other => panic!("expected Error::Cancelled, got {other:?}"), + Err(ErrorKind::Cancelled { program }) => assert_eq!(program, "gh"), + other => panic!("expected ErrorKind::Cancelled, got {other:?}"), } } diff --git a/src/command.rs b/src/command.rs index a43c0b6..c69c9bc 100644 --- a/src/command.rs +++ b/src/command.rs @@ -11,7 +11,7 @@ use std::time::Duration; use encoding_rs::Encoding; use crate::buffer::{LineTerminator, OutputBufferPolicy, StdioMode}; -use crate::error::{Error, Result}; +use crate::error::{Error, ErrorKind, Result}; use crate::parent_death::ParentDeathCleanup; use crate::pump::StreamConfig; use crate::result::ProcessResult; @@ -170,7 +170,7 @@ pub struct Command { /// window. A no-op off Windows (Unix already has a real signal tier). windows_graceful_ctrl_break: bool, /// When cancelled, the run's tree is killed and every consuming path - /// resolves to `Error::Cancelled`. Cheap to clone (internally `Arc`'d), so + /// resolves to `ErrorKind::Cancelled`. Cheap to clone (internally `Arc`'d), so /// a `Command` clone — including each `Pipeline` stage and each /// `Supervisor` incarnation — shares the same cancel state. cancel_token: Option, @@ -285,7 +285,7 @@ impl Command { /// the child's own working directory once [`current_dir`](Self::current_dir) /// is also set. /// - /// If resolution fails everywhere, [`Error::NotFound`](crate::Error::NotFound)'s + /// If resolution fails everywhere, [`ErrorKind::NotFound`](crate::ErrorKind::NotFound)'s /// `searched` includes these directories — first, in priority order — ahead /// of the `PATH` directories, so the diagnostic doesn't hide that they were /// checked too. @@ -400,7 +400,7 @@ impl Command { /// [`gid`](Self::gid) — the group id is set **before** the user id (once /// the uid drops, changing gid is no longer permitted), an ordering the /// standard library guarantees. On non-Unix targets the run fails with - /// [`Error::Unsupported`](crate::Error::Unsupported) — a requested + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) — a requested /// privilege drop is never silently skipped. /// /// **Linux cgroup caveat:** under the cgroup v2 mechanism @@ -436,7 +436,7 @@ impl Command { /// Ordering is handled for you: the OS applies `setgroups` → `setgid` → /// `setuid` (groups and gid must be set while still privileged, before the /// uid drops). On non-Unix targets the run fails with - /// [`Error::Unsupported`](crate::Error::Unsupported) — never silently + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) — never silently /// skipped. The Linux cgroup-v2 caveat from [`uid`](Self::uid) applies /// unchanged. pub fn groups(mut self, gids: impl AsRef<[u32]>) -> Self { @@ -450,7 +450,7 @@ impl Command { /// Containment is preserved: the group tracks the new session's process /// group (whose id is the child's pid), so kill-on-drop and the teardown /// verbs still reach it. On non-Unix targets the run fails with - /// [`Error::Unsupported`](crate::Error::Unsupported). + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). /// /// Honored by the `Command`-driven launch paths (`run`/`output_*`/ /// `start`, [`ProcessGroup::start`](crate::ProcessGroup::start), @@ -472,7 +472,7 @@ impl Command { /// a priority-class flag OR'd into `creation_flags`, the same seam as /// [`create_no_window`](Self::create_no_window). Unlike the privilege /// builders this never yields - /// [`Error::Unsupported`](crate::Error::Unsupported) — see + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) — see /// [`Priority`](crate::Priority) for why both platforms cover every /// variant, and the Unix caveat that lowering `nice` below its inherited /// value — [`Priority::AboveNormal`](crate::Priority::AboveNormal)/ @@ -492,7 +492,7 @@ impl Command { /// Applied via `pre_exec`, alongside [`setsid`](Self::setsid)/ /// [`groups`](Self::groups) — another knob on that same seam. On /// non-Unix targets the run fails with - /// [`Error::Unsupported`](crate::Error::Unsupported) rather than + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) rather than /// silently ignoring the requested mask. Only the low permission bits are /// meaningful (as with the `umask(2)` syscall itself); pass the value you /// would give the `umask` shell builtin, e.g. `0o022`. @@ -805,7 +805,7 @@ impl Command { /// Tie this run to `token`: cancelling it kills the process tree and makes /// every consuming path (`run`/`output_string`/`output_bytes`/`wait`/ /// `exit_code`/`probe`/`profile`/`finish` and the streamed - /// finishers) resolve to [`Error::Cancelled`](crate::Error::Cancelled). + /// finishers) resolve to [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled). /// In a [`Pipeline`](crate::Pipeline), a token on any stage cancels that /// stage and the cancellation errors the whole pipeline (the private /// pipeline group tears the other stages down). @@ -826,12 +826,12 @@ impl Command { /// pre-spawn short-circuit. A mid-run cancel during /// [`wait_for_line`](crate::RunningProcess::wait_for_line), by contrast, /// closes the stream and surfaces as that probe's - /// [`Error::NotReady`](crate::Error::NotReady), not `Cancelled` — the + /// [`ErrorKind::NotReady`](crate::ErrorKind::NotReady), not `Cancelled` — the /// consuming finisher afterwards still reports `Cancelled`. /// /// A cancelled run is never retried: [`retry`](Self::retry) policies and /// [`Supervisor`](crate::Supervisor) restarts both treat - /// `Error::Cancelled` as terminal — the token stays cancelled forever, so + /// `ErrorKind::Cancelled` as terminal — the token stays cancelled forever, so /// another attempt could only fail the same way. /// /// On a `Command` this **replaces** any previously set token (last write @@ -853,14 +853,14 @@ impl Command { /// [`Command`](Self::run), on [`ProcessRunnerExt`](crate::ProcessRunnerExt), /// and on [`CliClient`](crate::CliClient): the ones that surface failure as an /// [`Error`] the classifier can inspect (e.g. a transient network failure in - /// `stderr`, or [`Error::Timeout`](crate::Error::Timeout)). The non-erroring + /// `stderr`, or [`ErrorKind::Timeout`](crate::ErrorKind::Timeout)). The non-erroring /// `output_string`/`output_bytes` paths don't retry. /// /// Each attempt **re-executes the whole command** — a fresh process. Only /// retry operations that are safe to repeat: a side effect that already landed /// before the failure (a `git push` that reached the server, then dropped the /// connection) will be replayed. Prefer to gate retries on a classifier that - /// matches *pre-effect* failures (DNS/connection errors, [`Error::Timeout`] + /// matches *pre-effect* failures (DNS/connection errors, [`ErrorKind::Timeout`] /// while still connecting) rather than any non-zero exit. /// /// A [`timeout`](Self::timeout) bounds **each attempt**, not the whole retried @@ -874,24 +874,24 @@ impl Command { /// re-feeds it only when the failed attempt is **guaranteed not to have /// consumed it**. The launch reserves that payload *transactionally* and /// commits it only once a child exists, so a failure **before any child was - /// spawned** — [`NotFound`](crate::Error::NotFound), - /// [`Spawn`](crate::Error::Spawn) (e.g. a transient `ETXTBSY` that + /// spawned** — [`NotFound`](crate::ErrorKind::NotFound), + /// [`Spawn`](crate::ErrorKind::Spawn) (e.g. a transient `ETXTBSY` that /// [`is_transient`](crate::Error::is_transient) accepts), or - /// [`Unsupported`](crate::Error::Unsupported) — rolls the reservation back + /// [`Unsupported`](crate::ErrorKind::Unsupported) — rolls the reservation back /// and leaves the payload intact: such a command **is** retried (subject to /// the classifier) and the next attempt feeds the untouched source. Any /// other error may have reached a live child that already consumed the - /// source — a non-zero [`Exit`](crate::Error::Exit), - /// [`Timeout`](crate::Error::Timeout), [`Signalled`](crate::Error::Signalled), - /// a stdin-write [`Stdin`](crate::Error::Stdin) failure, - /// [`OutputTooLarge`](crate::Error::OutputTooLarge), or the ambiguous - /// [`Io`](crate::Error::Io) (which arises both before and after a child) — so + /// source — a non-zero [`Exit`](crate::ErrorKind::Exit), + /// [`Timeout`](crate::ErrorKind::Timeout), [`Signalled`](crate::ErrorKind::Signalled), + /// a stdin-write [`Stdin`](crate::ErrorKind::Stdin) failure, + /// [`OutputTooLarge`](crate::ErrorKind::OutputTooLarge), or the ambiguous + /// [`Io`](crate::ErrorKind::Io) (which arises both before and after a child) — so /// the first attempt's error is returned as-is, **not** retried (a retry /// would either replay empty stdin or spuriously classify the re-consume). /// Use a reusable source (`from_string`/`from_bytes`/`from_file`/ /// `from_iter_lines`) to retry unconditionally. (A one-shot source *re-run* /// outside this retry loop — a `Supervisor` incarnation, a pipeline re-run — - /// does fail loud with [`Error::Io`](crate::Error::Io) `InvalidInput` at + /// does fail loud with [`ErrorKind::Io`](crate::ErrorKind::Io) `InvalidInput` at /// launch instead.) /// /// **Inert outside the success-checking verbs.** A `retry` policy is @@ -915,7 +915,7 @@ impl Command { /// a `RetryPolicy` counts `max_retries` (the runs *after* the first), so /// `retry(3, …)` corresponds to `RetryPolicy::new().max_retries(2)`. /// - /// [`Error::Timeout`]: crate::Error::Timeout + /// [`ErrorKind::Timeout`]: crate::ErrorKind::Timeout pub fn retry( mut self, max_attempts: u32, @@ -1011,7 +1011,7 @@ impl Command { /// interactive pipe. Setting `inherit_stdin` **and** one of those is a /// contradiction (feed the child a source *and* let it read the terminal?), /// so it is rejected at the launch boundary with a typed - /// [`Error::Io`](crate::Error::Io) (`InvalidInput`) — the same failure mode + /// [`ErrorKind::Io`](crate::ErrorKind::Io) (`InvalidInput`) — the same failure mode /// as the other stdin misconfiguration the crate refuses (re-running a /// consumed one-shot source) — rather than silently letting one win. Drop the /// other stdin knob to resolve it. @@ -1368,7 +1368,7 @@ impl Command { } /// The [`prefer_local`](Self::prefer_local) directories, in priority order - /// (read by the `Error::NotFound` diagnostic enrichment in `runner.rs`). + /// (read by the `ErrorKind::NotFound` diagnostic enrichment in `runner.rs`). pub(crate) fn prefer_local_dirs(&self) -> &[PathBuf] { &self.prefer_local } @@ -1377,13 +1377,13 @@ impl Command { /// `PATH` away from the process `PATH` — an explicit `PATH` override/removal, /// [`env_clear`](Self::env_clear), or [`inherit_env`](Self::inherit_env) /// (which clears the inherited set). When true, the *`PATH`*-directory - /// naming in [`Error::NotFound`](crate::Error::NotFound) is skipped: + /// naming in [`ErrorKind::NotFound`](crate::ErrorKind::NotFound) is skipped: /// `find_in_path` reads the *process* `PATH`, so against a custom child /// `PATH` that list would be wrong. [`prefer_local`](Self::prefer_local) /// directories are unaffected by this gate and still get named — they're /// resolved by plain filesystem probes on the parent side, independent of /// the child's environment. A missing program still surfaces as - /// `Error::NotFound` (so [`is_not_found`](crate::Error::is_not_found) + /// `ErrorKind::NotFound` (so [`is_not_found`](crate::Error::is_not_found) /// holds), with `searched: None` only when there are no `prefer_local` /// directories to name either. pub(crate) fn customizes_path(&self) -> bool { @@ -1797,7 +1797,7 @@ impl Command { } } cmd.stdout(match &self.stdout_file { - Some(file) => Stdio::from(file.open().map_err(Error::Io)?), + Some(file) => Stdio::from(file.open().map_err(ErrorKind::Io)?), None => match self.stdout_mode { StdioMode::Piped => Stdio::piped(), StdioMode::Inherit => Stdio::inherit(), @@ -1805,7 +1805,7 @@ impl Command { }, }); cmd.stderr(match &self.stderr_file { - Some(file) => Stdio::from(file.open().map_err(Error::Io)?), + Some(file) => Stdio::from(file.open().map_err(ErrorKind::Io)?), None => match self.stderr_mode { StdioMode::Piped => Stdio::piped(), StdioMode::Inherit => Stdio::inherit(), @@ -1928,24 +1928,24 @@ impl Command { /// /// The launch surface shared by every run verb on `Command`: /// - /// - [`Error::NotFound`] — the program could not be located (not installed, + /// - [`ErrorKind::NotFound`] — the program could not be located (not installed, /// not on `PATH`, or the given path does not resolve to an executable). - /// - [`Error::Spawn`] — the program was located but the OS refused to start + /// - [`ErrorKind::Spawn`] — the program was located but the OS refused to start /// it (permission denied, a missing or non-directory working directory, a /// Windows `.cmd`/`.bat` that needs `cmd.exe`, …). - /// - [`Error::Unsupported`] — a requested POSIX-only primitive (running as + /// - [`ErrorKind::Unsupported`] — a requested POSIX-only primitive (running as /// another user/group, a new session via `setsid`, or a `umask`) is not /// available on this platform. - /// - [`Error::Cancelled`] — the [`cancel_on`](Self::cancel_on) token was + /// - [`ErrorKind::Cancelled`] — the [`cancel_on`](Self::cancel_on) token was /// already cancelled before the spawn. - /// - [`Error::Io`] — the private [`ProcessGroup`](crate::ProcessGroup) backing + /// - [`ErrorKind::Io`] — the private [`ProcessGroup`](crate::ProcessGroup) backing /// the run could not be created, or a one-shot streaming stdin source /// ([`Stdin::from_reader`](crate::Stdin::from_reader) / /// [`Stdin::from_lines`](crate::Stdin::from_lines)) was already consumed by /// a previous run. #[cfg_attr( feature = "limits", - doc = "- [`Error::ResourceLimit`](crate::Error::ResourceLimit) — a resource cap configured on the run's group could not be enforced." + doc = "- [`ErrorKind::ResourceLimit`](crate::ErrorKind::ResourceLimit) — a resource cap configured on the run's group could not be enforced." )] pub async fn start(&self) -> Result { JobRunner::new().start(self).await @@ -1963,10 +1963,10 @@ impl Command { /// timeout, and a signal-kill are *captured* in the returned /// [`ProcessResult`] rather than raised (call /// [`ensure_success`](crate::ProcessResult::ensure_success) to promote them); - /// beyond launch, only [`Error::Cancelled`] (a cancellation is always - /// raised), [`Error::OutputTooLarge`] (a fail-loud buffer overflowed), - /// [`Error::Stdin`] (a non-broken-pipe stdin failure on an - /// otherwise-successful run), and [`Error::Io`] surface. + /// beyond launch, only [`ErrorKind::Cancelled`] (a cancellation is always + /// raised), [`ErrorKind::OutputTooLarge`] (a fail-loud buffer overflowed), + /// [`ErrorKind::Stdin`] (a non-broken-pipe stdin failure on an + /// otherwise-successful run), and [`ErrorKind::Io`] surface. pub async fn output_string(&self) -> Result> { JobRunner::new().start(self).await?.output_string().await } @@ -1977,7 +1977,7 @@ impl Command { /// /// Identical to [`output_string`](Self::output_string) — a non-zero exit, a /// timeout, or a signal-kill is captured in the [`ProcessResult`], not raised - /// — except that a fail-loud [`Error::OutputTooLarge`] applies to the raw + /// — except that a fail-loud [`ErrorKind::OutputTooLarge`] applies to the raw /// stdout *byte* ceiling. pub async fn output_bytes(&self) -> Result>> { JobRunner::new().start(self).await?.output_bytes().await @@ -1985,16 +1985,16 @@ impl Command { /// Run to completion and return just the exit code (output is discarded). A /// run that yields no code surfaces as an error — a timeout as - /// [`Error::Timeout`](crate::Error::Timeout), a signal-kill as - /// [`Error::Signalled`](crate::Error::Signalled) — consistent with + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout), a signal-kill as + /// [`ErrorKind::Signalled`](crate::ErrorKind::Signalled) — consistent with /// [`ProcessRunnerExt::exit_code`](crate::ProcessRunnerExt::exit_code) and /// [`CliClient::exit_code`](crate::CliClient::exit_code). /// /// # Errors /// /// The launch failures listed on [`start`](Self::start), plus — when the run - /// produced no code — [`Error::Timeout`] (the deadline elapsed), - /// [`Error::Signalled`] (killed by a signal), or [`Error::Cancelled`]. A + /// produced no code — [`ErrorKind::Timeout`] (the deadline elapsed), + /// [`ErrorKind::Signalled`] (killed by a signal), or [`ErrorKind::Cancelled`]. A /// non-zero exit is returned as the code, not raised. pub async fn exit_code(&self) -> Result { JobRunner::new().exit_code(self).await @@ -2002,17 +2002,17 @@ impl Command { /// Run to completion, requiring an **accepted** exit (`0` by default, widened /// by [`ok_codes`](Self::ok_codes)), and return trimmed stdout. Any other - /// code is [`Error::Exit`](crate::Error::Exit). + /// code is [`ErrorKind::Exit`](crate::ErrorKind::Exit). /// /// # Errors /// /// The launch failures listed on [`start`](Self::start), plus the - /// success-checking failures: [`Error::Exit`] (a non-accepted exit code), - /// [`Error::Signalled`] (a signal-kill), [`Error::Timeout`] (the deadline + /// success-checking failures: [`ErrorKind::Exit`] (a non-accepted exit code), + /// [`ErrorKind::Signalled`] (a signal-kill), [`ErrorKind::Timeout`] (the deadline /// elapsed — *raised* here, unlike on - /// [`output_string`](Self::output_string)), [`Error::Cancelled`], - /// [`Error::OutputTooLarge`] (a fail-loud buffer truncated the presented - /// stdout), and [`Error::Stdin`] (a non-broken-pipe stdin failure on an + /// [`output_string`](Self::output_string)), [`ErrorKind::Cancelled`], + /// [`ErrorKind::OutputTooLarge`] (a fail-loud buffer truncated the presented + /// stdout), and [`ErrorKind::Stdin`] (a non-broken-pipe stdin failure on an /// otherwise-successful run). pub async fn run(&self) -> Result { JobRunner::new().run(self).await @@ -2028,12 +2028,12 @@ impl Command { /// # Errors /// /// The same success-checking surface as [`run`](Self::run) — - /// [`Error::Exit`] / [`Error::Signalled`] / [`Error::Timeout`] / - /// [`Error::Cancelled`] / [`Error::Stdin`], atop the launch failures on + /// [`ErrorKind::Exit`] / [`ErrorKind::Signalled`] / [`ErrorKind::Timeout`] / + /// [`ErrorKind::Cancelled`] / [`ErrorKind::Stdin`], atop the launch failures on /// [`start`](Self::start) — except that, as the lenient building block, /// `checked` does **not** fail loud on a bounded-buffer truncation (inspect /// [`ProcessResult::truncated`](crate::ProcessResult::truncated) yourself), so - /// it never returns [`Error::OutputTooLarge`]. + /// it never returns [`ErrorKind::OutputTooLarge`]. pub async fn checked(&self) -> Result> { JobRunner::new().checked(self).await } @@ -2046,8 +2046,8 @@ impl Command { /// # Errors /// /// The same surface as [`checked`](Self::checked) (the launch failures on - /// [`start`](Self::start) plus [`Error::Exit`] / [`Error::Signalled`] / - /// [`Error::Timeout`] / [`Error::Cancelled`] / [`Error::Stdin`]); only the + /// [`start`](Self::start) plus [`ErrorKind::Exit`] / [`ErrorKind::Signalled`] / + /// [`ErrorKind::Timeout`] / [`ErrorKind::Cancelled`] / [`ErrorKind::Stdin`]); only the /// captured output is discarded. pub async fn run_unit(&self) -> Result<()> { JobRunner::new().run_unit(self).await @@ -2055,17 +2055,17 @@ impl Command { /// Run a predicate command and read its exit code as a boolean: exit `0` → /// `Ok(true)`, exit `1` → `Ok(false)`, anything else → `Err` (any other code - /// as [`Error::Exit`], a timeout as [`Error::Timeout`](crate::Error::Timeout), - /// a signal-kill as [`Error::Signalled`](crate::Error::Signalled)). For tools + /// as [`ErrorKind::Exit`], a timeout as [`ErrorKind::Timeout`](crate::ErrorKind::Timeout), + /// a signal-kill as [`ErrorKind::Signalled`](crate::ErrorKind::Signalled)). For tools /// whose exit code *is* the answer — /// `git diff --quiet`, `git show-ref --verify --quiet`, `grep -q`, … /// /// # Errors /// - /// Any exit code other than `0`/`1` becomes [`Error::Exit`], and — atop the + /// Any exit code other than `0`/`1` becomes [`ErrorKind::Exit`], and — atop the /// launch failures on [`start`](Self::start) — a run that produced no code - /// errors as [`Error::Timeout`], [`Error::Signalled`], or - /// [`Error::Cancelled`]. The strict `0`/`1` contract holds regardless of the + /// errors as [`ErrorKind::Timeout`], [`ErrorKind::Signalled`], or + /// [`ErrorKind::Cancelled`]. The strict `0`/`1` contract holds regardless of the /// command's [`ok_codes`](Self::ok_codes). pub async fn probe(&self) -> Result { JobRunner::new().probe(self).await @@ -2080,9 +2080,9 @@ impl Command { /// # Errors /// /// The success-checking surface of [`run`](Self::run) (the launch failures on - /// [`start`](Self::start), plus [`Error::Exit`] / [`Error::Signalled`] / - /// [`Error::Timeout`] / [`Error::Cancelled`] / [`Error::Stdin`]), plus - /// [`Error::OutputTooLarge`] when a fail-loud buffer truncated the stdout the + /// [`start`](Self::start), plus [`ErrorKind::Exit`] / [`ErrorKind::Signalled`] / + /// [`ErrorKind::Timeout`] / [`ErrorKind::Cancelled`] / [`ErrorKind::Stdin`]), plus + /// [`ErrorKind::OutputTooLarge`] when a fail-loud buffer truncated the stdout the /// parser would see. The `parse` closure is infallible, so it adds no error. pub async fn parse(&self, parse: F) -> Result where @@ -2094,7 +2094,7 @@ impl Command { /// Run (requiring an **accepted** exit) and feed stdout to a *fallible* /// `parse` closure (the JSON-deserialization shape; a failure becomes - /// [`Error::Parse`](crate::Error::Parse) or whatever the closure returns). + /// [`ErrorKind::Parse`](crate::ErrorKind::Parse) or whatever the closure returns). /// Fails loud on truncation. Consistent with /// [`ProcessRunnerExt::try_parse`](crate::ProcessRunnerExt::try_parse) and /// [`CliClient::try_parse`](crate::CliClient::try_parse). @@ -2103,7 +2103,7 @@ impl Command { /// /// Everything [`parse`](Self::parse) can return, plus whatever the fallible /// `parse` closure yields on malformed output — typically - /// [`Error::Parse`](crate::Error::Parse). + /// [`ErrorKind::Parse`](crate::ErrorKind::Parse). pub async fn try_parse(&self, parse: F) -> Result where T: Send, @@ -2118,9 +2118,9 @@ impl Command { /// # Errors /// /// The launch failures listed on [`start`](Self::start), plus - /// [`Error::Timeout`] when a [`timeout`](Self::timeout) is set and its + /// [`ErrorKind::Timeout`] when a [`timeout`](Self::timeout) is set and its /// deadline elapses mid-stream (which tears the process down), - /// [`Error::Cancelled`], or [`Error::Io`] while streaming. A stream that ends + /// [`ErrorKind::Cancelled`], or [`ErrorKind::Io`] while streaming. A stream that ends /// with no match is `Ok(None)`, not an error. pub async fn first_line(&self, predicate: F) -> Result> where @@ -2153,7 +2153,7 @@ impl Command { /// Windows including a bare name found only through a non-`.exe` PATHEXT /// extension (`.cmd`/`.bat`/`.com`/…): the launch substitutes the resolved /// absolute path (the OS's own bare-name search appends only `.exe`), so such - /// a hit spawns instead of raising [`Error::Spawn`]. The one residual + /// a hit spawns instead of raising [`ErrorKind::Spawn`]. The one residual /// asymmetry is a preflight **miss** on Windows: the OS can still locate a /// bare name through the application directory, the current directory, or the /// system directories — routes this `PATH`-based model doesn't cover — so a @@ -2165,7 +2165,7 @@ impl Command { /// /// # Errors /// - /// [`Error::NotFound`](crate::Error::NotFound) when the program can't be + /// [`ErrorKind::NotFound`](crate::ErrorKind::NotFound) when the program can't be /// located — not installed, not on `PATH`, or a path that doesn't resolve to /// an executable. Its `searched` field lists the directories that were /// checked (`prefer_local` first, then `PATH`) for a bare-name lookup, and is @@ -2179,10 +2179,11 @@ impl Command { let path = self.resolution_path_source(); match resolve_program(self.program.as_os_str(), &self.prefer_local, path) { ProgramResolution::Found(found) => Ok(found), - ProgramResolution::NotFound { searched } => Err(Error::NotFound { + ProgramResolution::NotFound { searched } => Err(ErrorKind::NotFound { program: self.program_name(), searched, - }), + } + .into()), } } @@ -2469,7 +2470,7 @@ pub(crate) enum PathSource { /// The outcome of resolving a command's `program` to a concrete executable path /// **without spawning it** — the single decision the live launch path's -/// [`Error::NotFound`](crate::Error::NotFound) enrichment (in `runner.rs`) and +/// [`ErrorKind::NotFound`](crate::ErrorKind::NotFound) enrichment (in `runner.rs`) and /// the spawn-free [`which`](crate::which) / [`Command::resolve_program`] /// preflight both derive from, so the two can never disagree about whether a /// program is available. @@ -2480,7 +2481,7 @@ pub(crate) enum ProgramResolution { /// name the same way [`find_in_path_in`] models; preflight returns it. Found(PathBuf), /// Not resolvable. `searched` is the directory list for - /// [`Error::NotFound`](crate::Error::NotFound)'s field: `Some` for a + /// [`ErrorKind::NotFound`](crate::ErrorKind::NotFound)'s field: `Some` for a /// bare-name `PATH` lookup (the searched dirs, `prefer_local` first), /// `None` for a path-form program (no `PATH` search applied). NotFound { searched: Option }, @@ -2610,7 +2611,7 @@ pub(crate) fn probe_prefer_local(dirs: &[PathBuf], program: &OsStr) -> Option { - let searched = searched.expect("a bare-name lookup reports searched dirs"); + match err.kind() { + crate::ErrorKind::NotFound { searched, .. } => { + let searched = searched + .as_ref() + .expect("a bare-name lookup reports searched dirs"); assert!( searched.contains(&dir.path().to_string_lossy().into_owned()), "searched must include the prefer_local directory: {searched}" ); } - other => panic!("expected Error::NotFound, got {other:?}"), + other => panic!("expected ErrorKind::NotFound, got {other:?}"), } } @@ -3644,12 +3649,12 @@ mod tests { let err = Command::new(&missing) .resolve_program() .expect_err("a missing path-form program must not resolve"); - match err { - crate::Error::NotFound { searched, .. } => assert_eq!( - searched, None, + match err.kind() { + crate::ErrorKind::NotFound { searched, .. } => assert_eq!( + *searched, None, "a path-form lookup applies no PATH search, so searched is None" ), - other => panic!("expected Error::NotFound, got {other:?}"), + other => panic!("expected ErrorKind::NotFound, got {other:?}"), } } @@ -3843,7 +3848,7 @@ mod tests { // not make `probe_dir` report a match — otherwise `find_in_path` returns // `found == Some(..)` for a file that Windows cannot actually run, and the // error-enrichment branch in `runner::launch` turns a genuinely missing - // `git.exe` into `Error::Spawn` instead of `Error::NotFound`, breaking + // `git.exe` into `ErrorKind::Spawn` instead of `ErrorKind::NotFound`, breaking // `is_not_found()` for callers. #[cfg(windows)] #[test] diff --git a/src/digest.rs b/src/digest.rs index e4692d6..b4ac3fa 100644 --- a/src/digest.rs +++ b/src/digest.rs @@ -14,7 +14,7 @@ //! call site used to define its own copy; a constant edited in one but not the //! other would silently invalidate every already-recorded cassette (the recomputed //! digest stops matching the stored one), surfacing not as a build error but as a -//! baffling [`CassetteMiss`](crate::Error::CassetteMiss) at replay. Centralising +//! baffling [`CassetteMiss`](crate::ErrorKind::CassetteMiss) at replay. Centralising //! them here makes that drift impossible: there is one definition to change, and //! changing it is — by definition — a cassette-format break. diff --git a/src/doubles.rs b/src/doubles.rs index b28b31b..ae9b24f 100644 --- a/src/doubles.rs +++ b/src/doubles.rs @@ -36,7 +36,7 @@ //! [`Reply::pending`]: it parks the call (or never //! "exits", on `start`) until the command's token — per-command or //! [`CliClient::default_cancel_on`](crate::CliClient::default_cancel_on) — -//! cancels, then resolves with `Err(Error::Cancelled { .. })`, mirroring the +//! cancels, then resolves with `Err(ErrorKind::Cancelled { .. })`, mirroring the //! live contract. A pending call is likewise bounded by the command's //! [`Command::timeout`](crate::Command::timeout): with a deadline set it resolves //! timed-out (`Outcome::TimedOut`) rather than parking, on the bulk verbs and @@ -84,10 +84,10 @@ pub struct Reply { /// run with a failing exit code. #[derive(Debug, Clone)] enum SpawnError { - /// Drives [`Error::NotFound`](crate::Error::NotFound) (`is_not_found() == + /// Drives [`ErrorKind::NotFound`](crate::ErrorKind::NotFound) (`is_not_found() == /// true`). NotFound, - /// Drives [`Error::Spawn`](crate::Error::Spawn) carrying the given + /// Drives [`ErrorKind::Spawn`](crate::ErrorKind::Spawn) carrying the given /// [`std::io::ErrorKind`] and message. Spawn { kind: std::io::ErrorKind, @@ -131,7 +131,7 @@ impl Reply { } /// A timed-out reply — drives the timeout path so a test can assert that a - /// command which exceeds its deadline surfaces as [`Error::Timeout`](crate::Error::Timeout). + /// command which exceeds its deadline surfaces as [`ErrorKind::Timeout`](crate::ErrorKind::Timeout). /// /// On both the bulk verbs (`output_string` and friends) and a scripted /// [`start`](crate::ProcessRunner::start) this resolves **immediately** as @@ -180,7 +180,7 @@ impl Reply { /// for overrunning its deadline, for testing that an orchestration genuinely /// cancels (and cleans up) or bounds a hang, not just that it formats a canned /// error. A firing token resolves with - /// [`Error::Cancelled`](crate::Error::Cancelled) naming the program; a firing + /// [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled) naming the program; a firing /// deadline resolves as a timed-out run (`Outcome::TimedOut`), exactly as a /// [`timeout`](Self::timeout) reply would. /// @@ -240,7 +240,7 @@ impl Reply { /// Attach stdout to a reply — e.g. the `CONFLICT …` text `git merge` writes /// to stdout on a failing reply, so a test can exercise - /// [`Error::Exit`](crate::Error::Exit)'s stdout field / + /// [`ErrorKind::Exit`](crate::ErrorKind::Exit)'s stdout field / /// [`ProcessResult::diagnostic`](crate::ProcessResult::diagnostic). pub fn with_stdout(mut self, stdout: impl Into) -> Self { self.stdout = stdout.into(); @@ -258,10 +258,10 @@ impl Reply { /// A reply that fails as if the program could not be located at all — not /// installed, not on `PATH`, or the given path doesn't resolve — driving - /// [`Error::NotFound`](crate::Error::NotFound) + /// [`ErrorKind::NotFound`](crate::ErrorKind::NotFound) /// (`is_not_found() == true`), the hermetic mirror of a live spawn hitting a /// missing binary. A rule miss on the real runner instead lands on - /// [`Error::Spawn`](crate::Error::Spawn) (`is_not_found() == false` — a + /// [`ErrorKind::Spawn`](crate::ErrorKind::Spawn) (`is_not_found() == false` — a /// double-specific "no rule matched" config error, not a modeled spawn /// failure), so script this reply explicitly to test a "tool not installed → /// fallback" branch: `.on(["rg", …], Reply::not_found())` (or @@ -276,7 +276,7 @@ impl Reply { /// A reply that fails at spawn time with a generic OS-level error — /// permission denied, a busy executable, and so on — as opposed to /// [`not_found`](Self::not_found)'s "the program doesn't exist at all". - /// Drives [`Error::Spawn`](crate::Error::Spawn) with an `io::Error` of the + /// Drives [`ErrorKind::Spawn`](crate::ErrorKind::Spawn) with an `io::Error` of the /// given `kind` and `message`, so classifiers built on it /// (e.g. [`Error::is_permission_denied`](crate::Error::is_permission_denied)) /// answer on the fake exactly as they would for a live spawn failure. @@ -296,14 +296,20 @@ impl Reply { /// all. fn spawn_error_for(&self, program: String) -> Option { match &self.spawn_error { - Some(SpawnError::NotFound) => Some(crate::error::Error::NotFound { - program, - searched: None, - }), - Some(SpawnError::Spawn { kind, message }) => Some(crate::error::Error::Spawn { - program, - source: std::io::Error::new(*kind, message.clone()), - }), + Some(SpawnError::NotFound) => Some( + crate::error::ErrorKind::NotFound { + program, + searched: None, + } + .into(), + ), + Some(SpawnError::Spawn { kind, message }) => Some( + crate::error::ErrorKind::Spawn { + program, + source: std::io::Error::new(*kind, message.clone()), + } + .into(), + ), None => None, } } @@ -406,7 +412,7 @@ impl Reply { /// and the bulk and `start` verbs agree on one double. fn into_result(self, program: String, command: &Command) -> ProcessResult { // Carry the command's configured timeout so a timed-out reply surfaces as - // `Error::Timeout` with the real deadline, not a zero duration. + // `ErrorKind::Timeout` with the real deadline, not a zero duration. let timeout = command.configured_timeout(); let outcome = if self.timed_out { Outcome::TimedOut @@ -732,15 +738,16 @@ impl ScriptedRunner { return Ok(&entry.replies[i.min(entry.replies.len() - 1)]); } } - self.fallback - .as_ref() - .ok_or_else(|| crate::error::Error::Spawn { + self.fallback.as_ref().ok_or_else(|| { + crate::error::ErrorKind::Spawn { program: program.to_owned(), source: std::io::Error::new( std::io::ErrorKind::NotFound, "ScriptedRunner: no rule matched and no fallback set", ), - }) + } + .into() + }) } } @@ -796,7 +803,7 @@ impl ProcessRunner for ScriptedRunner { if let Some(token) = command.cancel_token() && token.is_cancelled() { - return Err(crate::error::Error::Cancelled { program }); + return Err(crate::error::ErrorKind::Cancelled { program }.into()); } // Reserve a one-shot streaming stdin source exactly like the live launch // path, and hold the reservation until we know this scripted run "starts a @@ -847,7 +854,7 @@ impl ProcessRunner for ScriptedRunner { if let Some(token) = command.cancel_token() && token.is_cancelled() { - return Err(crate::error::Error::Cancelled { program }); + return Err(crate::error::ErrorKind::Cancelled { program }.into()); } // See the matching comment in `output_string`: a one-shot streaming stdin // source is reserved here too, then committed once we know a scripted child @@ -873,7 +880,7 @@ impl ProcessRunner for ScriptedRunner { /// [`timeout`](crate::Command::timeout) and resolve exactly as the live bulk /// path would for a genuinely hung child. /// -/// - the token fires first → `Err(Error::Cancelled)`, the mirror of cancelling +/// - the token fires first → `Err(ErrorKind::Cancelled)`, the mirror of cancelling /// (and cleaning up) a live long-runner; /// - the `Command::timeout` deadline fires first → a synthesized timed-out /// [`ProcessResult`] (`Outcome::TimedOut`, empty output — the same shape a @@ -906,7 +913,7 @@ async fn park_until_cancelled(command: &Command, program: String) -> Result Err(crate::error::Error::Cancelled { program }), + () = cancelled => Err(crate::error::ErrorKind::Cancelled { program }.into()), // The same construction the bulk verb applies to a `Reply::timeout()`, so a // pending-then-timeout result is byte-for-byte a canned timeout on this verb. () = deadline => Ok(Reply::timeout() @@ -1115,7 +1122,7 @@ impl ProcessRunner for RecordingRunner { async fn output_bytes(&self, command: &Command) -> Result>> { // Don't fall through to the `start`-based default: a runner whose // `output_bytes` override behaves differently (e.g. rejects with - // `Error::Unsupported` instead of lossily re-encoding) must be honored, + // `ErrorKind::Unsupported` instead of lossily re-encoding) must be honored, // not silently replayed through `start`. self.calls .lock() @@ -1455,7 +1462,7 @@ mod tests { #[tokio::test] async fn first_line_reports_cancellation_not_a_missing_line() { // When the command's cancel token has fired, a `first_line` whose - // predicate never matched must surface `Error::Cancelled` — not `Ok(None)`, + // predicate never matched must surface `ErrorKind::Cancelled` — not `Ok(None)`, // which a readiness probe would misread as "the line never appeared". use crate::runner::ProcessRunnerExt; use tokio_util::sync::CancellationToken; @@ -1468,7 +1475,7 @@ mod tests { .cancel_on(token.child_token()); let result = runner.first_line(&cmd, |l| l.contains("ready")).await; assert!( - matches!(result, Err(crate::Error::Cancelled { .. })), + matches!(&result, Err(e) if matches!(e.kind(), crate::ErrorKind::Cancelled { .. })), "a cancelled probe must report Cancelled, got {result:?}" ); @@ -1852,7 +1859,10 @@ mod tests { .expect_err("the line never arrives, so the probe is NotReady"); let waited = start.elapsed(); - assert!(matches!(err, crate::Error::NotReady { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), crate::ErrorKind::NotReady { .. }), + "got {err:?}" + ); assert!( waited >= std::time::Duration::from_secs(9), "the probe must wait its full `within` (10s), not be cut short by the \ @@ -1876,7 +1886,10 @@ mod tests { .wait_for_line(|_| false, std::time::Duration::from_secs(1)) .await .expect_err("nothing matches within 1s"); - assert!(matches!(err, crate::Error::NotReady { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), crate::ErrorKind::NotReady { .. }), + "got {err:?}" + ); let finish = run.finish().await.expect("finish"); assert_eq!( @@ -1921,7 +1934,10 @@ mod tests { .wait_for_line(|_| false, std::time::Duration::from_secs(10)) .await .expect_err("nothing matches within 10s"); - assert!(matches!(err, crate::Error::NotReady { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), crate::ErrorKind::NotReady { .. }), + "got {err:?}" + ); // With 10 virtual seconds already burned against a 5s timeout, the // deadline is past: `finish` must time out AT ONCE (advancing no further @@ -2017,8 +2033,8 @@ mod tests { .output_string(&cmd) .await .expect_err("a non-piped stdout must error on a capture verb"); - match err { - crate::error::Error::Io(e) => { + match err.kind() { + crate::error::ErrorKind::Io(e) => { assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput) } other => panic!("expected Io(InvalidInput), got {other:?}"), @@ -2036,7 +2052,7 @@ mod tests { .await .expect_err("a scripted capture cannot read the child-owned file"); assert!( - matches!(err, crate::error::Error::Io(e) if e.kind() == std::io::ErrorKind::InvalidInput) + matches!(err.kind(), crate::error::ErrorKind::Io(e) if e.kind() == std::io::ErrorKind::InvalidInput) ); let dry_run = DryRunRunner::new(); @@ -2045,7 +2061,7 @@ mod tests { .await .expect_err("a dry-run capture cannot pretend to read the file"); assert!( - matches!(err, crate::error::Error::Io(e) if e.kind() == std::io::ErrorKind::InvalidInput) + matches!(err.kind(), crate::error::ErrorKind::Io(e) if e.kind() == std::io::ErrorKind::InvalidInput) ); assert!( dry_run.commands().is_empty(), @@ -2066,7 +2082,7 @@ mod tests { .await .expect_err("a pre-cancelled token short-circuits"); assert!( - matches!(err, crate::error::Error::Cancelled { .. }), + matches!(err.kind(), crate::error::ErrorKind::Cancelled { .. }), "got {err:?}" ); } @@ -2085,7 +2101,7 @@ mod tests { .await .expect_err("a pre-cancelled token short-circuits start"); assert!( - matches!(err, crate::error::Error::Cancelled { .. }), + matches!(err.kind(), crate::error::ErrorKind::Cancelled { .. }), "got {err:?}" ); } @@ -2161,9 +2177,10 @@ mod tests { } async fn output_bytes(&self, _command: &Command) -> Result>> { - Err(crate::error::Error::Unsupported { + Err(crate::error::ErrorKind::Unsupported { operation: "output_bytes".into(), - }) + } + .into()) } } @@ -2178,7 +2195,7 @@ mod tests { .await .expect_err("inner's Unsupported must be forwarded, not masked"); assert!( - matches!(err, crate::error::Error::Unsupported { .. }), + matches!(err.kind(), crate::error::ErrorKind::Unsupported { .. }), "got {err:?}" ); assert_eq!(rec.only_call().args_str(), ["cat-file", "blob", "HEAD"]); @@ -2204,7 +2221,7 @@ mod tests { .expect("the token resolves the run") .expect_err("cancellation is always an error"); assert!( - matches!(err, crate::error::Error::Cancelled { .. }), + matches!(err.kind(), crate::error::ErrorKind::Cancelled { .. }), "got {err:?}" ); } @@ -2250,7 +2267,7 @@ mod tests { .expect("the token resolves the race") .expect_err("a cancelled run surfaces as an error"); assert!( - matches!(err, crate::error::Error::Cancelled { .. }), + matches!(err.kind(), crate::error::ErrorKind::Cancelled { .. }), "got {err:?}" ); } @@ -2298,12 +2315,12 @@ mod tests { .output_string(&Command::new("git").arg("log")) .await .expect_err("an unmatched command with no fallback must error"); - match err { - crate::error::Error::Spawn { program, source } => { + match err.kind() { + crate::error::ErrorKind::Spawn { program, source } => { assert_eq!(program, "git"); assert_eq!(source.kind(), std::io::ErrorKind::NotFound); } - other => panic!("expected Error::Spawn, got {other:?}"), + other => panic!("expected ErrorKind::Spawn, got {other:?}"), } } @@ -2382,26 +2399,30 @@ mod tests { #[tokio::test] async fn timeout_reply_surfaces_as_timeout_error() { - use crate::error::Error; + use crate::error::ErrorKind; let runner = ScriptedRunner::new().fallback(Reply::timeout()); // capture/output exposes the flag without erroring … let out = runner.output_string(&Command::new("git")).await.unwrap(); assert!(out.timed_out()); // … but the success-checking helpers raise a distinct Timeout. assert!(matches!( - runner.run(&Command::new("git")).await.unwrap_err(), - Error::Timeout { .. } + runner.run(&Command::new("git")).await.unwrap_err().kind(), + ErrorKind::Timeout { .. } )); assert!(matches!( - runner.exit_code(&Command::new("git")).await.unwrap_err(), - Error::Timeout { .. } + runner + .exit_code(&Command::new("git")) + .await + .unwrap_err() + .kind(), + ErrorKind::Timeout { .. } )); // The reply carries the command's *real* configured deadline, matching the // live runner — not a zero duration. let cmd = Command::new("git").timeout(std::time::Duration::from_secs(7)); - match runner.run(&cmd).await.unwrap_err() { - Error::Timeout { timeout, .. } => { - assert_eq!(timeout, std::time::Duration::from_secs(7)) + match runner.run(&cmd).await.unwrap_err().kind() { + ErrorKind::Timeout { timeout, .. } => { + assert_eq!(*timeout, std::time::Duration::from_secs(7)) } other => panic!("expected Timeout, got {other:?}"), } @@ -2439,13 +2460,13 @@ mod tests { #[tokio::test] async fn signalled_reply_carries_signal_number() { - use crate::error::Error; + use crate::error::ErrorKind; let runner = ScriptedRunner::new().fallback(Reply::signalled(Some(9))); let result = runner.output_string(&Command::new("tool")).await.unwrap(); assert_eq!(result.outcome(), crate::Outcome::Signalled(Some(9))); assert!(matches!( - runner.run(&Command::new("tool")).await.unwrap_err(), - Error::Signalled { + runner.run(&Command::new("tool")).await.unwrap_err().kind(), + ErrorKind::Signalled { signal: Some(9), .. } @@ -2454,19 +2475,19 @@ mod tests { #[tokio::test] async fn signalled_reply_without_a_number_is_signalled_none() { - use crate::error::Error; + use crate::error::ErrorKind; let runner = ScriptedRunner::new().fallback(Reply::signalled(None)); let result = runner.output_string(&Command::new("tool")).await.unwrap(); assert_eq!(result.outcome(), crate::Outcome::Signalled(None)); assert!(matches!( - runner.run(&Command::new("tool")).await.unwrap_err(), - Error::Signalled { signal: None, .. } + runner.run(&Command::new("tool")).await.unwrap_err().kind(), + ErrorKind::Signalled { signal: None, .. } )); } #[tokio::test(start_paused = true)] async fn pending_parks_until_the_token_fires_then_cancels() { - use crate::error::Error; + use crate::error::ErrorKind; let token = crate::CancellationToken::new(); let runner = ScriptedRunner::new().on(["gh", "run", "watch"], Reply::pending()); let cmd = Command::new("gh") @@ -2482,9 +2503,9 @@ mod tests { "a pending reply must not resolve before cancellation" ); token.cancel(); - match call.await { - Err(Error::Cancelled { program }) => assert_eq!(program, "gh"), - other => panic!("expected Error::Cancelled, got {other:?}"), + match call.await.map_err(crate::error::Error::into_kind) { + Err(ErrorKind::Cancelled { program }) => assert_eq!(program, "gh"), + other => panic!("expected ErrorKind::Cancelled, got {other:?}"), } } @@ -2509,7 +2530,7 @@ mod tests { // bounded by the command's `timeout` just like the live bulk path and the // scripted `start` path. With no cancel token but a deadline set, the call // must resolve `TimedOut` at the deadline instead of parking forever. - use crate::error::Error; + use crate::error::ErrorKind; use crate::runner::ProcessRunnerExt; let runner = ScriptedRunner::new().fallback(Reply::pending()); let cmd = Command::new("hang").timeout(std::time::Duration::from_secs(3)); @@ -2522,13 +2543,13 @@ mod tests { assert_eq!(result.outcome(), Outcome::TimedOut); assert!(result.timed_out()); - // …and the checking verbs raise `Error::Timeout` carrying the command's + // …and the checking verbs raise `ErrorKind::Timeout` carrying the command's // real configured deadline — byte-for-byte a `Reply::timeout` on this verb. - match runner.run(&cmd).await.unwrap_err() { - Error::Timeout { timeout, .. } => { - assert_eq!(timeout, std::time::Duration::from_secs(3)) + match runner.run(&cmd).await.unwrap_err().kind() { + ErrorKind::Timeout { timeout, .. } => { + assert_eq!(*timeout, std::time::Duration::from_secs(3)) } - other => panic!("expected Error::Timeout, got {other:?}"), + other => panic!("expected ErrorKind::Timeout, got {other:?}"), } } @@ -2537,7 +2558,7 @@ mod tests { // When both a token and a timeout bound a pending bulk call, the one that // fires first wins — here the token cancels well before the (long) deadline, // so the call reports `Cancelled`, not `TimedOut`. - use crate::error::Error; + use crate::error::ErrorKind; let token = crate::CancellationToken::new(); let runner = ScriptedRunner::new().fallback(Reply::pending()); let cmd = Command::new("watch") @@ -2554,15 +2575,15 @@ mod tests { "the call must not resolve before either knob fires" ); token.cancel(); - match call.await { - Err(Error::Cancelled { program }) => assert_eq!(program, "watch"), - other => panic!("expected Error::Cancelled, got {other:?}"), + match call.await.map_err(crate::error::Error::into_kind) { + Err(ErrorKind::Cancelled { program }) => assert_eq!(program, "watch"), + other => panic!("expected ErrorKind::Cancelled, got {other:?}"), } } #[tokio::test] async fn probe_reads_exit_code_as_bool() { - use crate::error::Error; + use crate::error::ErrorKind; let runner = ScriptedRunner::new() .on(["t", "yes"], Reply::ok("")) .on(["t", "no"], Reply::fail(1, "")) @@ -2576,15 +2597,17 @@ mod tests { runner .probe(&Command::new("t").arg("boom")) .await - .unwrap_err(), - Error::Exit { code: 2, .. } + .unwrap_err() + .kind(), + ErrorKind::Exit { code: 2, .. } )); assert!(matches!( runner .probe(&Command::new("t").arg("other")) .await - .unwrap_err(), - Error::Timeout { .. } + .unwrap_err() + .kind(), + ErrorKind::Timeout { .. } )); } @@ -2630,8 +2653,10 @@ mod tests { .output_string(&cmd) .await .expect_err("a re-run of a consumed one-shot stdin source must fail loud"); - match second { - crate::error::Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), + match second.kind() { + crate::error::ErrorKind::Io(e) => { + assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput) + } other => panic!("expected Io(InvalidInput), got {other:?}"), } } @@ -2654,8 +2679,10 @@ mod tests { .start(&cmd) .await .expect_err("a re-run of a consumed one-shot stdin source must fail loud"); - match second { - crate::error::Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), + match second.kind() { + crate::error::ErrorKind::Io(e) => { + assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput) + } other => panic!("expected Io(InvalidInput), got {other:?}"), } } @@ -2673,8 +2700,12 @@ mod tests { .inherit_stdin() .stdin(crate::Stdin::from_string("payload")); for bad in [with_keep_open, with_source] { - match runner.output_string(&bad).await { - Err(crate::error::Error::Io(e)) => { + match runner + .output_string(&bad) + .await + .map_err(crate::error::Error::into_kind) + { + Err(crate::error::ErrorKind::Io(e)) => { assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput); } other => panic!("expected Io(InvalidInput) from the fake, got {other:?}"), @@ -2717,16 +2748,16 @@ mod tests { // `Reply::not_found()` fails as a spawn-side "the program doesn't // exist", not a completed run with a failing exit code — the fake's // analogue of a live spawn hitting a missing binary (D8). - use crate::error::Error; + use crate::error::ErrorKind; let runner = ScriptedRunner::new().on(["rg", "--version"], Reply::not_found()); let err = runner .output_string(&Command::new("rg").arg("--version")) .await .expect_err("a not_found reply must error, not return a canned exit code"); assert!(err.is_not_found(), "expected is_not_found(), got {err:?}"); - match err { - Error::NotFound { program, .. } => assert_eq!(program, "rg"), - other => panic!("expected Error::NotFound, got {other:?}"), + match err.kind() { + ErrorKind::NotFound { program, .. } => assert_eq!(program, "rg"), + other => panic!("expected ErrorKind::NotFound, got {other:?}"), } } @@ -2767,9 +2798,9 @@ mod tests { async fn spawn_error_reply_carries_the_io_kind() { // A generic OS-level spawn failure (as opposed to `not_found`'s "the // program doesn't exist at all") — e.g. permission denied — so - // classifiers built on `Error::Spawn`'s io kind answer on the fake + // classifiers built on `ErrorKind::Spawn`'s io kind answer on the fake // exactly as they would live. - use crate::error::Error; + use crate::error::ErrorKind; let runner = ScriptedRunner::new().fallback(Reply::spawn_error( std::io::ErrorKind::PermissionDenied, "eacces", @@ -2786,13 +2817,13 @@ mod tests { !err.is_not_found(), "a generic spawn error is not is_not_found()" ); - match err { - Error::Spawn { program, source } => { + match err.kind() { + ErrorKind::Spawn { program, source } => { assert_eq!(program, "locked-tool"); assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied); assert_eq!(source.to_string(), "eacces"); } - other => panic!("expected Error::Spawn, got {other:?}"), + other => panic!("expected ErrorKind::Spawn, got {other:?}"), } } @@ -2926,15 +2957,15 @@ mod tests { .output_string(&command) .await .expect_err("inherit_stdin with a configured source must be rejected"); - match error { - crate::Error::Io(error) => { + match error.kind() { + crate::ErrorKind::Io(error) => { assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); assert!( error.to_string().contains("inherit_stdin()"), "expected inherit_stdin context, got: {error}" ); } - other => panic!("expected Error::Io(InvalidInput), got {other:?}"), + other => panic!("expected ErrorKind::Io(InvalidInput), got {other:?}"), } assert!( runner.commands().is_empty(), @@ -3021,7 +3052,7 @@ mod tests { assert_eq!(finish.outcome, Outcome::Exited(2)); // The Ext verb `run()` goes through `checked` → `ensure_success`; it - // must succeed rather than surface `Error::Exit` for this command. + // must succeed rather than surface `ErrorKind::Exit` for this command. use crate::runner::ProcessRunnerExt; runner .run(&command) @@ -3094,7 +3125,7 @@ mod tests { .await .expect_err("the one-shot source is now consumed"); assert!( - matches!(err, crate::Error::Io(_)), + matches!(err.kind(), crate::ErrorKind::Io(_)), "expected Io, got {err:?}" ); } @@ -3115,7 +3146,7 @@ mod tests { .await .expect_err("a canned spawn_error is an error"); assert!( - matches!(err, crate::Error::Spawn { .. }), + matches!(err.kind(), crate::ErrorKind::Spawn { .. }), "expected Spawn, got {err:?}" ); @@ -3171,7 +3202,7 @@ mod tests { .await .expect_err("a pre-cancelled token short-circuits"); assert!( - matches!(err, crate::Error::Cancelled { .. }), + matches!(err.kind(), crate::ErrorKind::Cancelled { .. }), "expected Cancelled, got {err:?}" ); diff --git a/src/error.rs b/src/error.rs index 673ff2c..6f90986 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,22 +5,39 @@ use std::time::Duration; /// Errors produced when launching or running a child process. /// -/// Spawn failures, a non-zero exit ([`Exit`](Error::Exit)), timeouts, and IO -/// errors fold into one structured enum, so callers can pattern-match on the -/// failure mode instead of parsing strings. +/// Spawn failures, a non-zero exit ([`ErrorKind::Exit`]), timeouts, and IO +/// errors fold into one structured kind, so callers can pattern-match on the +/// failure mode — via [`kind`](Self::kind) — instead of parsing strings. /// -/// `Debug` is **manual, not derived**: the [`Exit`](Error::Exit) variant +/// A thin wrapper: `Error` boxes its [`ErrorKind`] (`Box`) so the +/// `Ok` path of every `Result` stays small — the largest kind +/// ([`Exit`](ErrorKind::Exit) / [`Timeout`](ErrorKind::Timeout) / +/// [`Signalled`](ErrorKind::Signalled), each carrying two captured streams) +/// no longer inflates every fallible return in the crate. +/// +/// `Display`, `Debug`, and [`std::error::Error::source`] all delegate to the +/// boxed [`ErrorKind`] — see there for the "manual, not derived `Debug`" +/// rationale (the stream-carrying kinds bound their captured output to a +/// 200-byte preview rather than dumping it into a `{e:?}` log line or +/// `.unwrap()` panic message). +pub struct Error { + kind: Box, +} + +/// The kinds of failure [`Error`] can carry — reach one via [`Error::kind`]. +/// +/// `Debug` is **manual, not derived**: the [`Exit`](Self::Exit) variant /// carries both captured streams in full, and a derived `Debug` would dump them /// — potentially multi-MiB — into a `{e:?}` log line or an `.unwrap()` panic /// message. The manual impl bounds each stream to a 200-byte preview (mirroring /// the [`Display`](std::fmt::Display) tail cap) and redacts -/// [`NotFound`](Error::NotFound)'s +/// [`NotFound`](Self::NotFound)'s /// `searched` (the `PATH` env value) to a directory count, honoring the crate's /// "never log environment values" rule. The exact streams remain reachable via /// the public fields. #[derive(thiserror::Error)] #[non_exhaustive] -pub enum Error { +pub enum ErrorKind { /// The child process could not be started (binary not found, permission /// denied, …). #[error("could not start `{program}`: {source}")] @@ -40,7 +57,7 @@ pub enum Error { /// was named (bare name vs path) or platform, so a caller matches one /// variant and [`is_not_found`](Error::is_not_found) classifies it. /// - /// Distinct from [`Spawn`](Error::Spawn), which covers OS-level failures + /// Distinct from [`Spawn`](Self::Spawn), which covers OS-level failures /// once the program *is* located (permission denied, busy, a bad working /// directory, a `.cmd`/`.bat` on Windows that needs `cmd.exe`, etc.) — /// those are **not** `is_not_found`. @@ -70,7 +87,7 @@ pub enum Error { /// A cassette replay found **no recording** matching the invocation — a /// stale or incomplete cassette, not a missing program. Kept distinct - /// from [`Spawn`](Error::Spawn) / [`NotFound`](Error::NotFound) so a wrapper + /// from [`Spawn`](Self::Spawn) / [`NotFound`](Self::NotFound) so a wrapper /// that treats "tool not installed" as an *optional* dependency does not /// silently swallow a stale cassette as an absent tool. /// @@ -92,12 +109,12 @@ pub enum Error { /// diagnostics to **stdout** on failure (`CONFLICT (content): …`, `nothing to /// commit, working tree clean`), so a caller building a user-facing message /// wants stdout as a fallback when stderr is empty — see - /// [`diagnostic`](Self::diagnostic). Consumers also classify on these fields + /// [`diagnostic`](Error::diagnostic). Consumers also classify on these fields /// (grep for a marker, parse a sub-code), so they are never truncated before /// the caller sees them; only the `Display` message below is bounded. /// /// The one-line `Display` message appends the **last non-empty line** of - /// [`diagnostic`](Self::diagnostic), capped at 200 bytes — `` `git` exited + /// [`diagnostic`](Error::diagnostic), capped at 200 bytes — `` `git` exited /// with code 2: fatal: boom `` — actionable in a log line without dumping /// multi-KiB streams into it. #[error("{}", display_exit(program, *code, stdout, stderr))] @@ -135,13 +152,13 @@ pub enum Error { /// Carries whatever the run captured **before** the deadline killed it: /// a hung tool's partial stderr is frequently the explanation /// (`waiting for lock held by pid 4123`, `connecting to db…`), so it is - /// reachable via [`diagnostic`](Self::diagnostic) and the public fields + /// reachable via [`diagnostic`](Error::diagnostic) and the public fields /// rather than lost. Empty when the producing path captured nothing (a /// streaming probe such as `first_line`, which never buffers). /// /// The one-line `Display` message appends the **last non-empty line** of - /// [`diagnostic`](Self::diagnostic), capped at 200 bytes — just like - /// [`Exit`](Error::Exit) — so a log line stays actionable without dumping + /// [`diagnostic`](Error::diagnostic), capped at 200 bytes — just like + /// [`Exit`](Self::Exit) — so a log line stays actionable without dumping /// the captured streams. #[error("{}", display_timeout(program, *timeout, stdout, stderr))] #[non_exhaustive] @@ -161,7 +178,7 @@ pub enum Error { /// (bounded) reaches the `Display` message. stderr: String, /// The exact captured stdout bytes before the kill, when the producing - /// path captured raw bytes — see [`Exit`](Error::Exit)'s `stdout_bytes` + /// path captured raw bytes — see [`Exit`](Self::Exit)'s `stdout_bytes` /// field for the full contract. `None` on the text path, or when the /// producing path captured nothing (a streaming probe). Read via /// [`Error::stdout_bytes`]. @@ -207,7 +224,7 @@ pub enum Error { /// accepted, the check never returned `true`, or the child exited before /// becoming ready. /// - /// Distinct from [`Timeout`](Error::Timeout): a probe deadline is separate + /// Distinct from [`Timeout`](Self::Timeout): a probe deadline is separate /// from the run's own [`Command::timeout`](crate::Command::timeout), and a /// failed probe does **not** kill the child — the caller decides what /// happens next. @@ -230,7 +247,7 @@ pub enum Error { /// parser the caller maps into this variant). /// /// `message` is caller-built and routinely embeds the unparsed output in - /// full, so — like the [`Exit`](Error::Exit) streams — both `Display` and + /// full, so — like the [`Exit`](Self::Exit) streams — both `Display` and /// `Debug` bound it to a 200-byte preview; the complete text stays /// reachable via the public field. #[error("{}", display_parse(program, message))] @@ -256,7 +273,7 @@ pub enum Error { /// Structured so a caller (e.g. the `processkit-py` binding) can branch on /// *which* limit and *why* without parsing `detail`'s English text — see /// [`LimitKind`](crate::LimitKind) / [`LimitReason`](crate::LimitReason), and - /// the [`limit_kind`](Self::limit_kind) / [`limit_reason`](Self::limit_reason) + /// the [`limit_kind`](Error::limit_kind) / [`limit_reason`](Error::limit_reason) /// accessors. #[cfg(feature = "limits")] #[error("{}", display_resource_limit(*kind, *reason, detail))] @@ -269,7 +286,7 @@ pub enum Error { /// Human-readable detail — the validation message for /// [`LimitReason::Invalid`](crate::LimitReason::Invalid), or the /// underlying OS error text otherwise. Bounded like other free-text - /// fields (see [`Parse`](Error::Parse)'s `message`) in `Display`/`Debug`. + /// fields (see [`Parse`](Self::Parse)'s `message`) in `Display`/`Debug`. detail: String, }, @@ -289,19 +306,19 @@ pub enum Error { /// ([`Command::cancel_on`](crate::Command::cancel_on)) and its process /// tree was killed. /// - /// Asymmetric with [`Timeout`](Error::Timeout) by design: a timeout is + /// Asymmetric with [`Timeout`](Self::Timeout) by design: a timeout is /// *captured* (`ProcessResult::timed_out`) on the non-checking paths, /// whereas a cancellation is **always** raised on every consuming path. /// When a run both times out and is cancelled, cancellation wins (it is /// checked first). /// - /// Unlike [`Timeout`](Error::Timeout) / [`Signalled`](Error::Signalled), + /// Unlike [`Timeout`](Self::Timeout) / [`Signalled`](Self::Signalled), /// this carries **no captured streams**: cancellation is a deliberate /// caller action that stops the run *immediately*. On the pre-spawn path (the /// token was already cancelled) nothing was captured at all; on the consuming /// verbs, any output captured before the kill is **intentionally discarded** — /// the caller initiated the stop and knows why, so a partial diagnostic would - /// be noise. [`diagnostic`](Self::diagnostic) returns `None`. + /// be noise. [`diagnostic`](Error::diagnostic) returns `None`. #[error("`{program}` was cancelled")] Cancelled { /// The program that was cancelled. @@ -310,13 +327,13 @@ pub enum Error { /// The process was terminated by a signal (**Unix only**) without producing an /// exit code. `signal` carries the signal number when the kernel reports one, - /// else `None`. On **Windows** a killed process reports [`Exit`](Error::Exit) + /// else `None`. On **Windows** a killed process reports [`Exit`](Self::Exit) /// with a platform code, never this — a live `Signalled` cannot occur there; it /// arises only from a [`ScriptedRunner`](crate::testing::ScriptedRunner) or a /// `record`-feature cassette replay, which report `Signalled(None)` to mirror /// Unix. /// - /// Distinct from [`Exit`](Error::Exit): a signal-terminated run has no exit + /// Distinct from [`Exit`](Self::Exit): a signal-terminated run has no exit /// code to check — it is always a failure. Produced by /// [`ensure_success`](crate::ProcessResult::ensure_success) and the /// `require_code` path when the outcome is @@ -324,8 +341,8 @@ pub enum Error { /// /// Carries whatever the run captured before the signal killed it — a /// crashing tool's partial stderr is often the diagnostic — reachable via - /// [`diagnostic`](Self::diagnostic) and the public fields. The one-line - /// `Display` appends the bounded diagnostic tail, like [`Exit`](Error::Exit). + /// [`diagnostic`](Error::diagnostic) and the public fields. The one-line + /// `Display` appends the bounded diagnostic tail, like [`Exit`](Self::Exit). #[error("{}", display_signalled(program, *signal, stdout, stderr))] #[non_exhaustive] Signalled { @@ -342,7 +359,7 @@ pub enum Error { /// non-empty line (bounded) reaches the `Display` message. stderr: String, /// The exact captured stdout bytes before the kill, when the producing - /// path captured raw bytes — see [`Exit`](Error::Exit)'s `stdout_bytes` + /// path captured raw bytes — see [`Exit`](Self::Exit)'s `stdout_bytes` /// field for the full contract. `None` on the text path. Read via /// [`Error::stdout_bytes`]. stdout_bytes: Option>, @@ -352,8 +369,8 @@ pub enum Error { /// than the routine broken pipe. /// /// This is raised by the consuming paths **only when the - /// run otherwise succeeded** — a non-zero [`Exit`](Error::Exit), a - /// [`Signalled`](Error::Signalled), or a [`Timeout`](Error::Timeout) is the + /// run otherwise succeeded** — a non-zero [`Exit`](Self::Exit), a + /// [`Signalled`](Self::Signalled), or a [`Timeout`](Self::Timeout) is the /// "realer" failure and wins (the stdin error is then dropped). A broken /// pipe (`EPIPE` / `ERROR_BROKEN_PIPE` — the child closing stdin before /// reading all of it) is routine and **never** surfaces. Diagnoses a @@ -381,7 +398,7 @@ pub enum Error { /// (waiting for exit, issuing a kill), controlling a process group /// (signalling, reaping, sampling stats), or reading/writing a cassette /// file. It is **not** a spawn/launch condition (those are - /// [`Spawn`](Error::Spawn) / [`NotFound`](Error::NotFound)). + /// [`Spawn`](Self::Spawn) / [`NotFound`](Self::NotFound)). /// /// There is **deliberately no blanket `From`**: the /// crate never lets an arbitrary foreign `io::Error` fall into this variant @@ -395,370 +412,195 @@ pub enum Error { } impl Error { + /// The [`ErrorKind`] this error carries + pub fn kind(&self) -> &ErrorKind { + &self.kind + } + + /// Unwraps this error into its owned [`ErrorKind`]. + pub fn into_kind(self) -> ErrorKind { + *self.kind + } + /// The best human-facing message for a failed run, trimmed of surrounding /// whitespace: captured standard error if it carries text, otherwise the /// captured standard output (where `git` puts `CONFLICT …` and `git commit` /// puts `nothing to commit`). Covers the variants that capture streams — a - /// non-zero [`Exit`](Error::Exit), a [`Timeout`](Error::Timeout) (the partial - /// output of a hung-then-killed tool), and a [`Signalled`](Error::Signalled) - /// crash. Returns `None` when there is no captured output to show — a silent - /// run (both streams blank) or a variant that carries none - /// ([`Spawn`](Error::Spawn), [`Cancelled`](Error::Cancelled), - /// [`Parse`](Error::Parse), [`Io`](Error::Io)) — so a caller can fall back to - /// the [`Display`](std::fmt::Display) message. For the raw, untrimmed stream - /// match on the variant's fields directly. + /// non-zero [`Exit`](ErrorKind::Exit), a [`Timeout`](ErrorKind::Timeout) (the + /// partial output of a hung-then-killed tool), and a + /// [`Signalled`](ErrorKind::Signalled) crash. Returns `None` when there is no + /// captured output to show — a silent run (both streams blank) or a variant + /// that carries none ([`Spawn`](ErrorKind::Spawn), + /// [`Cancelled`](ErrorKind::Cancelled), [`Parse`](ErrorKind::Parse), + /// [`Io`](ErrorKind::Io)) — so a caller can fall back to the + /// [`Display`](std::fmt::Display) message. For the raw, untrimmed stream + /// match on [`kind`](Self::kind)'s fields directly. pub fn diagnostic(&self) -> Option<&str> { - // Exhaustive on purpose: a future stream-carrying variant must add itself - // here rather than fall through a `_ => None` and be invisible to - // `diagnostic()`. `#[non_exhaustive]` only constrains downstream matches. - match self { - Error::Exit { stdout, stderr, .. } - | Error::Timeout { stdout, stderr, .. } - | Error::Signalled { stdout, stderr, .. } => exit_diagnostic(stdout, stderr), - Error::Spawn { .. } - | Error::NotFound { .. } - | Error::CassetteMiss { .. } - | Error::OutputTooLarge { .. } - | Error::NotReady { .. } - | Error::Parse { .. } - | Error::Unsupported { .. } - | Error::Cancelled { .. } - | Error::Stdin { .. } - | Error::Io(_) => None, - #[cfg(feature = "limits")] - Error::ResourceLimit { .. } => None, - } + self.kind.diagnostic() } - /// The captured standard output, for the variants that carry a stream — a - /// non-zero [`Exit`](Error::Exit), a [`Timeout`](Error::Timeout) (partial - /// output before the kill), or a [`Signalled`](Error::Signalled) crash — - /// `None` for every other variant. The raw stream in full (untrimmed); for - /// the best one-line message use [`diagnostic`](Self::diagnostic), for both - /// streams joined [`combined`](Self::combined). Reads the stream off the error - /// without destructuring a `#[non_exhaustive]` variant. + /// The captured standard output, for the kinds that carry a stream — a + /// non-zero [`Exit`](ErrorKind::Exit), a [`Timeout`](ErrorKind::Timeout) + /// (partial output before the kill), or a [`Signalled`](ErrorKind::Signalled) + /// crash — `None` for every other kind. The raw stream in full (untrimmed); + /// for the best one-line message use [`diagnostic`](Self::diagnostic), for + /// both streams joined [`combined`](Self::combined). pub fn stdout(&self) -> Option<&str> { - self.streams().map(|(stdout, _)| stdout) + self.kind.stdout() } - /// The captured standard error, for the stream-bearing variants (see + /// The captured standard error, for the stream-bearing kinds (see /// [`stdout`](Self::stdout)); `None` otherwise. The raw stream in full. pub fn stderr(&self) -> Option<&str> { - self.streams().map(|(_, stderr)| stderr) + self.kind.stderr() } /// The **exact** captured stdout bytes, when available — `Some` only for a - /// stream-bearing variant ([`Exit`](Error::Exit) / [`Timeout`](Error::Timeout) - /// / [`Signalled`](Error::Signalled)) produced by a checking verb built over + /// stream-bearing kind ([`Exit`](ErrorKind::Exit) / + /// [`Timeout`](ErrorKind::Timeout) / [`Signalled`](ErrorKind::Signalled)) + /// produced by a checking verb built over /// [`output_bytes`](crate::Command::output_bytes) (e.g. /// `output_bytes().await?.ensure_success()?`); `None` for every other - /// variant, and for a stream-bearing variant produced on the text path + /// kind, and for a stream-bearing kind produced on the text path /// (`output_string`/`run`/`checked`/…), where [`stdout`](Self::stdout) /// above is already the complete, non-lossy text. When `Some`, these bytes /// are the exact pre-decode stdout that [`stdout`](Self::stdout) is a lossy /// UTF-8 decode of — the two differ when the stream was not valid UTF-8. pub fn stdout_bytes(&self) -> Option<&[u8]> { - // Exhaustive on purpose (like `streams`): a future stream-carrying - // variant must add itself here, not fall through a `_`. - match self { - Error::Exit { stdout_bytes, .. } - | Error::Timeout { stdout_bytes, .. } - | Error::Signalled { stdout_bytes, .. } => stdout_bytes.as_deref(), - Error::Spawn { .. } - | Error::NotFound { .. } - | Error::CassetteMiss { .. } - | Error::OutputTooLarge { .. } - | Error::NotReady { .. } - | Error::Parse { .. } - | Error::Unsupported { .. } - | Error::Cancelled { .. } - | Error::Stdin { .. } - | Error::Io(_) => None, - #[cfg(feature = "limits")] - Error::ResourceLimit { .. } => None, - } + self.kind.stdout_bytes() } /// Standard output followed by standard error, joined — the [`Error`] twin of /// [`ProcessResult::combined`](crate::ProcessResult::combined). `Some` for the - /// stream-bearing variants, `None` otherwise. A `\n` is inserted between the + /// stream-bearing kinds, `None` otherwise. A `\n` is inserted between the /// streams only when both are non-empty and stdout doesn't already end in one. /// Use when a tool interleaves diagnostics across both streams, so a single /// [`diagnostic`](Self::diagnostic) stream (stderr *else* stdout) would miss a /// marker on the other. pub fn combined(&self) -> Option { - self.streams() - .map(|(stdout, stderr)| crate::result::combine_streams(stdout, stderr)) - } - - /// The captured `(stdout, stderr)` for the stream-bearing variants - /// ([`Exit`](Error::Exit) / [`Timeout`](Error::Timeout) / - /// [`Signalled`](Error::Signalled)), `None` for the rest — the single - /// exhaustive match the public stream accessors above derive from. - fn streams(&self) -> Option<(&str, &str)> { - // Exhaustive on purpose (like `diagnostic`/`io_source`): a future - // stream-carrying variant must add itself here, not fall through a `_`. - match self { - Error::Exit { stdout, stderr, .. } - | Error::Timeout { stdout, stderr, .. } - | Error::Signalled { stdout, stderr, .. } => Some((stdout, stderr)), - Error::Spawn { .. } - | Error::NotFound { .. } - | Error::CassetteMiss { .. } - | Error::OutputTooLarge { .. } - | Error::NotReady { .. } - | Error::Parse { .. } - | Error::Unsupported { .. } - | Error::Cancelled { .. } - | Error::Stdin { .. } - | Error::Io(_) => None, - #[cfg(feature = "limits")] - Error::ResourceLimit { .. } => None, - } + self.kind.combined() } /// The program (the CLI tool) this error is attributed to — `Some` for every - /// variant that names one (all except [`Unsupported`](Error::Unsupported), - /// [`Io`](Error::Io), and the `limits`-only `ResourceLimit`), `None` otherwise. + /// kind that names one (all except [`Unsupported`](ErrorKind::Unsupported), + /// [`Io`](ErrorKind::Io), and the `limits`-only `ResourceLimit`), `None` otherwise. /// The [`Error`] twin of /// [`ProcessResult::program`](crate::ProcessResult::program): the one - /// cross-cutting datum a wrapper routes or logs on, read without destructuring - /// a `#[non_exhaustive]` variant. + /// cross-cutting datum a wrapper routes or logs on. pub fn program(&self) -> Option<&str> { - // Exhaustive on purpose (like `diagnostic`/`streams`/`io_source`): a future - // program-naming variant must add itself here, not fall through a `_`. - match self { - Error::Spawn { program, .. } - | Error::NotFound { program, .. } - | Error::CassetteMiss { program } - | Error::Exit { program, .. } - | Error::Timeout { program, .. } - | Error::OutputTooLarge { program, .. } - | Error::NotReady { program, .. } - | Error::Parse { program, .. } - | Error::Cancelled { program } - | Error::Signalled { program, .. } - | Error::Stdin { program, .. } => Some(program), - Error::Unsupported { .. } | Error::Io(_) => None, - #[cfg(feature = "limits")] - Error::ResourceLimit { .. } => None, - } + self.kind.program() } /// Whether the **program could not be located** — it is not installed, not /// on `PATH`, or the given path does not resolve to an executable. True for - /// [`NotFound`](Error::NotFound) and **only** that variant: the launch + /// [`NotFound`](ErrorKind::NotFound) and **only** that kind: the launch /// path funnels every program-not-found failure into `NotFound`, so this is /// the one check a caller needs to surface a "command not installed?" hint. /// - /// `false` for every other variant — notably it does **not** fire for a - /// missing or invalid working directory (a [`Spawn`](Error::Spawn) carrying + /// `false` for every other kind — notably it does **not** fire for a + /// missing or invalid working directory (a [`Spawn`](ErrorKind::Spawn) carrying /// [`NotFound`](std::io::ErrorKind::NotFound)/`NotADirectory`): a bad `cwd` /// is not a missing program, so the hint would mislead. It is also `false` /// for a program that *is* installed but can't be executed directly (e.g. a /// Windows `.cmd`/`.bat` that needs `cmd.exe` — surfaced as `Spawn`). pub fn is_not_found(&self) -> bool { - matches!(self, Error::NotFound { .. }) + self.kind.is_not_found() } /// Whether this is a spawn/IO **permission denial** (`EACCES`/`EPERM`): the /// binary isn't executable, or the OS refused the launch. True for - /// [`Spawn`](Error::Spawn) / [`Io`](Error::Io) carrying + /// [`Spawn`](ErrorKind::Spawn) / [`Io`](ErrorKind::Io) carrying /// [`PermissionDenied`](std::io::ErrorKind::PermissionDenied); `false` /// otherwise. pub fn is_permission_denied(&self) -> bool { - self.io_source() - .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied) + self.kind.is_permission_denied() } /// Whether this is a **transient** spawn/IO condition a bare retry can clear /// — interrupted (`EINTR`), would-block (`EAGAIN`), a busy resource, a /// text-file-busy executable mid-write (`ETXTBSY`), or a Windows sharing/lock - /// violation. Classifies the [`Spawn`](Error::Spawn)/[`Io`](Error::Io) IO + /// violation. Classifies the [`Spawn`](ErrorKind::Spawn)/[`Io`](ErrorKind::Io) IO /// error only. /// /// **Scope: IO/spawn-level, never exit codes.** Whether a tool's non-zero - /// [`Exit`](Error::Exit) is retryable is domain-specific (a `git` 128 is not - /// generically transient) — that stays the caller's call. [`Timeout`](Error::Timeout) + /// [`Exit`](ErrorKind::Exit) is retryable is domain-specific (a `git` 128 is not + /// generically transient) — that stays the caller's call. [`Timeout`](ErrorKind::Timeout) /// is also excluded by design; compose it if wanted: /// `e.is_transient() || e.is_timeout()`. /// /// Pairs with [`Command::retry`](crate::Command::retry): /// `cmd.retry(3, backoff, |e| e.is_transient())`. pub fn is_transient(&self) -> bool { - self.io_source().is_some_and(is_transient_io) + self.kind.is_transient() } - /// The process exit code for a non-zero [`Exit`](Error::Exit); `None` for - /// every other variant (a timeout or a signal kill carries no exit code). + /// The process exit code for a non-zero [`Exit`](ErrorKind::Exit); `None` for + /// every other kind (a timeout or a signal kill carries no exit code). /// The same `code()` the crate's other disposition types expose /// ([`ProcessResult::code`](crate::ProcessResult::code) / /// [`Outcome::code`](crate::Outcome::code), and `RunProfile::code` under the - /// `stats` feature), so a code is one name everywhere. Reads the code off the - /// error without destructuring the variant. + /// `stats` feature), so a code is one name everywhere. pub fn code(&self) -> Option { - // Exhaustive on purpose (like `streams`/`program`): a future exit-code- - // carrying variant must add itself here, not fall through a `_` and be - // silently invisible to `code()`. - match self { - Error::Exit { code, .. } => Some(*code), - Error::Spawn { .. } - | Error::NotFound { .. } - | Error::CassetteMiss { .. } - | Error::Timeout { .. } - | Error::OutputTooLarge { .. } - | Error::NotReady { .. } - | Error::Parse { .. } - | Error::Unsupported { .. } - | Error::Cancelled { .. } - | Error::Signalled { .. } - | Error::Stdin { .. } - | Error::Io(_) => None, - #[cfg(feature = "limits")] - Error::ResourceLimit { .. } => None, - } + self.kind.code() } - /// The signal number for a [`Signalled`](Error::Signalled) run terminated - /// with a **known** signal (**Unix only**); `None` for every other variant and + /// The signal number for a [`Signalled`](ErrorKind::Signalled) run terminated + /// with a **known** signal (**Unix only**); `None` for every other kind and /// for a signal the kernel didn't expose — the [`Error`] twin of /// [`ProcessResult::signal`](crate::ProcessResult::signal) / - /// [`Outcome::signal`](crate::Outcome::signal). Reads the signal off the error - /// without destructuring the variant. + /// [`Outcome::signal`](crate::Outcome::signal). pub fn signal(&self) -> Option { - // Exhaustive on purpose (like `streams`/`program`): a future signal- - // carrying variant must add itself here, not fall through a `_`. - match self { - Error::Signalled { signal, .. } => *signal, - Error::Spawn { .. } - | Error::NotFound { .. } - | Error::CassetteMiss { .. } - | Error::Exit { .. } - | Error::Timeout { .. } - | Error::OutputTooLarge { .. } - | Error::NotReady { .. } - | Error::Parse { .. } - | Error::Unsupported { .. } - | Error::Cancelled { .. } - | Error::Stdin { .. } - | Error::Io(_) => None, - #[cfg(feature = "limits")] - Error::ResourceLimit { .. } => None, - } + self.kind.signal() } - /// Which limit a [`ResourceLimit`](Error::ResourceLimit) failure is about; - /// `None` for every other variant. Reads the field off the error without - /// destructuring the `#[non_exhaustive]` variant. + /// Which limit a [`ResourceLimit`](ErrorKind::ResourceLimit) failure is about; + /// `None` for every other kind. #[cfg(feature = "limits")] pub fn limit_kind(&self) -> Option { - // Exhaustive on purpose (like `signal`/`program`): a future variant - // must add itself here, not fall through a `_`. - match self { - Error::ResourceLimit { kind, .. } => Some(*kind), - Error::Spawn { .. } - | Error::NotFound { .. } - | Error::CassetteMiss { .. } - | Error::Exit { .. } - | Error::Timeout { .. } - | Error::OutputTooLarge { .. } - | Error::NotReady { .. } - | Error::Parse { .. } - | Error::Unsupported { .. } - | Error::Cancelled { .. } - | Error::Signalled { .. } - | Error::Stdin { .. } - | Error::Io(_) => None, - } + self.kind.limit_kind() } - /// Why a [`ResourceLimit`](Error::ResourceLimit) failure occurred; `None` - /// for every other variant. Reads the field off the error without - /// destructuring the `#[non_exhaustive]` variant. + /// Why a [`ResourceLimit`](ErrorKind::ResourceLimit) failure occurred; `None` + /// for every other kind. #[cfg(feature = "limits")] pub fn limit_reason(&self) -> Option { - // Exhaustive on purpose (like `signal`/`program`): a future variant - // must add itself here, not fall through a `_`. - match self { - Error::ResourceLimit { reason, .. } => Some(*reason), - Error::Spawn { .. } - | Error::NotFound { .. } - | Error::CassetteMiss { .. } - | Error::Exit { .. } - | Error::Timeout { .. } - | Error::OutputTooLarge { .. } - | Error::NotReady { .. } - | Error::Parse { .. } - | Error::Unsupported { .. } - | Error::Cancelled { .. } - | Error::Signalled { .. } - | Error::Stdin { .. } - | Error::Io(_) => None, - } + self.kind.limit_reason() } /// Whether the run was killed because it exceeded its /// [`Command::timeout`](crate::Command::timeout) — i.e. this is a - /// [`Timeout`](Error::Timeout). First-class here so the + /// [`Timeout`](ErrorKind::Timeout). First-class here so the /// [`is_transient`](Self::is_transient) retry-composition example can read - /// `e.is_transient() || e.is_timeout()` rather than matching the variant by hand. + /// `e.is_transient() || e.is_timeout()` rather than matching the kind by hand. /// The [`Error`] twin of the crate-wide deadline predicate /// [`ProcessResult::timed_out`](crate::ProcessResult::timed_out) (named /// `is_timeout` here to sit alongside the error's `is_*` predicate family). pub fn is_timeout(&self) -> bool { - matches!(self, Error::Timeout { .. }) + self.kind.is_timeout() } /// Whether the run was deliberately cancelled via its /// [`Command::cancel_on`](crate::Command::cancel_on) token — i.e. this is a - /// [`Cancelled`](Error::Cancelled). A caller that initiated the stop can + /// [`Cancelled`](ErrorKind::Cancelled). A caller that initiated the stop can /// swallow it rather than log or retry it as a real failure (the same - /// disposition [`Supervisor`](crate::Supervisor) treats as terminal), without - /// destructuring the variant. + /// disposition [`Supervisor`](crate::Supervisor) treats as terminal). pub fn is_cancelled(&self) -> bool { - matches!(self, Error::Cancelled { .. }) + self.kind.is_cancelled() } /// Whether the run was killed by a signal — i.e. this is a - /// [`Signalled`](Error::Signalled). Distinct from [`signal`](Self::signal): + /// [`Signalled`](ErrorKind::Signalled). Distinct from [`signal`](Self::signal): /// this is `true` even when the kernel didn't expose a number (a Unix kill /// where `signal()` is `None`, or any platform's signal disposition), so it is /// the reliable "died by a signal?" check — the predicate twin of - /// [`is_timeout`](Self::is_timeout) / [`is_cancelled`](Self::is_cancelled), - /// without destructuring the variant. + /// [`is_timeout`](Self::is_timeout) / [`is_cancelled`](Self::is_cancelled). pub fn is_signalled(&self) -> bool { - matches!(self, Error::Signalled { .. }) - } - - /// The underlying [`std::io::Error`] for the variants that carry one - /// ([`Spawn`](Error::Spawn), [`Io`](Error::Io)) — the basis for the io-level - /// classifiers above. - fn io_source(&self) -> Option<&std::io::Error> { - // Exhaustive on purpose: a future variant carrying an `io::Error` must - // add itself here so the io-level classifiers (`is_transient`, - // `is_permission_denied`) see it, rather than slipping through a wildcard. - match self { - Error::Spawn { source, .. } => Some(source), - Error::Io(source) => Some(source), - Error::NotFound { .. } - | Error::CassetteMiss { .. } - | Error::Exit { .. } - | Error::Timeout { .. } - | Error::OutputTooLarge { .. } - | Error::NotReady { .. } - | Error::Parse { .. } - | Error::Unsupported { .. } - | Error::Cancelled { .. } - | Error::Signalled { .. } - | Error::Stdin { .. } => None, - #[cfg(feature = "limits")] - Error::ResourceLimit { .. } => None, - } + self.kind.is_signalled() } - /// Construct an [`Exit`](Error::Exit) — a `#[doc(hidden)]` convenience for + /// Construct an [`Exit`](ErrorKind::Exit) — a `#[doc(hidden)]` convenience for /// custom [`ProcessRunner`](crate::ProcessRunner) doubles and error-classifier - /// tests, so they stop spelling out the struct literal (which the variant's + /// tests, so they stop spelling out the struct literal (which the kind's /// `#[non_exhaustive]` already rejects outside this crate, and which a future /// field addition would otherwise break) and go through one insulated /// constructor instead. Off the documented surface, but `pub` so downstream @@ -776,16 +618,17 @@ impl Error { stdout: impl Into, stderr: impl Into, ) -> Self { - Error::Exit { + ErrorKind::Exit { program: program.into(), code, stdout: stdout.into(), stderr: stderr.into(), stdout_bytes: None, } + .into() } - /// Construct a [`Timeout`](Error::Timeout) — see [`exit`](Self::exit). + /// Construct a [`Timeout`](ErrorKind::Timeout) — see [`exit`](Self::exit). #[doc(hidden)] pub fn timeout( program: impl Into, @@ -793,16 +636,17 @@ impl Error { stdout: impl Into, stderr: impl Into, ) -> Self { - Error::Timeout { + ErrorKind::Timeout { program: program.into(), timeout, stdout: stdout.into(), stderr: stderr.into(), stdout_bytes: None, } + .into() } - /// Construct a [`Signalled`](Error::Signalled) — see [`exit`](Self::exit). + /// Construct a [`Signalled`](ErrorKind::Signalled) — see [`exit`](Self::exit). #[doc(hidden)] pub fn signalled( program: impl Into, @@ -810,82 +654,384 @@ impl Error { stdout: impl Into, stderr: impl Into, ) -> Self { - Error::Signalled { + ErrorKind::Signalled { program: program.into(), signal, stdout: stdout.into(), stderr: stderr.into(), stdout_bytes: None, } + .into() } - /// Construct a [`Spawn`](Error::Spawn) — see [`exit`](Self::exit). + /// Construct a [`Spawn`](ErrorKind::Spawn) — see [`exit`](Self::exit). #[doc(hidden)] pub fn spawn(program: impl Into, source: std::io::Error) -> Self { - Error::Spawn { + ErrorKind::Spawn { program: program.into(), source, } + .into() } - /// Construct a [`NotFound`](Error::NotFound) — see [`exit`](Self::exit). + /// Construct a [`NotFound`](ErrorKind::NotFound) — see [`exit`](Self::exit). #[doc(hidden)] pub fn not_found(program: impl Into, searched: Option) -> Self { - Error::NotFound { + ErrorKind::NotFound { program: program.into(), searched, } + .into() } - /// Construct a [`Stdin`](Error::Stdin) — see [`exit`](Self::exit). + /// Construct a [`Stdin`](ErrorKind::Stdin) — see [`exit`](Self::exit). #[doc(hidden)] pub fn stdin(program: impl Into, source: std::io::Error) -> Self { - Error::Stdin { + ErrorKind::Stdin { program: program.into(), source, } + .into() } - /// Construct a [`Parse`](Error::Parse) from a caller-supplied parser's own + /// Construct a [`Parse`](ErrorKind::Parse) from a caller-supplied parser's own /// failure message. Unlike `exit`/`timeout`/`signalled`/`spawn`/`not_found`/ /// `stdin` above (`#[doc(hidden)]` insulated constructors meant for test /// doubles), this one is left on the **documented public surface**: an /// external parser that inspects a tool's output outside this crate's own /// `try_parse` helpers has no other way to report a parse failure as an - /// `Error::Parse` once the variant is `#[non_exhaustive]`, and that path is + /// `ErrorKind::Parse` once the kind is `#[non_exhaustive]`, and that path is /// a normal production use, not just a test-doubling convenience. pub fn parse(program: impl Into, message: impl Into) -> Self { - Error::Parse { + ErrorKind::Parse { program: program.into(), message: message.into(), } + .into() } } -/// Manual `Debug`: bounds the [`Exit`](Error::Exit) streams and redacts +/// Wraps a bare [`ErrorKind`] into the boxed [`Error`] every fallible path in +/// the crate returns — the conversion every construction site (and the `?` +/// operator, where a helper returns `Result`) goes through. +impl From for Error { + fn from(kind: ErrorKind) -> Self { + Error { + kind: Box::new(kind), + } + } +} + +/// Delegates to the boxed [`ErrorKind`]'s `Display` (derived by `thiserror`). +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.kind, f) + } +} + +/// Delegates to the boxed [`ErrorKind`]'s `source()` (derived by `thiserror` +/// from each kind's `#[source]` field / `#[error(transparent)]`). +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + std::error::Error::source(&*self.kind) + } +} + +/// Delegates to the boxed [`ErrorKind`]'s manual `Debug` — see there for why +/// it isn't derived. +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&self.kind, f) + } +} + +impl ErrorKind { + /// See [`Error::diagnostic`]. + fn diagnostic(&self) -> Option<&str> { + // Exhaustive on purpose: a future stream-carrying variant must add itself + // here rather than fall through a `_ => None` and be invisible to + // `diagnostic()`. `#[non_exhaustive]` only constrains downstream matches. + match self { + ErrorKind::Exit { stdout, stderr, .. } + | ErrorKind::Timeout { stdout, stderr, .. } + | ErrorKind::Signalled { stdout, stderr, .. } => exit_diagnostic(stdout, stderr), + ErrorKind::Spawn { .. } + | ErrorKind::NotFound { .. } + | ErrorKind::CassetteMiss { .. } + | ErrorKind::OutputTooLarge { .. } + | ErrorKind::NotReady { .. } + | ErrorKind::Parse { .. } + | ErrorKind::Unsupported { .. } + | ErrorKind::Cancelled { .. } + | ErrorKind::Stdin { .. } + | ErrorKind::Io(_) => None, + #[cfg(feature = "limits")] + ErrorKind::ResourceLimit { .. } => None, + } + } + + /// See [`Error::stdout`]. + fn stdout(&self) -> Option<&str> { + self.streams().map(|(stdout, _)| stdout) + } + + /// See [`Error::stderr`]. + fn stderr(&self) -> Option<&str> { + self.streams().map(|(_, stderr)| stderr) + } + + /// See [`Error::stdout_bytes`]. + fn stdout_bytes(&self) -> Option<&[u8]> { + // Exhaustive on purpose (like `streams`): a future stream-carrying + // variant must add itself here, not fall through a `_`. + match self { + ErrorKind::Exit { stdout_bytes, .. } + | ErrorKind::Timeout { stdout_bytes, .. } + | ErrorKind::Signalled { stdout_bytes, .. } => stdout_bytes.as_deref(), + ErrorKind::Spawn { .. } + | ErrorKind::NotFound { .. } + | ErrorKind::CassetteMiss { .. } + | ErrorKind::OutputTooLarge { .. } + | ErrorKind::NotReady { .. } + | ErrorKind::Parse { .. } + | ErrorKind::Unsupported { .. } + | ErrorKind::Cancelled { .. } + | ErrorKind::Stdin { .. } + | ErrorKind::Io(_) => None, + #[cfg(feature = "limits")] + ErrorKind::ResourceLimit { .. } => None, + } + } + + /// See [`Error::combined`]. + fn combined(&self) -> Option { + self.streams() + .map(|(stdout, stderr)| crate::result::combine_streams(stdout, stderr)) + } + + /// The captured `(stdout, stderr)` for the stream-bearing variants + /// ([`Exit`](Self::Exit) / [`Timeout`](Self::Timeout) / + /// [`Signalled`](Self::Signalled)), `None` for the rest — the single + /// exhaustive match the stream accessors above derive from. + fn streams(&self) -> Option<(&str, &str)> { + // Exhaustive on purpose (like `diagnostic`/`io_source`): a future + // stream-carrying variant must add itself here, not fall through a `_`. + match self { + ErrorKind::Exit { stdout, stderr, .. } + | ErrorKind::Timeout { stdout, stderr, .. } + | ErrorKind::Signalled { stdout, stderr, .. } => Some((stdout, stderr)), + ErrorKind::Spawn { .. } + | ErrorKind::NotFound { .. } + | ErrorKind::CassetteMiss { .. } + | ErrorKind::OutputTooLarge { .. } + | ErrorKind::NotReady { .. } + | ErrorKind::Parse { .. } + | ErrorKind::Unsupported { .. } + | ErrorKind::Cancelled { .. } + | ErrorKind::Stdin { .. } + | ErrorKind::Io(_) => None, + #[cfg(feature = "limits")] + ErrorKind::ResourceLimit { .. } => None, + } + } + + /// See [`Error::program`]. + fn program(&self) -> Option<&str> { + // Exhaustive on purpose (like `diagnostic`/`streams`/`io_source`): a future + // program-naming variant must add itself here, not fall through a `_`. + match self { + ErrorKind::Spawn { program, .. } + | ErrorKind::NotFound { program, .. } + | ErrorKind::CassetteMiss { program } + | ErrorKind::Exit { program, .. } + | ErrorKind::Timeout { program, .. } + | ErrorKind::OutputTooLarge { program, .. } + | ErrorKind::NotReady { program, .. } + | ErrorKind::Parse { program, .. } + | ErrorKind::Cancelled { program } + | ErrorKind::Signalled { program, .. } + | ErrorKind::Stdin { program, .. } => Some(program), + ErrorKind::Unsupported { .. } | ErrorKind::Io(_) => None, + #[cfg(feature = "limits")] + ErrorKind::ResourceLimit { .. } => None, + } + } + + /// See [`Error::is_not_found`]. + fn is_not_found(&self) -> bool { + matches!(self, ErrorKind::NotFound { .. }) + } + + /// See [`Error::is_permission_denied`]. + fn is_permission_denied(&self) -> bool { + self.io_source() + .is_some_and(|e| e.kind() == std::io::ErrorKind::PermissionDenied) + } + + /// See [`Error::is_transient`]. + fn is_transient(&self) -> bool { + self.io_source().is_some_and(is_transient_io) + } + + /// See [`Error::code`]. + fn code(&self) -> Option { + // Exhaustive on purpose (like `streams`/`program`): a future exit-code- + // carrying variant must add itself here, not fall through a `_` and be + // silently invisible to `code()`. + match self { + ErrorKind::Exit { code, .. } => Some(*code), + ErrorKind::Spawn { .. } + | ErrorKind::NotFound { .. } + | ErrorKind::CassetteMiss { .. } + | ErrorKind::Timeout { .. } + | ErrorKind::OutputTooLarge { .. } + | ErrorKind::NotReady { .. } + | ErrorKind::Parse { .. } + | ErrorKind::Unsupported { .. } + | ErrorKind::Cancelled { .. } + | ErrorKind::Signalled { .. } + | ErrorKind::Stdin { .. } + | ErrorKind::Io(_) => None, + #[cfg(feature = "limits")] + ErrorKind::ResourceLimit { .. } => None, + } + } + + /// See [`Error::signal`]. + fn signal(&self) -> Option { + // Exhaustive on purpose (like `streams`/`program`): a future signal- + // carrying variant must add itself here, not fall through a `_`. + match self { + ErrorKind::Signalled { signal, .. } => *signal, + ErrorKind::Spawn { .. } + | ErrorKind::NotFound { .. } + | ErrorKind::CassetteMiss { .. } + | ErrorKind::Exit { .. } + | ErrorKind::Timeout { .. } + | ErrorKind::OutputTooLarge { .. } + | ErrorKind::NotReady { .. } + | ErrorKind::Parse { .. } + | ErrorKind::Unsupported { .. } + | ErrorKind::Cancelled { .. } + | ErrorKind::Stdin { .. } + | ErrorKind::Io(_) => None, + #[cfg(feature = "limits")] + ErrorKind::ResourceLimit { .. } => None, + } + } + + /// See [`Error::limit_kind`]. + #[cfg(feature = "limits")] + fn limit_kind(&self) -> Option { + // Exhaustive on purpose (like `signal`/`program`): a future variant + // must add itself here, not fall through a `_`. + match self { + ErrorKind::ResourceLimit { kind, .. } => Some(*kind), + ErrorKind::Spawn { .. } + | ErrorKind::NotFound { .. } + | ErrorKind::CassetteMiss { .. } + | ErrorKind::Exit { .. } + | ErrorKind::Timeout { .. } + | ErrorKind::OutputTooLarge { .. } + | ErrorKind::NotReady { .. } + | ErrorKind::Parse { .. } + | ErrorKind::Unsupported { .. } + | ErrorKind::Cancelled { .. } + | ErrorKind::Signalled { .. } + | ErrorKind::Stdin { .. } + | ErrorKind::Io(_) => None, + } + } + + /// See [`Error::limit_reason`]. + #[cfg(feature = "limits")] + fn limit_reason(&self) -> Option { + // Exhaustive on purpose (like `signal`/`program`): a future variant + // must add itself here, not fall through a `_`. + match self { + ErrorKind::ResourceLimit { reason, .. } => Some(*reason), + ErrorKind::Spawn { .. } + | ErrorKind::NotFound { .. } + | ErrorKind::CassetteMiss { .. } + | ErrorKind::Exit { .. } + | ErrorKind::Timeout { .. } + | ErrorKind::OutputTooLarge { .. } + | ErrorKind::NotReady { .. } + | ErrorKind::Parse { .. } + | ErrorKind::Unsupported { .. } + | ErrorKind::Cancelled { .. } + | ErrorKind::Signalled { .. } + | ErrorKind::Stdin { .. } + | ErrorKind::Io(_) => None, + } + } + + /// See [`Error::is_timeout`]. + fn is_timeout(&self) -> bool { + matches!(self, ErrorKind::Timeout { .. }) + } + + /// See [`Error::is_cancelled`]. + fn is_cancelled(&self) -> bool { + matches!(self, ErrorKind::Cancelled { .. }) + } + + /// See [`Error::is_signalled`]. + fn is_signalled(&self) -> bool { + matches!(self, ErrorKind::Signalled { .. }) + } + + /// The underlying [`std::io::Error`] for the variants that carry one + /// ([`Spawn`](Self::Spawn), [`Io`](Self::Io)) — the basis for the io-level + /// classifiers above. + fn io_source(&self) -> Option<&std::io::Error> { + // Exhaustive on purpose: a future variant carrying an `io::Error` must + // add itself here so the io-level classifiers (`is_transient`, + // `is_permission_denied`) see it, rather than slipping through a wildcard. + match self { + ErrorKind::Spawn { source, .. } => Some(source), + ErrorKind::Io(source) => Some(source), + ErrorKind::NotFound { .. } + | ErrorKind::CassetteMiss { .. } + | ErrorKind::Exit { .. } + | ErrorKind::Timeout { .. } + | ErrorKind::OutputTooLarge { .. } + | ErrorKind::NotReady { .. } + | ErrorKind::Parse { .. } + | ErrorKind::Unsupported { .. } + | ErrorKind::Cancelled { .. } + | ErrorKind::Signalled { .. } + | ErrorKind::Stdin { .. } => None, + #[cfg(feature = "limits")] + ErrorKind::ResourceLimit { .. } => None, + } + } +} + +/// Manual `Debug`: bounds the [`Exit`](ErrorKind::Exit) streams and redacts /// the `PATH` value, so `{e:?}` / `.unwrap()` neither dumps a multi-MiB stream /// nor logs an environment value. Every other variant mirrors what the derive /// would print. -impl fmt::Debug for Error { +impl fmt::Debug for ErrorKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Error::Spawn { program, source } => f + ErrorKind::Spawn { program, source } => f .debug_struct("Spawn") .field("program", program) .field("source", source) .finish(), - Error::NotFound { program, searched } => f + ErrorKind::NotFound { program, searched } => f .debug_struct("NotFound") .field("program", program) // `searched` is the `PATH` env value — never rendered; summarize // as a directory count (`None` renders as `None`). .field("searched", &searched.as_deref().map(SearchedRedaction)) .finish(), - Error::CassetteMiss { program } => f + ErrorKind::CassetteMiss { program } => f .debug_struct("CassetteMiss") .field("program", program) .finish(), - Error::Exit { + ErrorKind::Exit { program, code, stdout, @@ -899,7 +1045,7 @@ impl fmt::Debug for Error { .field("stderr", &StreamPreview(stderr)) .field("stdout_bytes", &BytesPreview(stdout_bytes.as_deref())) .finish(), - Error::Timeout { + ErrorKind::Timeout { program, timeout, stdout, @@ -913,7 +1059,7 @@ impl fmt::Debug for Error { .field("stderr", &StreamPreview(stderr)) .field("stdout_bytes", &BytesPreview(stdout_bytes.as_deref())) .finish(), - Error::OutputTooLarge { + ErrorKind::OutputTooLarge { program, max_lines, max_bytes, @@ -927,19 +1073,19 @@ impl fmt::Debug for Error { .field("total_lines", total_lines) .field("total_bytes", total_bytes) .finish(), - Error::NotReady { program, timeout } => f + ErrorKind::NotReady { program, timeout } => f .debug_struct("NotReady") .field("program", program) .field("timeout", timeout) .finish(), - Error::Parse { program, message } => f + ErrorKind::Parse { program, message } => f .debug_struct("Parse") .field("program", program) // Caller-built, often the full unparsed output — bound it. .field("message", &StreamPreview(message)) .finish(), #[cfg(feature = "limits")] - Error::ResourceLimit { + ErrorKind::ResourceLimit { kind, reason, detail, @@ -952,15 +1098,15 @@ impl fmt::Debug for Error { // is short today. .field("detail", &StreamPreview(detail)) .finish(), - Error::Unsupported { operation } => f + ErrorKind::Unsupported { operation } => f .debug_struct("Unsupported") .field("operation", operation) .finish(), - Error::Cancelled { program } => f + ErrorKind::Cancelled { program } => f .debug_struct("Cancelled") .field("program", program) .finish(), - Error::Signalled { + ErrorKind::Signalled { program, signal, stdout, @@ -974,20 +1120,21 @@ impl fmt::Debug for Error { .field("stderr", &StreamPreview(stderr)) .field("stdout_bytes", &BytesPreview(stdout_bytes.as_deref())) .finish(), - Error::Stdin { program, source } => f + ErrorKind::Stdin { program, source } => f .debug_struct("Stdin") .field("program", program) .field("source", source) .finish(), - Error::Io(source) => f.debug_tuple("Io").field(source).finish(), + ErrorKind::Io(source) => f.debug_tuple("Io").field(source).finish(), } } } /// `Debug` for a captured stream, bounded to a 200-byte char-boundary preview -/// with a `(+N bytes)` note — the [`Exit`](Error::Exit) streams can be multi-MiB -/// and must never flood a `{e:?}` log line or `.unwrap()` panic message. Mirrors -/// the [`Display`](std::fmt::Display) tail cap in [`display_exit`]. +/// with a `(+N bytes)` note — the [`Exit`](ErrorKind::Exit) streams can be +/// multi-MiB and must never flood a `{e:?}` log line or `.unwrap()` panic +/// message. Mirrors the [`Display`](std::fmt::Display) tail cap in +/// [`display_exit`]. pub(crate) struct StreamPreview<'a>(pub(crate) &'a str); impl fmt::Debug for StreamPreview<'_> { @@ -1005,9 +1152,9 @@ impl fmt::Debug for StreamPreview<'_> { } } -/// `Debug` for the `stdout_bytes` field of [`Exit`](Error::Exit) / -/// [`Timeout`](Error::Timeout) / [`Signalled`](Error::Signalled): never dumps -/// the raw bytes (they may be binary, may carry secrets, and can be +/// `Debug` for the `stdout_bytes` field of [`Exit`](ErrorKind::Exit) / +/// [`Timeout`](ErrorKind::Timeout) / [`Signalled`](ErrorKind::Signalled): never +/// dumps the raw bytes (they may be binary, may carry secrets, and can be /// multi-MiB — the same "no unbounded payload in Debug" rule /// [`StreamPreview`] follows for the text streams) — only a length summary /// when present, `None` otherwise. @@ -1022,7 +1169,7 @@ impl fmt::Debug for BytesPreview<'_> { } } -/// `Debug` for [`NotFound`](Error::NotFound)'s `searched`: the `PATH` value is an +/// `Debug` for [`NotFound`](ErrorKind::NotFound)'s `searched`: the `PATH` value is an /// environment value and must never be logged, so it renders only as a directory /// count (``) — never the directories themselves. struct SearchedRedaction<'a>(&'a str); @@ -1049,7 +1196,7 @@ fn display_not_found(program: &str, searched: &Option) -> String { } } -/// Builds the [`Error::Io`] raised when a capture verb is called on a command +/// Builds the [`ErrorKind::Io`] raised when a capture verb is called on a command /// whose stdout was not piped (`Command::stdout` set to `Inherit`/`Null`, or /// redirected to a file). The /// live runner (`RunningProcess::ensure_stdout_capturable`) and both test @@ -1057,7 +1204,7 @@ fn display_not_found(program: &str, searched: &Option) -> String { /// branch) must reject this identically, so they all route through this one /// constructor instead of hand-rolling the message and `ErrorKind`. pub(crate) fn stdout_not_piped_error(program: &str) -> Error { - Error::Io(std::io::Error::new( + ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "`{program}`: stdout is not piped (Command::stdout was set to \ @@ -1065,6 +1212,7 @@ pub(crate) fn stdout_not_piped_error(program: &str) -> Error { use StdioMode::Piped to capture it" ), )) + .into() } /// `Parse`'s one-line `Display`: `` failed to parse `{program}` output: {message} `` @@ -1086,7 +1234,7 @@ fn display_parse(program: &str, message: &str) -> String { /// `ResourceLimit`'s one-line `Display`: `` {kind} limit {reason}: {detail} ``, /// e.g. `` memory limit could not be enforced: enabling cgroup controllers... `` /// or `` CPU limit is invalid: cpu_quota must be a finite value greater than 0 ``. -/// `detail` is bounded/sanitized like [`Parse`](Error::Parse)'s `message` — it +/// `detail` is bounded/sanitized like [`Parse`](ErrorKind::Parse)'s `message` — it /// may embed a raw OS error string, never trusted to be short or clean. #[cfg(feature = "limits")] fn display_resource_limit( @@ -1204,7 +1352,7 @@ const DIAG_CAP: usize = 200; /// budget counts only `text`, never what `out` already holds. The single /// sanitize-and-cap loop shared by the `Display` paths that embed /// attacker-influenced text — the diagnostic tail ([`append_diagnostic_tail`]) -/// and the [`Parse`](Error::Parse) message head ([`display_parse`]) — so the +/// and the [`Parse`](ErrorKind::Parse) message head ([`display_parse`]) — so the /// control-/bidi-injection defense and the cap can't drift apart. fn push_sanitized_capped(out: &mut String, text: &str, cap: usize) { let mut written = 0usize; @@ -1301,7 +1449,7 @@ mod tests { assert_eq!(only_err.combined().as_deref(), Some("err")); // Non-stream variants carry no streams. - let not_ready = Error::NotReady { + let not_ready = ErrorKind::NotReady { program: "server".into(), timeout: Duration::from_secs(1), }; @@ -1335,7 +1483,7 @@ mod tests { assert!(unknown_sig.is_signalled()); assert!(!exit.is_signalled() && !timeout.is_signalled()); - let cancelled = Error::Cancelled { + let cancelled = ErrorKind::Cancelled { program: "job".into(), }; assert!(cancelled.is_cancelled()); @@ -1349,7 +1497,7 @@ mod tests { // (Unsupported, Io) return None. assert_eq!(Error::exit("git", 1, "", "").program(), Some("git")); assert_eq!( - Error::NotReady { + ErrorKind::NotReady { program: "server".into(), timeout: Duration::from_secs(1), } @@ -1357,26 +1505,26 @@ mod tests { Some("server") ); assert_eq!( - Error::Cancelled { + ErrorKind::Cancelled { program: "job".into() } .program(), Some("job") ); assert_eq!( - Error::Unsupported { + ErrorKind::Unsupported { operation: "suspend".into() } .program(), None ); assert_eq!( - Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied)).program(), + ErrorKind::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied)).program(), None ); #[cfg(feature = "limits")] assert_eq!( - Error::ResourceLimit { + ErrorKind::ResourceLimit { kind: crate::limits::LimitKind::Memory, reason: crate::limits::LimitReason::Unsupported, detail: "no container".into() @@ -1391,15 +1539,15 @@ mod tests { // The constructors insulate doubles/tests from a future field addition to // these variants; round-trip through the accessors confirms the variant. assert!(matches!( - Error::exit("git", 2, "o", "e"), - Error::Exit { code: 2, .. } + Error::exit("git", 2, "o", "e").kind(), + ErrorKind::Exit { code: 2, .. } )); assert!(matches!( - Error::timeout("git", Duration::from_secs(3), "o", "e"), - Error::Timeout { .. } + Error::timeout("git", Duration::from_secs(3), "o", "e").kind(), + ErrorKind::Timeout { .. } )); - match Error::signalled("git", None, "o", "e") { - Error::Signalled { signal, .. } => assert_eq!(signal, None), + match Error::signalled("git", None, "o", "e").kind() { + ErrorKind::Signalled { signal, .. } => assert_eq!(*signal, None), other => panic!("expected Signalled, got {other:?}"), } } @@ -1409,7 +1557,7 @@ mod tests { // A hostile child's stderr last line carrying ANSI/BEL/NUL must not reach // a `{err}` log/terminal verbatim — control bytes become U+FFFD, while // printable text survives. - let err = Error::Exit { + let err = ErrorKind::Exit { program: "tool".into(), code: 1, stdout: String::new(), @@ -1428,7 +1576,7 @@ mod tests { fn display_tail_strips_bidi_controls_against_trojan_source() { // A hostile stderr last line carrying bidi-override controls // (CVE-2021-42574) must not reach a `{err}` line and visually reorder it. - let err = Error::Exit { + let err = ErrorKind::Exit { program: "tool".into(), code: 1, stdout: String::new(), @@ -1447,7 +1595,7 @@ mod tests { // U+2028 / U+2029 are NOT `char::is_control()`, yet terminals and log // viewers render them as a newline — a hostile last line carrying them // must not inject a break into the one-line `{err}` render. - let err = Error::Exit { + let err = ErrorKind::Exit { program: "tool".into(), code: 1, stdout: String::new(), @@ -1466,7 +1614,7 @@ mod tests { // `Parse` messages routinely embed attacker-influenced unparsed output; // the one-line Display must neutralize control AND bidi controls, not // just truncate. - let err = Error::Parse { + let err = ErrorKind::Parse { program: "jq".into(), message: "bad\x1b[31m\x07token\u{202E}flip\u{2069}sep\u{2028}end".into(), }; @@ -1485,7 +1633,7 @@ mod tests { // A derived Debug would dump both full streams into `{e:?}` / // `.unwrap()`. The manual Debug bounds each to a 200-byte preview. let huge = "x".repeat(10_000); - let err = Error::Exit { + let err = ErrorKind::Exit { program: "tool".into(), code: 1, stdout: huge.clone(), @@ -1505,7 +1653,7 @@ mod tests { // The bounded preview is still present and marked as truncated. assert!(dbg.contains("(+9800 bytes)"), "got: {dbg}"); // A short stream is shown verbatim (no truncation note). - let small = Error::Exit { + let small = ErrorKind::Exit { program: "tool".into(), code: 2, stdout: "hello".into(), @@ -1578,7 +1726,7 @@ mod tests { // `stdout_bytes` may carry the same multi-MiB payload as `stdout` (just // pre-decode) — Debug must summarize its length, never dump the bytes. let huge_bytes = vec![b'y'; 10_000]; - let err = Error::Exit { + let err = ErrorKind::Exit { program: "tool".into(), code: 1, stdout: String::from_utf8_lossy(&huge_bytes).into_owned(), @@ -1595,7 +1743,7 @@ mod tests { "must not dump the raw bytes: {dbg}" ); - let none_err = Error::Exit { + let none_err = ErrorKind::Exit { program: "tool".into(), code: 1, stdout: String::new(), @@ -1610,7 +1758,7 @@ mod tests { fn debug_redacts_the_path_value_in_not_found() { // `searched` is the PATH env value and must never appear in Debug // (which feeds `{e:?}` logs and `.unwrap()` panics). - let err = Error::NotFound { + let err = ErrorKind::NotFound { program: "tool".into(), searched: Some("/secret/bin:/another/private/dir".into()), }; @@ -1629,7 +1777,7 @@ mod tests { fn exit_display_appends_a_bounded_diagnostic_tail() { // The Display stays one actionable line — program + code + the LAST // non-empty diagnostic line — never the full captured streams. - let err = Error::Exit { + let err = ErrorKind::Exit { program: "git".into(), code: 2, stdout: "CONFLICT (content): merge conflict in a.rs".into(), @@ -1639,7 +1787,7 @@ mod tests { assert_eq!(err.to_string(), "`git` exited with code 2: fatal: boom"); // stderr blank → the stdout-borne message (git's CONFLICT) is used. - let err = Error::Exit { + let err = ErrorKind::Exit { program: "git".into(), code: 2, stdout: "CONFLICT (content): merge conflict in a.rs".into(), @@ -1654,7 +1802,7 @@ mod tests { #[test] fn exit_display_with_blank_streams_has_no_trailing_colon() { - let err = Error::Exit { + let err = ErrorKind::Exit { program: "git".into(), code: 2, stdout: String::new(), @@ -1669,7 +1817,7 @@ mod tests { // A multi-KiB single-line stderr must not poison the log line: the // tail is cut at 200 bytes on a char boundary, with an ellipsis. let huge = "é".repeat(3000); // 2 bytes/char exercises the boundary - let err = Error::Exit { + let err = ErrorKind::Exit { program: "x".into(), code: 1, stdout: String::new(), @@ -1686,7 +1834,7 @@ mod tests { fn diagnostic_is_none_for_non_exit_variants() { // A timeout that captured nothing has no diagnostic (streams-bearing // case covered in `timeout_and_signalled_carry_diagnostic_streams`). - let timeout = Error::Timeout { + let timeout = ErrorKind::Timeout { program: "git".into(), timeout: Duration::from_secs(1), stdout: String::new(), @@ -1694,24 +1842,24 @@ mod tests { stdout_bytes: None, }; assert_eq!(timeout.diagnostic(), None); - let unsupported = Error::Unsupported { + let unsupported = ErrorKind::Unsupported { operation: "suspend".into(), }; assert_eq!(unsupported.diagnostic(), None); - let not_ready = Error::NotReady { + let not_ready = ErrorKind::NotReady { program: "server".into(), timeout: Duration::from_secs(10), }; assert_eq!(not_ready.diagnostic(), None); { - let cancelled = Error::Cancelled { + let cancelled = ErrorKind::Cancelled { program: "job".into(), }; assert_eq!(cancelled.diagnostic(), None); } #[cfg(feature = "limits")] { - let limit = Error::ResourceLimit { + let limit = ErrorKind::ResourceLimit { kind: crate::limits::LimitKind::Memory, reason: crate::limits::LimitReason::Unenforceable, detail: "cgroup controller delegation unavailable".into(), @@ -1722,7 +1870,7 @@ mod tests { #[test] fn cancelled_display_names_the_program() { - let err = Error::Cancelled { + let err = ErrorKind::Cancelled { program: "long-job".into(), }; assert_eq!(err.to_string(), "`long-job` was cancelled"); @@ -1734,7 +1882,7 @@ mod tests { fn timeout_and_signalled_carry_diagnostic_streams() { // A hung-then-killed tool's partial stderr is the explanation — // reachable via diagnostic(), and its last line tails the Display. - let timeout = Error::Timeout { + let timeout = ErrorKind::Timeout { program: "db-migrate".into(), timeout: Duration::from_secs(30), stdout: String::new(), @@ -1751,7 +1899,7 @@ mod tests { ); // stderr blank → the stdout-borne message is used (mirrors Exit). - let signalled = Error::Signalled { + let signalled = ErrorKind::Signalled { program: "worker".into(), signal: Some(11), stdout: "processing batch 7\n".into(), @@ -1770,7 +1918,7 @@ mod tests { // Captured streams must be bounded in Debug, exactly like Exit — a // multi-MiB partial capture must never flood `{e:?}`. let huge = "x".repeat(10_000); - let timeout = Error::Timeout { + let timeout = ErrorKind::Timeout { program: "t".into(), timeout: Duration::from_secs(1), stdout: huge.clone(), @@ -1782,7 +1930,7 @@ mod tests { assert!(!dbg.contains(&"x".repeat(300)), "must not dump the stream"); assert!(dbg.contains("(+9800 bytes)"), "got: {dbg}"); - let signalled = Error::Signalled { + let signalled = ErrorKind::Signalled { program: "s".into(), signal: None, stdout: huge.clone(), @@ -1800,7 +1948,7 @@ mod tests { // unparsed output — it must be bounded like the `Exit` streams, never // dumped whole into a `{e}` log line or a `{e:?}` panic message. let huge = "x".repeat(10_000); - let err = Error::Parse { + let err = ErrorKind::Parse { program: "jq".into(), message: huge, }; @@ -1828,7 +1976,7 @@ mod tests { assert!(dbg.contains("bytes)"), "truncation note present: {dbg}"); // A short message is shown verbatim (no truncation, no ellipsis). - let small = Error::Parse { + let small = ErrorKind::Parse { program: "jq".into(), message: "unexpected token at line 3".into(), }; @@ -1841,7 +1989,7 @@ mod tests { #[test] fn not_ready_display_names_program_and_timeout() { - let err = Error::NotReady { + let err = ErrorKind::NotReady { program: "my-server".into(), timeout: Duration::from_secs(10), }; @@ -1850,7 +1998,7 @@ mod tests { #[test] fn unsupported_display_names_the_operation() { - let err = Error::Unsupported { + let err = ErrorKind::Unsupported { operation: "signal(Hup)".into(), }; assert_eq!( @@ -1864,7 +2012,7 @@ mod tests { fn resource_limit_display_carries_kind_and_reason() { use crate::limits::{LimitKind, LimitReason}; - let unsupported = Error::ResourceLimit { + let unsupported = ErrorKind::ResourceLimit { kind: LimitKind::Memory, reason: LimitReason::Unsupported, detail: "no cgroup or Job Object available".into(), @@ -1874,7 +2022,7 @@ mod tests { "memory limit is not supported on this platform: no cgroup or Job Object available" ); - let unenforceable = Error::ResourceLimit { + let unenforceable = ErrorKind::ResourceLimit { kind: LimitKind::Cpu, reason: LimitReason::Unenforceable, detail: "delegation unavailable".into(), @@ -1884,7 +2032,7 @@ mod tests { "CPU limit could not be enforced: delegation unavailable" ); - let invalid = Error::ResourceLimit { + let invalid = ErrorKind::ResourceLimit { kind: LimitKind::Processes, reason: LimitReason::Invalid, detail: "max_processes must be greater than 0".into(), @@ -1895,7 +2043,7 @@ mod tests { ); // A blank detail omits the trailing colon. - let no_detail = Error::ResourceLimit { + let no_detail = ErrorKind::ResourceLimit { kind: LimitKind::Memory, reason: LimitReason::Unsupported, detail: String::new(), @@ -1911,7 +2059,7 @@ mod tests { fn resource_limit_accessors_read_kind_and_reason_without_destructuring() { use crate::limits::{LimitKind, LimitReason}; - let err = Error::ResourceLimit { + let err = ErrorKind::ResourceLimit { kind: LimitKind::Cpu, reason: LimitReason::Invalid, detail: "boom".into(), @@ -1927,7 +2075,7 @@ mod tests { #[test] fn signalled_display_and_diagnostic() { - let with_signal = Error::Signalled { + let with_signal = ErrorKind::Signalled { program: "git".into(), signal: Some(9), stdout: String::new(), @@ -1940,7 +2088,7 @@ mod tests { assert!(!with_signal.is_permission_denied()); assert!(!with_signal.is_transient()); - let no_signal = Error::Signalled { + let no_signal = ErrorKind::Signalled { program: "git".into(), signal: None, stdout: String::new(), @@ -1952,7 +2100,7 @@ mod tests { #[test] fn not_found_display_and_classifier() { - let err = Error::NotFound { + let err = ErrorKind::NotFound { program: "my-tool".into(), searched: Some("/usr/bin:/usr/local/bin".into()), }; @@ -1975,14 +2123,14 @@ mod tests { // A path-form program (or a customized PATH) is `NotFound` with // `searched: None` — no PATH lookup happened, so the message must not // claim "on PATH". Still `is_not_found()`. - let err = Error::NotFound { + let err = ErrorKind::NotFound { program: "/no/such/tool".into(), searched: None, }; assert_eq!(err.to_string(), "`/no/such/tool` not found"); assert!(err.is_not_found()); // The bare-name case (a real PATH search) still says "on PATH". - let bare = Error::NotFound { + let bare = ErrorKind::NotFound { program: "tool".into(), searched: Some("/usr/bin".into()), }; @@ -1990,10 +2138,11 @@ mod tests { } fn spawn(kind: std::io::ErrorKind) -> Error { - Error::Spawn { + ErrorKind::Spawn { program: "x".into(), source: std::io::Error::from(kind), } + .into() } #[test] @@ -2003,14 +2152,14 @@ mod tests { // `Spawn`/`Io` carrying a `NotFound` io kind (e.g. a bad cwd) is not a // missing program, so the "not installed?" hint can't misfire. assert!( - Error::NotFound { + ErrorKind::NotFound { program: "x".into(), searched: None, } .is_not_found() ); assert!(!spawn(NotFound).is_not_found()); - assert!(!Error::Io(std::io::Error::from(NotFound)).is_not_found()); + assert!(!ErrorKind::Io(std::io::Error::from(NotFound)).is_not_found()); assert!(!spawn(NotFound).is_permission_denied()); assert!(spawn(PermissionDenied).is_permission_denied()); @@ -2032,7 +2181,7 @@ mod tests { ] { assert!(spawn(kind).is_transient(), "{kind:?} should be transient"); assert!( - Error::Io(std::io::Error::from(kind)).is_transient(), + ErrorKind::Io(std::io::Error::from(kind)).is_transient(), "{kind:?} (Io) should be transient" ); } @@ -2041,7 +2190,7 @@ mod tests { #[cfg(unix)] #[test] fn etxtbsy_is_transient_on_unix() { - let err = Error::Spawn { + let err = ErrorKind::Spawn { program: "busy".into(), source: std::io::Error::from_raw_os_error(libc::ETXTBSY), }; @@ -2053,7 +2202,7 @@ mod tests { #[test] fn sharing_and_lock_violations_are_transient_on_windows() { for code in [32, 33] { - let err = Error::Spawn { + let err = ErrorKind::Spawn { program: "locked".into(), source: std::io::Error::from_raw_os_error(code), }; @@ -2068,7 +2217,7 @@ mod tests { fn classifiers_are_false_for_non_io_variants() { // A tool's non-zero exit is never an io-level classification (its // retryability is the caller's domain), and Timeout is excluded too. - let exit = Error::Exit { + let exit = ErrorKind::Exit { program: "git".into(), code: 128, stdout: String::new(), @@ -2076,7 +2225,7 @@ mod tests { stdout_bytes: None, }; assert!(!exit.is_not_found() && !exit.is_permission_denied() && !exit.is_transient()); - let timeout = Error::Timeout { + let timeout = ErrorKind::Timeout { program: "x".into(), timeout: Duration::from_secs(1), stdout: String::new(), @@ -2092,34 +2241,34 @@ mod tests { #[test] fn spawn_not_found_stdin_and_parse_constructors_build_the_expected_variant() { let spawn = Error::spawn("git", std::io::Error::from_raw_os_error(2)); - match spawn { - Error::Spawn { program, source } => { + match spawn.kind() { + ErrorKind::Spawn { program, source } => { assert_eq!(program, "git"); assert_eq!(source.raw_os_error(), Some(2)); } - other => panic!("expected Error::Spawn, got {other:?}"), + other => panic!("expected ErrorKind::Spawn, got {other:?}"), } let not_found = Error::not_found("my-tool", Some("/usr/bin".into())); assert!(matches!( - ¬_found, - Error::NotFound { program, searched } + not_found.kind(), + ErrorKind::NotFound { program, searched } if program == "my-tool" && searched.as_deref() == Some("/usr/bin") )); let stdin = Error::stdin("git", std::io::Error::from_raw_os_error(32)); - match stdin { - Error::Stdin { program, source } => { + match stdin.kind() { + ErrorKind::Stdin { program, source } => { assert_eq!(program, "git"); assert_eq!(source.raw_os_error(), Some(32)); } - other => panic!("expected Error::Stdin, got {other:?}"), + other => panic!("expected ErrorKind::Stdin, got {other:?}"), } let parse = Error::parse("git", "unexpected token"); assert!(matches!( - &parse, - Error::Parse { program, message } + parse.kind(), + ErrorKind::Parse { program, message } if program == "git" && message == "unexpected token" )); } diff --git a/src/group.rs b/src/group.rs index ed9d479..dd2ae63 100644 --- a/src/group.rs +++ b/src/group.rs @@ -4,7 +4,7 @@ use std::time::Duration; use tokio::process::{Child, Command}; -use crate::error::{Error, Result}; +use crate::error::{Error, ErrorKind, Result}; #[cfg(feature = "limits")] use crate::limits::{LimitKind, LimitReason, ResourceLimits}; use crate::mechanism::Mechanism; @@ -138,7 +138,7 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Io`] if the OS rejects creating the group's containment primitive + /// [`ErrorKind::Io`] if the OS rejects creating the group's containment primitive /// (a Job Object on Windows, a cgroup on Linux). The default options set no /// resource caps, so no limit-enforcement failure can arise. pub fn new() -> Result { @@ -149,7 +149,7 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Io`] if the OS rejects creating the group's containment primitive. + /// [`ErrorKind::Io`] if the OS rejects creating the group's containment primitive. #[cfg_attr( feature = "limits", doc = "", @@ -157,7 +157,7 @@ impl ProcessGroup { doc = "now. When the active mechanism can't honor a requested limit (no", doc = "cgroup/Job Object, or a Linux cgroup whose controllers can't be enabled —", doc = "see [`ResourceLimits`] for the cgroup-v2 real-root requirement) this", - doc = "returns [`Error::ResourceLimit`] — rather than handing back an unbounded", + doc = "returns [`ErrorKind::ResourceLimit`] — rather than handing back an unbounded", doc = "group — and an invalid cap value returns it too, with", doc = "[`LimitReason::Invalid`](crate::LimitReason::Invalid)." )] @@ -168,7 +168,7 @@ impl ProcessGroup { Job::new(&options.limits).map_err(|source| { if options.limits.any() { // A real signal from the backend, not a guess: every - // backend reports `ErrorKind::Unsupported` exactly when no + // backend reports `io::ErrorKind::Unsupported` exactly when no // whole-tree container mechanism exists at all on this // platform (macOS/BSD's POSIX-only fallback, a Linux host // with no cgroup v2 mounted) — the same convention @@ -182,18 +182,18 @@ impl ProcessGroup { } else { LimitReason::Unenforceable }; - Error::ResourceLimit { + ErrorKind::ResourceLimit { kind: first_requested_kind(&options.limits), reason, detail: source.to_string(), } } else { - Error::Io(source) + ErrorKind::Io(source) } })? }; #[cfg(not(feature = "limits"))] - let job = Job::new().map_err(Error::Io)?; + let job = Job::new().map_err(ErrorKind::Io)?; Ok(Self { job, options }) } @@ -228,10 +228,10 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Spawn`] if the OS refuses to start `cmd` — the working directory + /// [`ErrorKind::Spawn`] if the OS refuses to start `cmd` — the working directory /// is bad, permission is denied, and so on. (This raw path reports every - /// launch failure as [`Error::Spawn`]; the `Command`-driven run helpers, by - /// contrast, translate a not-found program into [`Error::NotFound`].) + /// launch failure as [`ErrorKind::Spawn`]; the `Command`-driven run helpers, by + /// contrast, translate a not-found program into [`ErrorKind::NotFound`].) pub fn spawn(&self, mut cmd: Command) -> Result { self.spawn_with_options(&mut cmd, &crate::sys::SpawnOptions::default()) } @@ -244,10 +244,13 @@ impl ProcessGroup { cmd: &mut Command, opts: &crate::sys::SpawnOptions, ) -> Result { - let child = self.job.spawn(cmd, opts).map_err(|source| Error::Spawn { - program: program_name(cmd), - source, - })?; + let child = self + .job + .spawn(cmd, opts) + .map_err(|source| ErrorKind::Spawn { + program: program_name(cmd), + source, + })?; Ok(child) } @@ -282,12 +285,12 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Io`] if `child` has already been reaped (awaited), leaving no + /// [`ErrorKind::Io`] if `child` has already been reaped (awaited), leaving no /// live handle/pid to reference. Adopting an exited-but-unreaped child is a /// successful no-op. #[cfg(feature = "process-control")] pub fn adopt(&self, child: &Child) -> Result<()> { - self.job.adopt(child).map_err(Error::Io)?; + self.job.adopt(child).map_err(ErrorKind::Io)?; #[cfg(feature = "tracing")] tracing::trace!( target: "processkit", @@ -329,7 +332,7 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Io`] in two cases, both on the non-atomic Unix backends: the legacy + /// [`ErrorKind::Io`] in two cases, both on the non-atomic Unix backends: the legacy /// per-pid kill fallback (a pre-5.14 Linux kernel without `cgroup.kill`) when /// the tree won't drain within the bounded sweep, and the process-group /// mechanism (macOS/Linux fallback) when a live, non-zombie member rejects @@ -342,7 +345,7 @@ impl ProcessGroup { mechanism = ?self.mechanism(), "hard-killing every process in the group" ); - self.job.kill_all().map_err(Error::Io)?; + self.job.kill_all().map_err(ErrorKind::Io)?; Ok(()) } @@ -361,11 +364,11 @@ impl ProcessGroup { /// [`Command::windows_graceful_ctrl_break`](crate::Command::windows_graceful_ctrl_break), /// plus `WM_CLOSE` to every top-level window owned by a live member (an /// Electron app, a desktop tool, a windowed service). This TRIGGERS a clean - /// exit without waiting or escalating. It returns [`Error::Unsupported`] only + /// exit without waiting or escalating. It returns [`ErrorKind::Unsupported`] only /// when the group has **neither** a console-CTRL leader **nor** a windowed /// member (nothing a soft close could reach); every other signal /// ([`Signal::Hup`], [`Signal::Quit`], [`Signal::Usr1`], [`Signal::Usr2`], - /// [`Signal::Other`]) is always [`Error::Unsupported`]. + /// [`Signal::Other`]) is always [`ErrorKind::Unsupported`]. /// /// `SIGKILL` ([`Signal::Kill`], or `Other(libc::SIGKILL)`) is routed through /// the same whole-tree hard kill as [`kill_all`](Self::kill_all) @@ -385,7 +388,7 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Unsupported`] on Windows for [`Signal::Int`] / [`Signal::Term`] + /// [`ErrorKind::Unsupported`] on Windows for [`Signal::Int`] / [`Signal::Term`] /// only when the group has no console-CTRL leader and no windowed member (see /// Platform support above), and for every other non-[`Kill`](Signal::Kill) /// signal unconditionally (a Job Object has no POSIX signals). On the Linux @@ -452,8 +455,8 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Unsupported`] if the active mechanism cannot freeze the tree; - /// otherwise [`Error::Io`] if the OS rejects the freeze / `SIGSTOP`. + /// [`ErrorKind::Unsupported`] if the active mechanism cannot freeze the tree; + /// otherwise [`ErrorKind::Io`] if the OS rejects the freeze / `SIGSTOP`. #[cfg(feature = "process-control")] pub fn suspend(&self) -> Result<()> { self.job @@ -468,8 +471,8 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Unsupported`] if the active mechanism cannot thaw the tree; - /// otherwise [`Error::Io`] if the OS rejects the resume / `SIGCONT`. + /// [`ErrorKind::Unsupported`] if the active mechanism cannot thaw the tree; + /// otherwise [`ErrorKind::Io`] if the OS rejects the resume / `SIGCONT`. #[cfg(feature = "process-control")] pub fn resume(&self) -> Result<()> { self.job @@ -497,11 +500,11 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Io`] if the group's membership cannot be read (e.g. a failed + /// [`ErrorKind::Io`] if the group's membership cannot be read (e.g. a failed /// `cgroup.procs` read or Job Object query). #[cfg(feature = "process-control")] pub fn members(&self) -> Result> { - let pids = self.job.members().map_err(Error::Io)?; + let pids = self.job.members().map_err(ErrorKind::Io)?; Ok(pids) } @@ -548,13 +551,13 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Io`] only if the group's membership cannot be read (the same + /// [`ErrorKind::Io`] only if the group's membership cannot be read (the same /// failure as [`members`](Self::members) — a failed `cgroup.procs` read or Job /// Object query) or, on Windows, if the process-metadata snapshot cannot be /// created at all. A single member vanishing is not an error (it is skipped). #[cfg(feature = "process-control")] pub fn members_info(&self) -> Result> { - let infos = self.job.members_info().map_err(Error::Io)?; + let infos = self.job.members_info().map_err(ErrorKind::Io)?; Ok(infos) } @@ -591,7 +594,7 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Io`] if the graceful teardown fails — including, when + /// [`ErrorKind::Io`] if the graceful teardown fails — including, when /// `escalate_to_kill` performs the final hard kill, the same failures as /// [`kill_all`](Self::kill_all): the undrained-tree failure on the legacy /// pre-5.14 per-pid fallback, and a process-group member that rejects `SIGKILL` @@ -626,7 +629,7 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Io`] if the graceful teardown fails (see + /// [`ErrorKind::Io`] if the graceful teardown fails (see /// [`shutdown`](Self::shutdown) — the same undrained-tree failure on the legacy /// per-pid fallback and the process-group live-`EPERM` on the final hard kill /// apply). @@ -646,7 +649,7 @@ impl ProcessGroup { self.options.escalate_to_kill, ) .await - .map_err(Error::Io)?; + .map_err(ErrorKind::Io)?; Ok(()) } @@ -659,7 +662,7 @@ impl ProcessGroup { self.job .graceful_shutdown(signal, grace, true) .await - .map_err(Error::Io)?; + .map_err(ErrorKind::Io)?; Ok(()) } @@ -669,10 +672,10 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::Io`] if the platform's resource query fails. + /// [`ErrorKind::Io`] if the platform's resource query fails. #[cfg(feature = "stats")] pub fn stats(&self) -> Result { - let stats = self.job.stats().map_err(Error::Io)?; + let stats = self.job.stats().map_err(ErrorKind::Io)?; Ok(stats) } @@ -707,7 +710,7 @@ impl ProcessGroup { /// written back to `max`). On the **POSIX process-group** mechanism (macOS, the /// BSDs, and the Linux fallback with no usable cgroup) there is no whole-tree /// cap primitive, so a request carrying **any** cap is refused with - /// [`Error::ResourceLimit`] — never silently dropped — while an all-`None` + /// [`ErrorKind::ResourceLimit`] — never silently dropped — while an all-`None` /// request (lift every cap) is a trivial success, since the tree is already /// unbounded there. /// @@ -723,7 +726,7 @@ impl ProcessGroup { /// /// # Errors /// - /// [`Error::ResourceLimit`] — with [`LimitReason::Invalid`] for a nonsensical + /// [`ErrorKind::ResourceLimit`] — with [`LimitReason::Invalid`] for a nonsensical /// value (rejected by the shared `validate_limits` before the OS is touched, /// exactly as at creation), [`LimitReason::Unsupported`] when the active /// mechanism has no whole-tree accounting at all (a process-group mechanism), @@ -775,48 +778,52 @@ fn program_name(cmd: &Command) -> String { cmd.as_std().get_program().to_string_lossy().into_owned() } -/// Map a backend `ErrorKind::Unsupported` to the typed [`Error::Unsupported`], +/// Map a backend `io::ErrorKind::Unsupported` to the typed [`ErrorKind::Unsupported`], /// passing every other IO failure through unchanged. Unambiguous here: on the /// signal/suspend/resume paths the only producer of `Unsupported` is the /// backends' own "this platform can't do that" reporting. #[cfg(feature = "process-control")] fn map_unsupported(source: std::io::Error, operation: impl Into) -> Error { if source.kind() == std::io::ErrorKind::Unsupported { - Error::Unsupported { + ErrorKind::Unsupported { operation: operation.into(), } + .into() } else { - Error::Io(source) + ErrorKind::Io(source).into() } } /// Reject nonsensical limit values before touching the OS, so a typo surfaces as a -/// clear [`Error::ResourceLimit`] (`reason: Invalid`) rather than an opaque +/// clear [`ErrorKind::ResourceLimit`] (`reason: Invalid`) rather than an opaque /// kernel error. #[cfg(feature = "limits")] fn validate_limits(limits: &ResourceLimits) -> Result<()> { if limits.max_memory == Some(0) { - return Err(Error::ResourceLimit { + return Err(ErrorKind::ResourceLimit { kind: LimitKind::Memory, reason: LimitReason::Invalid, detail: "max_memory must be greater than 0".into(), - }); + } + .into()); } if limits.max_processes == Some(0) { - return Err(Error::ResourceLimit { + return Err(ErrorKind::ResourceLimit { kind: LimitKind::Processes, reason: LimitReason::Invalid, detail: "max_processes must be greater than 0".into(), - }); + } + .into()); } if let Some(cores) = limits.cpu_quota && !(cores.is_finite() && cores > 0.0) { - return Err(Error::ResourceLimit { + return Err(ErrorKind::ResourceLimit { kind: LimitKind::Cpu, reason: LimitReason::Invalid, detail: "cpu_quota must be a finite value greater than 0".into(), - }); + } + .into()); } Ok(()) } @@ -890,15 +897,15 @@ mod tests { ] { // `validate_limits` classifies as `Invalid` with the specific // field that failed — never a guess, and never touching the OS. - match validate_limits(&opts.limits) { - Err(Error::ResourceLimit { kind, reason, .. }) => { + match validate_limits(&opts.limits).map_err(Error::into_kind) { + Err(ErrorKind::ResourceLimit { kind, reason, .. }) => { assert_eq!(kind, expected_kind); assert_eq!(reason, LimitReason::Invalid); } other => panic!("expected ResourceLimit, got {other:?}"), } - match ProcessGroup::with_options(opts) { - Err(Error::ResourceLimit { kind, reason, .. }) => { + match ProcessGroup::with_options(opts).map_err(Error::into_kind) { + Err(ErrorKind::ResourceLimit { kind, reason, .. }) => { assert_eq!(kind, expected_kind); assert_eq!(reason, LimitReason::Invalid); } diff --git a/src/lib.rs b/src/lib.rs index 5ed6261..0d2c9f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,7 +42,7 @@ //! chain-wide teardown fanning the kill across every sub-group, pipefail //! outcome. [`Command::cancel_on`] ties a run to a //! [`CancellationToken`]: cancelling it kills the tree and every consuming -//! path resolves to [`Error::Cancelled`]. Spawn-time sandboxing knobs: +//! path resolves to [`ErrorKind::Cancelled`]. Spawn-time sandboxing knobs: //! [`Command::inherit_env`] (env allow-list), [`Command::uid`] / //! [`Command::gid`] (Unix privilege drop), [`Command::setsid`], //! [`Command::create_no_window`], [`Command::priority`] (CPU-scheduling @@ -118,7 +118,7 @@ //! members_info, adopt}`, plus the enriched `MemberInfo` member snapshot. //! - **`limits`** — whole-tree resource caps: `ResourceLimits`, the //! `max_memory`/`max_processes`/`cpu_quota` builders on -//! [`ProcessGroupOptions`], and `Error::ResourceLimit`. Implies `stats`. +//! [`ProcessGroupOptions`], and `ErrorKind::ResourceLimit`. Implies `stats`. //! - **`mock`** — the `mockall`-generated `testing::MockRunner` for //! consumers' tests. Its //! `expect_*` surface is generated by `mockall` and is **exempt from this @@ -220,7 +220,7 @@ pub use batch::{output_all, output_all_bytes}; pub use buffer::{LineTerminator, OutputBufferPolicy, OverflowMode, StdioMode}; pub use client::{CliClient, IntoCommand}; pub use command::Command; -pub use error::{Error, Result}; +pub use error::{Error, ErrorKind, Result}; pub use group::{ProcessGroup, ProcessGroupOptions}; #[cfg(feature = "limits")] pub use limits::{LimitKind, LimitReason, ResourceLimits}; @@ -264,10 +264,10 @@ use std::ffi::OsStr; /// /// # Errors /// -/// The same surface as [`Command::run`]: a launch failure ([`Error::NotFound`] / -/// [`Error::Spawn`] / [`Error::Unsupported`] / [`Error::Io`]), a non-accepted -/// exit ([`Error::Exit`]), [`Error::Signalled`], [`Error::Timeout`], or -/// [`Error::OutputTooLarge`] on a fail-loud truncation. +/// The same surface as [`Command::run`]: a launch failure ([`ErrorKind::NotFound`] / +/// [`ErrorKind::Spawn`] / [`ErrorKind::Unsupported`] / [`ErrorKind::Io`]), a non-accepted +/// exit ([`ErrorKind::Exit`]), [`ErrorKind::Signalled`], [`ErrorKind::Timeout`], or +/// [`ErrorKind::OutputTooLarge`] on a fail-loud truncation. pub async fn run(program: impl AsRef, args: I) -> Result where I: IntoIterator, @@ -283,8 +283,8 @@ where /// /// The same surface as [`Command::output_string`]: a non-zero exit, a timeout, /// and a signal-kill are *captured* in the returned [`ProcessResult`], not -/// raised; beyond a launch failure, only [`Error::Cancelled`], -/// [`Error::OutputTooLarge`], [`Error::Stdin`], and [`Error::Io`] surface. +/// raised; beyond a launch failure, only [`ErrorKind::Cancelled`], +/// [`ErrorKind::OutputTooLarge`], [`ErrorKind::Stdin`], and [`ErrorKind::Io`] surface. pub async fn output_string( program: impl AsRef, args: I, @@ -314,7 +314,7 @@ where /// `stat`s); no async runtime is required. /// /// **The reverse holds on Unix, not fully on Windows.** A `which` miss is the -/// exact [`Error::NotFound`] a run would raise on Unix, where `execvp` searches +/// exact [`ErrorKind::NotFound`] a run would raise on Unix, where `execvp` searches /// `PATH` only. On Windows the OS *also* locates a bare name through routes this /// `PATH`-based preflight deliberately doesn't model — the application directory, /// the current directory, and the system directories — so a Windows `which` miss @@ -323,7 +323,7 @@ where /// /// # Errors /// -/// [`Error::NotFound`] when the program can't be located +/// [`ErrorKind::NotFound`] when the program can't be located /// — not installed, not on `PATH`, or a path that doesn't resolve to an /// executable. Its `searched` field names the directories checked for a /// bare-name lookup, and [`is_not_found`](crate::Error::is_not_found) classifies @@ -358,31 +358,32 @@ pub fn which(program: impl AsRef) -> Result { /// take its writer via [`take_stdin`](RunningProcess::take_stdin) /// (or don't keep stdin open) before racing it. /// -/// An empty `processes` slice is an error ([`Error::Io`] with +/// An empty `processes` slice is an error ([`ErrorKind::Io`] with /// [`InvalidInput`](std::io::ErrorKind::InvalidInput)) rather than a future /// that never resolves. /// /// The first finisher's result carries the same errors as a bulk verb: -/// `Error::Cancelled` for a cancelled run, or [`Error::Stdin`] when its stdin +/// `ErrorKind::Cancelled` for a cancelled run, or [`ErrorKind::Stdin`] when its stdin /// source failed (non-broken-pipe) on an otherwise-successful exit. A non-zero /// exit or signal is *not* an error here — it is returned as its [`Outcome`]. /// /// # Errors /// -/// [`Error::Io`] with [`InvalidInput`](std::io::ErrorKind::InvalidInput) when +/// [`ErrorKind::Io`] with [`InvalidInput`](std::io::ErrorKind::InvalidInput) when /// `processes` is empty. Otherwise the first finisher's error surfaces: -/// [`Error::Cancelled`] (a cancelled run), [`Error::Stdin`] (a non-broken-pipe -/// stdin-source failure on an otherwise-successful exit), or [`Error::Io`] (a +/// [`ErrorKind::Cancelled`] (a cancelled run), [`ErrorKind::Stdin`] (a non-broken-pipe +/// stdin-source failure on an otherwise-successful exit), or [`ErrorKind::Io`] (a /// failed reap). A non-zero exit or signal is returned as an [`Outcome`], not an /// error. pub async fn wait_any(processes: &mut [&mut RunningProcess]) -> Result<(usize, Outcome)> { use std::future::Future; if processes.is_empty() { - return Err(Error::Io(std::io::Error::new( + return Err(ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, "wait_any requires at least one process", - ))); + )) + .into()); } let mut waits: Vec<_> = processes .iter_mut() @@ -415,14 +416,14 @@ pub async fn wait_any(processes: &mut [&mut RunningProcess]) -> Result<(usize, O /// /// If a contender fails to reap (an OS I/O error), that `Err` is returned and /// the remaining processes stay waitable (cancel-safe). A contender's -/// `Error::Cancelled` (cancelled run) or [`Error::Stdin`] (a non-broken-pipe +/// `ErrorKind::Cancelled` (cancelled run) or [`ErrorKind::Stdin`] (a non-broken-pipe /// stdin-source failure on its otherwise-successful exit) likewise short-circuits /// the join — like the bulk verbs, these surface as an `Err`, not an `Outcome`. /// /// # Errors /// -/// A contender's [`Error::Io`] (a failed reap), [`Error::Cancelled`] (a -/// cancelled run), or [`Error::Stdin`] (a non-broken-pipe stdin-source failure +/// A contender's [`ErrorKind::Io`] (a failed reap), [`ErrorKind::Cancelled`] (a +/// cancelled run), or [`ErrorKind::Stdin`] (a non-broken-pipe stdin-source failure /// on its otherwise-successful exit) short-circuits the join; the remaining /// processes stay waitable (cancel-safe). A non-zero exit or signal is returned /// as an [`Outcome`], not an error. An empty slice resolves to an empty `Vec`. @@ -744,8 +745,8 @@ mod tests { .await .expect_err("cancelled run must error"); assert!( - matches!(err, crate::Error::Cancelled { .. }), - "expected Error::Cancelled, got {err:?}" + matches!(err.kind(), crate::ErrorKind::Cancelled { .. }), + "expected ErrorKind::Cancelled, got {err:?}" ); } @@ -768,13 +769,16 @@ mod tests { let err = super::wait_any(&mut [&mut process]) .await .expect_err("first wait_any: cancelled run errors"); - assert!(matches!(err, crate::Error::Cancelled { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), crate::ErrorKind::Cancelled { .. }), + "got {err:?}" + ); let err2 = super::wait_any(&mut [&mut process]) .await .expect_err("re-wait must stay cancelled, not flip to Ok"); assert!( - matches!(err2, crate::Error::Cancelled { .. }), + matches!(err2.kind(), crate::ErrorKind::Cancelled { .. }), "repeat wait_any must preserve the cancellation, got {err2:?}" ); } @@ -784,11 +788,11 @@ mod tests { let err = super::wait_any(&mut []) .await .expect_err("an empty race must error, not pend forever"); - match err { - crate::Error::Io(source) => { + match err.kind() { + crate::ErrorKind::Io(source) => { assert_eq!(source.kind(), std::io::ErrorKind::InvalidInput); } - other => panic!("expected Error::Io(InvalidInput), got {other:?}"), + other => panic!("expected ErrorKind::Io(InvalidInput), got {other:?}"), } } @@ -878,13 +882,13 @@ mod tests { let err = run .stdout_lines() .expect_err("a second stdout_lines must be a loud error"); - assert!(matches!(err, crate::Error::Io(_)), "got {err:?}"); + assert!(matches!(err.kind(), crate::ErrorKind::Io(_)), "got {err:?}"); let err = run .finish() .await .expect_err("overflow from first pump must still be visible"); assert!( - matches!(err, crate::Error::OutputTooLarge { .. }), + matches!(err.kind(), crate::ErrorKind::OutputTooLarge { .. }), "expected OutputTooLarge, got {err:?}" ); } @@ -905,7 +909,7 @@ mod tests { let err = run .output_events() .expect_err("a second output_events must be a loud error"); - assert!(matches!(err, crate::Error::Io(_)), "got {err:?}"); + assert!(matches!(err.kind(), crate::ErrorKind::Io(_)), "got {err:?}"); let _ = run.finish().await; } } diff --git a/src/limits.rs b/src/limits.rs index 7515ef2..71d932a 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -24,7 +24,7 @@ /// cgroup v2**. On macOS/the BSDs and the Linux process-group fallback there is /// no whole-tree limit primitive, so /// requesting *any* limit there fails fast with -/// [`Error::ResourceLimit`](crate::Error::ResourceLimit) rather than silently +/// [`ErrorKind::ResourceLimit`](crate::ErrorKind::ResourceLimit) rather than silently /// leaving the tree unbounded. /// /// **Linux (cgroup v2): limits need this process at the *real* cgroup root.** @@ -40,7 +40,7 @@ /// real hierarchy root (a minimal init not managed by systemd) — **not** under any /// systemd session/scope/service, and **not** in an ordinary container. When the /// controllers can't be enabled, group creation **fails fast** -/// ([`Error::ResourceLimit`](crate::Error::ResourceLimit)) rather than silently +/// ([`ErrorKind::ResourceLimit`](crate::ErrorKind::ResourceLimit)) rather than silently /// leaving the tree unbounded — an unenforced limit is no protection. The crate /// deliberately does **not** migrate your process into a sub-cgroup to make limits /// work elsewhere (the create-leaf→migrate-self→enable dance); do that externally @@ -89,7 +89,7 @@ impl ResourceLimits { } /// Which [`ResourceLimits`] field an -/// [`Error::ResourceLimit`](crate::Error::ResourceLimit) failure is about — +/// [`ErrorKind::ResourceLimit`](crate::ErrorKind::ResourceLimit) failure is about — /// [`Memory`](Self::Memory) for [`max_memory`](ResourceLimits::max_memory), /// [`Processes`](Self::Processes) for /// [`max_processes`](ResourceLimits::max_processes), [`Cpu`](Self::Cpu) for @@ -112,7 +112,7 @@ pub enum LimitKind { } /// Why a requested resource limit could not be applied — the classification an -/// [`Error::ResourceLimit`](crate::Error::ResourceLimit) failure carries so a +/// [`ErrorKind::ResourceLimit`](crate::ErrorKind::ResourceLimit) failure carries so a /// caller (e.g. the `processkit-py` binding) can branch on the *kind* of failure /// without parsing the English `detail` text. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/pipeline.rs b/src/pipeline.rs index e877489..73b923b 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -185,7 +185,7 @@ impl Pipeline { /// Cancel the **whole chain** when `token` fires: the token reaches every /// stage (see gap-fill below), so each stage's run is cancelled and kills its /// own subtree, and the run resolves to - /// [`Error::Cancelled`](crate::Error::Cancelled). This is **proactive** — + /// [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled). This is **proactive** — /// firing the token cancels the stages directly, it does not wait for them to /// notice a closed pipe. /// @@ -442,13 +442,13 @@ impl Pipeline { /// A failing stage (and a timeout) is *captured* in the returned /// [`ProcessResult`] (pipefail attribution), not raised. `Err` means a stage /// could not be *started or driven*: a launch failure - /// ([`Error::NotFound`](crate::Error::NotFound) / - /// [`Error::Spawn`](crate::Error::Spawn) / - /// [`Error::Unsupported`](crate::Error::Unsupported)), - /// [`Error::Cancelled`](crate::Error::Cancelled), - /// [`Error::OutputTooLarge`](crate::Error::OutputTooLarge) (a fail-loud - /// overflow of the last stage), [`Error::Stdin`](crate::Error::Stdin), or - /// [`Error::Io`](crate::Error::Io). + /// ([`ErrorKind::NotFound`](crate::ErrorKind::NotFound) / + /// [`ErrorKind::Spawn`](crate::ErrorKind::Spawn) / + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported)), + /// [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled), + /// [`ErrorKind::OutputTooLarge`](crate::ErrorKind::OutputTooLarge) (a fail-loud + /// overflow of the last stage), [`ErrorKind::Stdin`](crate::ErrorKind::Stdin), or + /// [`ErrorKind::Io`](crate::ErrorKind::Io). pub async fn output_string(&self) -> Result> { self.capture(|last| async move { last.output_string().await }) .await @@ -656,25 +656,25 @@ impl Pipeline { /// Run the chain, require **every** stage to exit cleanly, and return the /// last stage's trimmed stdout. A failure surfaces as the first failing - /// stage's [`Error::Exit`](crate::Error::Exit) (pipefail attribution; + /// stage's [`ErrorKind::Exit`](crate::ErrorKind::Exit) (pipefail attribution; /// [`unchecked_in_pipe`](Command::unchecked_in_pipe) stages are exempt, so a chain whose /// only failures are unchecked returns `Ok`). - /// [`Error::Timeout`](crate::Error::Timeout) is produced by the whole-chain + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) is produced by the whole-chain /// [`timeout`](Self::timeout) or by **any** stage's own /// [`Command::timeout`] — the attributed stage's *own* deadline is reported, /// not the chain's. /// /// # Errors /// - /// The first failing stage's [`Error::Exit`](crate::Error::Exit) (pipefail + /// The first failing stage's [`ErrorKind::Exit`](crate::ErrorKind::Exit) (pipefail /// attribution; [`unchecked_in_pipe`](Command::unchecked_in_pipe) stages are - /// exempt), [`Error::Signalled`](crate::Error::Signalled), - /// [`Error::Timeout`](crate::Error::Timeout) (the whole-chain + /// exempt), [`ErrorKind::Signalled`](crate::ErrorKind::Signalled), + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) (the whole-chain /// [`timeout`](Self::timeout) or any stage's own — the attributed stage's - /// deadline is reported), [`Error::Cancelled`](crate::Error::Cancelled), - /// [`Error::OutputTooLarge`](crate::Error::OutputTooLarge) (a fail-loud + /// deadline is reported), [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled), + /// [`ErrorKind::OutputTooLarge`](crate::ErrorKind::OutputTooLarge) (a fail-loud /// truncation of the last stage), plus any launch failure or - /// [`Error::Stdin`](crate::Error::Stdin). + /// [`ErrorKind::Stdin`](crate::ErrorKind::Stdin). pub async fn run(&self) -> Result { let out = self.checked().await?; self.reject_if_last_truncated(&out)?; @@ -690,11 +690,11 @@ impl Pipeline { /// # Errors /// /// The same pipefail surface as [`run`](Self::run) — - /// [`Error::Exit`](crate::Error::Exit) / - /// [`Error::Signalled`](crate::Error::Signalled) / - /// [`Error::Timeout`](crate::Error::Timeout) / - /// [`Error::Cancelled`](crate::Error::Cancelled), plus launch failures and - /// [`Error::Stdin`](crate::Error::Stdin) — but, as the lenient building block, + /// [`ErrorKind::Exit`](crate::ErrorKind::Exit) / + /// [`ErrorKind::Signalled`](crate::ErrorKind::Signalled) / + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) / + /// [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled), plus launch failures and + /// [`ErrorKind::Stdin`](crate::ErrorKind::Stdin) — but, as the lenient building block, /// it does not fail loud on a bounded-buffer truncation. pub async fn checked(&self) -> Result> { self.output_string().await?.ensure_success() @@ -713,16 +713,16 @@ impl Pipeline { /// Run the chain and return the pipefail-attributed exit code. A chain that /// produced no code surfaces as an error — a whole-chain or stage timeout as - /// [`Error::Timeout`](crate::Error::Timeout), a signal-kill as - /// [`Error::Signalled`](crate::Error::Signalled) — mirroring + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout), a signal-kill as + /// [`ErrorKind::Signalled`](crate::ErrorKind::Signalled) — mirroring /// [`Command::exit_code`](crate::Command::exit_code). /// /// # Errors /// /// A chain that produced no code errors as - /// [`Error::Timeout`](crate::Error::Timeout) (whole-chain or stage), - /// [`Error::Signalled`](crate::Error::Signalled), or - /// [`Error::Cancelled`](crate::Error::Cancelled), atop any launch failure. A + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) (whole-chain or stage), + /// [`ErrorKind::Signalled`](crate::ErrorKind::Signalled), or + /// [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled), atop any launch failure. A /// non-zero pipefail code is returned, not raised. pub async fn exit_code(&self) -> Result { self.output_string().await?.require_code() @@ -730,9 +730,9 @@ impl Pipeline { /// Read the chain's pipefail-attributed exit code as a boolean: `0` → /// `Ok(true)`, `1` → `Ok(false)`, anything else → `Err` (other code as - /// [`Error::Exit`](crate::Error::Exit), a timeout as - /// [`Error::Timeout`](crate::Error::Timeout), a signal-kill as - /// [`Error::Signalled`](crate::Error::Signalled)). For a chain whose final + /// [`ErrorKind::Exit`](crate::ErrorKind::Exit), a timeout as + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout), a signal-kill as + /// [`ErrorKind::Signalled`](crate::ErrorKind::Signalled)). For a chain whose final /// answer is a yes/no exit — `producer | grep -q pattern`. Mirrors /// [`Command::probe`](crate::Command::probe) and keeps its strict 0/1 /// contract regardless of any stage's `ok_codes`. @@ -746,10 +746,10 @@ impl Pipeline { /// # Errors /// /// A pipefail code other than `0`/`1` becomes - /// [`Error::Exit`](crate::Error::Exit); a chain with no code errors as - /// [`Error::Timeout`](crate::Error::Timeout), - /// [`Error::Signalled`](crate::Error::Signalled), or - /// [`Error::Cancelled`](crate::Error::Cancelled), atop any launch failure. + /// [`ErrorKind::Exit`](crate::ErrorKind::Exit); a chain with no code errors as + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout), + /// [`ErrorKind::Signalled`](crate::ErrorKind::Signalled), or + /// [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled), atop any launch failure. pub async fn probe(&self) -> Result { let result = self.output_string().await?; result.probe_bool() @@ -766,12 +766,12 @@ impl Pipeline { /// # Errors /// /// The pipefail surface of [`run`](Self::run) (launch failures, - /// [`Error::Exit`](crate::Error::Exit) / - /// [`Error::Signalled`](crate::Error::Signalled) / - /// [`Error::Timeout`](crate::Error::Timeout) / - /// [`Error::Cancelled`](crate::Error::Cancelled) / - /// [`Error::Stdin`](crate::Error::Stdin)), plus - /// [`Error::OutputTooLarge`](crate::Error::OutputTooLarge) when a fail-loud + /// [`ErrorKind::Exit`](crate::ErrorKind::Exit) / + /// [`ErrorKind::Signalled`](crate::ErrorKind::Signalled) / + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) / + /// [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled) / + /// [`ErrorKind::Stdin`](crate::ErrorKind::Stdin)), plus + /// [`ErrorKind::OutputTooLarge`](crate::ErrorKind::OutputTooLarge) when a fail-loud /// buffer truncated the last stage's stdout. The `parse` closure is /// infallible, so it adds no error. pub async fn parse(&self, parse: F) -> Result @@ -785,7 +785,7 @@ impl Pipeline { /// Run the chain (requiring a clean pipefail outcome) and feed the last /// stage's stdout to a *fallible* `parse` closure (the JSON-deserialization - /// shape; a failure becomes [`Error::Parse`](crate::Error::Parse) or whatever + /// shape; a failure becomes [`ErrorKind::Parse`](crate::ErrorKind::Parse) or whatever /// the closure returns). Fails loud on truncation. Mirrors /// [`Command::try_parse`](crate::Command::try_parse). /// @@ -793,7 +793,7 @@ impl Pipeline { /// /// Everything [`parse`](Self::parse) can return, plus whatever the fallible /// `parse` closure yields on malformed output — typically - /// [`Error::Parse`](crate::Error::Parse). + /// [`ErrorKind::Parse`](crate::ErrorKind::Parse). pub async fn try_parse(&self, parse: F) -> Result where F: FnOnce(&str) -> Result, @@ -1344,17 +1344,18 @@ impl std::ops::BitOr for Pipeline { } fn join_error(err: tokio::task::JoinError) -> crate::Error { - crate::Error::Io(std::io::Error::other(format!( + crate::ErrorKind::Io(std::io::Error::other(format!( "pipeline stage task failed: {err}" ))) + .into() } /// Drain every task in `tasks` to completion in **true completion order** /// (`JoinSet::join_next`, not a left-to-right positional await), firing /// `teardown` the instant *any* task ends badly — a raw `Err` (a stage's own -/// [`Error::Cancelled`](crate::Error::Cancelled) / -/// [`Error::Stdin`](crate::Error::Stdin) / [`Error::Io`](crate::Error::Io) / -/// [`Error::OutputTooLarge`](crate::Error::OutputTooLarge)) or a task panic +/// [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled) / +/// [`ErrorKind::Stdin`](crate::ErrorKind::Stdin) / [`ErrorKind::Io`](crate::ErrorKind::Io) / +/// [`ErrorKind::OutputTooLarge`](crate::ErrorKind::OutputTooLarge)) or a task panic /// (surfaced here as a `JoinError`). /// /// This is `capture`'s liveness fix, factored out so it's testable without @@ -1492,8 +1493,8 @@ mod tests { "stdout is what the chain produced — the last stage's" ); assert!(!result.timed_out()); - match result.ensure_success() { - Err(crate::Error::Exit { + match result.ensure_success().map_err(crate::Error::into_kind) { + Err(crate::ErrorKind::Exit { program, code, stdout, @@ -1505,7 +1506,7 @@ mod tests { assert_eq!(stdout, "final"); assert_eq!(stderr, "b broke"); } - other => panic!("expected Error::Exit, got {other:?}"), + other => panic!("expected ErrorKind::Exit, got {other:?}"), } } @@ -1547,11 +1548,11 @@ mod tests { assert_eq!(result.program(), "a", "pipefail blames the FIRST failure"); assert_eq!(result.code(), Some(1)); assert_eq!(result.stderr(), "first"); - match result.ensure_success() { - Err(crate::Error::Exit { program, .. }) => { + match result.ensure_success().map_err(crate::Error::into_kind) { + Err(crate::ErrorKind::Exit { program, .. }) => { assert_eq!(program, "a", "...and so does the error surface"); } - other => panic!("expected Error::Exit, got {other:?}"), + other => panic!("expected ErrorKind::Exit, got {other:?}"), } } @@ -1696,8 +1697,8 @@ mod tests { let result = pf(vec![timed], last(Outcome::Exited(0), false), "out"); assert_eq!(result.program(), "slow"); assert!(result.timed_out()); - match result.ensure_success() { - Err(crate::Error::Timeout { + match result.ensure_success().map_err(crate::Error::into_kind) { + Err(crate::ErrorKind::Timeout { program, timeout, .. }) => { assert_eq!(program, "slow"); @@ -1707,7 +1708,7 @@ mod tests { "the stage's own deadline, not the chain's 0ns" ); } - other => panic!("expected Error::Timeout, got {other:?}"), + other => panic!("expected ErrorKind::Timeout, got {other:?}"), } } @@ -1756,12 +1757,12 @@ mod tests { "the failure that triggered teardown wins, not a torn-down victim" ); assert_eq!(result.code(), Some(3)); - match result.ensure_success() { - Err(crate::Error::Exit { program, code, .. }) => { + match result.ensure_success().map_err(crate::Error::into_kind) { + Err(crate::ErrorKind::Exit { program, code, .. }) => { assert_eq!(program, "downstream"); assert_eq!(code, 3); } - other => panic!("expected Error::Exit, got {other:?}"), + other => panic!("expected ErrorKind::Exit, got {other:?}"), } } @@ -1833,14 +1834,14 @@ mod tests { assert_eq!(result.code(), None); assert_eq!(result.stderr(), "killed"); assert!(!result.timed_out(), "a stage kill is not a chain timeout"); - match result.ensure_success() { - Err(crate::Error::Signalled { + match result.ensure_success().map_err(crate::Error::into_kind) { + Err(crate::ErrorKind::Signalled { program, signal, .. }) => { assert_eq!(program, "a"); assert_eq!(signal, None); } - other => panic!("expected Error::Signalled, got {other:?}"), + other => panic!("expected ErrorKind::Signalled, got {other:?}"), } } @@ -1878,7 +1879,9 @@ mod tests { // `Err` a checked-failure path never produces, so nothing but the // centralized `drain_unordered` firing `teardown` on its behalf can // wake the sibling above. - tasks.spawn(async { Err(crate::Error::Io(std::io::Error::other("downstream boom"))) }); + tasks.spawn(async { + Err(crate::ErrorKind::Io(std::io::Error::other("downstream boom")).into()) + }); let drained = tokio::time::timeout(Duration::from_secs(5), drain_unordered(tasks, &teardown)) @@ -1888,8 +1891,8 @@ mod tests { once the sibling's raw error has fired teardown", ); - match drained { - Err(crate::Error::Io(err)) => { + match drained.map_err(crate::Error::into_kind) { + Err(crate::ErrorKind::Io(err)) => { assert_eq!(err.to_string(), "downstream boom"); } other => panic!("expected the downstream stage's own Io error, got {other:?}"), @@ -1931,8 +1934,8 @@ mod tests { once the sibling's panic has fired teardown", ); - match drained { - Err(crate::Error::Io(err)) => { + match drained.map_err(crate::Error::into_kind) { + Err(crate::ErrorKind::Io(err)) => { assert!( err.to_string().contains("pipeline stage task failed"), "expected the wrapped JoinError, got {err}" diff --git a/src/priority.rs b/src/priority.rs index 286213b..6669f75 100644 --- a/src/priority.rs +++ b/src/priority.rs @@ -11,7 +11,7 @@ /// platform families — `setpriority` is plain POSIX (Linux, macOS, the BSDs /// alike) and every Windows edition has all five priority classes — so /// [`Command::priority`](crate::Command::priority) never yields -/// [`Error::Unsupported`](crate::Error::Unsupported). +/// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum Priority { @@ -30,7 +30,7 @@ pub enum Priority { /// the kernel to *lower* the child's `nice` back to `0` — the same kind /// of decrease as [`AboveNormal`](Priority::AboveNormal)/[`High`](Priority::High), /// and subject to the same `CAP_SYS_NICE`/root requirement and - /// [`Error::Spawn`](crate::Error::Spawn) failure mode described there. + /// [`ErrorKind::Spawn`](crate::ErrorKind::Spawn) failure mode described there. /// Windows is unaffected: `NORMAL_PRIORITY_CLASS` never needs a privilege /// check. Normal, @@ -40,7 +40,7 @@ pub enum Priority { /// **Unix caveat:** same privilege requirement as /// [`High`](Priority::High) — lowering `nice` below its inherited value /// needs `CAP_SYS_NICE`/root, and without it the spawn fails as - /// [`Error::Spawn`](crate::Error::Spawn) rather than silently applying a + /// [`ErrorKind::Spawn`](crate::ErrorKind::Spawn) rather than silently applying a /// smaller increase. AboveNormal, /// Highest ordinary (non-real-time) priority. @@ -48,7 +48,7 @@ pub enum Priority { /// **Unix caveat:** lowering `nice` below its inherited value needs /// `CAP_SYS_NICE` (Linux) or an equivalent privilege elsewhere; without it /// the OS refuses the change and the spawn fails as - /// [`Error::Spawn`](crate::Error::Spawn), exactly like any other rejected + /// [`ErrorKind::Spawn`](crate::ErrorKind::Spawn), exactly like any other rejected /// `pre_exec` hook — it is never silently downgraded to a lower priority. /// Windows needs no special privilege for `HIGH_PRIORITY_CLASS`. /// Unix: `nice(-10)`. Windows: `HIGH_PRIORITY_CLASS`. diff --git a/src/pump.rs b/src/pump.rs index 6ffc930..70f2ed9 100644 --- a/src/pump.rs +++ b/src/pump.rs @@ -108,7 +108,7 @@ pub(crate) struct SharedLines { /// EOF (or a broken-pipe read, treated as EOF) leaves it `None`. A consuming /// finisher reads it (via [`take_read_error`](Self::take_read_error)) after /// the pump joins and surfaces an incomplete capture as - /// [`Error::Io`](crate::Error::Io) instead of a silent short read reported as + /// [`ErrorKind::Io`](crate::ErrorKind::Io) instead of a silent short read reported as /// a full, successful capture. Its own `Mutex` (not `Inner`'s) so the hot /// `push` path is untouched; poison is recovered rather than propagated, /// matching [`close`](Self::close). @@ -131,7 +131,7 @@ struct Inner { mode: OverflowMode, closed: bool, /// Set when `OverflowMode::Error` is active and a ceiling is reached — the - /// consuming path turns this into [`Error::OutputTooLarge`](crate::Error::OutputTooLarge). + /// consuming path turns this into [`ErrorKind::OutputTooLarge`](crate::ErrorKind::OutputTooLarge). overflowed: bool, /// Flipped on by [`start_discarding`](SharedLines::start_discarding) when a /// streaming consumer is gone and a discard verb adopts this sink: the @@ -227,7 +227,7 @@ impl SharedLines { // the ceiling. With neither cap set it is a ceiling with no // ceiling — a misconfiguration treated as zero-tolerance. The pipe // is still drained so the child never blocks; the consuming verb - // turns `overflowed` into `Error::OutputTooLarge`. + // turns `overflowed` into `ErrorKind::OutputTooLarge`. OverflowMode::Error => { let over = match (inner.max_lines, inner.max_bytes) { (None, None) => true, @@ -380,7 +380,7 @@ impl SharedLines { /// Record the first OS read error the pump hit while draining the stream — /// the incomplete-capture signal a consuming finisher turns into - /// [`Error::Io`](crate::Error::Io). Only the *first* error is kept (a later + /// [`ErrorKind::Io`](crate::ErrorKind::Io). Only the *first* error is kept (a later /// one is ignored); a clean EOF, or a broken-pipe read treated as EOF, records /// nothing. Poison-tolerant, like [`close`](Self::close), because it runs on /// the pump task's normal *and* unwind exit paths. @@ -394,7 +394,7 @@ impl SharedLines { /// Take the recorded OS read error, if any. Consumes it (by value — a /// `std::io::Error` is not `Clone`), so a consuming finisher calls it once /// after the pump has joined and wraps a `Some` in - /// [`Error::Io`](crate::Error::Io); `None` means the stream drained to a clean + /// [`ErrorKind::Io`](crate::ErrorKind::Io); `None` means the stream drained to a clean /// EOF and the capture is complete. pub(crate) fn take_read_error(&self) -> Option { self.read_error @@ -551,7 +551,7 @@ pub(crate) async fn pump_lines_term( /// reads to EOF so the child never blocks on a full pipe; on an OS read error it /// flushes what it has, **records the error on the sink** /// ([`set_read_error`](SharedLines::set_read_error)) so a consuming finisher can -/// surface the incomplete capture as [`Error::Io`](crate::Error::Io) rather than +/// surface the incomplete capture as [`ErrorKind::Io`](crate::ErrorKind::Io) rather than /// a silent short read, and closes the sink. A broken-pipe read (the writer end /// closing) is the normal end of a stream and is treated as a clean EOF, not an /// error. @@ -898,7 +898,7 @@ where // Record the OS read error AFTER flushing the partial tail (so the // already-decoded prefix is still delivered) and before the sink // closes, so a finisher that joins this pump surfaces the - // incomplete capture as `Error::Io`. + // incomplete capture as `ErrorKind::Io`. sink.0.set_read_error(e); } break; @@ -1815,7 +1815,7 @@ mod tests { async fn clean_eof_records_no_read_error() { // A stream that drains to a normal EOF is a complete capture: the sink must // carry no read error, so a finisher reports success rather than - // `Error::Io` — the non-regression guard against a false-positive read error. + // `ErrorKind::Io` — the non-regression guard against a false-positive read error. let sink = SharedLines::new(&OutputBufferPolicy::unbounded()); pump_lines(&b"a\nb\n"[..], encoding_rs::UTF_8, None, sink.clone()).await; assert_eq!(sink.drain(), vec!["a", "b"]); @@ -1847,7 +1847,7 @@ mod tests { // stream — std maps it to `Ok(0)` already, but the pump also defensively // folds it into a clean EOF: the buffered line is delivered and NO read // error is recorded, so a normal writer-closed stream never spuriously - // reports `Error::Io`. + // reports `ErrorKind::Io`. let reader = ChunkedReader::erroring_with( [b"tail\n".to_vec()], std::io::Error::from(std::io::ErrorKind::BrokenPipe), diff --git a/src/result.rs b/src/result.rs index 8fcc114..435954d 100644 --- a/src/result.rs +++ b/src/result.rs @@ -3,7 +3,7 @@ use std::fmt; use std::time::Duration; -use crate::error::Error; +use crate::error::{Error, ErrorKind}; /// How a run ended — the explicit form of the `code()`/`timed_out()` pair. /// @@ -107,7 +107,7 @@ pub struct ProcessResult { stdout: T, stderr: String, outcome: Outcome, - /// Carried so the success-checking helpers can build a faithful [`Error::Timeout`]. + /// Carried so the success-checking helpers can build a faithful [`ErrorKind::Timeout`]. timeout: Option, /// `Duration::ZERO` for synthetic results that didn't time a real process. duration: Duration, @@ -115,7 +115,7 @@ pub struct ProcessResult { /// dropped captured output lines. truncated: bool, /// Retained + dropped, so a checking verb can build a faithful - /// [`Error::OutputTooLarge`]; meaningful only when `truncated`. + /// [`ErrorKind::OutputTooLarge`]; meaningful only when `truncated`. total_lines: usize, total_bytes: usize, /// Exit codes treated as success by [`is_success`](Self::is_success) / @@ -283,12 +283,12 @@ impl ProcessResult { /// /// # Errors /// - /// - [`Error::Timeout`] if the run was killed by its deadline (checked + /// - [`ErrorKind::Timeout`] if the run was killed by its deadline (checked /// *first*, so a run that both timed out and exited non-zero reports the /// timeout). - /// - [`Error::Signalled`](crate::Error::Signalled) if it was terminated by a + /// - [`ErrorKind::Signalled`](crate::ErrorKind::Signalled) if it was terminated by a /// signal, with no exit code. - /// - [`Error::Exit`] for an exit code outside the accepted set, carrying the + /// - [`ErrorKind::Exit`] for an exit code outside the accepted set, carrying the /// code and both captured streams in full (the /// [`Display`](std::fmt::Display) impl bounds what it prints; the fields /// stay complete for classification). @@ -298,34 +298,37 @@ impl ProcessResult { { match self.outcome { Outcome::Exited(code) if self.ok_codes.contains(&code) => Ok(self), - Outcome::TimedOut => Err(Error::Timeout { + Outcome::TimedOut => Err(ErrorKind::Timeout { program: self.program.clone(), timeout: self.timeout.unwrap_or_default(), stdout: self.stdout.as_text(), stderr: self.stderr.clone(), stdout_bytes: self.stdout.into_raw(), - }), - Outcome::Signalled(signal) => Err(Error::Signalled { + } + .into()), + Outcome::Signalled(signal) => Err(ErrorKind::Signalled { program: self.program.clone(), signal, stdout: self.stdout.as_text(), stderr: self.stderr.clone(), stdout_bytes: self.stdout.into_raw(), - }), - Outcome::Exited(code) => Err(Error::Exit { + } + .into()), + Outcome::Exited(code) => Err(ErrorKind::Exit { program: self.program.clone(), code, stdout: self.stdout.as_text(), stderr: self.stderr.clone(), stdout_bytes: self.stdout.into_raw(), - }), + } + .into()), } } /// The exit code for the code-returning convenience helpers /// (`Command::exit_code`, `ProcessRunnerExt::exit_code`, `CliClient::exit_code`): - /// a timeout surfaces as [`Error::Timeout`], a signal-kill (no code) as - /// [`Error::Signalled`], otherwise the code. Takes `self` by value (rather + /// a timeout surfaces as [`ErrorKind::Timeout`], a signal-kill (no code) as + /// [`ErrorKind::Signalled`], otherwise the code. Takes `self` by value (rather /// than `&self`) so the bytes-path payload can move its raw stdout into the /// error instead of cloning it — every call site already holds a /// freshly-produced, otherwise-unused `ProcessResult`. @@ -335,20 +338,22 @@ impl ProcessResult { { match self.outcome { Outcome::Exited(code) => Ok(code), - Outcome::TimedOut => Err(Error::Timeout { + Outcome::TimedOut => Err(ErrorKind::Timeout { program: self.program.clone(), timeout: self.timeout.unwrap_or_default(), stdout: self.stdout.as_text(), stderr: self.stderr.clone(), stdout_bytes: self.stdout.into_raw(), - }), - Outcome::Signalled(signal) => Err(Error::Signalled { + } + .into()), + Outcome::Signalled(signal) => Err(ErrorKind::Signalled { program: self.program.clone(), signal, stdout: self.stdout.as_text(), stderr: self.stderr.clone(), stdout_bytes: self.stdout.into_raw(), - }), + } + .into()), } } @@ -383,7 +388,7 @@ impl ProcessResult { } /// Record the total lines/bytes the streams produced (retained + dropped), - /// so a checking verb can build a faithful [`Error::OutputTooLarge`] when it + /// so a checking verb can build a faithful [`ErrorKind::OutputTooLarge`] when it /// refuses truncated output. Producer-only. pub(crate) fn with_overflow_totals(mut self, total_lines: usize, total_bytes: usize) -> Self { self.total_lines = total_lines; @@ -405,7 +410,7 @@ impl ProcessResult { /// Refuse silently-truncated output. The checking verbs that hand back /// stdout *as if complete* (`run`/`parse`/`try_parse`) call this so a bounded - /// drop-policy that discarded lines surfaces as [`Error::OutputTooLarge`] + /// drop-policy that discarded lines surfaces as [`ErrorKind::OutputTooLarge`] /// rather than feeding a parser a truncated tail. The lenient capture verbs /// (`output_string`/`output_bytes`/`checked`) do **not** call it — they /// return the result with [`truncated`](Self::truncated) set so the caller @@ -416,13 +421,14 @@ impl ProcessResult { max_bytes: Option, ) -> Result<(), Error> { if self.truncated { - return Err(Error::OutputTooLarge { + return Err(ErrorKind::OutputTooLarge { program: self.program.clone(), max_lines, max_bytes, total_lines: self.total_lines, total_bytes: self.total_bytes, - }); + } + .into()); } Ok(()) } @@ -535,7 +541,7 @@ fn contains_ascii_ci(haystack: &str, needle: &str) -> bool { .any(|w| w.eq_ignore_ascii_case(needle)) } -/// Render captured stdout as text for [`Error::Exit`], whatever the payload type: +/// Render captured stdout as text for [`ErrorKind::Exit`], whatever the payload type: /// a [`String`] is taken as-is; raw bytes are decoded lossily. Also exposes the /// exact captured bytes when the payload type carries them losslessly /// (`Vec` — the `output_bytes()` path), so a checking verb @@ -665,16 +671,16 @@ mod tests { assert!(!killed.timed_out()); assert!(!killed.is_success()); assert_eq!(killed.outcome(), Outcome::Signalled(Some(9))); - match killed.ensure_success().unwrap_err() { + match killed.ensure_success().unwrap_err().kind() { // The captured stdout flows into the error. - Error::Signalled { + ErrorKind::Signalled { program, signal, stdout, .. } => { assert_eq!(program, "git"); - assert_eq!(signal, Some(9)); + assert_eq!(*signal, Some(9)); assert_eq!(stdout, "out"); } other => panic!("expected Signalled, got {other:?}"), @@ -736,8 +742,8 @@ mod tests { assert!(!bad.is_success()); assert_eq!(bad.code(), Some(2)); let err = bad.ensure_success().unwrap_err(); - match err { - Error::Exit { + match err.kind() { + ErrorKind::Exit { program, code, stdout, @@ -745,7 +751,7 @@ mod tests { .. } => { assert_eq!(program, "git"); - assert_eq!(code, 2); + assert_eq!(*code, 2); assert_eq!(stdout, "CONFLICT (content): merge conflict in a.rs"); assert_eq!(stderr, "boom"); } @@ -780,7 +786,7 @@ mod tests { conflict.diagnostic(), "CONFLICT (content): merge conflict in a.rs" ); - let Error::Exit { .. } = conflict.clone().ensure_success().unwrap_err() else { + let ErrorKind::Exit { .. } = conflict.clone().ensure_success().unwrap_err().kind() else { panic!("expected Exit"); }; assert_eq!( @@ -812,15 +818,15 @@ mod tests { ); assert!(timed.timed_out()); assert_eq!(timed.code(), None); - match timed.ensure_success().unwrap_err() { - Error::Timeout { + match timed.ensure_success().unwrap_err().kind() { + ErrorKind::Timeout { program, timeout, stdout, .. } => { assert_eq!(program, "git"); - assert_eq!(timeout, Duration::from_millis(500)); + assert_eq!(*timeout, Duration::from_millis(500)); // Partial stdout captured before the kill is carried. assert_eq!(stdout, "out"); } @@ -831,8 +837,8 @@ mod tests { #[test] fn signal_kill_surfaces_as_signalled_error() { // A signal-terminated run (no code, not a timeout) must surface - // `Error::Signalled` from both `require_code` and `ensure_success`, - // never a synthetic `Error::Exit { code: -1 }` sentinel. + // `ErrorKind::Signalled` from both `require_code` and `ensure_success`, + // never a synthetic `ErrorKind::Exit { code: -1 }` sentinel. let killed = ProcessResult::new( "git".into(), "out".to_owned(), @@ -843,15 +849,15 @@ mod tests { assert_eq!(killed.code(), None); assert!(!killed.is_success()); assert!(matches!( - killed.clone().require_code().unwrap_err(), - Error::Signalled { .. } + killed.clone().require_code().unwrap_err().kind(), + ErrorKind::Signalled { .. } )); - match killed.ensure_success().unwrap_err() { - Error::Signalled { + match killed.ensure_success().unwrap_err().kind() { + ErrorKind::Signalled { program, signal, .. } => { assert_eq!(program, "git"); - assert_eq!(signal, None); + assert_eq!(*signal, None); } other => panic!("expected Signalled for a signal-kill, got {other:?}"), } @@ -954,7 +960,8 @@ mod tests { None, ); assert_eq!(bad.stderr().len(), 10_000); - let Error::Exit { stdout, stderr, .. } = bad.ensure_success().unwrap_err() else { + let err = bad.ensure_success().unwrap_err(); + let ErrorKind::Exit { stdout, stderr, .. } = err.kind() else { panic!("expected Exit"); }; assert_eq!(stdout.len(), 10_000, "full stdout carried, untruncated"); @@ -985,8 +992,8 @@ mod tests { Some(raw.as_slice()), "the exact pre-decode bytes must survive, byte for byte" ); - match &err { - Error::Exit { stdout_bytes, .. } => { + match &err.kind() { + ErrorKind::Exit { stdout_bytes, .. } => { assert_eq!(stdout_bytes.as_deref(), Some(raw.as_slice())); } other => panic!("expected Exit, got {other:?}"), @@ -1032,8 +1039,8 @@ mod tests { .with_ok_codes(vec![0, 1]); assert!(!two.is_success(), "an unaccepted code is still a failure"); assert!(matches!( - two.ensure_success().unwrap_err(), - Error::Exit { code: 2, .. } + two.ensure_success().unwrap_err().kind(), + ErrorKind::Exit { code: 2, .. } )); } @@ -1078,8 +1085,11 @@ mod tests { ) .with_truncated(true) .with_overflow_totals(5000, 1_000_000); - match truncated.reject_if_truncated(Some(100), None) { - Err(Error::OutputTooLarge { + match truncated + .reject_if_truncated(Some(100), None) + .map_err(Error::into_kind) + { + Err(ErrorKind::OutputTooLarge { program, max_lines, max_bytes, diff --git a/src/runner.rs b/src/runner.rs index 055625e..1e793ca 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -58,7 +58,7 @@ pub trait ProcessRunner: Send + Sync { /// `&ProcessGroup` / [`JobRunner`] like text ones. Defaulted in terms of /// [`start`](Self::start) — so a runner that overrides `start` gets byte /// capture for free, and an `output_string`-only runner (one that does **not** - /// override `start`) surfaces [`Error::Unsupported`](crate::Error::Unsupported), + /// override `start`) surfaces [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), /// matching `start`. A text fixture (a `record`-feature cassette stores /// lossy-UTF-8) cannot reproduce exact bytes; capture bytes from a real or /// scripted runner. @@ -69,7 +69,7 @@ pub trait ProcessRunner: Send + Sync { /// Start `command` and return a live [`RunningProcess`] for streaming, /// readiness probes, or incremental consumption. /// - /// Defaulted to [`Error::Unsupported`](crate::Error::Unsupported) so an + /// Defaulted to [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) so an /// `output_string`-only runner (a hand-rolled double, a cassette runner) keeps /// compiling; the real runners ([`JobRunner`], `&ProcessGroup`) and /// [`ScriptedRunner`](crate::testing::ScriptedRunner) override it. @@ -83,9 +83,10 @@ pub trait ProcessRunner: Send + Sync { /// guarantee statically. async fn start(&self, command: &Command) -> Result { let _ = command; - Err(crate::Error::Unsupported { + Err(crate::ErrorKind::Unsupported { operation: "start".into(), - }) + } + .into()) } } @@ -158,7 +159,7 @@ impl ProcessRunner for std::sync::Arc { pub trait ProcessRunnerExt: ProcessRunner { /// Run, require an **accepted** exit, and return trimmed stdout. Accepted is /// `0` by default, widened by [`Command::ok_codes`](crate::Command::ok_codes); - /// any other code is [`Error::Exit`](crate::Error::Exit). + /// any other code is [`ErrorKind::Exit`](crate::ErrorKind::Exit). async fn run(&self, command: &Command) -> Result { let result = self.checked(command).await?; // `run` presents stdout as if complete, so fail loud on a bounded-buffer @@ -175,8 +176,8 @@ pub trait ProcessRunnerExt: ProcessRunner { } /// Run and return just the exit code. A run that produced no code surfaces as - /// an error — a timeout as [`Error::Timeout`](crate::Error::Timeout), a - /// signal-kill as [`Error::Signalled`](crate::Error::Signalled) — rather than a + /// an error — a timeout as [`ErrorKind::Timeout`](crate::ErrorKind::Timeout), a + /// signal-kill as [`ErrorKind::Signalled`](crate::ErrorKind::Signalled) — rather than a /// synthetic sentinel, mirroring /// [`ensure_success`](crate::ProcessResult::ensure_success). async fn exit_code(&self, command: &Command) -> Result { @@ -188,9 +189,9 @@ pub trait ProcessRunnerExt: ProcessRunner { /// Run a predicate command and read its exit code as a boolean: exit `0` → /// `Ok(true)`, exit `1` → `Ok(false)`, anything else → `Err` (other code as - /// [`Error::Exit`](crate::Error::Exit), timeout as - /// [`Error::Timeout`](crate::Error::Timeout), signal-kill as - /// [`Error::Signalled`](crate::Error::Signalled)). For + /// [`ErrorKind::Exit`](crate::ErrorKind::Exit), timeout as + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout), signal-kill as + /// [`ErrorKind::Signalled`](crate::ErrorKind::Signalled)). For /// commands whose exit code *is* the answer — `git diff --quiet`, `grep -q`, … async fn probe(&self, command: &Command) -> Result { retrying(command, || async { @@ -251,7 +252,7 @@ pub trait ProcessRunnerExt: ProcessRunner { /// Run (requiring an **accepted** exit) and feed the captured stdout to a /// *fallible* `parse` closure — the shape of JSON deserialization, where a - /// parse failure becomes [`Error::Parse`](crate::Error::Parse) (or whatever + /// parse failure becomes [`ErrorKind::Parse`](crate::ErrorKind::Parse) (or whatever /// error the closure returns). Like [`parse`](Self::parse) it is built on /// [`checked`](Self::checked), fails loud on truncation, and — being generic /// over `F` — cannot be dispatched through a `dyn ProcessRunnerExt` **object** @@ -274,7 +275,7 @@ pub trait ProcessRunnerExt: ProcessRunner { /// Stream `command`'s stdout and return the first line matching `predicate` /// (`None` if the stream ends first), bounded by the command's /// [`timeout`](crate::Command::timeout): a `Some` deadline surfaces as - /// [`Error::Timeout`](crate::Error::Timeout) and tears the process down. On an + /// [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) and tears the process down. On an /// **own-group** runner ([`JobRunner`], the default) that teardown covers the /// whole tree; on a **shared** [`ProcessGroup`](crate::ProcessGroup) it reaches /// the run's direct child by pid — a forking child's grandchildren (and, on the @@ -385,21 +386,22 @@ pub trait ProcessRunnerExt: ProcessRunner { .saturating_add(TEARDOWN_BACKSTOP_MARGIN); match tokio::time::timeout(backstop, raced).await { Ok(Ok(found)) => found, - Ok(Err(())) => return Err(crate::Error::Cancelled { program }), + Ok(Err(())) => return Err(crate::ErrorKind::Cancelled { program }.into()), Err(_elapsed) => { - return Err(crate::Error::Timeout { + return Err(crate::ErrorKind::Timeout { program, timeout: limit, stdout: String::new(), // streaming probe buffers nothing stderr: String::new(), stdout_bytes: None, - }); + } + .into()); } } } None => match raced.await { Ok(found) => found, - Err(()) => return Err(crate::Error::Cancelled { program }), + Err(()) => return Err(crate::ErrorKind::Cancelled { program }.into()), }, }; // Distinguish a deadline kill (arbiter `TS_TIMED_OUT`, set before the kill, @@ -413,13 +415,14 @@ pub trait ProcessRunnerExt: ProcessRunner { if found.is_none() && arbiter.load(std::sync::atomic::Ordering::Acquire) == crate::running::TS_TIMED_OUT { - return Err(crate::Error::Timeout { + return Err(crate::ErrorKind::Timeout { program, timeout: timeout.unwrap_or_default(), stdout: String::new(), stderr: String::new(), stdout_bytes: None, - }); + } + .into()); } Ok(found) } @@ -427,11 +430,11 @@ pub trait ProcessRunnerExt: ProcessRunner { /// Whether `err` is a launch failure **guaranteed to have occurred before any /// child process was spawned** — the program was never located -/// ([`Error::NotFound`](crate::Error::NotFound)), the spawn attempt itself -/// failed ([`Error::Spawn`](crate::Error::Spawn) — including a transient +/// ([`ErrorKind::NotFound`](crate::ErrorKind::NotFound)), the spawn attempt itself +/// failed ([`ErrorKind::Spawn`](crate::ErrorKind::Spawn) — including a transient /// `ETXTBSY` that [`Error::is_transient`](crate::Error::is_transient) accepts), /// or a required platform primitive was refused up front -/// ([`Error::Unsupported`](crate::Error::Unsupported)). In each of these no live +/// ([`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported)). In each of these no live /// child ever existed. /// /// This is what lets [`retrying`] safely re-run a command carrying a **one-shot** @@ -441,7 +444,7 @@ pub trait ProcessRunnerExt: ProcessRunner { /// rolls the reservation back and leaves the payload intact for the retried /// attempt. Every other error may have reached a live child that already /// consumed the source, so it is **not** treated as pre-child — including the -/// ambiguous [`Error::Io`](crate::Error::Io), which arises both before a child +/// ambiguous [`ErrorKind::Io`](crate::ErrorKind::Io), which arises both before a child /// (a process group that could not be created, a source already consumed by an /// *earlier* run) and after one (driving or tearing down a live child). /// @@ -450,10 +453,10 @@ pub trait ProcessRunnerExt: ProcessRunner { /// correctly refused a one-shot retry until it is deliberately added here. fn is_pre_child_launch_failure(err: &crate::Error) -> bool { matches!( - err, - crate::Error::NotFound { .. } - | crate::Error::Spawn { .. } - | crate::Error::Unsupported { .. } + err.kind(), + crate::ErrorKind::NotFound { .. } + | crate::ErrorKind::Spawn { .. } + | crate::ErrorKind::Unsupported { .. } ) } @@ -525,9 +528,9 @@ where tokio::select! { biased; () = token.cancelled() => { - return Err(crate::Error::Cancelled { + return Err(crate::ErrorKind::Cancelled { program: command.program_name(), - }); + }.into()); } () = tokio::time::sleep(delay) => {} } @@ -561,17 +564,17 @@ impl JobRunner { /// /// # Errors /// - /// The full launch surface: [`Error::NotFound`](crate::Error::NotFound) or - /// [`Error::Spawn`](crate::Error::Spawn) (the program could not be located or - /// started), [`Error::Unsupported`](crate::Error::Unsupported) (a POSIX-only + /// The full launch surface: [`ErrorKind::NotFound`](crate::ErrorKind::NotFound) or + /// [`ErrorKind::Spawn`](crate::ErrorKind::Spawn) (the program could not be located or + /// started), [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) (a POSIX-only /// primitive — user/group switch, `setsid`, umask — unavailable on this - /// platform), [`Error::Cancelled`](crate::Error::Cancelled) (the command's - /// token was already cancelled), or [`Error::Io`](crate::Error::Io) (the + /// platform), [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled) (the command's + /// token was already cancelled), or [`ErrorKind::Io`](crate::ErrorKind::Io) (the /// private [`ProcessGroup`] could not be created, or a one-shot streaming /// stdin source was already consumed by a previous run). #[cfg_attr( feature = "limits", - doc = "A resource cap on the new group that cannot be enforced is [`Error::ResourceLimit`](crate::Error::ResourceLimit)." + doc = "A resource cap on the new group that cannot be enforced is [`ErrorKind::ResourceLimit`](crate::ErrorKind::ResourceLimit)." )] pub async fn start(&self, command: &Command) -> Result { let group = ProcessGroup::new()?; @@ -599,12 +602,12 @@ impl ProcessGroup { /// /// # Errors /// - /// The launch surface: [`Error::NotFound`](crate::Error::NotFound) / - /// [`Error::Spawn`](crate::Error::Spawn) (locate/start failure), - /// [`Error::Unsupported`](crate::Error::Unsupported) (a POSIX-only primitive + /// The launch surface: [`ErrorKind::NotFound`](crate::ErrorKind::NotFound) / + /// [`ErrorKind::Spawn`](crate::ErrorKind::Spawn) (locate/start failure), + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) (a POSIX-only primitive /// unavailable on this platform), - /// [`Error::Cancelled`](crate::Error::Cancelled) (a pre-cancelled token), or - /// [`Error::Io`](crate::Error::Io) (e.g. a one-shot stdin source already + /// [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled) (a pre-cancelled token), or + /// [`ErrorKind::Io`](crate::ErrorKind::Io) (e.g. a one-shot stdin source already /// consumed). Unlike [`JobRunner::start`], no new group is created here — the /// child joins this existing group. pub async fn start(&self, command: &Command) -> Result { @@ -663,7 +666,7 @@ pub(crate) fn take_stdin_for_run( // `Stdin::empty()`). Reject the conflict here, at the shared launch // boundary every runner routes through (live launch, the scripted/fake // doubles, and cassette record via `JobRunner`), as a typed - // `Error::Io(InvalidInput)` — the same failure mode as the one-shot-consumed + // `ErrorKind::Io(InvalidInput)` — the same failure mode as the one-shot-consumed // guard below — rather than silently letting one setting win. if command.keeps_stdin_open() { return Err(inherit_stdin_conflict(command, "keep_stdin_open()")); @@ -684,7 +687,7 @@ pub(crate) fn take_stdin_for_run( match command.stdin_source() { Some(source) => match source.take_for_run() { Ok(reservation) => Ok(Some(reservation)), - Err(crate::stdin::OneShotConsumed) => Err(crate::Error::Io(std::io::Error::new( + Err(crate::stdin::OneShotConsumed) => Err(crate::ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "`{}`: its one-shot streaming stdin (from_reader/from_lines) was \ @@ -693,7 +696,8 @@ pub(crate) fn take_stdin_for_run( (re-runnable), or rebuild the command with a fresh source", command.program_name() ), - ))), + )) + .into()), }, None => Ok(None), } @@ -701,11 +705,11 @@ pub(crate) fn take_stdin_for_run( /// The typed error raised when [`Command::inherit_stdin`](crate::Command::inherit_stdin) /// is combined with another stdin knob that would drive/close stdin (`other` -/// names it). An `Error::Io(InvalidInput)` — mirroring the crate's other +/// names it). An `ErrorKind::Io(InvalidInput)` — mirroring the crate's other /// stdin-misconfiguration refusal (a consumed one-shot source) — so a caller /// gets one uniform "bad stdin setup" failure mode to match on. fn inherit_stdin_conflict(command: &Command, other: &str) -> crate::Error { - crate::Error::Io(std::io::Error::new( + crate::ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "`{}`: inherit_stdin() cannot be combined with {other} — a child either \ @@ -714,6 +718,7 @@ fn inherit_stdin_conflict(command: &Command, other: &str) -> crate::Error { command.program_name() ), )) + .into() } /// Build the OS command, spawn it into `group`, wire stdin, and wrap everything @@ -726,27 +731,27 @@ pub(crate) async fn launch(group: &ProcessGroup, command: &Command) -> Result Result Result Result child, - Err(crate::Error::Spawn { source, .. }) - if source.kind() == std::io::ErrorKind::NotFound => + Err(e) + // this would be cleaner with if let guards in match, + // but those are not supported until Rust 1.95 + if matches!(e.kind(), crate::ErrorKind::Spawn { source, .. } if source.kind() == std::io::ErrorKind::NotFound) => { + let crate::ErrorKind::Spawn { source, .. } = e.into_kind() else { + unreachable!() + }; if is_bare_name(command.program()) { // Reuse the *same* spawn-free resolution the preflight helper // uses (`command::resolve_program`) to enrich the diagnostic — @@ -823,20 +835,23 @@ pub(crate) async fn launch(group: &ProcessGroup, command: &Command) -> Result Err(crate::Error::Spawn { + ProgramResolution::Found(_) => Err(crate::ErrorKind::Spawn { program: command.program_name(), source, - }), - ProgramResolution::NotFound { searched } => Err(crate::Error::NotFound { + } + .into()), + ProgramResolution::NotFound { searched } => Err(crate::ErrorKind::NotFound { program: command.program_name(), searched, - }), + } + .into()), }; } - return Err(crate::Error::NotFound { + return Err(crate::ErrorKind::NotFound { program: command.program_name(), searched: None, - }); + } + .into()); } Err(other) => return Err(other), }; @@ -906,7 +921,7 @@ pub(crate) async fn launch(group: &ProcessGroup, command: &Command) -> Result {} other => panic!("a Unix backslash name must be enriched as bare: {other:?}"), @@ -972,7 +987,7 @@ mod tests { /// `inherit_stdin()` + `keep_stdin_open()` is a contradiction (share the /// parent's stdin AND be handed an interactive pipe) and is rejected at the - /// launch boundary with a typed `Error::Io(InvalidInput)`, not a silent + /// launch boundary with a typed `ErrorKind::Io(InvalidInput)`, not a silent /// last-write-wins. #[test] fn inherit_stdin_conflicts_with_keep_stdin_open() { @@ -980,9 +995,9 @@ mod tests { // (it guards a live payload), so assert on the error via `match`, not // `expect_err`. let command = Command::new("child").inherit_stdin().keep_stdin_open(); - match take_stdin_for_run(&command) { - Err(Error::Io(io)) => assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput), - Err(other) => panic!("expected Error::Io(InvalidInput), got {other:?}"), + match take_stdin_for_run(&command).map_err(Error::into_kind) { + Err(ErrorKind::Io(io)) => assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput), + Err(other) => panic!("expected ErrorKind::Io(InvalidInput), got {other:?}"), Ok(_) => panic!("inherit_stdin + keep_stdin_open must be rejected"), } // Order-independent: the conflict is rejected regardless of builder order. @@ -1005,9 +1020,9 @@ mod tests { crate::Stdin::from_reader(&b"stream"[..]), ] { let command = Command::new("child").stdin(source).inherit_stdin(); - match take_stdin_for_run(&command) { - Err(Error::Io(io)) => assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput), - Err(other) => panic!("expected Error::Io(InvalidInput), got {other:?}"), + match take_stdin_for_run(&command).map_err(Error::into_kind) { + Err(ErrorKind::Io(io)) => assert_eq!(io.kind(), std::io::ErrorKind::InvalidInput), + Err(other) => panic!("expected ErrorKind::Io(InvalidInput), got {other:?}"), Ok(_) => panic!("inherit_stdin + a stdin source must be rejected"), } } @@ -1068,7 +1083,7 @@ mod tests { async fn retry_retries_until_success() { let runner = flaky(2); let cmd = Command::new("x").retry(5, Duration::from_millis(0), |e| { - matches!(e, Error::Exit { .. }) + matches!(e.kind(), ErrorKind::Exit { .. }) }); assert_eq!(runner.run(&cmd).await.unwrap(), "out"); assert_eq!(runner.calls.load(Ordering::SeqCst), 3); // 2 failures + 1 success @@ -1114,7 +1129,7 @@ mod tests { #[tokio::test] async fn one_shot_stdin_is_not_retried_on_a_post_child_error() { - // `flaky` reports a non-zero exit — a *post-child* `Error::Exit`: a child + // `flaky` reports a non-zero exit — a *post-child* `ErrorKind::Exit`: a child // ran, so a one-shot source would have been consumed. The gate refuses to // retry it (contrast the pre-child launch failure covered below), while a // re-runnable source still retries to the cap. @@ -1272,7 +1287,7 @@ mod tests { .await .expect_err("a post-child timeout on a one-shot command errors"); assert!( - matches!(err, Error::Timeout { .. }), + matches!(err.kind(), ErrorKind::Timeout { .. }), "the first post-child error is returned as-is, got {err:?}" ); assert_eq!( @@ -1288,8 +1303,8 @@ mod tests { let runner = ScriptedRunner::new().on(["tool", "x"], Reply::fail(2, "boom")); let cmd = Command::new("tool").args(["x"]).ok_codes([0, 1, 2]); assert!(matches!( - runner.probe(&cmd).await, - Err(Error::Exit { code: 2, .. }) + &runner.probe(&cmd).await, + Err(e) if matches!(e.kind(), ErrorKind::Exit { code: 2, .. }) )); } @@ -1311,14 +1326,13 @@ mod tests { let ok_runner = ScriptedRunner::new().on(["tool"], Reply::ok("nope")); let err = ok_runner .try_parse::(&Command::new("tool"), |s| { - s.trim().parse::().map_err(|e| Error::Parse { - program: "tool".into(), - message: e.to_string(), - }) + s.trim() + .parse::() + .map_err(|e| Error::parse("tool", e.to_string())) }) .await .expect_err("a parser failure is an error"); - assert!(matches!(err, Error::Parse { .. }), "got {err:?}"); + assert!(matches!(err.kind(), ErrorKind::Parse { .. }), "got {err:?}"); let fail_runner = ScriptedRunner::new().on(["tool"], Reply::fail(3, "boom")); let err = fail_runner @@ -1327,7 +1341,10 @@ mod tests { }) .await .expect_err("a non-zero exit is an error"); - assert!(matches!(err, Error::Exit { code: 3, .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::Exit { code: 3, .. }), + "got {err:?}" + ); } #[tokio::test] @@ -1353,7 +1370,10 @@ mod tests { }) .await .expect_err("a truncated capture must fail loud, not parse a clipped tail"); - assert!(matches!(err, Error::OutputTooLarge { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::OutputTooLarge { .. }), + "got {err:?}" + ); } #[tokio::test(start_paused = true)] @@ -1377,7 +1397,10 @@ mod tests { .run(&cmd) .await .expect_err("a cancelled backoff errors"); - assert!(matches!(err, Error::Cancelled { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::Cancelled { .. }), + "got {err:?}" + ); assert!( start.elapsed() < Duration::from_secs(60), "the backoff must be cancellable, took {:?}", @@ -1390,7 +1413,7 @@ mod tests { async fn retry_sleeps_the_backoff_between_attempts() { let runner = flaky(2); let cmd = Command::new("x").retry(5, Duration::from_millis(100), |e| { - matches!(e, Error::Exit { .. }) + matches!(e.kind(), ErrorKind::Exit { .. }) }); let start = tokio::time::Instant::now(); assert_eq!(runner.run(&cmd).await.unwrap(), "out"); @@ -1411,9 +1434,10 @@ mod tests { impl ProcessRunner for AlwaysCancelled { async fn output_string(&self, command: &Command) -> Result> { self.0.fetch_add(1, Ordering::SeqCst); - Err(Error::Cancelled { + Err(ErrorKind::Cancelled { program: command.program().to_string_lossy().into_owned(), - }) + } + .into()) } } @@ -1423,7 +1447,7 @@ mod tests { let cmd = Command::new("x").retry(5, Duration::from_millis(0), |_| true); let err = runner.run(&cmd).await.expect_err("cancelled run errors"); assert!( - matches!(err, Error::Cancelled { .. }), + matches!(err.kind(), ErrorKind::Cancelled { .. }), "expected Cancelled, got {err:?}" ); assert_eq!( @@ -1524,7 +1548,10 @@ mod tests { .await .expect_err("a missing program must error"); assert!( - matches!(err, Error::NotFound { .. } | Error::Spawn { .. }), + matches!( + err.kind(), + ErrorKind::NotFound { .. } | ErrorKind::Spawn { .. } + ), "expected a pre-child launch failure, got {err:?}" ); @@ -1544,7 +1571,10 @@ mod tests { .output_string(&stdin_echo(source)) .await .expect_err("the one-shot source is consumed after the successful run"); - assert!(matches!(err, Error::Io(_)), "expected Io, got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::Io(_)), + "expected Io, got {err:?}" + ); } #[tokio::test] @@ -1567,7 +1597,7 @@ mod tests { // The loser fails loud on the taken source, not silently with empty stdin. let loser = if r1.is_err() { r1 } else { r2 }; assert!( - matches!(loser.unwrap_err(), Error::Io(_)), + matches!(loser.unwrap_err().kind(), ErrorKind::Io(_)), "the losing concurrent launch must fail loud" ); } @@ -1588,7 +1618,7 @@ mod tests { .await .expect_err("a pre-cancelled launch errors"); assert!( - matches!(err, Error::Cancelled { .. }), + matches!(err.kind(), ErrorKind::Cancelled { .. }), "expected Cancelled, got {err:?}" ); @@ -1622,6 +1652,9 @@ mod tests { .output_string(&exits_zero(source)) .await .expect_err("the one-shot source stays consumed after a successful spawn"); - assert!(matches!(err, Error::Io(_)), "expected Io, got {err:?}"); + assert!( + matches!(err.kind(), ErrorKind::Io(_)), + "expected Io, got {err:?}" + ); } } diff --git a/src/running/mod.rs b/src/running/mod.rs index d093c64..422a80f 100644 --- a/src/running/mod.rs +++ b/src/running/mod.rs @@ -30,7 +30,7 @@ use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout}; use tokio::task::JoinHandle; use crate::buffer::{OutputBufferPolicy, OverflowMode, clamp_dropoldest_tail, push_capped_bytes}; -use crate::error::Error; +use crate::error::ErrorKind; use crate::error::Result; use crate::group::ProcessGroup; use crate::pump::{SharedLines, StreamConfig, pump_lines_core}; @@ -160,7 +160,7 @@ pub struct RunningProcess { stdout_pump: Option>, stderr_pump: Option>, // Non-broken-pipe stdin failure stashed by `observe_stdin_task`; surfaced as - // `Error::Stdin` by `checked_outcome` only when the run otherwise succeeded. + // `ErrorKind::Stdin` by `checked_outcome` only when the run otherwise succeeded. stdin_error: Option, // Bulk capture verbs fail loudly on non-piped stdout rather than returning empty. stdout_piped: bool, @@ -507,14 +507,15 @@ impl RunningProcess { fn ensure_stdout_streamable(&self) -> Result<()> { self.ensure_stdout_capturable()?; // (a) non-piped stdout if self.stdout_sink.is_some() { - return Err(Error::Io(std::io::Error::new( + return Err(ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "`{}`: stdout was already consumed by an earlier stdout_lines/output_events \ call — stream it once (a second call would yield an empty stream)", self.program ), - ))); + )) + .into()); } Ok(()) } @@ -535,16 +536,16 @@ impl RunningProcess { /// [`ensure_success`](ProcessResult::ensure_success) to turn a non-zero, /// timed-out, or signalled outcome into an error. The `Err` cases are: /// - /// - [`Error::Cancelled`] — the run was cancelled via + /// - [`ErrorKind::Cancelled`] — the run was cancelled via /// [`Command::cancel_on`](crate::Command::cancel_on). Unlike a timeout, /// cancellation is *always* raised (and discards any captured output). - /// - [`Error::OutputTooLarge`] — the + /// - [`ErrorKind::OutputTooLarge`] — the /// [`OutputBufferPolicy`](crate::OutputBufferPolicy) is fail-loud /// ([`OverflowMode::Error`](crate::OverflowMode)) and the captured output /// exceeded its line or byte ceiling. - /// - [`Error::Stdin`] — a configured stdin source failed for a reason other + /// - [`ErrorKind::Stdin`] — a configured stdin source failed for a reason other /// than a broken pipe, on an *otherwise-successful* run. - /// - [`Error::Io`] — stdout is not piped, a prior streaming call already + /// - [`ErrorKind::Io`] — stdout is not piped, a prior streaming call already /// consumed it as decoded lines, or waiting on the child failed. pub async fn output_string(mut self) -> Result> { let finished = self @@ -592,13 +593,13 @@ impl RunningProcess { /// (stderr captured as text). On **timeout** the bytes read before the /// deadline are returned as a best-effort prefix (the outcome is /// [`Outcome::TimedOut`]); a **cancelled** run instead errors with - /// [`Error::Cancelled`] and no bytes — cancellation via + /// [`ErrorKind::Cancelled`] and no bytes — cancellation via /// [`Command::cancel_on`](crate::Command::cancel_on) is always terminal. /// /// A byte ceiling on the [`OutputBufferPolicy`] bounds the raw stdout capture /// (its `max_lines` does not — raw bytes have no lines): with /// [`OverflowMode::Error`](crate::OverflowMode) a flood past the cap errors - /// with [`Error::OutputTooLarge`], while the drop modes keep a bounded + /// with [`ErrorKind::OutputTooLarge`], while the drop modes keep a bounded /// head/tail and set [`ProcessResult::truncated`]. With no byte cap the /// capture is unbounded — bound a flooding child with /// [`with_max_bytes`](crate::OutputBufferPolicy::with_max_bytes) or a @@ -606,12 +607,12 @@ impl RunningProcess { /// /// # Errors /// - /// Returns [`Error::Io(InvalidInput)`](std::io::ErrorKind::InvalidInput) if + /// Returns [`ErrorKind::Io(InvalidInput)`](std::io::ErrorKind::InvalidInput) if /// stdout is not piped, or if a prior streaming call already consumed stdout /// as decoded lines (the raw bytes cannot be reconstructed). Returns - /// [`Error::OutputTooLarge`] if the byte ceiling is set to + /// [`ErrorKind::OutputTooLarge`] if the byte ceiling is set to /// [`OverflowMode::Error`](crate::OverflowMode) and the raw stdout exceeds it. - /// (A cancelled run is [`Error::Cancelled`]; a non-zero exit, a timeout, or a + /// (A cancelled run is [`ErrorKind::Cancelled`]; a non-zero exit, a timeout, or a /// signal-kill is *captured* in the returned [`ProcessResult`]'s /// [`outcome`](ProcessResult::outcome), not raised.) /// @@ -623,7 +624,7 @@ impl RunningProcess { pub async fn output_bytes(mut self) -> Result>> { self.ensure_stdout_capturable()?; if self.stdout_sink.is_some() || self.stderr_sink.is_some() { - return Err(Error::Io(std::io::Error::new( + return Err(ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "`{}`: output_bytes cannot follow a streaming call (stdout was already \ @@ -631,7 +632,8 @@ impl RunningProcess { call output_bytes without streaming first", self.program ), - ))); + )) + .into()); } let stderr_sink = SharedLines::new(&self.buffer); self.stderr_pump = self.backend.take_stderr_reader().map(|pipe| { @@ -659,7 +661,7 @@ impl RunningProcess { let stdout_mode = self.buffer.overflow; // Shared signals the raw drain writes and the bounded teardown reads even if // it has to abort the task — including the first non-broken-pipe OS read - // error, so an incomplete byte capture surfaces as `Error::Io` below rather + // error, so an incomplete byte capture surfaces as `ErrorKind::Io` below rather // than a silently-truncated `Ok(ProcessResult)` prefix. let signals = RawStdoutSignals { seen: Arc::new(AtomicUsize::new(0)), @@ -708,26 +710,28 @@ impl RunningProcess { // the stderr line ceiling below. Raw stdout has no lines, so report only // the byte ceiling that actually fired (`max_lines: None`). if signals.overflowed.load(Ordering::Relaxed) { - return Err(crate::Error::OutputTooLarge { + return Err(crate::ErrorKind::OutputTooLarge { program: self.program.clone(), max_lines: None, max_bytes: self.buffer.max_bytes, total_lines: 0, total_bytes: signals.seen.load(Ordering::Relaxed), - }); + } + .into()); } if stderr_sink.overflowed() { - return Err(crate::Error::OutputTooLarge { + return Err(crate::ErrorKind::OutputTooLarge { program: self.program.clone(), max_lines: self.buffer.max_lines, max_bytes: self.buffer.max_bytes, total_lines: stderr_sink.count(), total_bytes: stderr_sink.seen_bytes(), - }); + } + .into()); } // An incomplete capture from a first OS read error on either stream - // surfaces as `Error::Io` — a short raw-stdout prefix (or a truncated + // surfaces as `ErrorKind::Io` — a short raw-stdout prefix (or a truncated // stderr) is not a full success. Checked after the overflow ceilings (the // more specific signal if both fire) and after `checked_outcome` // (cancellation wins). A timeout closes the pipe with a *clean* EOF, not a @@ -739,10 +743,10 @@ impl RunningProcess { .expect("stdout read-error slot poisoned") .take() { - return Err(Error::Io(source)); + return Err(ErrorKind::Io(source).into()); } if let Some(source) = stderr_sink.take_read_error() { - return Err(Error::Io(source)); + return Err(ErrorKind::Io(source).into()); } let stderr_lines = stderr_sink.drain(); @@ -772,15 +776,15 @@ impl RunningProcess { /// /// Reports the raw outcome — timeout and signals are not raised as errors /// here. Exception: cancellation via `Command::cancel_on` always errors with - /// `Error::Cancelled`. + /// `ErrorKind::Cancelled`. /// /// # Errors /// /// A timeout or signal-kill is *captured* in the returned [`Outcome`], not - /// raised. The `Err` cases are [`Error::Cancelled`] (the run was cancelled + /// raised. The `Err` cases are [`ErrorKind::Cancelled`] (the run was cancelled /// via [`Command::cancel_on`](crate::Command::cancel_on) — always raised), - /// [`Error::Stdin`] (a non-broken-pipe stdin-source failure on an - /// otherwise-successful run), or [`Error::Io`] (waiting on the child failed). + /// [`ErrorKind::Stdin`] (a non-broken-pipe stdin-source failure on an + /// otherwise-successful run), or [`ErrorKind::Io`] (waiting on the child failed). pub async fn wait(mut self) -> Result { Ok(self .finish_lines(CaptureMode::Discard, /* expose_counts */ false, || {}) @@ -793,7 +797,7 @@ impl RunningProcess { /// awaited. /// /// Only an **own-group** handle can be shut down here — a **shared-group** - /// handle returns [`Error::Unsupported`](crate::Error::Unsupported) because + /// handle returns [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) because /// shutting it down would tear down the caller's other children too. /// /// If the configured timeout deadline already elapsed when `shutdown` is @@ -801,25 +805,26 @@ impl RunningProcess { /// /// # Errors /// - /// - [`Error::Unsupported`] — this is a **shared-group** handle, which does + /// - [`ErrorKind::Unsupported`] — this is a **shared-group** handle, which does /// not own its group (tearing it down would kill the caller's other /// children); use [`ProcessGroup::shutdown`](crate::ProcessGroup::shutdown) /// or [`start_kill`](Self::start_kill) instead. - /// - [`Error::Cancelled`] — the run was cancelled via + /// - [`ErrorKind::Cancelled`] — the run was cancelled via /// [`Command::cancel_on`](crate::Command::cancel_on). - /// - [`Error::Stdin`] — a non-broken-pipe stdin-source failure on an + /// - [`ErrorKind::Stdin`] — a non-broken-pipe stdin-source failure on an /// otherwise-successful run. - /// - [`Error::Io`] — the graceful teardown or the exit wait failed. + /// - [`ErrorKind::Io`] — the graceful teardown or the exit wait failed. /// /// A timeout or signal-kill is *captured* in the returned [`Outcome`], not /// raised. pub async fn shutdown(mut self, grace: std::time::Duration) -> Result { let Some(group) = self.backend.own_group().cloned() else { - return Err(Error::Unsupported { + return Err(ErrorKind::Unsupported { operation: "shutdown (a shared-group handle does not own its group — \ use ProcessGroup::shutdown, or start_kill for just this child)" .into(), - }); + } + .into()); }; // Disable the concurrent `wait()`'s deadline arm to avoid two overlapping // graceful teardowns. A timeout that already elapsed still classifies @@ -891,10 +896,10 @@ impl RunningProcess { /// /// The same surface as [`wait`](Self::wait): a timeout or signal-kill is /// *captured* in the returned [`RunProfile`](crate::stats::RunProfile)'s - /// outcome, not raised. The `Err` cases are [`Error::Cancelled`] (cancelled - /// via [`Command::cancel_on`](crate::Command::cancel_on)), [`Error::Stdin`] + /// outcome, not raised. The `Err` cases are [`ErrorKind::Cancelled`] (cancelled + /// via [`Command::cancel_on`](crate::Command::cancel_on)), [`ErrorKind::Stdin`] /// (a non-broken-pipe stdin-source failure on an otherwise-successful run), - /// or [`Error::Io`] (waiting on the child failed). + /// or [`ErrorKind::Io`] (waiting on the child failed). #[cfg(feature = "stats")] pub async fn profile(mut self, every: Duration) -> Result { use std::sync::{Arc, Mutex}; @@ -1040,19 +1045,20 @@ impl RunningProcess { if matches!(capture, CaptureMode::Lines) { for sink in [&stdout_sink, &stderr_sink] { if sink.overflowed() { - return Err(crate::Error::OutputTooLarge { + return Err(crate::ErrorKind::OutputTooLarge { program: self.program.clone(), max_lines: self.buffer.max_lines, max_bytes: self.buffer.max_bytes, total_lines: sink.count(), total_bytes: sink.seen_bytes(), - }); + } + .into()); } } } // A first OS read error on either pipe means the capture is incomplete: - // surface it as `Error::Io` for the capturing (`output_string`) and the + // surface it as `ErrorKind::Io` for the capturing (`output_string`) and the // discard (`wait`/`profile`) paths alike, rather than reporting a // silently-short read as a full success. Checked after the fail-loud // overflow ceiling (the more specific signal if both fire) and after @@ -1061,7 +1067,7 @@ impl RunningProcess { // normal writer-closed stream never trips this. for sink in [&stdout_sink, &stderr_sink] { if let Some(source) = sink.take_read_error() { - return Err(Error::Io(source)); + return Err(ErrorKind::Io(source).into()); } } @@ -1104,16 +1110,18 @@ impl RunningProcess { // discarding real output. `unwrap_or(false)` — `None` is not yet // snapshotted; treat conservatively as "not cancelled". if self.cancel_at_exit.unwrap_or(false) { - return Err(Error::Cancelled { + return Err(ErrorKind::Cancelled { program: self.program.clone(), - }); + } + .into()); } let succeeded = matches!(outcome, Outcome::Exited(code) if self.ok_codes.contains(&code)); if succeeded && let Some(source) = self.stdin_error.take() { - return Err(Error::Stdin { + return Err(ErrorKind::Stdin { program: self.program.clone(), source, - }); + } + .into()); } Ok(outcome) } @@ -1153,7 +1161,7 @@ impl RunningProcess { /// `from_reader`/`from_file` source that erred while the pumps were still /// draining the child's output — was re-parked and would otherwise never /// reach `self.stdin_error`, letting an otherwise-successful run report a - /// silent success (exactly the case `Error::Stdin` exists to diagnose). + /// silent success (exactly the case `ErrorKind::Stdin` exists to diagnose). /// /// This waits for that writer, but only *bounded* by [`PUMP_TEARDOWN`]: a /// writer still blocked on a genuinely hung source is aborted and left @@ -1315,7 +1323,7 @@ impl RunningProcess { // polling — no reap, gate untouched — so losers stay usable. let status = gated_reap(&gate, real.child_mut()) .await - .map_err(Error::Io)?; + .map_err(ErrorKind::Io)?; match status.code() { Some(code) => Outcome::Exited(code), None => { @@ -1584,7 +1592,7 @@ impl RunningProcess { /// /// # Errors /// - /// [`Error::Io`] if the OS rejects the kill for a reason other than the + /// [`ErrorKind::Io`] if the OS rejects the kill for a reason other than the /// child having already been reaped (which is treated as a no-op success). pub fn start_kill(&mut self) -> Result<()> { match &mut self.backend { @@ -1593,7 +1601,7 @@ impl RunningProcess { // tokio/std currently return `Ok` for a reaped child; treat // `InvalidInput` as the same no-op in case that ever changes. Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => {} - Err(e) => return Err(Error::Io(e)), + Err(e) => return Err(ErrorKind::Io(e).into()), }, Backend::Scripted(s) => s.kill(), } @@ -1864,7 +1872,7 @@ struct RawStdoutSignals { overflowed: Arc, /// Set when a drop-mode byte cap discarded bytes (the truncation signal). truncated: Arc, - /// The first non-broken-pipe OS read error, surfaced as [`Error::Io`]. + /// The first non-broken-pipe OS read error, surfaced as [`ErrorKind::Io`]. read_error: Arc>>, } @@ -1872,7 +1880,7 @@ struct RawStdoutSignals { /// ceiling (`cap`/`mode`) and updating the shared `signals` (bytes seen, the two /// overflow flags, and the first non-broken-pipe OS read error) so /// [`RunningProcess::output_bytes`] can surface an incomplete capture as -/// [`Error::Io`] instead of a silently-short prefix. The raw (non-line) analogue +/// [`ErrorKind::Io`] instead of a silently-short prefix. The raw (non-line) analogue /// of [`pump_lines_core`](crate::pump)'s read loop, extracted as a seam so the /// read-error / broken-pipe / clean-EOF classification is unit-testable without a /// live child. A broken-pipe read (the writer closing) is the normal end of a @@ -1973,19 +1981,22 @@ mod tests { .expect("scripted start") } - /// A stashed non-broken-pipe stdin failure surfaces as `Error::Stdin` only on + /// A stashed non-broken-pipe stdin failure surfaces as `ErrorKind::Stdin` only on /// an otherwise-successful outcome; a non-zero exit or a signal is the "realer" /// failure and wins (outcome passed through). #[tokio::test] async fn stdin_error_surfaces_only_on_a_successful_outcome() { let mut run = scripted_handle(&[0]).await; run.stdin_error = Some(std::io::Error::other("boom")); - match run.checked_outcome(Outcome::Exited(0)) { - Err(Error::Stdin { program, source }) => { + match run + .checked_outcome(Outcome::Exited(0)) + .map_err(crate::Error::into_kind) + { + Err(ErrorKind::Stdin { program, source }) => { assert_eq!(program, "tool"); assert_eq!(source.to_string(), "boom"); } - other => panic!("expected Error::Stdin, got {other:?}"), + other => panic!("expected ErrorKind::Stdin, got {other:?}"), } // Non-zero exit wins: outcome returned for the caller's classifier. @@ -2012,8 +2023,8 @@ mod tests { let mut run = scripted_handle(&[0, 3]).await; run.stdin_error = Some(std::io::Error::other("boom")); assert!(matches!( - run.checked_outcome(Outcome::Exited(3)), - Err(Error::Stdin { .. }) + &run.checked_outcome(Outcome::Exited(3)), + Err(e) if matches!(e.kind(), ErrorKind::Stdin { .. }) )); } @@ -2071,8 +2082,8 @@ mod tests { .output_bytes() .await .expect_err("output_bytes after streaming must error, not return empty"); - match err { - Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), + match err.kind() { + ErrorKind::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), other => panic!("expected Io(InvalidInput), got {other:?}"), } } @@ -2258,7 +2269,7 @@ mod tests { run.timeout_state.store(TS_TIMED_OUT, Ordering::Release); token.cancel(); match run.wait().await { - Err(Error::Cancelled { .. }) => {} + Err(e) if matches!(e.kind(), ErrorKind::Cancelled { .. }) => {} other => panic!("expected Err(Cancelled), got {other:?}"), } } @@ -2304,7 +2315,7 @@ mod tests { .wait_for(|| async { false }, Duration::from_secs(5)) .await { - Err(Error::NotReady { .. }) => {} + Err(e) if matches!(e.kind(), ErrorKind::NotReady { .. }) => {} other => panic!("expected Err(NotReady), got {other:?}"), } // Cancel only now, after the probe already observed the exit: the frozen @@ -2340,13 +2351,13 @@ mod tests { .wait_for(|| async { false }, Duration::from_secs(5)) .await { - Err(Error::NotReady { .. }) => {} + Err(e) if matches!(e.kind(), ErrorKind::NotReady { .. }) => {} other => panic!("expected Err(NotReady), got {other:?}"), } // The cancel active at observation is preserved: the finisher reports it, // never a silent `Ok` for a run the cancel really tore down. match run.wait().await { - Err(Error::Cancelled { .. }) => {} + Err(e) if matches!(e.kind(), ErrorKind::Cancelled { .. }) => {} other => panic!("expected Err(Cancelled), got {other:?}"), } } @@ -2673,8 +2684,8 @@ mod tests { .start(&Command::new("tool").stdout(crate::StdioMode::Null)) .await .unwrap(); - match run.output_string().await { - Err(Error::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), + match run.output_string().await.map_err(crate::Error::into_kind) { + Err(ErrorKind::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), other => panic!("expected Io(InvalidInput), got {other:?}"), } @@ -2683,7 +2694,10 @@ mod tests { .start(&Command::new("tool").stdout(crate::StdioMode::Inherit)) .await .unwrap(); - assert!(matches!(run.output_bytes().await, Err(Error::Io(_)))); + assert!(matches!( + &run.output_bytes().await, + Err(e) if matches!(e.kind(), ErrorKind::Io(_)) + )); let run = ScriptedRunner::new() .fallback(Reply::ok("hi")) @@ -2782,7 +2796,7 @@ mod tests { ); assert!( err.is_some(), - "the raw stdout OS read error is recorded for output_bytes to surface as Error::Io" + "the raw stdout OS read error is recorded for output_bytes to surface as ErrorKind::Io" ); } @@ -2814,7 +2828,7 @@ mod tests { // --- T-087: consuming finishers surface a recorded read error ----------- /// The capturing line finisher (`output_string`, via `finish_lines`) surfaces - /// a recorded stdout read error as `Error::Io` rather than a silently-short + /// a recorded stdout read error as `ErrorKind::Io` rather than a silently-short /// `Ok(ProcessResult)`. The sink stands in for one a pump populated (the pump /// seam is covered in `pump.rs`); a clean-EOF sink carries no error, so a /// normal run is unaffected — the other tests here exercise that path. @@ -2824,22 +2838,22 @@ mod tests { let sink = SharedLines::new(&OutputBufferPolicy::unbounded()); sink.set_read_error(std::io::Error::other("stdout read boom")); run.stdout_sink = Some(sink); - match run.output_string().await { - Err(Error::Io(e)) => assert_eq!(e.to_string(), "stdout read boom"), + match run.output_string().await.map_err(crate::Error::into_kind) { + Err(ErrorKind::Io(e)) => assert_eq!(e.to_string(), "stdout read boom"), other => panic!("expected Err(Io) for an incomplete capture, got {other:?}"), } } /// The discard finisher (`wait`, also via `finish_lines`) likewise classifies - /// an incomplete stderr capture as `Error::Io`, not a silent success. + /// an incomplete stderr capture as `ErrorKind::Io`, not a silent success. #[tokio::test] async fn wait_surfaces_a_recorded_read_error_as_io() { let mut run = scripted_handle(&[0]).await; let sink = SharedLines::new(&OutputBufferPolicy::unbounded()); sink.set_read_error(std::io::Error::other("stderr read boom")); run.stderr_sink = Some(sink); - match run.wait().await { - Err(Error::Io(e)) => assert_eq!(e.to_string(), "stderr read boom"), + match run.wait().await.map_err(crate::Error::into_kind) { + Err(ErrorKind::Io(e)) => assert_eq!(e.to_string(), "stderr read boom"), other => panic!("expected Err(Io) for an incomplete capture, got {other:?}"), } } diff --git a/src/running/probes.rs b/src/running/probes.rs index 3eff74c..fefd885 100644 --- a/src/running/probes.rs +++ b/src/running/probes.rs @@ -22,7 +22,7 @@ use tokio::net::TcpStream; #[cfg(unix)] use tokio::net::UnixStream; -use crate::error::{Error, Result}; +use crate::error::{Error, ErrorKind, Result}; use super::RunningProcess; @@ -37,7 +37,7 @@ const CONNECT_ATTEMPT_CAP: Duration = Duration::from_secs(1); impl RunningProcess { /// Wait until a stdout line matches `predicate` (returning that line), or - /// fail with [`Error::NotReady`] when `within` elapses — or immediately + /// fail with [`ErrorKind::NotReady`] when `within` elapses — or immediately /// when stdout closes before a match (e.g. the child exited and no /// descendant kept the pipe open), since no further line can arrive. A /// child that exits while a descendant still holds its stdout keeps the @@ -68,11 +68,11 @@ impl RunningProcess { /// /// # Errors /// - /// - [`Error::NotReady`] when `within` elapses with no matching line, or + /// - [`ErrorKind::NotReady`] when `within` elapses with no matching line, or /// immediately when stdout closes first (no further line can arrive). This - /// is a *probe* deadline — distinct from [`Error::Timeout`], and a failed + /// is a *probe* deadline — distinct from [`ErrorKind::Timeout`], and a failed /// probe neither kills the child nor flips its outcome to `TimedOut`. - /// - [`Error::Io`] when stdout was not piped, or a prior streaming verb + /// - [`ErrorKind::Io`] when stdout was not piped, or a prior streaming verb /// already consumed it (so no line stream can be drained). pub async fn wait_for_line( &mut self, @@ -102,7 +102,7 @@ impl RunningProcess { } /// Wait until `check` (re-invoked every ~50 ms, first attempt immediate) - /// returns `true`, or fail with [`Error::NotReady`] when `within` elapses — + /// returns `true`, or fail with [`ErrorKind::NotReady`] when `within` elapses — /// or immediately when the child exits first (a dead process never becomes /// ready). /// @@ -137,9 +137,9 @@ impl RunningProcess { /// /// # Errors /// - /// [`Error::NotReady`] when `within` elapses before `check` returns `true`, + /// [`ErrorKind::NotReady`] when `within` elapses before `check` returns `true`, /// or immediately when the child exits first (a dead process never becomes - /// ready). This is a *probe* deadline — distinct from [`Error::Timeout`]: a + /// ready). This is a *probe* deadline — distinct from [`ErrorKind::Timeout`]: a /// failed probe does not kill the child or touch its outcome. pub async fn wait_for(&mut self, check: F, within: Duration) -> Result<()> where @@ -150,7 +150,7 @@ impl RunningProcess { } /// Wait until a TCP connection to `addr` is accepted, or fail with - /// [`Error::NotReady`] when `within` elapses — or immediately when the + /// [`ErrorKind::NotReady`] when `within` elapses — or immediately when the /// child exits first. /// /// One connect attempt per ~50 ms tick (each attempt itself bounded so a @@ -163,9 +163,9 @@ impl RunningProcess { /// /// # Errors /// - /// [`Error::NotReady`] when `within` elapses before a connection to `addr` is + /// [`ErrorKind::NotReady`] when `within` elapses before a connection to `addr` is /// accepted, or immediately when the child exits first. This is a *probe* - /// deadline — distinct from [`Error::Timeout`]: a failed probe does not kill + /// deadline — distinct from [`ErrorKind::Timeout`]: a failed probe does not kill /// the child or touch its outcome. pub async fn wait_for_port(&mut self, addr: SocketAddr, within: Duration) -> Result<()> { // Clamp so a `Duration::MAX`-ish `within` can't overflow the deadline. @@ -298,10 +298,11 @@ impl RunningProcess { } fn not_ready(&self, within: Duration) -> Error { - Error::NotReady { + ErrorKind::NotReady { program: self.program.clone(), timeout: within, } + .into() } } diff --git a/src/running/stream.rs b/src/running/stream.rs index 2488a93..f4a96c2 100644 --- a/src/running/stream.rs +++ b/src/running/stream.rs @@ -79,7 +79,7 @@ impl RunningProcess { /// /// # Errors /// - /// [`Error::Io`](crate::Error::Io) when stdout was not piped, or a prior + /// [`ErrorKind::Io`](crate::ErrorKind::Io) when stdout was not piped, or a prior /// streaming verb ([`stdout_lines`](Self::stdout_lines) / /// [`output_events`](Self::output_events) / `wait_for_line`) already consumed /// it — returned instead of a stream that would silently be empty. @@ -103,7 +103,7 @@ impl RunningProcess { ); // Background-drain stderr; `ensure_stderr_drain` is idempotent, retaining // its first sink/pump so `finish` can await the last line and return the - // collected stderr. + // collected stderr. self.ensure_stderr_drain(); let stdout_sink = SharedLines::new(&self.buffer); @@ -294,12 +294,12 @@ impl RunningProcess { /// [`outcome`](Finished::outcome), not raised. The `Err` cases are /// [`Error::Cancelled`](crate::Error::Cancelled) (the run was cancelled via /// [`Command::cancel_on`](crate::Command::cancel_on)), - /// [`Error::OutputTooLarge`](crate::Error::OutputTooLarge) (a fail-loud + /// [`ErrorKind::OutputTooLarge`](crate::ErrorKind::OutputTooLarge) (a fail-loud /// [`OutputBufferPolicy`](crate::OutputBufferPolicy) overflowed on a sink the /// caller opted into by streaming — a bare `finish` discards untouched stdout /// and never trips it), [`Error::Stdin`](crate::Error::Stdin) (a /// non-broken-pipe stdin-source failure on an otherwise-successful run), or - /// [`Error::Io`](crate::Error::Io) (waiting on the child failed). + /// [`ErrorKind::Io`](crate::ErrorKind::Io) (waiting on the child failed). pub async fn finish(mut self) -> Result { // A bare `finish()` (no prior `stdout_lines`/`output_events` took stdout) // still has to drain the leftover stdout so the child can't block on a full @@ -356,13 +356,14 @@ impl RunningProcess { .flatten() { if sink.overflowed() { - return Err(crate::Error::OutputTooLarge { + return Err(crate::ErrorKind::OutputTooLarge { program: self.program.clone(), max_lines: self.buffer.max_lines, max_bytes: self.buffer.max_bytes, total_lines: sink.count(), total_bytes: sink.seen_bytes(), - }); + } + .into()); } } // A first OS read error on either pipe means an incomplete capture: @@ -378,7 +379,7 @@ impl RunningProcess { .flatten() { if let Some(source) = sink.take_read_error() { - return Err(crate::Error::Io(source)); + return Err(crate::ErrorKind::Io(source).into()); } } // `dropped()` = lines the buffer policy discarded from the background @@ -408,7 +409,7 @@ impl RunningProcess { /// /// # Errors /// - /// [`Error::Io`](crate::Error::Io) when stdout was not piped, or a prior + /// [`ErrorKind::Io`](crate::ErrorKind::Io) when stdout was not piped, or a prior /// streaming verb already consumed it — returned instead of a stream that /// would silently be empty. pub fn output_events(&mut self) -> Result { @@ -791,8 +792,8 @@ mod tests { .as_ref() .expect("streaming installed the stdout sink") .set_read_error(std::io::Error::other("stream read boom")); - match run.finish().await { - Err(crate::Error::Io(e)) => assert_eq!(e.to_string(), "stream read boom"), + match run.finish().await.map_err(crate::Error::into_kind) { + Err(crate::ErrorKind::Io(e)) => assert_eq!(e.to_string(), "stream read boom"), other => panic!("expected Err(Io) for an incomplete streamed capture, got {other:?}"), } } diff --git a/src/signal.rs b/src/signal.rs index 4b3d9c9..39604f3 100644 --- a/src/signal.rs +++ b/src/signal.rs @@ -11,7 +11,7 @@ /// a console `CTRL_BREAK` to a child opted into /// [`Command::windows_graceful_ctrl_break`](crate::Command::windows_graceful_ctrl_break), /// plus `WM_CLOSE` to every top-level window a live member owns. Those two yield -/// [`Error::Unsupported`](crate::Error::Unsupported) only when the group has no such +/// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) only when the group has no such /// target; every other variant is always unsupported on Windows. /// /// [`Other`](Signal::Other) is an escape hatch carrying a raw signal number on diff --git a/src/stdin.rs b/src/stdin.rs index 31c80ee..3bc2bcc 100644 --- a/src/stdin.rs +++ b/src/stdin.rs @@ -36,7 +36,7 @@ fn lock_cell(cell: &Arc>) -> MutexGuard<'_, T> { /// [`from_lines`](Self::from_lines)) are one-shot — their payload feeds the /// first run that actually **starts a child** and is consumed then. Re-running /// or retrying a [`Command`](crate::Command) that reuses a consumed one-shot -/// source **fails loud** (an [`Error::Io`](crate::Error::Io) at launch) rather +/// source **fails loud** (an [`ErrorKind::Io`](crate::ErrorKind::Io) at launch) rather /// than silently feeding the next run empty stdin; use a reusable source /// (`from_string`/`from_bytes`/`from_file`/`from_iter_lines`) to re-run. /// diff --git a/src/supervisor.rs b/src/supervisor.rs index 34155b8..2a21a07 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -135,7 +135,7 @@ enum GateOutcome { /// with [`StopReason::RestartsExhausted`]. Exhausted, /// A cancel token fired during the backoff/storm pause — end supervision - /// with `Error::Cancelled`. + /// with `ErrorKind::Cancelled`. Cancelled, /// A [`SupervisionSession::stop`] fired during the backoff/storm pause — end /// supervision with [`StopReason::Stopped`], launching no further incarnation. @@ -413,15 +413,16 @@ impl SupervisionSession { async fn await_completion( completion: Option>>, ) -> Result { + use crate::{Error, ErrorKind}; match completion { Some(rx) => rx.await.unwrap_or_else(|_| { - Err(crate::Error::Io(std::io::Error::other( + Err(Error::from(ErrorKind::Io(std::io::Error::other( "supervision task ended without reporting an outcome", - ))) + )))) }), - None => Err(crate::Error::Io(std::io::Error::other( + None => Err(Error::from(ErrorKind::Io(std::io::Error::other( "supervision outcome already taken", - ))), + )))), } } } @@ -615,7 +616,7 @@ enum Wake { /// The delay elapsed normally. Elapsed, /// The command's [`cancel_on`](Command::cancel_on) token fired — a terminal - /// [`Error::Cancelled`](crate::Error::Cancelled). + /// [`ErrorKind::Cancelled`](crate::ErrorKind::Cancelled). Cancelled, /// A [`SupervisionSession::stop`] was requested — end with /// [`StopReason::Stopped`]. @@ -1060,7 +1061,7 @@ impl Supervisor { /// /// An incarnation cancelled via its token ([`Command::cancel_on`](crate::Command::cancel_on)) /// is **terminal**: supervision returns that - /// `Error::Cancelled` immediately, regardless of policy or budget — the + /// `ErrorKind::Cancelled` immediately, regardless of policy or budget — the /// token stays cancelled, so a restart would only be cancelled again. /// /// A [`health_check`](Self::health_check) force-kill relies on this same @@ -1098,7 +1099,7 @@ impl Supervisor { // Reject up front a configuration that could genuinely need a second // incarnation but only has a one-shot stdin source to feed it: the // first incarnation would consume the source, and every restart after - // it would fail to launch at all (`Error::Io`, "already consumed" — + // it would fail to launch at all (`ErrorKind::Io`, "already consumed" — // see `runner::take_stdin_for_run`), which under the default OnCrash // policy spins forever as a rapid crash-restart-backoff loop instead // of ever making progress. Caught here, before the first run even @@ -1563,7 +1564,7 @@ impl Supervisor { let started = Instant::now(); let handle = match self.runner.start(command).await { Ok(handle) => handle, - Err(crate::Error::Unsupported { .. }) => { + Err(err) if matches!(err.kind(), crate::ErrorKind::Unsupported { .. }) => { // A capture-only runner: it exposes no live handle. Drive this and // every later incarnation through the plain capture verb instead — // no live pid / graceful stop, but supervision is unaffected. @@ -1656,9 +1657,10 @@ impl Supervisor { /// The terminal `Cancelled` error for supervision cut short by a cancel token /// firing during a backoff or storm pause. fn cancelled_err(&self, command: &Command) -> crate::Error { - crate::Error::Cancelled { + crate::ErrorKind::Cancelled { program: command.program_name(), } + .into() } /// Whether this supervisor's configuration could genuinely need more than @@ -1686,12 +1688,12 @@ impl Supervisor { /// The typed, early error for [`may_restart`](Self::may_restart) + /// [`has_unusable_one_shot_stdin`](Self::has_unusable_one_shot_stdin) both - /// holding: the same `Error::Io`/`InvalidInput` shape + /// holding: the same `ErrorKind::Io`/`InvalidInput` shape /// `runner::take_stdin_for_run` raises when a later incarnation actually /// hits the consumed source, but reported before any incarnation runs at /// all instead of after a wasted (and then endlessly repeated) attempt. fn one_shot_restart_err(&self) -> crate::Error { - crate::Error::Io(std::io::Error::new( + crate::ErrorKind::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( "`{}`: this supervisor's restart policy ({:?}, max_restarts: {:?}) may run \ @@ -1705,6 +1707,7 @@ impl Supervisor { self.max_restarts, ), )) + .into() } /// Sleep `delay`, waking early if the supervised command's @@ -1983,10 +1986,11 @@ mod tests { } fn spawn_err() -> Result> { - Err(crate::Error::Spawn { + Err(crate::ErrorKind::Spawn { program: "fake".into(), source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such binary"), - }) + } + .into()) } fn supervise(runner: SeqRunner) -> Supervisor { @@ -2206,13 +2210,16 @@ mod tests { // task: a mistyped program name never recovers on its own. let err = supervise(SeqRunner::new(vec![spawn_err()])) .give_up_when(|attempt| match attempt { - GiveUpAttempt::Failed(err) => matches!(err, crate::Error::Spawn { .. }), + GiveUpAttempt::Failed(err) => matches!(err.kind(), crate::ErrorKind::Spawn { .. }), GiveUpAttempt::Crashed(_) => false, }) .run() .await .expect_err("a classified-permanent spawn failure must not restart forever"); - assert!(matches!(err, crate::Error::Spawn { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), crate::ErrorKind::Spawn { .. }), + "got {err:?}" + ); } #[tokio::test] @@ -2298,7 +2305,10 @@ mod tests { .run() .await .expect_err("the budget-exhausting attempt errored"); - assert!(matches!(err, crate::Error::Spawn { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), crate::ErrorKind::Spawn { .. }), + "got {err:?}" + ); } #[tokio::test] @@ -2316,9 +2326,10 @@ mod tests { // Always would restart any failure; Cancelled must end supervision at // once — the second reply is never consumed (SeqRunner panics if so). let err = supervise(SeqRunner::new(vec![ - Err(crate::Error::Cancelled { + Err(crate::ErrorKind::Cancelled { program: "fake".into(), - }), + } + .into()), ok(), ])) .restart(RestartPolicy::Always) @@ -2326,7 +2337,10 @@ mod tests { .run() .await .expect_err("a cancelled incarnation is terminal"); - assert!(matches!(err, crate::Error::Cancelled { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), crate::ErrorKind::Cancelled { .. }), + "got {err:?}" + ); } #[tokio::test] @@ -2336,7 +2350,10 @@ mod tests { .run() .await .expect_err("Never does not retry a spawn failure"); - assert!(matches!(err, crate::Error::Spawn { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), crate::ErrorKind::Spawn { .. }), + "got {err:?}" + ); } #[tokio::test(start_paused = true)] @@ -2547,15 +2564,19 @@ mod tests { #[tokio::test(start_paused = true)] async fn cancellation_is_terminal_before_any_storm_pause() { let start = tokio::time::Instant::now(); - let err = supervise(SeqRunner::new(vec![Err(crate::Error::Cancelled { + let err = supervise(SeqRunner::new(vec![Err(crate::ErrorKind::Cancelled { program: "fake".into(), - })])) + } + .into())])) .storm_pause(Duration::from_secs(60)) .failure_threshold(0.0) .run() .await .expect_err("cancelled is terminal"); - assert!(matches!(err, crate::Error::Cancelled { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), crate::ErrorKind::Cancelled { .. }), + "got {err:?}" + ); assert_eq!(start.elapsed(), Duration::ZERO, "no storm pause was taken"); } @@ -2650,7 +2671,10 @@ mod tests { }); let start = tokio::time::Instant::now(); let err = sv.run().await.expect_err("cancelled during backoff"); - assert!(matches!(err, crate::Error::Cancelled { .. }), "got {err:?}"); + assert!( + matches!(err.kind(), crate::ErrorKind::Cancelled { .. }), + "got {err:?}" + ); assert!( start.elapsed() < Duration::from_secs(1), "backoff must be cancellable promptly (~100ms), took {:?}", @@ -2693,7 +2717,7 @@ mod tests { .run() .await .expect_err("an unlimited OnCrash policy could need a second incarnation"); - assert!(matches!(err, crate::Error::Io(_)), "got {err:?}"); + assert!(matches!(err.kind(), crate::ErrorKind::Io(_)), "got {err:?}"); assert_eq!( start.elapsed(), Duration::ZERO, @@ -2714,7 +2738,7 @@ mod tests { .run() .await .expect_err("Always could always need a second incarnation"); - assert!(matches!(err, crate::Error::Io(_)), "got {err:?}"); + assert!(matches!(err.kind(), crate::ErrorKind::Io(_)), "got {err:?}"); assert_eq!(start.elapsed(), Duration::ZERO); } @@ -2733,7 +2757,7 @@ mod tests { .run() .await .expect_err("max_restarts(2) could still need a second incarnation"); - assert!(matches!(err, crate::Error::Io(_)), "got {err:?}"); + assert!(matches!(err.kind(), crate::ErrorKind::Io(_)), "got {err:?}"); assert_eq!(start.elapsed(), Duration::ZERO); } diff --git a/tests/integration/batch.rs b/tests/integration/batch.rs index afff74d..b083446 100644 --- a/tests/integration/batch.rs +++ b/tests/integration/batch.rs @@ -130,13 +130,13 @@ async fn output_all_collects_a_failing_command_as_data() { #[tokio::test] #[ignore = "spawns a real subprocess fed a failing stdin source, observed via wait_all"] async fn failing_stdin_source_surfaces_as_error_stdin_via_wait_all() { - use processkit::{Error, Stdin}; + use processkit::{ErrorKind, Stdin}; use std::pin::Pin; use std::task::{Context, Poll}; // E8: the wait_any/wait_all path (wait_exit) must observe a finished stdin // writer that failed for a non-broken-pipe reason and surface it as - // Error::Stdin on an otherwise-successful run, matching the bulk verbs' B3 + // ErrorKind::Stdin on an otherwise-successful run, matching the bulk verbs' B3 // contract — previously this path never called observe_stdin_task, so the // failure was silently lost and the join reported a clean Outcome. // @@ -175,6 +175,7 @@ async fn failing_stdin_source_surfaces_as_error_stdin_via_wait_all() { let err = tokio::time::timeout(Duration::from_secs(15), wait_all(&mut [&mut child])) .await .expect("wait_all must not hang") - .expect_err("a failed stdin writer on a successful run must surface as Error::Stdin"); - assert!(matches!(err, Error::Stdin { .. }), "got: {err:?}"); + .expect_err("a failed stdin writer on a successful run must surface as ErrorKind::Stdin") + .into_kind(); + assert!(matches!(err, ErrorKind::Stdin { .. }), "got: {err:?}"); } diff --git a/tests/integration/cancellation.rs b/tests/integration/cancellation.rs index bd81092..d46b1cf 100644 --- a/tests/integration/cancellation.rs +++ b/tests/integration/cancellation.rs @@ -2,7 +2,7 @@ use std::time::{Duration, Instant}; -use processkit::{CancellationToken, Command, ProcessGroup}; +use processkit::{CancellationToken, Command, Error, ProcessGroup}; use crate::common::*; @@ -54,8 +54,8 @@ async fn cancel_mid_run_errors_and_kills_only_the_cancelled_child() { .await .expect_err("a cancelled run must error, not produce a result"); assert!( - matches!(err, processkit::Error::Cancelled { .. }), - "expected Error::Cancelled, got {err:?}" + matches!(err.kind(), processkit::ErrorKind::Cancelled { .. }), + "expected ErrorKind::Cancelled, got {err:?}" ); canceller.await.expect("canceller task"); // The cancelled child is dead AND reaped by the time `output_string` @@ -81,7 +81,7 @@ async fn client_default_cancel_on_cancels_a_real_run() { // The client-level default (`default_cancel_on`) acceptance: a hanging // child run through a client configured once is killed — tree and all — - // when the token fires, surfacing Error::Cancelled to the awaiting call. + // when the token fires, surfacing ErrorKind::Cancelled to the awaiting call. let token = CancellationToken::new(); let sleeper = sleep_secs(30); let client = CliClient::new(sleeper.program()).default_cancel_on(token.clone()); @@ -103,8 +103,8 @@ async fn client_default_cancel_on_cancels_a_real_run() { .await .expect_err("a cancelled run must error, not produce a result"); assert!( - matches!(err, processkit::Error::Cancelled { .. }), - "expected Error::Cancelled, got {err:?}" + matches!(err.kind(), processkit::ErrorKind::Cancelled { .. }), + "expected ErrorKind::Cancelled, got {err:?}" ); canceller.await.expect("canceller task"); // Death proof: the prompt Cancelled return (the cancel arm kills the tree @@ -130,8 +130,8 @@ async fn pre_cancelled_token_short_circuits_before_spawning() { .await .expect_err("a pre-cancelled run must not start"); assert!( - matches!(err, processkit::Error::Cancelled { .. }), - "expected Error::Cancelled, got {err:?}" + matches!(err.kind(), processkit::ErrorKind::Cancelled { .. }), + "expected ErrorKind::Cancelled, got {err:?}" ); } @@ -199,8 +199,8 @@ async fn cancel_ends_the_stream_and_finish_reports_it() { .await .expect_err("finishing a cancelled streamed run must error"); assert!( - matches!(err, processkit::Error::Cancelled { .. }), - "expected Error::Cancelled, got {err:?}" + matches!(err.kind(), processkit::ErrorKind::Cancelled { .. }), + "expected ErrorKind::Cancelled, got {err:?}" ); } @@ -239,10 +239,11 @@ async fn first_line_cancel_surfaces_cancelled_promptly() { "cancelled first_line probe", probe.first_line(|_| false), ) - .await; + .await + .map_err(Error::into_kind); canceller.await.expect("canceller task"); assert!( - matches!(result, Err(processkit::Error::Cancelled { .. })), + matches!(result, Err(processkit::ErrorKind::Cancelled { .. })), "a cancelled streaming probe must error Cancelled, got {result:?}" ); } @@ -284,10 +285,11 @@ async fn shared_group_first_line_cancel_tears_down_the_child() { "shared-group first_line cancel", group.first_line(&probe, |_| false), ) - .await; + .await + .map_err(Error::into_kind); canceller.await.expect("canceller task"); assert!( - matches!(result, Err(processkit::Error::Cancelled { .. })), + matches!(result, Err(processkit::ErrorKind::Cancelled { .. })), "a cancelled shared-group probe must error Cancelled, got {result:?}" ); @@ -349,10 +351,11 @@ async fn shared_group_first_line_cancel_without_timeout_is_bounded_on_a_forking_ "no-timeout shared-group first_line cancel on a forking child", group.first_line(&forking, |_| false), ) - .await; + .await + .map_err(Error::into_kind); canceller.await.expect("canceller task"); assert!( - matches!(result, Err(processkit::Error::Cancelled { .. })), + matches!(result, Err(processkit::ErrorKind::Cancelled { .. })), "a cancelled no-timeout probe on a forking shared-group child must return \ Cancelled promptly, got {result:?}" ); diff --git a/tests/integration/capture.rs b/tests/integration/capture.rs index 2bf3690..5902a83 100644 --- a/tests/integration/capture.rs +++ b/tests/integration/capture.rs @@ -3,7 +3,7 @@ use std::time::{Duration, Instant}; -use processkit::Command; +use processkit::{Command, ErrorKind}; use crate::common::*; @@ -53,7 +53,7 @@ async fn working_directory_that_is_a_file_errors_as_not_a_directory() { #[tokio::test] #[ignore = "exercises the real spawn path (creates a process group)"] async fn missing_program_surfaces_not_found_with_searched_path() { - // A bare program name that isn't on PATH must produce Error::NotFound + // A bare program name that isn't on PATH must produce ErrorKind::NotFound // with a message that names the searched directories — not the opaque // OS ENOENT that would otherwise be indistinguishable from a missing cwd. let err = Command::new("processkit-definitely-not-installed-424242") @@ -61,8 +61,8 @@ async fn missing_program_surfaces_not_found_with_searched_path() { .await .expect_err("an unknown program must error"); assert!( - matches!(err, processkit::Error::NotFound { .. }), - "expected Error::NotFound, got {err:?}" + matches!(err.kind(), ErrorKind::NotFound { .. }), + "expected ErrorKind::NotFound, got {err:?}" ); assert!(err.is_not_found(), "is_not_found() must be true: {err:?}"); let msg = err.to_string(); @@ -181,17 +181,17 @@ async fn lowercase_path_env_keeps_the_not_found_searched_path() { .await .expect_err("an unknown program must error"); - match err { - processkit::Error::NotFound { searched, .. } => assert_eq!( - searched, - Some(system_path), + match err.kind() { + processkit::ErrorKind::NotFound { searched, .. } => assert_eq!( + searched.as_ref(), + Some(&system_path), "lowercase `path` must not replace the process PATH on Unix" ), - other => panic!("expected Error::NotFound, got {other:?}"), + other => panic!("expected ErrorKind::NotFound, got {other:?}"), } } -// T-054: when resolution fails everywhere, `Error::NotFound`'s `searched` +// T-054: when resolution fails everywhere, `ErrorKind::NotFound`'s `searched` // diagnostic must include the `prefer_local` directories too — not just PATH // — so the caller can tell they were checked. #[tokio::test] @@ -203,22 +203,23 @@ async fn missing_program_not_found_searched_includes_prefer_local_dirs() { .prefer_local(dir.path()) .output_string() .await - .expect_err("an unknown program must error"); + .expect_err("an unknown program must error") + .into_kind(); match err { - processkit::Error::NotFound { searched, .. } => { + processkit::ErrorKind::NotFound { searched, .. } => { let searched = searched.expect("a bare-name lookup must report searched dirs"); assert!( searched.contains(&dir.path().to_string_lossy().into_owned()), "searched must include the prefer_local directory: {searched}" ); } - other => panic!("expected Error::NotFound, got {other:?}"), + other => panic!("expected ErrorKind::NotFound, got {other:?}"), } } // R-01 (secondary observation): `prefer_local` resolution is parent-side // (plain filesystem probes under caller-named directories) and independent -// of the child's own environment, so `Error::NotFound`'s `searched` must +// of the child's own environment, so `ErrorKind::NotFound`'s `searched` must // still name the `prefer_local` directories even when the command also // customizes the child's `PATH` (here via `inherit_env`, which clears the // inherited set) — only the process-`PATH` portion of the diagnostic is @@ -235,9 +236,10 @@ async fn missing_program_not_found_searched_includes_prefer_local_dirs_even_with .inherit_env(["HOME"]) // customizes_path(): true .output_string() .await - .expect_err("an unknown program must error"); + .expect_err("an unknown program must error") + .into_kind(); match err { - processkit::Error::NotFound { searched, .. } => { + processkit::ErrorKind::NotFound { searched, .. } => { let searched = searched.expect( "a prefer_local directory was probed even though PATH itself is customized", ); @@ -248,7 +250,7 @@ async fn missing_program_not_found_searched_includes_prefer_local_dirs_even_with entries, since those don't apply to the customized child env" ); } - other => panic!("expected Error::NotFound, got {other:?}"), + other => panic!("expected ErrorKind::NotFound, got {other:?}"), } } @@ -264,7 +266,7 @@ async fn missing_program_not_found_searched_includes_prefer_local_dirs_even_with async fn preflight_resolution_agrees_with_the_actual_spawn() { // Whether a real spawn LOCATED the program: `Ok`, or any error that isn't // `NotFound` (e.g. a non-zero exit), means the launch found and ran it; - // only `Error::NotFound` means it couldn't be located. + // only `ErrorKind::NotFound` means it couldn't be located. async fn spawn_located(cmd: Command) -> bool { match cmd.output_string().await { Ok(_) => true, @@ -375,7 +377,7 @@ async fn preflight_resolution_agrees_with_the_actual_spawn() { // PATHEXT model finds the `.cmd`), and — now that `build_tokio` substitutes the // resolved absolute path for such a match — the ACTUAL spawn succeeds too, // instead of failing (before the fix the OS's bare-name search appended only -// `.exe`, missed the `.cmd`, and the launch raised `Error::Spawn`). A plain +// `.exe`, missed the `.cmd`, and the launch raised `ErrorKind::Spawn`). A plain // `spawn_located` check wouldn't prove the fix — the pre-fix failure was a // non-`NotFound` `Spawn` error, which `spawn_located` counts as "located" — so // this asserts a genuinely successful run. @@ -418,7 +420,7 @@ async fn preflight_and_spawn_agree_on_a_non_exe_pathext_program_on_path() { ); // (b) The ACTUAL launch now spawns it successfully — not the pre-fix - // `Error::Spawn` from the OS's `.exe`-only bare-name search missing the + // `ErrorKind::Spawn` from the OS's `.exe`-only bare-name search missing the // `.cmd`. `.expect(...)` here is the load-bearing assertion: it fails on the // pre-fix behavior and passes only once the resolved absolute path is // substituted at spawn. @@ -487,8 +489,8 @@ async fn stdout_null_makes_capture_verbs_error_but_discard_verbs_run() { .output_string() .await .expect_err("output_string on a non-piped stdout must error (D5)"); - match err { - processkit::Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), + match err.kind() { + processkit::ErrorKind::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), other => panic!("expected Io(InvalidInput), got {other:?}"), } @@ -584,8 +586,8 @@ async fn file_redirect_rejects_capture_and_streaming_verbs() { .output_string() .await .expect_err("a file is not a capture pipe"); - match err { - processkit::Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), + match err.kind() { + processkit::ErrorKind::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidInput), other => panic!("expected Io(InvalidInput), got {other:?}"), } @@ -757,7 +759,6 @@ async fn untaken_keep_stdin_open_pipe_is_closed_by_bulk_verbs() { #[tokio::test] #[ignore = "spawns a real subprocess fed a failing stdin source"] async fn failing_stdin_source_surfaces_as_error_stdin_on_a_successful_run() { - use processkit::Error; use std::pin::Pin; use std::task::{Context, Poll}; @@ -781,19 +782,21 @@ async fn failing_stdin_source_surfaces_as_error_stdin_on_a_successful_run() { }; // B3 (Decision 2): the child sees EOF (the sink is dropped on the writer's // error) and exits 0 — a success — so the stashed non-broken-pipe stdin - // failure now surfaces as `Error::Stdin` instead of being swallowed. + // failure now surfaces as `ErrorKind::Stdin` instead of being swallowed. let err = reads_stdin .stdin(processkit::Stdin::from_reader(FailingReader)) .output_string() .await - .expect_err("a failed stdin writer on a successful run must surface as Error::Stdin"); - assert!(matches!(err, Error::Stdin { .. }), "got: {err:?}"); + .expect_err("a failed stdin writer on a successful run must surface as ErrorKind::Stdin"); + assert!( + matches!(err.kind(), ErrorKind::Stdin { .. }), + "got: {err:?}" + ); } #[tokio::test] #[ignore = "spawns a real subprocess fed a panicking stdin source"] async fn panicking_stdin_source_surfaces_as_error_stdin_not_silent_success() { - use processkit::Error; use std::pin::Pin; use std::task::{Context, Poll}; @@ -815,14 +818,15 @@ async fn panicking_stdin_source_surfaces_as_error_stdin_not_silent_success() { Command::new("cat") }; // L1: the writer task panics; its `JoinError` must be surfaced as - // `Error::Stdin` on an otherwise-successful run, not swallowed into a clean + // `ErrorKind::Stdin` on an otherwise-successful run, not swallowed into a clean // success (a panicking source is a real failure the caller must see). let err = reads_stdin .stdin(processkit::Stdin::from_reader(PanickingReader)) .output_string() .await - .expect_err("a panicking stdin writer on a successful run must surface as Error::Stdin"); - assert!(matches!(err, Error::Stdin { .. }), "got: {err:?}"); + .expect_err("a panicking stdin writer on a successful run must surface as ErrorKind::Stdin") + .into_kind(); + assert!(matches!(err, ErrorKind::Stdin { .. }), "got: {err:?}"); } #[tokio::test] @@ -858,7 +862,7 @@ async fn nonzero_exit_wins_over_a_failing_stdin_source() { }; // B3 (Decision 2): exit 1 is outside `ok_codes`, so the run is NOT a success // — the stdin failure is dropped, not surfaced. `output_string` returns the - // result carrying the real exit code rather than `Err(Error::Stdin)`. + // result carrying the real exit code rather than `Err(ErrorKind::Stdin)`. let result = nonzero .stdin(processkit::Stdin::from_reader(OneLineThenFail(false))) .output_string() @@ -875,7 +879,6 @@ async fn nonzero_exit_wins_over_a_failing_stdin_source() { #[tokio::test] #[ignore = "spawns a real subprocess whose stdin source fails during pump teardown"] async fn stdin_source_failing_during_pump_teardown_still_surfaces_as_error_stdin() { - use processkit::Error; use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; @@ -912,7 +915,7 @@ async fn stdin_source_failing_during_pump_teardown_still_surfaces_as_error_stdin // `echo` prints a line and exits 0 *without* consuming stdin, so the run // succeeds while the parked writer is still in flight — a child success plus - // a swallowed stdin failure is precisely the `Error::Stdin` case. Its stdout + // a swallowed stdin failure is precisely the `ErrorKind::Stdin` case. Its stdout // line gives the pumps something to drain during teardown. let outputs_and_exits = if cfg!(windows) { Command::new("cmd").args(["/c", "echo hello"]) @@ -931,9 +934,10 @@ async fn stdin_source_failing_during_pump_teardown_still_surfaces_as_error_stdin .await .expect_err( "a stdin writer that fails during the join_pumps window must still surface as \ - Error::Stdin, not a silent success", - ); - assert!(matches!(err, Error::Stdin { .. }), "got: {err:?}"); + ErrorKind::Stdin, not a silent success", + ) + .into_kind(); + assert!(matches!(err, ErrorKind::Stdin { .. }), "got: {err:?}"); } #[tokio::test] @@ -1011,16 +1015,17 @@ async fn timeout_kills_and_flags() { #[tokio::test] #[ignore = "spawns a real subprocess and waits for the timeout"] async fn exit_code_surfaces_timeout_as_error() { - // `Command::exit_code` must report a timeout as `Error::Timeout`, not the + // `Command::exit_code` must report a timeout as `ErrorKind::Timeout`, not the // synthetic `-1` — consistent with the runner/CliClient code paths. let err = sleeper() .timeout(Duration::from_millis(300)) .exit_code() .await - .expect_err("a timed-out run has no meaningful exit code"); + .expect_err("a timed-out run has no meaningful exit code") + .into_kind(); assert!( - matches!(err, processkit::Error::Timeout { .. }), - "expected Error::Timeout, got {err:?}" + matches!(err, processkit::ErrorKind::Timeout { .. }), + "expected ErrorKind::Timeout, got {err:?}" ); } @@ -1029,7 +1034,7 @@ async fn exit_code_surfaces_timeout_as_error() { async fn first_line_honors_timeout_instead_of_hanging() { // A long-running command that emits NO stdout: without a timeout `first_line` // would block forever waiting for a line. With a deadline it must give up and - // surface `Error::Timeout` promptly — never hang. + // surface `ErrorKind::Timeout` promptly — never hang. let silent = if cfg!(windows) { Command::new("powershell").args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]) } else { @@ -1040,10 +1045,11 @@ async fn first_line_honors_timeout_instead_of_hanging() { .timeout(Duration::from_millis(300)) .first_line(|_| true) .await - .expect_err("a stalled run should time out, not return Ok(None)"); + .expect_err("a stalled run should time out, not return Ok(None)") + .into_kind(); assert!( - matches!(err, processkit::Error::Timeout { .. }), - "expected Error::Timeout, got {err:?}" + matches!(err, processkit::ErrorKind::Timeout { .. }), + "expected ErrorKind::Timeout, got {err:?}" ); // Generous anti-hang bound (the sleeper runs ~30s if the timeout is // broken): under full-suite load PowerShell's cold start alone has been @@ -1062,7 +1068,7 @@ async fn shared_group_first_line_honors_timeout_and_tears_down() { use processkit::{ProcessGroup, ProcessRunnerExt}; // A2: a first_line probe on a SHARED group must honor the command timeout — - // surface Error::Timeout AND tear the child down (the shared-group deadline + // surface ErrorKind::Timeout AND tear the child down (the shared-group deadline // watchdog now reaches the direct child by pid). A single-process idle emits // no matching line and outlives the deadline. let group = ProcessGroup::new().expect("group"); @@ -1080,10 +1086,11 @@ async fn shared_group_first_line_honors_timeout_and_tears_down() { group.first_line(&silent, |_| false), ) .await - .expect_err("a stalled shared-group probe must time out, not hang or return Ok(None)"); + .expect_err("a stalled shared-group probe must time out, not hang or return Ok(None)") + .into_kind(); assert!( - matches!(err, processkit::Error::Timeout { .. }), - "expected Error::Timeout, got {err:?}" + matches!(err, processkit::ErrorKind::Timeout { .. }), + "expected ErrorKind::Timeout, got {err:?}" ); assert!( start.elapsed() < Duration::from_secs(15), @@ -1199,7 +1206,7 @@ async fn ok_codes_widens_success_through_output_string_and_bytes() { #[ignore = "spawns a real subprocess that overflows a fail-loud buffer"] async fn fail_loud_buffer_surfaces_output_too_large() { // A child that prints more lines than a fail-loud ceiling must surface - // Error::OutputTooLarge through the real spawn+pump path — the fail-loud + // ErrorKind::OutputTooLarge through the real spawn+pump path — the fail-loud // DoS guard, end-to-end. `five_lines` prints 5 lines; the cap is 2, so the // run errors even though the child exited 0. (The pipe is still drained, so // the child never blocks.) @@ -1209,20 +1216,20 @@ async fn fail_loud_buffer_surfaces_output_too_large() { .output_string() .await .expect_err("5 lines over a 2-line fail-loud cap must error"); - match err { - processkit::Error::OutputTooLarge { + match err.kind() { + processkit::ErrorKind::OutputTooLarge { max_lines, total_lines, .. } => { assert_eq!( - max_lines, + *max_lines, Some(2), "the configured line cap is reported: {err:?}" ); - assert!(total_lines >= 5, "every line is counted: {total_lines}"); + assert!(*total_lines >= 5, "every line is counted: {total_lines}"); } - other => panic!("expected Error::OutputTooLarge, got {other:?}"), + other => panic!("expected ErrorKind::OutputTooLarge, got {other:?}"), } // A run that stays under the cap must NOT error (control case). @@ -1254,9 +1261,10 @@ async fn output_bytes_honors_the_byte_cap() { ) .output_bytes() .await - .expect_err("raw stdout over a 4-byte fail-loud cap must error"); + .expect_err("raw stdout over a 4-byte fail-loud cap must error") + .into_kind(); match err { - processkit::Error::OutputTooLarge { + processkit::ErrorKind::OutputTooLarge { max_bytes, max_lines, total_bytes, @@ -1272,7 +1280,7 @@ async fn output_bytes_honors_the_byte_cap() { "every byte seen is counted: {total_bytes}" ); } - other => panic!("expected Error::OutputTooLarge, got {other:?}"), + other => panic!("expected ErrorKind::OutputTooLarge, got {other:?}"), } // Drop mode (the policy default overflow): the retained bytes are bounded @@ -1322,11 +1330,12 @@ async fn checking_verbs_reject_truncated_output_e2e() { .output_buffer(OutputBufferPolicy::bounded(2)) .run() .await - .expect_err("run must reject truncated stdout (B12)"); + .expect_err("run must reject truncated stdout (B12)") + .into_kind(); assert!( matches!( err, - processkit::Error::OutputTooLarge { + processkit::ErrorKind::OutputTooLarge { max_lines: Some(2), .. } diff --git a/tests/integration/env_privileges.rs b/tests/integration/env_privileges.rs index b060bc9..140fe68 100644 --- a/tests/integration/env_privileges.rs +++ b/tests/integration/env_privileges.rs @@ -183,7 +183,7 @@ async fn priority_high_with_uid_drop_and_no_groups_succeeds() { // only the pre-drop (root) process has. Before the fix, the `None` // branch let std's own `.uid()`/`.gid()` builder methods perform the drop // — those apply *before* any user pre_exec hook, including the priority - // hook — so this exact combination failed with `Error::Spawn` (EPERM from + // hook — so this exact combination failed with `ErrorKind::Spawn` (EPERM from // setpriority under the already-dropped uid). It must now succeed, // identically to the `groups`-present path. // SAFETY: geteuid is a pure query. @@ -344,7 +344,7 @@ async fn windows_unix_only_builders_are_unsupported() { .await .expect_err("a privilege request must not be silently skipped"); assert!( - matches!(err, processkit::Error::Unsupported { .. }), + matches!(err, processkit::ErrorKind::Unsupported { .. }), "expected Unsupported for {what}, got {err:?}" ); } diff --git a/tests/integration/limits.rs b/tests/integration/limits.rs index 771e3e4..f96d76d 100644 --- a/tests/integration/limits.rs +++ b/tests/integration/limits.rs @@ -4,14 +4,14 @@ #[cfg(windows)] use processkit::Command; use processkit::{ - Error, LimitKind, LimitReason, Mechanism, ProcessGroup, ProcessGroupOptions, ResourceLimits, + Error, ErrorKind, LimitKind, LimitReason, Mechanism, ProcessGroup, ProcessGroupOptions, ResourceLimits, }; #[tokio::test] #[ignore = "creates an OS job/cgroup with a resource limit"] async fn limits_are_enforced_or_rejected_per_platform() { // Setting a limit must either be honored by a real container (Windows Job - // Object / Linux cgroup) or fail fast with `Error::ResourceLimit` — never + // Object / Linux cgroup) or fail fast with `ErrorKind::ResourceLimit` — never // silently hand back an unbounded group. let res = ProcessGroup::with_options(ProcessGroupOptions::default().max_memory(64 * 1024 * 1024)); @@ -19,13 +19,13 @@ async fn limits_are_enforced_or_rejected_per_platform() { let group = res.expect("Windows Job Objects enforce a memory cap"); assert!(matches!(group.mechanism(), Mechanism::JobObject)); } else if cfg!(target_os = "linux") { - match res { + match res.map_err(Error::into_kind) { Ok(group) => assert!(matches!(group.mechanism(), Mechanism::CgroupV2)), // Common on dev boxes / CI without cgroup delegation — the fail-fast // path. A capable mechanism (cgroup v2 is mounted) exists here; this // *specific* request just couldn't be applied — `Unenforceable`, not // `Unsupported`. - Err(Error::ResourceLimit { kind, reason, .. }) => { + Err(ErrorKind::ResourceLimit { kind, reason, .. }) => { assert_eq!(kind, LimitKind::Memory); assert_eq!(reason, LimitReason::Unenforceable); eprintln!("skipping cgroup enforcement: controller delegation unavailable"); @@ -35,8 +35,8 @@ async fn limits_are_enforced_or_rejected_per_platform() { } else { // macOS/BSD have no whole-tree cap at all — `Unsupported`, not // `Unenforceable` (no mechanism exists to even attempt this against). - match res { - Err(Error::ResourceLimit { kind, reason, .. }) => { + match res.map_err(Error::into_kind) { + Err(ErrorKind::ResourceLimit { kind, reason, .. }) => { assert_eq!(kind, LimitKind::Memory); assert_eq!(reason, LimitReason::Unsupported); } diff --git a/tests/integration/pipeline.rs b/tests/integration/pipeline.rs index c2878ff..71f3114 100644 --- a/tests/integration/pipeline.rs +++ b/tests/integration/pipeline.rs @@ -124,9 +124,10 @@ async fn pipeline_pipefail_attributes_the_first_failure() { .pipe(sort_stage()) .run() .await - .expect_err("a failing stage must fail run()"); + .expect_err("a failing stage must fail run()") + .into_kind(); assert!( - matches!(err, processkit::Error::Exit { code: 3, .. }), + matches!(err, processkit::ErrorKind::Exit { code: 3, .. }), "expected Exit with code 3, got {err:?}" ); } @@ -173,7 +174,7 @@ async fn pipeline_failure_tears_down_a_quiet_upstream_on_a_raw_stage_error_too() // T-085: distinct from `pipeline_failure_tears_down_a_quiet_upstream_immediately` // above — that test's failure is a *checked* `Outcome` (a plain non-zero // exit), which already fired proactive teardown before this fix landed. - // This one's failure is a *raw* `Err` (`Error::Cancelled`, via a per-stage + // This one's failure is a *raw* `Err` (`ErrorKind::Cancelled`, via a per-stage // `Command::cancel_on` on just the LAST stage — deliberately not the // whole-chain `Pipeline::cancel_on`, so the quiet upstream carries no // token of its own) surfacing straight out of a stage's task, past the @@ -197,9 +198,10 @@ async fn pipeline_failure_tears_down_a_quiet_upstream_on_a_raw_stage_error_too() .pipe(cancels_soon) .output_string() .await - .expect_err("a per-stage-cancelled last stage must surface as Err"); + .expect_err("a per-stage-cancelled last stage must surface as Err") + .into_kind(); assert!( - matches!(err, processkit::Error::Cancelled { .. }), + matches!(err, processkit::ErrorKind::Cancelled { .. }), "expected Cancelled, got {err:?}" ); assert!( @@ -608,9 +610,10 @@ async fn pipeline_parse_fails_loud_on_a_truncated_last_stage() { .pipe(sort_stage().output_buffer(OutputBufferPolicy::bounded(2))) .parse(|s| s.to_owned()) .await - .expect_err("a truncated last stage must fail loud"); + .expect_err("a truncated last stage must fail loud") + .into_kind(); assert!( - matches!(err, processkit::Error::OutputTooLarge { .. }), + matches!(err, processkit::ErrorKind::OutputTooLarge { .. }), "got {err:?}" ); } @@ -631,9 +634,10 @@ async fn pipeline_run_fails_loud_on_a_truncated_last_stage() { .pipe(sort_stage().output_buffer(OutputBufferPolicy::bounded(2))) .run() .await - .expect_err("a truncated last stage must fail loud on run()"); + .expect_err("a truncated last stage must fail loud on run()") + .into_kind(); assert!( - matches!(err, processkit::Error::OutputTooLarge { .. }), + matches!(err, processkit::ErrorKind::OutputTooLarge { .. }), "got {err:?}" ); } @@ -642,7 +646,7 @@ async fn pipeline_run_fails_loud_on_a_truncated_last_stage() { #[ignore = "spawns a real long-running pipeline and cancels it"] async fn pipeline_cancel_on_tears_the_whole_chain_down() { // S-1: a token fired mid-run cancels every stage; the run resolves to - // Error::Cancelled rather than hanging on the endless producer. + // ErrorKind::Cancelled rather than hanging on the endless producer. use tokio_util::sync::CancellationToken; let token = CancellationToken::new(); let chain = endless_yes() @@ -658,9 +662,10 @@ async fn pipeline_cancel_on_tears_the_whole_chain_down() { let err = chain .output_string() .await - .expect_err("a cancelled chain errors"); + .expect_err("a cancelled chain errors") + .into_kind(); assert!( - matches!(err, processkit::Error::Cancelled { .. }), + matches!(err, processkit::ErrorKind::Cancelled { .. }), "expected Cancelled, got {err:?}" ); assert!( diff --git a/tests/integration/process_control.rs b/tests/integration/process_control.rs index 1cf8f94..aa22a3e 100644 --- a/tests/integration/process_control.rs +++ b/tests/integration/process_control.rs @@ -207,8 +207,8 @@ fn windows_signal_non_kill_is_unsupported() { .signal(sig) .expect_err("a non-Kill signal with no soft-close target must be rejected on Windows"); assert!( - matches!(err, processkit::Error::Unsupported { .. }), - "expected Error::Unsupported for {sig:?}, got {err:?}" + matches!(err, processkit::ErrorKind::Unsupported { .. }), + "expected ErrorKind::Unsupported for {sig:?}, got {err:?}" ); } } @@ -413,9 +413,10 @@ async fn adopt_of_a_reaped_child_errors_instead_of_tracking_nothing() { // rather than silently tracking nothing. let err = group .adopt(&child) - .expect_err("adopting a reaped child must error"); + .expect_err("adopting a reaped child must error") + .into_kind(); assert!( - matches!(err, processkit::Error::Io(_)), + matches!(err, processkit::ErrorKind::Io(_)), "expected the no-pid Io error, got {err:?}" ); } @@ -474,9 +475,10 @@ async fn empty_group_accepts_lifecycle_calls() { // signal to fall back on. let err = group .signal(Signal::Term) - .expect_err("Term on an empty Windows group has no soft-close target"); + .expect_err("Term on an empty Windows group has no soft-close target") + .into_kind(); assert!( - matches!(err, processkit::Error::Unsupported { .. }), + matches!(err, processkit::ErrorKind::Unsupported { .. }), "expected Unsupported, got {err:?}" ); } else { diff --git a/tests/integration/readiness.rs b/tests/integration/readiness.rs index dcc7f70..81866b1 100644 --- a/tests/integration/readiness.rs +++ b/tests/integration/readiness.rs @@ -41,9 +41,10 @@ async fn wait_for_line_not_ready_when_silent() { let err = process .wait_for_line(|_| true, Duration::from_millis(300)) .await - .expect_err("a silent child never becomes ready"); + .expect_err("a silent child never becomes ready") + .into_kind(); assert!( - matches!(err, processkit::Error::NotReady { .. }), + matches!(err, processkit::ErrorKind::NotReady { .. }), "expected NotReady, got {err:?}" ); assert!( @@ -64,9 +65,10 @@ async fn wait_for_line_not_ready_fast_when_child_exits_silently() { let err = process .wait_for_line(|l| l.contains("never-printed"), Duration::from_secs(30)) .await - .expect_err("the banner never appears"); + .expect_err("the banner never appears") + .into_kind(); assert!( - matches!(err, processkit::Error::NotReady { .. }), + matches!(err, processkit::ErrorKind::NotReady { .. }), "expected NotReady, got {err:?}" ); assert!( @@ -134,9 +136,10 @@ async fn wait_for_port_gives_up_after_the_listener_closes_mid_retry() { ) .await .expect("probe finished in time") - .expect_err("the closed listener never becomes ready again"); + .expect_err("the closed listener never becomes ready again") + .into_kind(); assert!( - matches!(err, processkit::Error::NotReady { .. }), + matches!(err, processkit::ErrorKind::NotReady { .. }), "expected NotReady, got {err:?}" ); assert!( @@ -260,9 +263,10 @@ async fn wait_for_fails_fast_when_child_exits() { let err = process .wait_for(|| async { false }, Duration::from_secs(30)) .await - .expect_err("an exited child never becomes ready"); + .expect_err("an exited child never becomes ready") + .into_kind(); assert!( - matches!(err, processkit::Error::NotReady { .. }), + matches!(err, processkit::ErrorKind::NotReady { .. }), "expected NotReady, got {err:?}" ); assert!( diff --git a/tests/integration/shutdown.rs b/tests/integration/shutdown.rs index 535bcb6..4d3f048 100644 --- a/tests/integration/shutdown.rs +++ b/tests/integration/shutdown.rs @@ -608,7 +608,7 @@ async fn shutdown_reports_timed_out_when_the_deadline_already_elapsed() { } // D4: a shared-group handle (ProcessGroup::start) does not own its group, so -// `shutdown` refuses with Error::Unsupported — the caller tears the group down +// `shutdown` refuses with ErrorKind::Unsupported — the caller tears the group down // via ProcessGroup::shutdown instead. #[tokio::test] #[ignore = "spawns a real subprocess; D4 shutdown is unsupported on a shared-group handle"] @@ -621,9 +621,10 @@ async fn shutdown_is_unsupported_on_a_shared_group_handle() { let err = run .shutdown(Duration::from_secs(1)) .await - .expect_err("a shared-group handle cannot be gracefully shut down"); + .expect_err("a shared-group handle cannot be gracefully shut down") + .into_kind(); assert!( - matches!(err, processkit::Error::Unsupported { .. }), + matches!(err, processkit::ErrorKind::Unsupported { .. }), "expected Unsupported, got {err:?}" ); // The child survived (shared-group Drop doesn't kill); tear it down here. diff --git a/tests/integration/stdin_inherit.rs b/tests/integration/stdin_inherit.rs index a6584e9..8c2d916 100644 --- a/tests/integration/stdin_inherit.rs +++ b/tests/integration/stdin_inherit.rs @@ -5,7 +5,7 @@ use std::io::Write; use std::time::Duration; -use processkit::{Command, Error, Stdin}; +use processkit::{Command, ErrorKind, Stdin}; use crate::common::raw_stdin_echo; @@ -94,7 +94,7 @@ async fn run_inner() { } /// `inherit_stdin()` + `keep_stdin_open()` is refused end-to-end through a public -/// run verb, before any child is spawned, as a typed `Error::Io(InvalidInput)`. +/// run verb, before any child is spawned, as a typed `ErrorKind::Io(InvalidInput)`. #[tokio::test] #[ignore = "drives the real launch path (though it rejects before spawning)"] async fn inherit_stdin_with_keep_stdin_open_is_rejected() { @@ -103,15 +103,16 @@ async fn inherit_stdin_with_keep_stdin_open_is_rejected() { .keep_stdin_open() .output_string() .await - .expect_err("inherit_stdin + keep_stdin_open must be rejected at launch"); + .expect_err("inherit_stdin + keep_stdin_open must be rejected at launch") + .into_kind(); assert!( - matches!(&err, Error::Io(io) if io.kind() == std::io::ErrorKind::InvalidInput), - "expected Error::Io(InvalidInput), got {err:?}" + matches!(&err, ErrorKind::Io(io) if io.kind() == std::io::ErrorKind::InvalidInput), + "expected ErrorKind::Io(InvalidInput), got {err:?}" ); } /// `inherit_stdin()` + a configured `stdin(Stdin::…)` source is likewise refused -/// through a public run verb as a typed `Error::Io(InvalidInput)`. +/// through a public run verb as a typed `ErrorKind::Io(InvalidInput)`. #[tokio::test] #[ignore = "drives the real launch path (though it rejects before spawning)"] async fn inherit_stdin_with_a_source_is_rejected() { @@ -120,9 +121,10 @@ async fn inherit_stdin_with_a_source_is_rejected() { .inherit_stdin() .output_string() .await - .expect_err("inherit_stdin + a stdin source must be rejected at launch"); + .expect_err("inherit_stdin + a stdin source must be rejected at launch") + .into_kind(); assert!( - matches!(&err, Error::Io(io) if io.kind() == std::io::ErrorKind::InvalidInput), - "expected Error::Io(InvalidInput), got {err:?}" + matches!(&err, ErrorKind::Io(io) if io.kind() == std::io::ErrorKind::InvalidInput), + "expected ErrorKind::Io(InvalidInput), got {err:?}" ); } diff --git a/tests/integration/streaming.rs b/tests/integration/streaming.rs index 69c54d3..afd8f0e 100644 --- a/tests/integration/streaming.rs +++ b/tests/integration/streaming.rs @@ -334,10 +334,11 @@ async fn second_stdout_lines_call_is_a_loud_error() { // not a silently-empty stream. let err = process .stdout_lines() - .expect_err("a second stdout_lines must be a loud error"); + .expect_err("a second stdout_lines must be a loud error") + .into_kind(); assert!( - matches!(err, processkit::Error::Io(_)), - "expected Error::Io, got {err:?}" + matches!(err, processkit::ErrorKind::Io(_)), + "expected ErrorKind::Io, got {err:?}" ); let _ = process.finish().await; diff --git a/tests/stress/main.rs b/tests/stress/main.rs index cbaa2bb..d371d79 100644 --- a/tests/stress/main.rs +++ b/tests/stress/main.rs @@ -29,7 +29,9 @@ mod interleave; use std::time::Duration; -use processkit::{Command, JobRunner, ProcessGroup, RunningProcess, output_all, wait_all}; +use processkit::{ + Command, ErrorKind, JobRunner, ProcessGroup, RunningProcess, output_all, wait_all, +}; use crate::common::*; @@ -227,7 +229,7 @@ async fn concurrent_kill_reaps_every_handle() { } /// 6. A cancellation storm: fire many tokens at once and assert every in-flight -/// run resolves to `Error::Cancelled` (and its tree is torn down). +/// run resolves to `ErrorKind::Cancelled` (and its tree is torn down). #[tokio::test] async fn cancellation_storm_resolves_every_call() { use processkit::{CancellationToken, Error}; @@ -255,10 +257,11 @@ async fn cancellation_storm_resolves_every_call() { let result = tokio::time::timeout(Duration::from_secs(15), task) .await .expect("cancelled run resolves in time") - .expect("task did not panic"); + .expect("task did not panic") + .map_err(Error::into_kind); assert!( - matches!(result, Err(Error::Cancelled { .. })), - "every cancelled run resolves to Error::Cancelled, got {result:?}" + matches!(result, Err(ErrorKind::Cancelled { .. })), + "every cancelled run resolves to ErrorKind::Cancelled, got {result:?}" ); } }