From f844465e35103b5a0e8bdc920177ec521e9e4d2c Mon Sep 17 00:00:00 2001 From: Max Wase Date: Thu, 7 May 2026 20:55:54 +0200 Subject: [PATCH 1/5] Init skill and claude marketplace --- .claude-plugin/marketplace.json | 16 + .gitignore | 6 +- plugins/errortools/.claude-plugin/plugin.json | 23 ++ plugins/errortools/skills/errortools/SKILL.md | 276 ++++++++++++++++++ 4 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 plugins/errortools/.claude-plugin/plugin.json create mode 100644 plugins/errortools/skills/errortools/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..a97cafc --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-marketplace.json", + "name": "errortools-marketplace", + "owner": { + "name": "Max Wase", + "email": "max.vvase@gmail.com" + }, + "description": "Marketplace distributing the errortools Rust error-handling skill", + "plugins": [ + { + "name": "rust-error-handling", + "source": "./plugins/errortools", + "description": "Rust error-handling guidance built around errortools and thiserror crates" + } + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4522df4..ed871df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /target -.claude/ -.agents/ -.codex \ No newline at end of file +.agents +.claude +.codex diff --git a/plugins/errortools/.claude-plugin/plugin.json b/plugins/errortools/.claude-plugin/plugin.json new file mode 100644 index 0000000..9b1f7d4 --- /dev/null +++ b/plugins/errortools/.claude-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "rust-error-handling", + "description": "Rust error-handling guidance built around errortools and thiserror crates (MainResult, FormatError, custom Format strategies)", + "version": "0.1.0", + "author": { + "name": "Max Wase", + "email": "max.vvase@gmail.com" + }, + "homepage": "https://github.com/maxwase/errortools", + "repository": "https://github.com/maxwase/errortools", + "license": "MIT", + "keywords": [ + "rust", + "error", + "thiserror", + "main", + "format", + "chain", + "source", + "handling" + ], + "$schema": "https://www.schemastore.org/claude-code-plugin-manifest.json" +} \ No newline at end of file diff --git a/plugins/errortools/skills/errortools/SKILL.md b/plugins/errortools/skills/errortools/SKILL.md new file mode 100644 index 0000000..9d4989a --- /dev/null +++ b/plugins/errortools/skills/errortools/SKILL.md @@ -0,0 +1,276 @@ +--- +name: errortools +description: Use when writing or refactoring Rust error-handling code — covers idiomatic source-chain design with `thiserror` and ad-hock error logging. +--- + +# Rust error-handling skill + +Apply this whenever you are designing error types, deciding how `main` returns errors, or formatting an error chain for users or logs in a Rust project. The `errortools` crate ([crates.io](https://crates.io/crates/errortools), [docs.rs](https://docs.rs/errortools)) provides the runtime pieces; this skill encodes the conventions for using it well. + +## When to reach for it + +- Binary's `main` currently does the `if let Err(e) = run() { eprintln!(...); exit(1) }` dance — replace with `MainResult`. +- You see `Error: Outer(Inner(Io(Os { ... })))` in output — that's `Debug` formatting bleeding through; switch to `MainResult` or `FormatError`. +- You need to log a full source chain on one line (structured logs) or as a tree (human terminal). +- You want a project-specific error format — implement `Format` once, reuse via `MainResult` and `e.formatted::()`. + +If the project does not depend on `errortools`, add it to `Cargo.toml`: + +```toml +[dependencies] +errortools = "0.1" +thiserror = "2" +``` + +`errortools` is `no_std`-capable: disable the default `std` feature for embedded targets (`default-features = false`). + +## Core API cheat sheet + +| Item | Purpose | +|---|---| +| `MainResult` | Return type for `fn main`. Renders `E` via `Format` strategy `F` instead of `Debug`. | +| `OneLine` | Default strategy: joins error + sources with `": "`. | +| `Tree` | Indented multi-line strategy with `└──` connectors. | +| `Format` trait | Implement on a unit type to define a custom strategy. | +| `FormatError` ext trait | Adds `.one_line()`, `.tree()`, `.formatted::()` to any `&dyn Error`. | +| `chain(&dyn Error)` | Iterator over the error and its `source()` chain — use inside `Format` impls. | +| `Formatted` | Wrapper whose `Display` runs strategy `F` over `E`. | +| `DisplaySwapDebug` | Swaps `Debug` and `Display`, so returning it from `main` prints the `Display` form. | + +## Patterns + +### Pattern: `MainResult` for binary entrypoints + +```rust +use errortools::MainResult; +use std::{fs, io}; + +#[derive(Debug, thiserror::Error)] +enum Error { + #[error("failed to load config")] + Config(#[source] io::Error), +} + +fn main() -> MainResult { + fs::read_to_string("missing.toml").map_err(Error::Config)?; + Ok(()) +} +``` + +Output: + +```text +Error: failed to load config: No such file or directory (os error 2) +``` + +For tree output, parameterise the strategy: `fn main() -> MainResult`. + +### Pattern: ad-hoc logging mid-function + +When you cannot return — e.g., inside a `tokio::spawn`, an event handler, or a retry loop — use `FormatError`: + +```rust +use errortools::FormatError; + +if let Err(e) = do_thing().await { + tracing::error!("do_thing failed: {}", e.one_line()); +} +``` + +Pick the strategy inline: + +```rust +use errortools::{FormatError, Tree}; +eprintln!("{}", e.formatted::()); +``` + +### Pattern: custom format strategy + +Define once per project, reuse everywhere: + +```rust +use core::{error::Error, fmt}; +use errortools::{Format, FormatError, chain}; +use itertools::Itertools; + +pub struct Arrow; +impl Format for Arrow { + fn fmt(error: &dyn Error, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", chain(error).format(" -> ")) + } +} + +// usage: +// fn main() -> MainResult { ... } +// tracing::error!("{}", e.formatted::()); +``` + +`chain` walks `error.source()` repeatedly — never call `source()` by hand inside a `Format` impl. + +## Error-type discipline + +### Defining the error type + +1. **MUST** derive `thiserror::Error` + `Debug`. One error type per module, named `Error` (used as `feature::Error` from outside). + + ```rust + // GOOD + #[derive(Debug, thiserror::Error)] + pub enum Error { /* … */ } + ``` + +2. **MUST** collapse single-variant enums to structs. + + ```rust + // BAD + pub enum Error { ReadFile(#[source] io::Error) } + // GOOD + pub struct ReadFile(#[source] io::Error); + ``` + +3. **MUST** use a tuple variant when wrapping a foreign error with no extra context. + + ```rust + // GOOD + #[error("Failed to open config")] + ConfigOpen(#[source] io::Error), + ``` + +4. **MUST** use a struct variant when extra context is needed; put context in fields, never inside the message via `format!`. + + ```rust + // BAD + #[error("render template {}", name)] + Render(String, #[source] tera::Error), + // GOOD + #[error("render template {name}")] + Render { name: String, #[source] source: tera::Error }, + ``` + +5. **MUST NOT** print the source inside the variant message — `#[source]` already chains it. `OneLine` / `Tree` walk `source()` and join. + + ```rust + // BAD + #[error("read failed: {0}")] Read(#[source] io::Error), + // GOOD + #[error("read failed")] Read(#[source] io::Error), + ``` + +6. **PREFER** specific variants over generic ones. `&'static str` payloads only when the variant is one-off. + + ```rust + // BAD + Other(String), + // GOOD + #[error("Failed to join task '{0}'")] + TokioJoin(&'static str), + ``` + +### Converting at the call site + +7. **MUST** pass the variant constructor directly to `map_err`. + + ```rust + // BAD + .map_err(|source| Error::Config { source })? + // GOOD + .map_err(Error::Config)? + ``` + +8. **PREFER** chaining through existing variants over inventing new wrapper variants. + + ```rust + // BAD — new top-level variant just to wrap an inner one + #[error("inner")] InnerWrap(#[source] inner::Error), + // GOOD — reuse via From + .ok_or(top::Error::from(inner::Error::Foo(ctx)))? + ``` + +9. **MUST NOT** put `#[from]` on context-less variants. `#[from]` is allowed only when the source error already carries the operation context. + + ```rust + // BAD — every SQL collapses to one variant + #[error("db")] Db(#[from] sqlx::Error), + // GOOD + #[error("load user {id}")] + LoadUser { id: UserId, #[source] source: sqlx::Error }, + ``` + +10. **MUST NOT** hand-write `impl From for Error`. Use `#[source]` or `#[from]` only. + +11. **PREFER** `#[error(transparent)]` + `#[from]` only when the inner error is the whole story (re-export wrappers). + +### Logging mid-flow + +12. **PREFER** `errortools::FormatError` when you cannot return — never walk `source()` by hand. + + ```rust + // BAD + let mut cur: &dyn Error = &e; + while let Some(s) = cur.source() { /* … */ } + // GOOD + use errortools::FormatError; + tracing::error!("do_thing: {}", e.one_line()); + ``` + +### Returning from `main` + +13. **MUST** use `fn main() -> MainResult` (or `MainResult`). The strategy renders the chain via `Display`; `Debug` never reaches stderr. + + ```rust + // BAD + fn main() -> Result<(), Error> { … } + // GOOD + fn main() -> errortools::MainResult { … } + ``` + +14. **MUST** confine `exit(1)` and `panic!()` to `main`. Business logic returns `Result`. + +15. **MUST** do graceful shutdown in `main` (join threads, close connections). **AVOID** calling `drop(v)` manually — rely on scope. + +### Panics + +16. **MUST NOT** `unwrap()` / `expect()` in production or library code. If unavoidable, document it under `# Panics`. + + ```rust + // BAD + let cfg = load().unwrap(); + // GOOD + let cfg = load().map_err(config::Error::Config)?; + ``` + +### Batch operations + +17. **MUST NOT** silently skip failed items unless the API contract says so — fail fast, or collect and report per-item errors. + +### `anyhow` / `Box` + +18. **AVOID** `anyhow` or other dynamic error types outside tests / throwaway scripts. Production code uses explicit `thiserror` enums. + +### Tests + +19. **MUST** assert the exact error variant with arguments, not `.is_err()`. + + ```rust + // BAD + assert!(result.is_err()); + // GOOD + assert!(matches!(result, Err(Error::Config(_)))); + ``` + +## Choosing a format strategy + +| Context | Strategy | +|---|---| +| CLI tools, default | `OneLine` (single tidy line, greppable) | +| Interactive terminals where chains can be deep | `Tree` | +| Structured logs (JSON, OpenTelemetry) | `OneLine` — keep one log line per error | +| Project house style | Custom `Format` impl, applied uniformly | + +Switch globally by changing the type parameter on `MainResult` — there is no need to touch call sites. + +## References + +- README: +- Examples: (`one_line`, `tree`, `format_error`, `custom_format`, `transparent`) +- API docs: From 2f31e4160c0e226b2ea91ce04a982f8d91102f7b Mon Sep 17 00:00:00 2001 From: Max Wase Date: Thu, 7 May 2026 21:25:41 +0200 Subject: [PATCH 2/5] typo fix Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plugins/errortools/skills/errortools/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/errortools/skills/errortools/SKILL.md b/plugins/errortools/skills/errortools/SKILL.md index 9d4989a..e4370d3 100644 --- a/plugins/errortools/skills/errortools/SKILL.md +++ b/plugins/errortools/skills/errortools/SKILL.md @@ -1,6 +1,6 @@ --- name: errortools -description: Use when writing or refactoring Rust error-handling code — covers idiomatic source-chain design with `thiserror` and ad-hock error logging. +description: Use when writing or refactoring Rust error-handling code — covers idiomatic source-chain design with `thiserror` and ad-hoc error logging. --- # Rust error-handling skill From 89e8c46bfbe64b106f1c41f2f93f1285c845117f Mon Sep 17 00:00:00 2001 From: Max Wase Date: Tue, 23 Jun 2026 20:52:11 +0300 Subject: [PATCH 3/5] Update skill --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 29 ++- Cargo.lock | 2 +- Cargo.toml | 2 +- plugins/errortools/.claude-plugin/plugin.json | 2 +- plugins/errortools/skills/errortools/SKILL.md | 198 +++++++++++----- .../errortools/references/formatting.md | 224 ++++++++++++++++++ .../errortools/references/many-errors.md | 176 ++++++++++++++ .../errortools/references/suggestions.md | 89 +++++++ .../errortools/references/with-context.md | 151 ++++++++++++ 10 files changed, 806 insertions(+), 69 deletions(-) create mode 100644 plugins/errortools/skills/errortools/references/formatting.md create mode 100644 plugins/errortools/skills/errortools/references/many-errors.md create mode 100644 plugins/errortools/skills/errortools/references/suggestions.md create mode 100644 plugins/errortools/skills/errortools/references/with-context.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a97cafc..881dc91 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "rust-error-handling", "source": "./plugins/errortools", - "description": "Rust error-handling guidance built around errortools and thiserror crates" + "description": "Rust error-handling guidance built around the errortools and thiserror crates — error-type design, MainResult, context tagging, aggregation, and custom formatting" } ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 282df63..8eb5a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.2.0] - 2026-06-23 +## [0.3.0] - 2026-06-23 + +### Added + +- `ManyErrors` (requires `alloc`) — aggregates context-tagged errors as a rose tree. Costs nothing until needed: `None` while empty, one inline slot for the first error, a `Vec` only from the second. Implements `Error`, `Add`, `FromIterator`, and `Extend` for `Node`, `WithContext`, `(C, E)`, and `ControlFlow` pairs. `into_result(ok)` converts to `Ok`/`Err` in one call. +- `Node` and `Subgroup` (requires `alloc`) — the two child variants of `ManyErrors`: leaf `WithContext` pairs and labeled sub-groups. +- `Iter`, `IterMut`, `IntoIter` (requires `alloc`) — iterator types over the direct children of a `ManyErrors`, returned by `iter()`, `iter_mut()`, and `into_iter()`. +- `Tree`, `List`, `Bullets`, `Joined` (requires `alloc`) — aggregate rendering strategies. `Tree` draws a branching Unicode tree walking each leaf's source chain. `List` renders a numbered outline. `Bullets` uses `•` markers. `Joined` serializes to a single `;`-separated line. All are available via inherent helpers (`errs.tree()`, etc.) and via `errs.formatted::()` for full generic control. +- `Connectors` / `TreeConnectors` traits with the `Unicode` and `Ascii` glyph sets, shared by `Chain` and the aggregate `Tree` (so `Chain` and `Tree` look consistent). + +### Changed + +- **Breaking:** `Tree` (the per-error source-chain ladder) renamed to `Chain`. The type-parameter API changed too: the old `Marker + Indent` pair is replaced by a single `Connectors` impl (e.g. `Chain`). The `Tree` name is now used for the `ManyErrors` aggregate branching renderer. +- **Breaking:** `FormatError::tree()` renamed to `FormatError::chain()`, matching the `Tree` → `Chain` strategy rename. +- **Breaking:** the `separator::Colon` tag (added in 0.2.0) renamed to `separator::ColonChar`, to avoid colliding with the `with_context::Colon` *pair* strategy — a misimport between the two would compile but render each leaf as a bare `":"`. + +## [0.2.0] - 2026-05-17 ### Added @@ -16,21 +32,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Suggest` trait for per-variant "Did you mean…" hints, and the `Suggestion` `Format` strategy that renders them. `error.suggestion()` prints the top-level hint via `Display`. Default `Suggest::fmt` writes nothing, so types only implement it for variants that have a hint. - `DisplayPath` wrapper in the `path_display` module — drops `&Path`/`PathBuf` into any `Display` context. - `T` generic on `MainResult` so `main` can return `ExitCode` or any `Termination` type. -- `Add` — type-level combinator that runs two `Format` strategies in sequence. Compose with the new `separator` module (`NewLine`, `Space`, `Empty`, `ColonChar`, `ColonSpace`) to build strategies like `Add, Suggestion>` without writing a new impl. Bounds compose automatically — using `Suggestion` in the chain still requires `E: Suggest`. +- `Add` — type-level combinator that runs two `Format` strategies in sequence. Compose with the new `separator` module (`NewLine`, `Space`, `Empty`, `Colon`, `ColonSpace`) to build strategies like `Add, Suggestion>` without writing a new impl. Bounds compose automatically — using `Suggestion` in the chain still requires `E: Suggest`. - `WithSep` alias — sugar for `Add, R>` so the separator reads in the middle. Plus pre-baked variants in `separator`: `WithSpace`, `WithNewLine`, `WithColonSpace`. - `with_context::ContextField`, `ErrorField`, and `ContextPath` (std-only) — `Format` extractor strategies that read fields of `WithContext`. Compose with separators to build custom pair strategies, e.g. `WithSep` for a space-delimited pair. -- `Chain` — per-error source-chain ladder renderer (renamed from `Tree`, see breaking changes). Uses the same [`Connectors`] glyph vocabulary as the new aggregate `Tree`. -- `ManyErrors` (requires `alloc`) — aggregates context-tagged errors as a rose tree. Costs nothing until needed: `None` while empty, one inline slot for the first error, a `Vec` only from the second. Implements `Error`, `Add`, `FromIterator`, and `Extend` for `Node`, `WithContext`, `(C, E)`, and `ControlFlow` pairs. `into_result(ok)` converts to `Ok`/`Err` in one call. -- `Node` and `Subgroup` (requires `alloc`) — the two child variants of `ManyErrors`: leaf `WithContext` pairs and labeled sub-groups. -- `Iter`, `IterMut`, `IntoIter` (requires `alloc`) — iterator types over the direct children of a `ManyErrors`, returned by `iter()`, `iter_mut()`, and `into_iter()`. -- `Tree`, `List`, `Bullets`, `Joined` (requires `alloc`) — aggregate rendering strategies. `Tree` draws a branching Unicode tree walking each leaf's source chain. `List` renders a numbered outline. `Bullets` uses `•` markers. `Joined` serializes to a single `;`-separated line. All are available via inherent helpers (`errs.tree()`, etc.) and via `errs.formatted::()` for full generic control. ### Changed - **Breaking:** `Format` is now `Format` (the `E: Error` bound on the trait is gone). Strategies that walk the source chain still need `E: Error` and declare it themselves. The motivation: composing strategies through non-error types (e.g. `WithContext`) required dropping the trait-level `Error` bound. Existing impls keep working — the bound just moves from the trait to each impl that needs it. - **Breaking:** `with_context::Colon` and `with_context::PathColon` are now `type` aliases over `Add` rather than dedicated structs: `Colon = Add, ErrorField>`. The `WithContext::<_, _, Colon>` / `WithContextColon` / `WithPath` surface is unchanged; only code that named the strategies as `struct Colon;` (e.g. an explicit `impl ContextFormat for Colon`) is affected. - **Breaking:** `WithContext`'s `Display`, `Error`, and `with_format` bounds switched from `F: ContextFormat` to `F: Format>`. -- **Breaking:** `Tree` (the 0.1.0 per-error source-chain ladder) renamed to `Chain`. The type-parameter API changed: the old `Marker + Indent` pair is replaced by a single `Connectors` impl (e.g. `Chain`). The `Tree` name is now used for the `ManyErrors` aggregate branching renderer. ### Removed @@ -52,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DisplaySwapDebug` wrapper that swaps `Debug`/`Display`. - `no_std` support via `default-features = false`. -[Unreleased]: https://github.com/maxwase/errortools/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/maxwase/errortools/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/maxwase/errortools/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/maxwase/errortools/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/maxwase/errortools/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index e411552..6b6a8e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,7 +27,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "errortools" -version = "0.2.0" +version = "0.3.0" dependencies = [ "derive-where", "itertools", diff --git a/Cargo.toml b/Cargo.toml index 57635e2..4170b15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "errortools" authors = ["Max Wase "] -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Quality of life utilities for error handling in Rust." repository = "https://github.com/maxwase/errortools" diff --git a/plugins/errortools/.claude-plugin/plugin.json b/plugins/errortools/.claude-plugin/plugin.json index 9b1f7d4..5ef8597 100644 --- a/plugins/errortools/.claude-plugin/plugin.json +++ b/plugins/errortools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rust-error-handling", - "description": "Rust error-handling guidance built around errortools and thiserror crates (MainResult, FormatError, custom Format strategies)", + "description": "Rust error-handling guidance built around the errortools and thiserror crates — MainResult, FormatError, WithContext, ManyErrors, suggestions, and custom Format strategies", "version": "0.1.0", "author": { "name": "Max Wase", diff --git a/plugins/errortools/skills/errortools/SKILL.md b/plugins/errortools/skills/errortools/SKILL.md index e4370d3..b79ce67 100644 --- a/plugins/errortools/skills/errortools/SKILL.md +++ b/plugins/errortools/skills/errortools/SKILL.md @@ -1,41 +1,73 @@ --- name: errortools -description: Use when writing or refactoring Rust error-handling code — covers idiomatic source-chain design with `thiserror` and ad-hoc error logging. +description: Use when writing, refactoring, or reviewing Rust error-handling code with `thiserror` and the `errortools` crate — designing error enums and source chains, returning errors from `main`, attaching context like paths/IDs/retry counts, aggregating many failures, formatting chains for users or logs, or adding "did you mean" suggestions. Reach for this whenever a Rust task touches error types, `#[from]`/`#[source]`, `main` returning a `Result`, error logging, or `errortools`/`MainResult`/`FormatError` — even when not named explicitly. --- # Rust error-handling skill -Apply this whenever you are designing error types, deciding how `main` returns errors, or formatting an error chain for users or logs in a Rust project. The `errortools` crate ([crates.io](https://crates.io/crates/errortools), [docs.rs](https://docs.rs/errortools)) provides the runtime pieces; this skill encodes the conventions for using it well. +Apply this whenever you are designing error types, deciding how `main` returns +errors, attaching context to an error, aggregating a batch of failures, or +formatting an error chain for users or logs in a Rust project. The `errortools` +crate ([crates.io](https://crates.io/crates/errortools), +[docs.rs](https://docs.rs/errortools)) provides the runtime pieces; this skill +encodes the conventions for using it well. ## When to reach for it -- Binary's `main` currently does the `if let Err(e) = run() { eprintln!(...); exit(1) }` dance — replace with `MainResult`. -- You see `Error: Outer(Inner(Io(Os { ... })))` in output — that's `Debug` formatting bleeding through; switch to `MainResult` or `FormatError`. -- You need to log a full source chain on one line (structured logs) or as a tree (human terminal). -- You want a project-specific error format — implement `Format` once, reuse via `MainResult` and `e.formatted::()`. +- A binary's `main` does the `if let Err(e) = run() { eprintln!(...); exit(1) }` + dance — replace it with `MainResult`. +- You see `Error: Outer(Inner(Io(Os { ... })))` in output — that's `Debug` + formatting bleeding through; switch to `MainResult`. +- You need a full source chain on one line (structured logs) or as an indented + ladder (human terminal). +- You're tempted to add a single-variant wrapper just to attach a path or an + attempt number — reach for `WithContext` instead. +- An operation should report *all* failures, not just the first — use `ManyErrors`. +- You want a project-specific error format — implement `Format` once and reuse it + via `MainResult` and `e.formatted::()`. If the project does not depend on `errortools`, add it to `Cargo.toml`: ```toml [dependencies] -errortools = "0.1" +errortools = "0.3" thiserror = "2" ``` -`errortools` is `no_std`-capable: disable the default `std` feature for embedded targets (`default-features = false`). +`errortools` is `no_std`-capable: disable the default `std` feature for embedded +targets (`default-features = false`). The `alloc` feature (implied by `std`) +gates `ManyErrors` and the aggregate shapes. ## Core API cheat sheet | Item | Purpose | |---|---| -| `MainResult` | Return type for `fn main`. Renders `E` via `Format` strategy `F` instead of `Debug`. | -| `OneLine` | Default strategy: joins error + sources with `": "`. | -| `Tree` | Indented multi-line strategy with `└──` connectors. | -| `Format` trait | Implement on a unit type to define a custom strategy. | -| `FormatError` ext trait | Adds `.one_line()`, `.tree()`, `.formatted::()` to any `&dyn Error`. | +| `MainResult` | Return type for `fn main`. Renders `E` via strategy `F` instead of `Debug`. `T` lets `main` return `ExitCode`. | +| `OneLine` | Default strategy: error + sources joined with `": "`. | +| `Chain` | Per-error indented source-chain ladder (`└─`). *(was named `Tree` in ≤ 0.2)* | +| `Tree` / `List` / `Bullets` / `Joined` | Aggregate `ManyErrors` shapes (needs `alloc`). | +| `FormatError` | Ext trait on any `&dyn Error`: `.one_line()`, `.chain()`, `.suggestion()`, `.formatted::()`. | +| `Format` trait | Implement on a unit type to define a custom strategy (`Format`). | | `chain(&dyn Error)` | Iterator over the error and its `source()` chain — use inside `Format` impls. | | `Formatted` | Wrapper whose `Display` runs strategy `F` over `E`. | -| `DisplaySwapDebug` | Swaps `Debug` and `Display`, so returning it from `main` prints the `Display` form. | +| `WithContext` / `WithPath` | Tag an error with a context value (path, attempt, ID) without a wrapper variant. | +| `ManyErrors` | Aggregate of context-tagged failures; render as tree/list/bullets/joined (needs `alloc`). | +| `Suggest` / `Suggestion` | Per-error "did you mean…" hints via `.suggestion()`. | +| `Add` + `separator::*` | Compose two strategies, e.g. `WithNewLine`. | +| `DisplaySwapDebug` | Swaps `Debug`/`Display`; powers `MainResult`. | + +### Going deeper + +The error-type discipline below is the spine and applies to almost every task. +For the larger subsystems, read the matching reference file when the task calls +for it: + +| Need | Reference | +|---|---| +| Choosing/combining formats, custom `Format`, `MainResult` internals | `references/formatting.md` | +| Attaching a path / attempt / ID to an error | `references/with-context.md` | +| Collecting and reporting many failures at once | `references/many-errors.md` | +| "Did you mean…" recovery hints | `references/suggestions.md` | ## Patterns @@ -63,11 +95,12 @@ Output: Error: failed to load config: No such file or directory (os error 2) ``` -For tree output, parameterise the strategy: `fn main() -> MainResult`. +For the indented ladder, parameterise the strategy: `fn main() -> MainResult`. ### Pattern: ad-hoc logging mid-function -When you cannot return — e.g., inside a `tokio::spawn`, an event handler, or a retry loop — use `FormatError`: +When you cannot return — inside a `tokio::spawn`, an event handler, a retry loop +— use `FormatError`. Never walk `source()` by hand. ```rust use errortools::FormatError; @@ -80,38 +113,46 @@ if let Err(e) = do_thing().await { Pick the strategy inline: ```rust -use errortools::{FormatError, Tree}; -eprintln!("{}", e.formatted::()); +use errortools::FormatError; +eprintln!("{}", e.chain()); // indented ladder +// eprintln!("{}", e.formatted::()); // any custom strategy F ``` ### Pattern: custom format strategy -Define once per project, reuse everywhere: +Implement `Format` once per project, reuse everywhere. The trait bounds +nothing on `E`; a chain-walking strategy declares `E: Error` itself and walks +with `chain`: ```rust use core::{error::Error, fmt}; -use errortools::{Format, FormatError, chain}; -use itertools::Itertools; +use errortools::{Format, chain}; pub struct Arrow; -impl Format for Arrow { - fn fmt(error: &dyn Error, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", chain(error).format(" -> ")) +impl Format for Arrow { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, e) in chain(&error).enumerate() { + if i > 0 { f.write_str(" -> ")?; } + write!(f, "{e}")?; + } + Ok(()) } } -// usage: -// fn main() -> MainResult { ... } +// fn main() -> MainResult { … } // tracing::error!("{}", e.formatted::()); ``` -`chain` walks `error.source()` repeatedly — never call `source()` by hand inside a `Format` impl. +`chain` walks `error.source()` repeatedly — never call `source()` by hand inside +a `Format` impl. See `references/formatting.md` for composing strategies with +`Add`/separators and for `Chain`/connectors. ## Error-type discipline ### Defining the error type -1. **MUST** derive `thiserror::Error` + `Debug`. One error type per module, named `Error` (used as `feature::Error` from outside). +1. **MUST** derive `thiserror::Error` + `Debug`. One error type per module, named + `Error` (used as `feature::Error` from outside). ```rust // GOOD @@ -136,7 +177,8 @@ impl Format for Arrow { ConfigOpen(#[source] io::Error), ``` -4. **MUST** use a struct variant when extra context is needed; put context in fields, never inside the message via `format!`. +4. **MUST** use a struct variant when extra context is needed; put context in + fields, never inside the message via `format!`. ```rust // BAD @@ -147,7 +189,22 @@ impl Format for Arrow { Render { name: String, #[source] source: tera::Error }, ``` -5. **MUST NOT** print the source inside the variant message — `#[source]` already chains it. `OneLine` / `Tree` walk `source()` and join. +5. **PREFER** `WithContext` / `WithPath` to attach *incidental* context — a path, + a retry attempt, a record ID — over inventing a single-variant wrapper whose + only job is to hold it. `WithContext`'s `source()` skips its own inner error, + so the chain never doubles up. Keep context in a real variant only when callers + branch on it. See `references/with-context.md`. + + ```rust + // BAD — a wrapper variant that exists only to staple a path on + #[error("io at {path}")] + IoAt { path: PathBuf, #[source] source: io::Error }, + // GOOD + File::create(&path).map_err(|e| WithContext::new(path, e))?; + ``` + +6. **MUST NOT** print the source inside the variant message — `#[source]` already + chains it. `OneLine` / `Chain` walk `source()` and join. ```rust // BAD @@ -156,7 +213,8 @@ impl Format for Arrow { #[error("read failed")] Read(#[source] io::Error), ``` -6. **PREFER** specific variants over generic ones. `&'static str` payloads only when the variant is one-off. +7. **PREFER** specific variants over generic ones. `&'static str` payloads only + when the variant is one-off. ```rust // BAD @@ -168,7 +226,7 @@ impl Format for Arrow { ### Converting at the call site -7. **MUST** pass the variant constructor directly to `map_err`. +8. **MUST** pass the variant constructor directly to `map_err`. ```rust // BAD @@ -177,7 +235,7 @@ impl Format for Arrow { .map_err(Error::Config)? ``` -8. **PREFER** chaining through existing variants over inventing new wrapper variants. +9. **PREFER** chaining through existing variants over inventing new wrapper variants. ```rust // BAD — new top-level variant just to wrap an inner one @@ -186,23 +244,27 @@ impl Format for Arrow { .ok_or(top::Error::from(inner::Error::Foo(ctx)))? ``` -9. **MUST NOT** put `#[from]` on context-less variants. `#[from]` is allowed only when the source error already carries the operation context. +10. **MUST NOT** put `#[from]` on context-less variants. `#[from]` is allowed only + when the source error already carries the operation context. - ```rust - // BAD — every SQL collapses to one variant - #[error("db")] Db(#[from] sqlx::Error), - // GOOD - #[error("load user {id}")] - LoadUser { id: UserId, #[source] source: sqlx::Error }, - ``` + ```rust + // BAD — every SQL collapses to one variant + #[error("db")] Db(#[from] sqlx::Error), + // GOOD + #[error("load user {id}")] + LoadUser { id: UserId, #[source] source: sqlx::Error }, + ``` -10. **MUST NOT** hand-write `impl From for Error`. Use `#[source]` or `#[from]` only. +11. **MUST NOT** hand-write `impl From for Error`. Use `#[source]` + or `#[from]` only. -11. **PREFER** `#[error(transparent)]` + `#[from]` only when the inner error is the whole story (re-export wrappers). +12. **PREFER** `#[error(transparent)]` + `#[from]` only when the inner error is the + whole story (re-export wrappers). ### Logging mid-flow -12. **PREFER** `errortools::FormatError` when you cannot return — never walk `source()` by hand. +13. **PREFER** `errortools::FormatError` when you cannot return — never walk + `source()` by hand. ```rust // BAD @@ -215,7 +277,8 @@ impl Format for Arrow { ### Returning from `main` -13. **MUST** use `fn main() -> MainResult` (or `MainResult`). The strategy renders the chain via `Display`; `Debug` never reaches stderr. +14. **MUST** use `fn main() -> MainResult` (or `MainResult`). + The strategy renders the chain via `Display`; `Debug` never reaches stderr. ```rust // BAD @@ -224,13 +287,16 @@ impl Format for Arrow { fn main() -> errortools::MainResult { … } ``` -14. **MUST** confine `exit(1)` and `panic!()` to `main`. Business logic returns `Result`. +15. **MUST** confine `exit(1)` and `panic!()` to `main`. Business logic returns + `Result`. -15. **MUST** do graceful shutdown in `main` (join threads, close connections). **AVOID** calling `drop(v)` manually — rely on scope. +16. **MUST** do graceful shutdown in `main` (join threads, close connections). + **AVOID** calling `drop(v)` manually — rely on scope. ### Panics -16. **MUST NOT** `unwrap()` / `expect()` in production or library code. If unavoidable, document it under `# Panics`. +17. **MUST NOT** `unwrap()` / `expect()` in production or library code. If + unavoidable, document it under `# Panics`. ```rust // BAD @@ -241,15 +307,30 @@ impl Format for Arrow { ### Batch operations -17. **MUST NOT** silently skip failed items unless the API contract says so — fail fast, or collect and report per-item errors. +18. **MUST NOT** silently skip failed items unless the API contract says so — fail + fast, or collect and report per-item errors. `ManyErrors` is the tool for the + collect-and-report case: push `(context, error)` pairs (or `push_group` for + nesting), then `into_result(ok)`. See `references/many-errors.md`. + + ```rust + // BAD — swallow failures + for item in items { let _ = process(item); } + // GOOD + let mut errs = ManyErrors::new(); + for item in &items { + if let Err(e) = process(item) { errs.push(item.id, e); } + } + errs.into_result(())?; + ``` ### `anyhow` / `Box` -18. **AVOID** `anyhow` or other dynamic error types outside tests / throwaway scripts. Production code uses explicit `thiserror` enums. +19. **AVOID** `anyhow` or other dynamic error types outside tests / throwaway + scripts. Production code uses explicit `thiserror` enums. ### Tests -19. **MUST** assert the exact error variant with arguments, not `.is_err()`. +20. **MUST** assert the exact error variant with arguments, not `.is_err()`. ```rust // BAD @@ -263,14 +344,19 @@ impl Format for Arrow { | Context | Strategy | |---|---| | CLI tools, default | `OneLine` (single tidy line, greppable) | -| Interactive terminals where chains can be deep | `Tree` | -| Structured logs (JSON, OpenTelemetry) | `OneLine` — keep one log line per error | -| Project house style | Custom `Format` impl, applied uniformly | +| Interactive terminals, deep chains | `Chain` (or `Chain`) | +| Structured logs (JSON, OpenTelemetry) | `OneLine` — one log line per error | +| A batch of independent failures | `Tree` / `List` / `Bullets` / `Joined` (`ManyErrors`) | +| Error + recovery hint | `WithNewLine` | +| Project house style | custom `Format` impl, applied uniformly | -Switch globally by changing the type parameter on `MainResult` — there is no need to touch call sites. +Switch globally by changing the type parameter on `MainResult` — no call sites +change. Details and composition in `references/formatting.md`. ## References - README: -- Examples: (`one_line`, `tree`, `format_error`, `custom_format`, `transparent`) +- Runnable examples: + (`one_line`, `chain`, `format_error`, `custom_format`, `transparent`, + `with_context`, `many_errors`) - API docs: diff --git a/plugins/errortools/skills/errortools/references/formatting.md b/plugins/errortools/skills/errortools/references/formatting.md new file mode 100644 index 0000000..bec0d4e --- /dev/null +++ b/plugins/errortools/skills/errortools/references/formatting.md @@ -0,0 +1,224 @@ +# Formatting & rendering strategies + +How `errortools` turns an error and its source chain into text. Read this when +choosing how `main` reports an error, how you log a chain mid-flow, or when you +need a project-specific format. + +The mental model: a **`Format` strategy** is a unit type that knows how to +write some value `E` to a `fmt::Formatter`. You rarely instantiate one — you +name it as a type parameter (`MainResult`) or hand it to a wrapper +(`e.formatted::()`). `Formatted` is the wrapper whose `Display` +runs strategy `F` over `E`. + +## Returning from `main`: `MainResult` + +`MainResult` is a drop-in `Result` for `fn main`. When +`main` returns `Err`, the runtime prints it via `Debug` — `MainResult` arranges +for that `Debug` print to emit `F`'s `Display`-style output instead of the raw +derived `Debug`. `?` converts your error in automatically. + +```rust +use errortools::MainResult; +use std::{fs, io}; + +#[derive(Debug, thiserror::Error)] +enum AppError { + #[error("failed to load config")] + Config(#[source] ConfigError), +} + +#[derive(Debug, thiserror::Error)] +enum ConfigError { + #[error("failed to read file")] + Read(#[source] io::Error), +} + +fn main() -> MainResult { + fs::read_to_string("does-not-exist.toml") + .map_err(ConfigError::Read) + .map_err(AppError::Config)?; + Ok(()) +} +``` + +```text +Error: failed to load config: failed to read file: No such file or directory (os error 2) +``` + +Swap the strategy with the second type parameter — no call sites change: + +```rust +fn main() -> errortools::MainResult { /* … */ } +``` + +```text +Error: failed to load config +└─ failed to read file + └─ No such file or directory (os error 2) +``` + +The third parameter `T` is the success type (default `()`). Return an +`ExitCode` (or any `Termination`) when you need a custom exit status: + +```rust +use std::process::ExitCode; +fn main() -> errortools::MainResult { + // … on success: + Ok(ExitCode::SUCCESS) +} +``` + +## Logging mid-flow: `FormatError` + +When you cannot return — inside a `tokio::spawn`, an event loop, a retry — the +`FormatError` extension trait renders any `&dyn Error` on the spot. Never walk +`source()` by hand. + +```rust +use errortools::FormatError; + +if let Err(e) = do_thing() { + tracing::error!("do_thing failed: {}", e.one_line()); + // do_thing failed: outer: middle: inner +} +``` + +`FormatError` methods: + +| Method | Strategy | Output | +|---|---|---| +| `.one_line()` | `OneLine` | error + sources joined by `": "` | +| `.chain()` | `Chain` | indented source-chain ladder (`└─`) | +| `.suggestion()` | `Suggestion` | top-level "did you mean…" hint (needs `Suggest`) — see `suggestions.md` | +| `.formatted::()` | any `F` | render with an arbitrary strategy | + +Pick a strategy inline: + +```rust +use errortools::{Chain, FormatError}; + +if let Err(e) = do_thing() { + eprintln!("{}", e.formatted::()); + // outer + // └─ middle + // └─ inner +} +``` + +## `OneLine` vs `Chain` + +| Strategy | Shape | Glyphs | +|---|---|---| +| `OneLine` | `outer: middle: inner` | — | +| `Chain` | indented ladder, one source per line | `└─ ` + ` ` (Unicode) | + +`Chain` is parameterised by a `Connectors` glyph set. Use `Ascii` where Unicode +box art won't render: + +```rust +use errortools::{Ascii, Chain, Formatted}; +println!("{}", Formatted::<_, Chain>::new(err)); +// outer +// `- middle +// `- inner +``` + +`Connectors` exposes `LAST`/`GAP`; the branching `TreeConnectors` supertrait +adds `BRANCH`/`VERT` for the aggregate `Tree`. Both `Unicode` and `Ascii` +implement both, so `Chain` and `Tree` look consistent. + +## Custom `Format` strategy + +Implement `Format` on a unit type. The trait imposes **no bound** on `E` +(`Format`); a strategy that walks the source chain declares +`E: Error` itself. Walk the chain with `chain(&error)` — never call `source()` +by hand. + +```rust +use core::{error::Error, fmt}; +use errortools::{Format, FormatError, chain}; +use itertools::Itertools; // consumer needs the `itertools` crate for `.format` + +struct Arrow; + +impl Format for Arrow { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // `&error` is `&&E`; it coerces to `&dyn Error`. + write!(f, "{}", chain(&error).format(" -> ")) + } +} + +// fn main() -> MainResult { … } +println!("{}", my_error.formatted::()); // outer -> middle -> inner +``` + +Without the `itertools` dependency, fold the chain by hand: + +```rust +fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, e) in chain(&error).enumerate() { + if i > 0 { f.write_str(" -> ")?; } + write!(f, "{e}")?; + } + Ok(()) +} +``` + +Define a custom strategy once per project and reuse it everywhere via the type +parameter — there is one place to change the house style. + +## Composing strategies: `Add` + separators + +`Add` runs two strategies against the same value, left then right. There +is no implicit separator — drop a separator strategy in the middle, or use the +`WithSep` alias so the separator reads in order: + +```rust +use errortools::{Formatted, OneLine, Suggestion, separator::{NewLine, WithSep}}; + +// Same as Add, Suggestion>. +// Renders "\n". +type Brief = WithSep; +eprintln!("{}", Formatted::<_, Brief>::new(err)); +``` + +Zero-think aliases for the common separators live in `errortools::separator`: +`WithSpace`, `WithNewLine`, `WithColonSpace`. Separator tags +themselves: `NewLine`, `Space`, `Empty`, `ColonChar`, `ColonSpace`. + +```rust +use errortools::{Formatted, OneLine, Suggestion, separator::WithNewLine}; +type Brief = WithNewLine; +eprintln!("{}", Formatted::<_, Brief>::new(err)); +``` + +Bounds compose automatically: `Add` only implements +`Format` when `E: Error + Suggest`, because `Suggestion`'s own impl carries +the `Suggest` bound. `Add` writes both sides unconditionally — if `R` produces +nothing (a `Suggestion` variant with no hint), the separator is still written. + +This same combinator powers defaults elsewhere: `WithContext`'s `Colon` is +`WithColonSpace` (see `with-context.md`). + +## Choosing a strategy + +| Context | Strategy | +|---|---| +| CLI tools, default | `OneLine` — single greppable line | +| Interactive terminals, deep chains | `Chain` (or `Chain`) | +| Structured logs (JSON, OTel) | `OneLine` — one log line per error | +| Aggregate of many failures | `Tree` / `List` / `Bullets` / `Joined` (see `many-errors.md`) | +| Error + fix hint | `WithNewLine` (see `suggestions.md`) | +| Project house style | a custom `Format` impl, applied uniformly | + +## How `MainResult` works + +```rust +pub type MainResult = + Result>>; +``` + +`DisplaySwapDebug` swaps a type's `Debug` and `Display` impls. `main` prints +the returned error via `Debug`; the swap routes that to the inner `Display`, +which is `Formatted` running strategy `F`. The blanket `From` impl is +what lets `?` lift your error into the wrapper. diff --git a/plugins/errortools/skills/errortools/references/many-errors.md b/plugins/errortools/skills/errortools/references/many-errors.md new file mode 100644 index 0000000..41e31c2 --- /dev/null +++ b/plugins/errortools/skills/errortools/references/many-errors.md @@ -0,0 +1,176 @@ +# Aggregating many failures: `ManyErrors` + +Read this when an operation shouldn't stop at the first failure — validating a +whole config, deploying to every region, parsing a batch — and you want to +report all of them, grouped and readable, instead of just the first. (Requires +the `alloc` feature, on by default.) + +`ManyErrors` is a context-tagged collection arranged as a rose tree. It +costs nothing until it has to: `None` while empty, one inline slot for the first +error, a `Vec` only once a second arrives. + +## Building it + +```rust +use errortools::ManyErrors; + +let mut errs = ManyErrors::new(); +errs.push("eu-west-1", RegionError::Refused); // (context, error) leaf +errs.push("us-east-1", RegionError::Refused); + +errs.into_result(())?; // Ok(()) if empty, Err(ManyErrors) otherwise +``` + +| Method | Effect | +|---|---| +| `new()` / `is_empty()` / `len()` | construct / inspect | +| `push(context, error)` | append a leaf, promoting `None → One → Many` | +| `push_group(label, sub)` | append a named nested `ManyErrors` | +| `push_node(node)` | append anything `Into` — a `(C, E)` pair, `WithContext`, `Subgroup`, or `Node` | +| `into_result(ok)` | `Ok(ok)` if empty, else `Err(self)` | +| `with_formats::()` | swap leaf + group-label strategies | + +You can also collect straight from an iterator of `(context, error)` pairs or +`WithContext` values — `ManyErrors` implements `FromIterator` and `Extend`, +which composes with itertools' `partition_result`: + +```rust +let errs: ManyErrors<&str, io::Error> = nodes.into_iter().collect(); +``` + +Group related failures with `push_group`, and the shapes nest: + +```rust +use errortools::ManyErrors; + +let mut east = ManyErrors::new(); +east.push("i-0a1", RegionError::Refused); +east.push("i-0b2", RegionError::Refused); + +let mut all: ManyErrors<&str, RegionError> = ManyErrors::new(); +all.push_group("us-east-1", east); +all.push("eu-west-1", RegionError::Refused); +``` + +## Rendering shapes + +The shapes are **inherent helpers** — no turbofish — and they walk each leaf's +source chain: + +```rust +println!("{}", all.tree()); // Unicode branching tree, with count header +println!("{}", all.list()); // numbered outline (1. 1.1. 2.) +println!("{}", all.bullets()); // • bulleted +println!("{}", all.joined()); // ;-separated single line, parens around groups +``` + +`tree()`: + +```text +2 errors: +├─ us-east-1 (2 errors): +│ ├─ i-0a1: connection refused +│ └─ i-0b2: connection refused +└─ eu-west-1: connection refused +``` + +The default `Display` (`{all}`) is deliberately a **shallow one-line summary** — +each error's own text, no source chains — so it's safe to embed in a message or +log, following the Rust convention that an error's `Display` is its own message: + +```text +2 errors: us-east-1 (2 errors: i-0a1: connection refused; i-0b2: connection refused); eu-west-1: connection refused +``` + +For full control — ASCII connectors, no count header — go through the inherent +`formatted` with explicit generics. `Tree`: + +```rust +use errortools::{Formatted, many_errors::{Ascii, Tree}}; +println!("{}", Formatted::<_, Tree>::new(&all)); // ASCII, no header +println!("{}", all.formatted::>()); // same, shorter +``` + +## Two `formatted` methods — use the inherent one + +`errs.formatted::()` resolves to an **inherent** method that is completely +unbounded (and `const`): wrapping always compiles; whether the combination can +print is decided by `F`'s own `Format` bounds at the `Display` call site. + +The `FormatError::formatted` *trait* method also exists, but calling it requires +`ManyErrors: Error`, which drags `C: Debug` and `GC: Debug` onto your context +types (the `Error` supertrait) even though no strategy needs them to render. At a +call site the inherent method wins automatically and produces the identical +value — so just write `errs.formatted::()`. A `PathBuf` context with +`PathColon` then renders in every shape even though `PathBuf` has no `Display`. + +The same rule runs through the whole path: no shape demands anything from a +context directly. Leaves print through the leaf strategy `F`, group labels +through the label strategy `GF`, and those decide the bounds. + +## Children are errors too + +```rust +use errortools::FormatError; + +// Iterate and log each child, or stick one in a #[source]. +for node in &errs { + tracing::warn!("{}", node.one_line()); +} +``` + +## Footgun: `one_line()` / `chain()` on an aggregate + +A `ManyErrors` *is* an `Error`, so `errs.one_line()` and `errs.chain()` compile — +but they print only the shallow summary, because `Error::source()` is always +`None` (an aggregate has no single linear cause). The deep versions are +`joined()` (one line) and `tree()` (multi-line). + +Same logic in reverse: a `ManyErrors` buried in another error's `#[source]` +chain shows up as **one summary line** under `OneLine`/`Chain`, and the walk +stops there — branching can't be recovered through `dyn Error`. If you want its +branches rendered, lift it into a `push_group` of an outer `ManyErrors` instead +of chaining it as a source. + +## Custom aggregate shapes + +Need your own layout? Implement `Format>` and match on the public +`ManyErrors` / `Node` variants, plus a small `&T` ref-forwarder so +`Formatted<&ManyErrors<…>, _>` works: + +```rust +use core::fmt::{self, Display, Formatter}; +use errortools::{Format, ManyErrors, Node}; + +struct Dashed; + +impl Format> for Dashed { + fn fmt(errors: &ManyErrors, f: &mut Formatter<'_>) -> fmt::Result { + for node in errors { + match node { + Node::Leaf(w) => writeln!(f, "- {}: {}", w.context, w.error)?, + Node::Group(g) => writeln!(f, "- {} ({} nested)", g.context, g.errors.len())?, + } + } + Ok(()) + } +} + +impl Format<&T> for Dashed where Dashed: Format { + fn fmt(errors: &&T, f: &mut Formatter<'_>) -> fmt::Result { + >::fmt(*errors, f) + } +} +``` + +To keep output consistent with the built-ins, reuse their public helpers rather +than reinventing them: `many_errors::strategy::{ErrorCount, LeafChain, +NO_ERRORS}`, `indent::indented` for re-indenting multiline content, and the +`impl_ref_format!` macro for the `&T` trampoline. See the `many_errors::strategy` +module docs for a worked example. + +Group labels can differ from leaf contexts via the third parameter +`ManyErrors`, but `GC` defaults to `C`, so the common case stays two +params. Label decoration is a separate lever: the group-label strategy `GF` +(default `AsDisplay`) is label-only and never sees the nested errors — laying +those out is the aggregate strategy's job. diff --git a/plugins/errortools/skills/errortools/references/suggestions.md b/plugins/errortools/skills/errortools/references/suggestions.md new file mode 100644 index 0000000..dd5e30f --- /dev/null +++ b/plugins/errortools/skills/errortools/references/suggestions.md @@ -0,0 +1,89 @@ +# Suggestions: "Did you mean…" hints + +Read this when an error should carry a recovery hint for the user — "copy +`config.example.toml` to `config.toml`", "pass `--help`", "check the path +exists" — separate from the error message itself. + +Implement the `Suggest` trait on your error type to give per-variant hints, then +render them with `error.suggestion()` (or the `Suggestion` `Format` strategy). + +```rust +use core::fmt; +use errortools::{FormatError, Suggest}; + +#[derive(Debug, thiserror::Error)] +enum Error { + #[error("Config file missing")] + NoConfig, + #[error("Network down")] + Network, +} + +impl Suggest for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoConfig => f.write_str("Did you copy config.example.toml to config.toml?"), + Self::Network => Ok(()), // no hint for this variant + } + } +} + +eprintln!("{}\n{}", Error::NoConfig.one_line(), Error::NoConfig.suggestion()); +// Config file missing +// Did you copy config.example.toml to config.toml? +``` + +`Suggest::fmt` defaults to writing nothing, so a type only needs arms for the +variants that actually have a hint. + +## Top-level only — by design + +`error.suggestion()` prints **only the top-level error's** hint; the source +chain is not walked. This is intentional: an inner error's hint is often +irrelevant once it has been wrapped, and printing it would just add noise. The +convention is that each error that should suggest something implements `Suggest`, +and a top-level error's hint can deliberately concatenate an inner hint when it's +relevant — with nesting that matches the error chain. + +`Suggest` is dispatched on the concrete outer type, so it is **not** delegated +through `#[error(transparent)]` either: `transparent` collapses `Display` and +`source`, but the outer type's `Suggest` impl always wins. + +## Pairing the error with its hint + +A bare `error.suggestion()` is just the hint. To show the error *and* its hint, +compose the two strategies with `Add` (see `formatting.md`). The +`WithNewLine` alias puts the hint on its own line: + +```rust +use errortools::{Formatted, OneLine, Suggestion, separator::WithNewLine}; + +type Brief = WithNewLine; +eprintln!("{}", Formatted::<_, Brief>::new(err)); +// +// +``` + +`Add` writes both sides unconditionally, so a variant with no hint still emits +the separator (a trailing newline) — predictable for line-oriented output. + +## In `main`: `MainResultWithSuggestion` + +To print the error and its hint straight out of `main`, return +`MainResultWithSuggestion` instead of `MainResult`. It is +`MainResult>` — the error via `F` (default +`OneLine`), a newline, then the top-level suggestion: + +```rust +use errortools::{MainResultWithSuggestion, Suggest}; + +fn main() -> MainResultWithSuggestion { + do_work()?; // any Err with Suggest renders as "\n" + Ok(()) +} +``` + +Customize the layout with the `WithSuggestion` type alias (e.g. a space +separator, or a different error strategy `F`), or drop down to `MainResult` +with your own `Add`-composed strategy. The `T` success generic still applies, so +`MainResultWithSuggestion` works too. diff --git a/plugins/errortools/skills/errortools/references/with-context.md b/plugins/errortools/skills/errortools/references/with-context.md new file mode 100644 index 0000000..b2dcf73 --- /dev/null +++ b/plugins/errortools/skills/errortools/references/with-context.md @@ -0,0 +1,151 @@ +# Attaching context: `WithContext` + +Read this when an error needs a value carried alongside it — a file path, a step +number, a retry attempt, a record ID — and inventing a one-off wrapper variant +just to hold that value would be noise. + +`WithContext` pairs a context value `C` with an error `E` and +renders the pair through a `Format` strategy `F`. It is the idiomatic +alternative to single-variant wrapper enums whose only job is to staple a path +onto an `io::Error`. + +The key behaviour: `WithContext`'s `Error::source()` returns the **inner +error's** source, skipping the inner error itself (its `Display` already prints +it). So chain-walking strategies (`OneLine`, `Chain`, the aggregate shapes) +never print the wrapped error twice. + +## The default `Colon` strategy + +`Colon` renders `": "`. Use the `WithContextColon` alias to get +type inference on `new` without a turbofish: + +```rust +use errortools::{FormatError, with_context::WithContextColon}; +use std::io; + +let err = io::Error::new(io::ErrorKind::NotFound, "file missing"); +let ctx = WithContextColon::new("path/to/config", err); +assert_eq!(ctx.one_line().to_string(), "path/to/config: file missing"); +``` + +Any `Display` context works — a retry attempt number, for instance: + +```rust +use errortools::WithContext; +use std::{fs::File, io, path::Path, num::NonZeroUsize}; + +fn create_with_retry( + path: &Path, + attempts: NonZeroUsize, +) -> Result> { + let last = attempts.get(); + for _ in 1..last { + if let Ok(f) = File::create(path) { return Ok(f); } + } + // Tag the final failure with its attempt number → ": ". + File::create(path).map_err(|e| WithContext::new(last, e)) +} +``` + +## Paths: `PathColon` and `WithPath` + +`PathColon` (std only) calls `Path::display` for you, so `&Path`/`PathBuf` go +straight in without a `Display` newtype. `WithPath` names the path case +(`= WithContext`). Lift it into your error enum with `#[from]`: + +```rust +use errortools::{MainResult, WithContext, with_context::WithPath}; +use std::{fs::File, io, path::Path}; + +#[derive(Debug, thiserror::Error)] +#[error("failed to create file")] +struct Error(#[from] WithPath<&'static Path, io::Error>); + +fn main() -> MainResult { + let path = Path::new("no/such/dir/foo.txt"); + File::create(path).map_err(|e| Error::from(WithContext::new(path, e)))?; + Ok(()) +} +``` + +```text +Error: failed to create file: no/such/dir/foo.txt: No such file or directory (os error 2) +``` + +`#[from]` is what pins the format parameter: `WithContext::new(path, e)` infers +`PathColon` from the target `WithPath<…>`, no turbofish needed. + +## Nesting context layers + +Layers nest, and the chain reads outside-in. Wrap a +`WithContext` (attempt) inside a `WithPath<&Path, …>` (path) +and you get `": : "`: + +```rust +#[derive(Debug, thiserror::Error)] +enum FsError { + #[error("Failed to create file")] + Create(#[source] WithPath>), +} +``` + +The crate's [`with_context`](https://github.com/maxwase/errortools/blob/master/examples/with_context.rs) +example threads this end-to-end through `MainResult`. + +## Customizing the rendering + +`WithContext` formats through any `F: Format>`. Two ways to +change the look: + +**1. Compose field extractors with a separator.** `Colon` is just +`WithColonSpace`. Swap the separator to change the +delimiter — every extractor/separator is a `Format` tag: + +```rust +use errortools::{WithContext, separator::WithSpace, with_context::{ContextField, ErrorField}}; + +// Same as Colon but a single space instead of ": ". +type SpacePair = WithSpace; +let w = WithContext::<_, _, SpacePair>::new("step", "boom"); +assert_eq!(w.to_string(), "step boom"); +``` + +The extractors: `ContextField` reads `w.context`, `ErrorField` reads `w.error`, +and `ContextPath` (std only) reads `w.context` via `Path::display`. + +**2. Write a one-shot impl** when the layout is unusual. Pull fields off the +`&WithContext` directly and declare whatever bounds you need: + +```rust +use core::fmt::{self, Display, Formatter}; +use errortools::{Format, WithContext}; + +struct Arrow; +impl Format> for Arrow { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{} -> {}", w.context, w.error) + } +} + +let w = WithContext::<_, _, Arrow>::new(1, "boom"); +assert_eq!(w.to_string(), "1 -> boom"); +``` + +> **Render the error's own text.** Because `Error::source` deliberately skips the +> inner error, chain-walking renderers assume your strategy already printed +> `w.error`. A strategy that omits it (context-only) silently drops the error +> text from every deep rendering. + +`with_format::()` switches the strategy on an existing value without +touching the stored fields. `From<(C, E)>` lets a `(context, error)` tuple +become a `WithContext` directly — handy with iterator adapters. + +## When to reach for it (and when not) + +- **Reach for it** instead of a single-variant wrapper struct/enum whose only + purpose is to attach a path/ID/attempt to a foreign error. This keeps the + source chain honest and avoids variant sprawl. +- **Still use a real variant** when the context *is* the operation's identity + and you'll match on it (e.g. `LoadUser { id, #[source] source }`). Context + that callers branch on belongs in the enum; context that's purely for the + message belongs in `WithContext`. From f489b3236c9c33bf1361d5c2a159418d87ee0309 Mon Sep 17 00:00:00 2001 From: Max Wase Date: Tue, 23 Jun 2026 21:10:33 +0300 Subject: [PATCH 4/5] Split of skills --- CHANGELOG.md | 30 +- README.md | 36 +- examples/chain.rs | 8 +- examples/custom_format.rs | 6 +- examples/format_error.rs | 2 +- examples/many_errors.rs | 6 +- examples/one_line.rs | 6 +- examples/transparent.rs | 6 +- plugins/errortools/skills/errortools/SKILL.md | 362 ------------------ .../migrating-from-unstructured/SKILL.md | 225 +++++++++++ .../skills/rust-error-handling/SKILL.md | 44 +++ .../skills/structured-error-handling/SKILL.md | 260 +++++++++++++ .../skills/using-errortools/SKILL.md | 194 ++++++++++ .../references/formatting.md | 40 +- .../references/many-errors.md | 30 +- .../references/suggestions.md | 14 +- .../references/with-context.md | 20 +- 17 files changed, 825 insertions(+), 464 deletions(-) delete mode 100644 plugins/errortools/skills/errortools/SKILL.md create mode 100644 plugins/errortools/skills/migrating-from-unstructured/SKILL.md create mode 100644 plugins/errortools/skills/rust-error-handling/SKILL.md create mode 100644 plugins/errortools/skills/structured-error-handling/SKILL.md create mode 100644 plugins/errortools/skills/using-errortools/SKILL.md rename plugins/errortools/skills/{errortools => using-errortools}/references/formatting.md (84%) rename plugins/errortools/skills/{errortools => using-errortools}/references/many-errors.md (86%) rename plugins/errortools/skills/{errortools => using-errortools}/references/suggestions.md (89%) rename plugins/errortools/skills/{errortools => using-errortools}/references/with-context.md (89%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb5a04..4355107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,34 +11,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `ManyErrors` (requires `alloc`) — aggregates context-tagged errors as a rose tree. Costs nothing until needed: `None` while empty, one inline slot for the first error, a `Vec` only from the second. Implements `Error`, `Add`, `FromIterator`, and `Extend` for `Node`, `WithContext`, `(C, E)`, and `ControlFlow` pairs. `into_result(ok)` converts to `Ok`/`Err` in one call. -- `Node` and `Subgroup` (requires `alloc`) — the two child variants of `ManyErrors`: leaf `WithContext` pairs and labeled sub-groups. -- `Iter`, `IterMut`, `IntoIter` (requires `alloc`) — iterator types over the direct children of a `ManyErrors`, returned by `iter()`, `iter_mut()`, and `into_iter()`. -- `Tree`, `List`, `Bullets`, `Joined` (requires `alloc`) — aggregate rendering strategies. `Tree` draws a branching Unicode tree walking each leaf's source chain. `List` renders a numbered outline. `Bullets` uses `•` markers. `Joined` serializes to a single `;`-separated line. All are available via inherent helpers (`errs.tree()`, etc.) and via `errs.formatted::()` for full generic control. +- `ManyErrors` (requires `alloc`) -- aggregates context-tagged errors as a rose tree. Costs nothing until needed: `None` while empty, one inline slot for the first error, a `Vec` only from the second. Implements `Error`, `Add`, `FromIterator`, and `Extend` for `Node`, `WithContext`, `(C, E)`, and `ControlFlow` pairs. `into_result(ok)` converts to `Ok`/`Err` in one call. +- `Node` and `Subgroup` (requires `alloc`) -- the two child variants of `ManyErrors`: leaf `WithContext` pairs and labeled sub-groups. +- `Iter`, `IterMut`, `IntoIter` (requires `alloc`) -- iterator types over the direct children of a `ManyErrors`, returned by `iter()`, `iter_mut()`, and `into_iter()`. +- `Tree`, `List`, `Bullets`, `Joined` (requires `alloc`) -- aggregate rendering strategies. `Tree` draws a branching Unicode tree walking each leaf's source chain. `List` renders a numbered outline. `Bullets` uses `•` markers. `Joined` serializes to a single `;`-separated line. All are available via inherent helpers (`errs.tree()`, etc.) and via `errs.formatted::()` for full generic control. - `Connectors` / `TreeConnectors` traits with the `Unicode` and `Ascii` glyph sets, shared by `Chain` and the aggregate `Tree` (so `Chain` and `Tree` look consistent). ### Changed - **Breaking:** `Tree` (the per-error source-chain ladder) renamed to `Chain`. The type-parameter API changed too: the old `Marker + Indent` pair is replaced by a single `Connectors` impl (e.g. `Chain`). The `Tree` name is now used for the `ManyErrors` aggregate branching renderer. - **Breaking:** `FormatError::tree()` renamed to `FormatError::chain()`, matching the `Tree` → `Chain` strategy rename. -- **Breaking:** the `separator::Colon` tag (added in 0.2.0) renamed to `separator::ColonChar`, to avoid colliding with the `with_context::Colon` *pair* strategy — a misimport between the two would compile but render each leaf as a bare `":"`. +- **Breaking:** the `separator::Colon` tag (added in 0.2.0) renamed to `separator::ColonChar`, to avoid colliding with the `with_context::Colon` *pair* strategy -- a misimport between the two would compile but render each leaf as a bare `":"`. ## [0.2.0] - 2026-05-17 ### Added -- `WithContext` — tag any error with a context value (path, attempt number, etc.). `Colon` is the default strategy; `PathColon` handles +- `WithContext` -- tag any error with a context value (path, attempt number, etc.). `Colon` is the default strategy; `PathColon` handles `Path`/`PathBuf` directly without a newtype. `WithPath` aliases the path case. The wrapper's `Error::source` skips its inner error so chain walkers don't print it twice. -- `Suggest` trait for per-variant "Did you mean…" hints, and the `Suggestion` `Format` strategy that renders them. `error.suggestion()` prints the top-level hint via `Display`. Default `Suggest::fmt` writes nothing, so types only implement it for variants that have a hint. -- `DisplayPath` wrapper in the `path_display` module — drops `&Path`/`PathBuf` into any `Display` context. +- `Suggest` trait for per-variant "Did you mean..." hints, and the `Suggestion` `Format` strategy that renders them. `error.suggestion()` prints the top-level hint via `Display`. Default `Suggest::fmt` writes nothing, so types only implement it for variants that have a hint. +- `DisplayPath` wrapper in the `path_display` module -- drops `&Path`/`PathBuf` into any `Display` context. - `T` generic on `MainResult` so `main` can return `ExitCode` or any `Termination` type. -- `Add` — type-level combinator that runs two `Format` strategies in sequence. Compose with the new `separator` module (`NewLine`, `Space`, `Empty`, `Colon`, `ColonSpace`) to build strategies like `Add, Suggestion>` without writing a new impl. Bounds compose automatically — using `Suggestion` in the chain still requires `E: Suggest`. -- `WithSep` alias — sugar for `Add, R>` so the separator reads in the middle. Plus pre-baked variants in `separator`: `WithSpace`, `WithNewLine`, `WithColonSpace`. -- `with_context::ContextField`, `ErrorField`, and `ContextPath` (std-only) — `Format` extractor strategies that read fields of `WithContext`. Compose with separators to build custom pair strategies, e.g. `WithSep` for a space-delimited pair. +- `Add` -- type-level combinator that runs two `Format` strategies in sequence. Compose with the new `separator` module (`NewLine`, `Space`, `Empty`, `Colon`, `ColonSpace`) to build strategies like `Add, Suggestion>` without writing a new impl. Bounds compose automatically -- using `Suggestion` in the chain still requires `E: Suggest`. +- `WithSep` alias -- sugar for `Add, R>` so the separator reads in the middle. Plus pre-baked variants in `separator`: `WithSpace`, `WithNewLine`, `WithColonSpace`. +- `with_context::ContextField`, `ErrorField`, and `ContextPath` (std-only) -- `Format` extractor strategies that read fields of `WithContext`. Compose with separators to build custom pair strategies, e.g. `WithSep` for a space-delimited pair. ### Changed -- **Breaking:** `Format` is now `Format` (the `E: Error` bound on the trait is gone). Strategies that walk the source chain still need `E: Error` and declare it themselves. The motivation: composing strategies through non-error types (e.g. `WithContext`) required dropping the trait-level `Error` bound. Existing impls keep working — the bound just moves from the trait to each impl that needs it. +- **Breaking:** `Format` is now `Format` (the `E: Error` bound on the trait is gone). Strategies that walk the source chain still need `E: Error` and declare it themselves. The motivation: composing strategies through non-error types (e.g. `WithContext`) required dropping the trait-level `Error` bound. Existing impls keep working -- the bound just moves from the trait to each impl that needs it. - **Breaking:** `with_context::Colon` and `with_context::PathColon` are now `type` aliases over `Add` rather than dedicated structs: `Colon = Add, ErrorField>`. The `WithContext::<_, _, Colon>` / `WithContextColon` / `WithPath` surface is unchanged; only code that named the strategies as `struct Colon;` (e.g. an explicit `impl ContextFormat for Colon`) is affected. - **Breaking:** `WithContext`'s `Display`, `Error`, and `with_format` bounds switched from `F: ContextFormat` to `F: Format>`. @@ -51,10 +51,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `MainResult` — drop-in `Result` for `fn main` that prints errors via +- `MainResult` -- drop-in `Result` for `fn main` that prints errors via `Display` (and the full source chain) instead of `Debug`. -- `OneLine` format — joins the error chain with `": "`. -- `Tree` format — multi-line indented tree, marker and indent +- `OneLine` format -- joins the error chain with `": "`. +- `Tree` format -- multi-line indented tree, marker and indent customizable via any `Display + Default` types. - `Format` trait + `chain()` iterator for writing custom strategies. - `FormatError` extension trait (`one_line()`, `tree()`, `formatted::()`) diff --git a/README.md b/README.md index 3855d25..1990c66 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ use std::{fs, io}; #[derive(Debug, thiserror::Error)] enum Error { - #[error("failed to load config")] + #[error("Failed to load config")] Config(#[source] io::Error), } @@ -45,7 +45,7 @@ fn main() -> MainResult { Output: ```text -Error: failed to load config: No such file or directory (os error 2) +Error: Failed to load config: No such file or directory (os error 2) ``` The error and its full source chain print joined with `": "`. No `run()` wrapper, no manual loop. @@ -60,7 +60,7 @@ use std::{fs, io}; #[derive(Debug, thiserror::Error)] enum AppError { - #[error("failed to load config")] + #[error("Failed to load config")] Config(#[source] io::Error), } @@ -71,7 +71,7 @@ fn main() -> MainResult { ``` ```text -Error: failed to load config +Error: Failed to load config └─ No such file or directory (os error 2) ``` @@ -87,7 +87,7 @@ use errortools::{MainResult, WithContext, with_context::WithPath}; use std::{fs::File, io, path::Path}; #[derive(Debug, thiserror::Error)] -#[error("failed to create file")] +#[error("Failed to create file")] struct Error(#[from] WithPath<&'static Path, io::Error>); fn main() -> MainResult { @@ -98,7 +98,7 @@ fn main() -> MainResult { ``` ```text -Error: failed to create file: no/such/dir/foo.txt: No such file or directory (os error 2) +Error: Failed to create file: no/such/dir/foo.txt: No such file or directory (os error 2) ``` Retry attempt numbers fit too. The default `Colon` strategy takes any @@ -129,7 +129,7 @@ so there are two ways to customize it: `Format` tag. 2. **Write a one-shot impl** when the layout is unusual: `impl Format> for MyFmt { ... }`. - You declare your own bounds — `Colon` asks for `Display`, `PathColon` asks + You declare your own bounds -- `Colon` asks for `Display`, `PathColon` asks for `AsRef`, you ask for whatever you need. ## But why? @@ -201,8 +201,8 @@ type Brief = WithSep; eprintln!("{}", Formatted::<_, Brief>::new(err)); ``` -For the common separators there are zero-think aliases — `WithSpace`, -`WithNewLine`, `WithColonSpace` — all in `errortools::separator`: +For the common separators there are zero-think aliases -- `WithSpace`, +`WithNewLine`, `WithColonSpace` -- all in `errortools::separator`: ```rust,ignore use errortools::{Formatted, OneLine, Suggestion, separator::WithNewLine}; @@ -214,7 +214,7 @@ eprintln!("{}", Formatted::<_, Brief>::new(err)); Bounds compose: `Add` only implements `Format` when `E: Error + Suggest`, because `Suggestion`'s impl carries that bound. -The same combinator powers the `WithContext` default — `Colon` is just a type +The same combinator powers the `WithContext` default -- `Colon` is just a type alias for `WithColonSpace`, where `ContextField`/`ErrorField` are extractor strategies that read the pair's fields. To get a different delimiter, swap one piece: @@ -229,7 +229,7 @@ assert_eq!(w.to_string(), "step boom"); ## Suggestions -For "Did you mean…" hints, implement `Suggest` on your error type and call +For "Did you mean..." hints, implement `Suggest` on your error type and call `error.suggestion()`: ```rust,ignore @@ -264,7 +264,7 @@ The idea is that every error that is supposed to have a suggestion should implem ## Many errors at once -Some operations shouldn't stop at the first failure — validating a config, deploying to every region, parsing a batch. You want all of them, grouped and readable. That's `ManyErrors`: a context-tagged collection you can render as a tree, list, or single line. +Some operations shouldn't stop at the first failure -- validating a config, deploying to every region, parsing a batch. You want all of them, grouped and readable. That's `ManyErrors`: a context-tagged collection you can render as a tree, list, or single line. ```rust,ignore use errortools::ManyErrors; @@ -276,7 +276,7 @@ errs.push("us-east-1", RegionError::Refused); errs.into_result(())?; // Ok if empty, Err(ManyErrors) otherwise ``` -It costs nothing until it has to: `None` while empty, one inline slot for the first error, a `Vec` only once a second arrives. You can also collect straight from an iterator of `(context, error)` pairs or `WithContext` values — including itertools' `partition_result`. +It costs nothing until it has to: `None` while empty, one inline slot for the first error, a `Vec` only once a second arrives. You can also collect straight from an iterator of `(context, error)` pairs or `WithContext` values -- including itertools' `partition_result`. Group related failures with `push_group` and the shapes nest. `tree()` gives the Unicode tree, walking each error's source chain: @@ -288,13 +288,13 @@ Group related failures with `push_group` and the shapes nest. `tree()` gives the └─ eu-west-1: connection refused ``` -The default `Display` (`{errs}`) is deliberately a shallow one-line *summary* — each error's own text, no source chains — so it's safe to embed in a message or log, following the Rust convention that an error's `Display` is its own message: +The default `Display` (`{errs}`) is deliberately a shallow one-line *summary* -- each error's own text, no source chains -- so it's safe to embed in a message or log, following the Rust convention that an error's `Display` is its own message: ```text 2 errors: us-east-1 (2 errors: i-0a1: connection refused; i-0b2: connection refused); eu-west-1: connection refused ``` -For the full picture, the shapes are inherent helpers, no turbofish — `tree()` and `joined()` walk the source chains, `list()` and `bullets()` too: +For the full picture, the shapes are inherent helpers, no turbofish -- `tree()` and `joined()` walk the source chains, `list()` and `bullets()` too: ```rust,ignore println!("{}", errs.tree()); // Unicode tree (above) @@ -303,11 +303,11 @@ println!("{}", errs.bullets()); // • bulleted println!("{}", errs.joined()); // ;-separated one line, parens around groups ``` -For full control — ASCII connectors, no count header — go through `formatted`: `errs.formatted::>()`. That's an inherent method, not the `FormatError` one, and the difference matters: the trait version requires `ManyErrors: Error`, which drags `Debug` bounds onto your context types. The inherent one has no bounds at all. Wrapping always compiles; whether the combination can print is decided by the strategy's own `Format` bounds, at the call site that actually prints. +For full control -- ASCII connectors, no count header -- go through `formatted`: `errs.formatted::>()`. That's an inherent method, not the `FormatError` one, and the difference matters: the trait version requires `ManyErrors: Error`, which drags `Debug` bounds onto your context types. The inherent one has no bounds at all. Wrapping always compiles; whether the combination can print is decided by the strategy's own `Format` bounds, at the call site that actually prints. The same rule runs through the whole rendering path: no shape demands anything from a context directly. Leaves print through the leaf strategy, group labels through the label strategy, and those decide the bounds. A `PathBuf` context with `PathColon` renders in every shape even though `PathBuf` has no `Display`. -Need a shape of your own? Implement `Format>` and match on the public `ManyErrors` / `Node` variants. The same helpers the built-in shapes use are public so your output stays consistent: `many_errors::strategy::{ErrorCount, LeafChain, NO_ERRORS}`, `indent::indented` for re-indenting multiline content, and the `impl_ref_format!` macro for the `&T` trampoline. See the `many_errors::strategy` module docs for a worked example. +Need a shape of your own? Implement `Format>` and match on the public `ManyErrors` / `Node` variants. The same helpers the built-in shapes use are public so your output stays consistent: `many_errors::strategy::{ErrorCount, LeafChain, NO_ERRORS}`, `indent::indented` for re-indenting multiline content, and the `impl_ref_format!` macro for the `&T` trampoline. See the `many_errors::strategy` module docs for a worked example. Group labels can differ from leaf contexts via the third parameter, `ManyErrors`, but `GC` defaults to `C`, so the common case stays two params. @@ -324,7 +324,7 @@ for node in &errs { } ``` -One footgun to know about: `errs.one_line()` and `errs.chain()` compile (a `ManyErrors` is an error) but print the shallow summary, because `source()` is `None` — an aggregate has no single linear cause. `joined()` and `tree()` are the deep versions. Same logic applies in reverse: a `ManyErrors` buried in another error's `#[source]` chain shows up as one summary line. If you want its branches rendered, lift it into `push_group` instead. +One footgun to know about: `errs.one_line()` and `errs.chain()` compile (a `ManyErrors` is an error) but print the shallow summary, because `source()` is `None` -- an aggregate has no single linear cause. `joined()` and `tree()` are the deep versions. Same logic applies in reverse: a `ManyErrors` buried in another error's `#[source]` chain shows up as one summary line. If you want its branches rendered, lift it into `push_group` instead. ## How it works diff --git a/examples/chain.rs b/examples/chain.rs index 3a5567c..cf3fd6f 100644 --- a/examples/chain.rs +++ b/examples/chain.rs @@ -5,8 +5,8 @@ //! Output: //! //! ```text -//! Error: failed to load config -//! └─ failed to read file +//! Error: Failed to load config +//! └─ Failed to read file //! └─ No such file or directory (os error 2) //! ``` @@ -16,13 +16,13 @@ use errortools::{Chain, MainResult}; #[derive(Debug, thiserror::Error)] enum AppError { - #[error("failed to load config")] + #[error("Failed to load config")] Config(#[source] ConfigError), } #[derive(Debug, thiserror::Error)] enum ConfigError { - #[error("failed to read file")] + #[error("Failed to read file")] Read(#[source] io::Error), } diff --git a/examples/custom_format.rs b/examples/custom_format.rs index 8234149..b96c3fc 100644 --- a/examples/custom_format.rs +++ b/examples/custom_format.rs @@ -2,7 +2,7 @@ //! //! Run: `cargo run --example custom_format` //! -//! Output: `outer -> middle -> inner` +//! Output: `Outer -> Middle -> inner` use core::{error::Error, fmt}; use std::io; @@ -20,13 +20,13 @@ impl Format for Arrow { #[derive(Debug, thiserror::Error)] enum AppError { - #[error("outer")] + #[error("Outer")] Outer(#[source] MidError), } #[derive(Debug, thiserror::Error)] enum MidError { - #[error("middle")] + #[error("Middle")] Middle(#[source] io::Error), } diff --git a/examples/format_error.rs b/examples/format_error.rs index 8008f0d..de0a6ab 100644 --- a/examples/format_error.rs +++ b/examples/format_error.rs @@ -8,7 +8,7 @@ use errortools::FormatError; #[derive(Debug, thiserror::Error)] enum AppError { - #[error("failed to load config")] + #[error("Failed to load config")] Config(#[source] io::Error), } diff --git a/examples/many_errors.rs b/examples/many_errors.rs index 58e82bc..fefdf75 100644 --- a/examples/many_errors.rs +++ b/examples/many_errors.rs @@ -9,15 +9,15 @@ use errortools::{Formatted, ManyErrors, many_errors::Tree}; #[derive(Debug, thiserror::Error)] enum DeployError { - #[error("deploy failed")] + #[error("Deploy failed")] Failed(#[source] ManyErrors<&'static str, RegionError>), } #[derive(Debug, thiserror::Error)] enum RegionError { - #[error("connection refused")] + #[error("Connection refused")] Refused, - #[error("timed out")] + #[error("Timed out")] Timeout(#[source] io::Error), } diff --git a/examples/one_line.rs b/examples/one_line.rs index 0398f79..900c464 100644 --- a/examples/one_line.rs +++ b/examples/one_line.rs @@ -5,7 +5,7 @@ //! Output: //! //! ```text -//! Error: failed to load config: failed to read file: No such file or directory (os error 2) +//! Error: Failed to load config: Failed to read file: No such file or directory (os error 2) //! ``` use std::{fs, io}; @@ -14,13 +14,13 @@ use errortools::MainResult; #[derive(Debug, thiserror::Error)] enum AppError { - #[error("failed to load config")] + #[error("Failed to load config")] Config(#[source] ConfigError), } #[derive(Debug, thiserror::Error)] enum ConfigError { - #[error("failed to read file")] + #[error("Failed to read file")] Read(#[source] io::Error), } diff --git a/examples/transparent.rs b/examples/transparent.rs index 448ae5b..a13baa7 100644 --- a/examples/transparent.rs +++ b/examples/transparent.rs @@ -9,7 +9,7 @@ //! Output: //! //! ```text -//! Error: failed to load config: failed to read file: missing +//! Error: Failed to load config: Failed to read file: missing //! ``` //! //! Note `Io` does not appear in the chain — the `transparent` variant is @@ -27,13 +27,13 @@ enum AppError { #[derive(Debug, thiserror::Error)] enum ConfigError { - #[error("failed to load config")] + #[error("Failed to load config")] Load(#[source] FileError), } #[derive(Debug, thiserror::Error)] enum FileError { - #[error("failed to read file")] + #[error("Failed to read file")] Read(#[source] io::Error), } diff --git a/plugins/errortools/skills/errortools/SKILL.md b/plugins/errortools/skills/errortools/SKILL.md deleted file mode 100644 index b79ce67..0000000 --- a/plugins/errortools/skills/errortools/SKILL.md +++ /dev/null @@ -1,362 +0,0 @@ ---- -name: errortools -description: Use when writing, refactoring, or reviewing Rust error-handling code with `thiserror` and the `errortools` crate — designing error enums and source chains, returning errors from `main`, attaching context like paths/IDs/retry counts, aggregating many failures, formatting chains for users or logs, or adding "did you mean" suggestions. Reach for this whenever a Rust task touches error types, `#[from]`/`#[source]`, `main` returning a `Result`, error logging, or `errortools`/`MainResult`/`FormatError` — even when not named explicitly. ---- - -# Rust error-handling skill - -Apply this whenever you are designing error types, deciding how `main` returns -errors, attaching context to an error, aggregating a batch of failures, or -formatting an error chain for users or logs in a Rust project. The `errortools` -crate ([crates.io](https://crates.io/crates/errortools), -[docs.rs](https://docs.rs/errortools)) provides the runtime pieces; this skill -encodes the conventions for using it well. - -## When to reach for it - -- A binary's `main` does the `if let Err(e) = run() { eprintln!(...); exit(1) }` - dance — replace it with `MainResult`. -- You see `Error: Outer(Inner(Io(Os { ... })))` in output — that's `Debug` - formatting bleeding through; switch to `MainResult`. -- You need a full source chain on one line (structured logs) or as an indented - ladder (human terminal). -- You're tempted to add a single-variant wrapper just to attach a path or an - attempt number — reach for `WithContext` instead. -- An operation should report *all* failures, not just the first — use `ManyErrors`. -- You want a project-specific error format — implement `Format` once and reuse it - via `MainResult` and `e.formatted::()`. - -If the project does not depend on `errortools`, add it to `Cargo.toml`: - -```toml -[dependencies] -errortools = "0.3" -thiserror = "2" -``` - -`errortools` is `no_std`-capable: disable the default `std` feature for embedded -targets (`default-features = false`). The `alloc` feature (implied by `std`) -gates `ManyErrors` and the aggregate shapes. - -## Core API cheat sheet - -| Item | Purpose | -|---|---| -| `MainResult` | Return type for `fn main`. Renders `E` via strategy `F` instead of `Debug`. `T` lets `main` return `ExitCode`. | -| `OneLine` | Default strategy: error + sources joined with `": "`. | -| `Chain` | Per-error indented source-chain ladder (`└─`). *(was named `Tree` in ≤ 0.2)* | -| `Tree` / `List` / `Bullets` / `Joined` | Aggregate `ManyErrors` shapes (needs `alloc`). | -| `FormatError` | Ext trait on any `&dyn Error`: `.one_line()`, `.chain()`, `.suggestion()`, `.formatted::()`. | -| `Format` trait | Implement on a unit type to define a custom strategy (`Format`). | -| `chain(&dyn Error)` | Iterator over the error and its `source()` chain — use inside `Format` impls. | -| `Formatted` | Wrapper whose `Display` runs strategy `F` over `E`. | -| `WithContext` / `WithPath` | Tag an error with a context value (path, attempt, ID) without a wrapper variant. | -| `ManyErrors` | Aggregate of context-tagged failures; render as tree/list/bullets/joined (needs `alloc`). | -| `Suggest` / `Suggestion` | Per-error "did you mean…" hints via `.suggestion()`. | -| `Add` + `separator::*` | Compose two strategies, e.g. `WithNewLine`. | -| `DisplaySwapDebug` | Swaps `Debug`/`Display`; powers `MainResult`. | - -### Going deeper - -The error-type discipline below is the spine and applies to almost every task. -For the larger subsystems, read the matching reference file when the task calls -for it: - -| Need | Reference | -|---|---| -| Choosing/combining formats, custom `Format`, `MainResult` internals | `references/formatting.md` | -| Attaching a path / attempt / ID to an error | `references/with-context.md` | -| Collecting and reporting many failures at once | `references/many-errors.md` | -| "Did you mean…" recovery hints | `references/suggestions.md` | - -## Patterns - -### Pattern: `MainResult` for binary entrypoints - -```rust -use errortools::MainResult; -use std::{fs, io}; - -#[derive(Debug, thiserror::Error)] -enum Error { - #[error("failed to load config")] - Config(#[source] io::Error), -} - -fn main() -> MainResult { - fs::read_to_string("missing.toml").map_err(Error::Config)?; - Ok(()) -} -``` - -Output: - -```text -Error: failed to load config: No such file or directory (os error 2) -``` - -For the indented ladder, parameterise the strategy: `fn main() -> MainResult`. - -### Pattern: ad-hoc logging mid-function - -When you cannot return — inside a `tokio::spawn`, an event handler, a retry loop -— use `FormatError`. Never walk `source()` by hand. - -```rust -use errortools::FormatError; - -if let Err(e) = do_thing().await { - tracing::error!("do_thing failed: {}", e.one_line()); -} -``` - -Pick the strategy inline: - -```rust -use errortools::FormatError; -eprintln!("{}", e.chain()); // indented ladder -// eprintln!("{}", e.formatted::()); // any custom strategy F -``` - -### Pattern: custom format strategy - -Implement `Format` once per project, reuse everywhere. The trait bounds -nothing on `E`; a chain-walking strategy declares `E: Error` itself and walks -with `chain`: - -```rust -use core::{error::Error, fmt}; -use errortools::{Format, chain}; - -pub struct Arrow; -impl Format for Arrow { - fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for (i, e) in chain(&error).enumerate() { - if i > 0 { f.write_str(" -> ")?; } - write!(f, "{e}")?; - } - Ok(()) - } -} - -// fn main() -> MainResult { … } -// tracing::error!("{}", e.formatted::()); -``` - -`chain` walks `error.source()` repeatedly — never call `source()` by hand inside -a `Format` impl. See `references/formatting.md` for composing strategies with -`Add`/separators and for `Chain`/connectors. - -## Error-type discipline - -### Defining the error type - -1. **MUST** derive `thiserror::Error` + `Debug`. One error type per module, named - `Error` (used as `feature::Error` from outside). - - ```rust - // GOOD - #[derive(Debug, thiserror::Error)] - pub enum Error { /* … */ } - ``` - -2. **MUST** collapse single-variant enums to structs. - - ```rust - // BAD - pub enum Error { ReadFile(#[source] io::Error) } - // GOOD - pub struct ReadFile(#[source] io::Error); - ``` - -3. **MUST** use a tuple variant when wrapping a foreign error with no extra context. - - ```rust - // GOOD - #[error("Failed to open config")] - ConfigOpen(#[source] io::Error), - ``` - -4. **MUST** use a struct variant when extra context is needed; put context in - fields, never inside the message via `format!`. - - ```rust - // BAD - #[error("render template {}", name)] - Render(String, #[source] tera::Error), - // GOOD - #[error("render template {name}")] - Render { name: String, #[source] source: tera::Error }, - ``` - -5. **PREFER** `WithContext` / `WithPath` to attach *incidental* context — a path, - a retry attempt, a record ID — over inventing a single-variant wrapper whose - only job is to hold it. `WithContext`'s `source()` skips its own inner error, - so the chain never doubles up. Keep context in a real variant only when callers - branch on it. See `references/with-context.md`. - - ```rust - // BAD — a wrapper variant that exists only to staple a path on - #[error("io at {path}")] - IoAt { path: PathBuf, #[source] source: io::Error }, - // GOOD - File::create(&path).map_err(|e| WithContext::new(path, e))?; - ``` - -6. **MUST NOT** print the source inside the variant message — `#[source]` already - chains it. `OneLine` / `Chain` walk `source()` and join. - - ```rust - // BAD - #[error("read failed: {0}")] Read(#[source] io::Error), - // GOOD - #[error("read failed")] Read(#[source] io::Error), - ``` - -7. **PREFER** specific variants over generic ones. `&'static str` payloads only - when the variant is one-off. - - ```rust - // BAD - Other(String), - // GOOD - #[error("Failed to join task '{0}'")] - TokioJoin(&'static str), - ``` - -### Converting at the call site - -8. **MUST** pass the variant constructor directly to `map_err`. - - ```rust - // BAD - .map_err(|source| Error::Config { source })? - // GOOD - .map_err(Error::Config)? - ``` - -9. **PREFER** chaining through existing variants over inventing new wrapper variants. - - ```rust - // BAD — new top-level variant just to wrap an inner one - #[error("inner")] InnerWrap(#[source] inner::Error), - // GOOD — reuse via From - .ok_or(top::Error::from(inner::Error::Foo(ctx)))? - ``` - -10. **MUST NOT** put `#[from]` on context-less variants. `#[from]` is allowed only - when the source error already carries the operation context. - - ```rust - // BAD — every SQL collapses to one variant - #[error("db")] Db(#[from] sqlx::Error), - // GOOD - #[error("load user {id}")] - LoadUser { id: UserId, #[source] source: sqlx::Error }, - ``` - -11. **MUST NOT** hand-write `impl From for Error`. Use `#[source]` - or `#[from]` only. - -12. **PREFER** `#[error(transparent)]` + `#[from]` only when the inner error is the - whole story (re-export wrappers). - -### Logging mid-flow - -13. **PREFER** `errortools::FormatError` when you cannot return — never walk - `source()` by hand. - - ```rust - // BAD - let mut cur: &dyn Error = &e; - while let Some(s) = cur.source() { /* … */ } - // GOOD - use errortools::FormatError; - tracing::error!("do_thing: {}", e.one_line()); - ``` - -### Returning from `main` - -14. **MUST** use `fn main() -> MainResult` (or `MainResult`). - The strategy renders the chain via `Display`; `Debug` never reaches stderr. - - ```rust - // BAD - fn main() -> Result<(), Error> { … } - // GOOD - fn main() -> errortools::MainResult { … } - ``` - -15. **MUST** confine `exit(1)` and `panic!()` to `main`. Business logic returns - `Result`. - -16. **MUST** do graceful shutdown in `main` (join threads, close connections). - **AVOID** calling `drop(v)` manually — rely on scope. - -### Panics - -17. **MUST NOT** `unwrap()` / `expect()` in production or library code. If - unavoidable, document it under `# Panics`. - - ```rust - // BAD - let cfg = load().unwrap(); - // GOOD - let cfg = load().map_err(config::Error::Config)?; - ``` - -### Batch operations - -18. **MUST NOT** silently skip failed items unless the API contract says so — fail - fast, or collect and report per-item errors. `ManyErrors` is the tool for the - collect-and-report case: push `(context, error)` pairs (or `push_group` for - nesting), then `into_result(ok)`. See `references/many-errors.md`. - - ```rust - // BAD — swallow failures - for item in items { let _ = process(item); } - // GOOD - let mut errs = ManyErrors::new(); - for item in &items { - if let Err(e) = process(item) { errs.push(item.id, e); } - } - errs.into_result(())?; - ``` - -### `anyhow` / `Box` - -19. **AVOID** `anyhow` or other dynamic error types outside tests / throwaway - scripts. Production code uses explicit `thiserror` enums. - -### Tests - -20. **MUST** assert the exact error variant with arguments, not `.is_err()`. - - ```rust - // BAD - assert!(result.is_err()); - // GOOD - assert!(matches!(result, Err(Error::Config(_)))); - ``` - -## Choosing a format strategy - -| Context | Strategy | -|---|---| -| CLI tools, default | `OneLine` (single tidy line, greppable) | -| Interactive terminals, deep chains | `Chain` (or `Chain`) | -| Structured logs (JSON, OpenTelemetry) | `OneLine` — one log line per error | -| A batch of independent failures | `Tree` / `List` / `Bullets` / `Joined` (`ManyErrors`) | -| Error + recovery hint | `WithNewLine` | -| Project house style | custom `Format` impl, applied uniformly | - -Switch globally by changing the type parameter on `MainResult` — no call sites -change. Details and composition in `references/formatting.md`. - -## References - -- README: -- Runnable examples: - (`one_line`, `chain`, `format_error`, `custom_format`, `transparent`, - `with_context`, `many_errors`) -- API docs: diff --git a/plugins/errortools/skills/migrating-from-unstructured/SKILL.md b/plugins/errortools/skills/migrating-from-unstructured/SKILL.md new file mode 100644 index 0000000..78af7bb --- /dev/null +++ b/plugins/errortools/skills/migrating-from-unstructured/SKILL.md @@ -0,0 +1,225 @@ +--- +name: migrating-from-unstructured +description: > + Step-by-step guides for moving Rust error handling from anyhow or unstructured + patterns to typed thiserror enums and the errortools crate. Use whenever a task + involves replacing anyhow, removing Box, converting .context() calls, + eliminating unwrap/expect from production code, or replacing manual eprintln + + exit(1). Trigger on: anyhow, bail!, ensure!, .context(, Box, + String error, exit(1), unwrap(), expect() -- even mid-refactor. After migration, + see structured-error-handling for error type design and using-errortools for + MainResult / FormatError / ManyErrors. +--- + +# migrating-from-unstructured + +Two migration paths are documented here. Both are mechanical and can be done +incrementally; each step is independently shippable. + +After migration: +- Error type design rules → `structured-error-handling` +- Rendering, `MainResult`, `WithContext`, `ManyErrors` → `using-errortools` + +--- + +## Migrating from `anyhow` + +`anyhow` trades type safety for convenience: callers cannot branch on error +variants, and the chain is opaque. + +### Mental model: + +1. There's **MUST** be a single entry and single exit point for a program. +The entry point is `fn main() -> MainResult`. The exit point is the `?` operator, which propagates errors to `main`. Every error **MUST** be typed and handled at the call site, either by propagating or by branching on the variant. Spawned tasks, event handlers **MUST** be collected and joined, polled in a `select!`, or otherwise returned to the main task. If you cannot propagate, use `FormatError` to log the error chain. +2. From one error message it **MUST** be possible to reconstruct the exact place in the code where the error was generated. This message can be rather long, but the chain must be complete. This is the opposite of `anyhow`, which hides the source chain and makes it impossible to branch on variants. + + +**Step 1: Replace the dependency.** + +```toml +# remove +anyhow = "1" +# add +errortools = "0.3" +thiserror = "2" +``` + +**Step 2: Replace `anyhow::Error` / `anyhow::Result` with typed returns.** + +```rust +// BAD +use anyhow::{Context, Result}; +fn load(path: &Path) -> Result { ... } + +// GOOD +fn load(path: &Path) -> Result { ... } +fn main() -> errortools::MainResult { ... } +``` + +**Step 3: Convert `.context("...")` / `.with_context(|| ...)` to typed variants.** + +Every `.context` call signals that a new variant or `WithContext` is needed. + +```rust +// BAD +fs::read_to_string(path).context("read config")? + +// GOOD -- when callers might match on the variant +#[error("Read config")] +ReadConfig(#[source] io::Error), +... +fs::read_to_string(path).map_err(Error::ReadConfig)? + +// GOOD -- when the path is incidental context callers won't branch on +fs::read_to_string(path).map_err(|e| WithPath::new(path, e))? +``` + +**Step 4: Replace `bail!` / `ensure!`.** + +```rust +// BAD +bail!("count must be positive, got {count}"); +ensure!(count > 0, "count must be positive, got {count}"); + +// GOOD +return Err(Error::InvalidCount { count }); +``` + +**Step 5: Remove `anyhow` from `main`.** + +```rust +// BAD +fn main() -> anyhow::Result<()> { ... } + +// GOOD +fn main() -> errortools::MainResult { ... } +``` + +**Step 6: Leave unstructured error handling in tests.** Tests may keep it as a +convenience return type. That is the one acceptable exception. This includes panics as well. +One note: The tests **MUST** match the exact error variant or message, not `assert!(res.is_err())`. + +--- + +## Migrating from unstructured error handling + +"Unstructured" means: `String` errors, manual `eprintln!` + `exit(1)`, +`Box`, hand-rolled `source()` walks, or scattered `unwrap`/`expect`, tracing, +or panic!() calls in production code. + +**Step 1: Introduce a typed error enum per module.** + +```rust +// BAD +fn parse(s: &str) -> Result { + s.parse().map_err(|e| format!("bad number: {e}")) +} + +// GOOD +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Bad number")] + Parse(#[source] std::num::ParseIntError), + ... +} + +fn parse(s: &str) -> Result { + ... + s.parse().map_err(Error::Parse) +} +``` + +Never embed the source message in the variant text. `#[source]` chains it and +`using-errortools` renders the chain. + +**Step 2: Replace `Box` return types.** + +```rust +// BAD +fn run() -> Result<(), Box> { ... } + +// GOOD +fn run() -> Result<(), Error> { ... } +fn main() -> errortools::MainResult { ... } +``` + +**Step 3: Replace manual `eprintln!` + `exit(1)` in `main`.** + +```rust +// BAD +fn main() { + if let Err(e) = run() { + eprintln!("Error: {e}"); + std::process::exit(1); + } +} + +// BAD +fn run() -> Result<(), Error> { + ... +} + +// GOOD +fn main() -> errortools::MainResult { + /// main logic without eprintln! or exit(1) or wrapper functions +} +``` + +**Step 4: Replace hand-rolled source walks.** + +```rust +// BAD +let mut cur: &dyn std::error::Error = &e; +loop { + eprintln!(" caused by: {cur}"); + match cur.source() { Some(s) => cur = s, None => break } +} + +// GOOD +use errortools::FormatError; +tracing::error!("{}", e.one_line()); // single log line +// or +eprintln!("{}", e.chain()); // indented ladder for terminals +``` + +**Step 5: Replace `unwrap` / `expect` in production code.** + +Every `unwrap`/`expect` outside tests needs a `Result`-returning path or a +documented invariant under `# Panics`. + +```rust +// BAD +let val = map.get("key").unwrap(); + +// GOOD +let val = map.get("key").ok_or(Error::MissingKey)?; + +// BEST +let key = "key"; +let val = map.get(key).ok_or(Error::MissingKey(key))?; +``` + +**Step 6: Replace silent skips in loops.** + +```rust +// BAD +for item in items { + let _ = process(item); // failures vanish +} + +// GOOD +let mut errs = ManyErrors::new(); +for item in items { + if let Err(e) = process(item) { errs.push(item.id, e); } +} +errs.into_result(())?; + +// BEST +items.into_iter() + .map(|item| process(item).map_err(|e| (item.id, e))) + .collect::>() + .into_result(())?; +``` + +See `using-errortools` → `references/many-errors.md` for nesting and rendering +options. diff --git a/plugins/errortools/skills/rust-error-handling/SKILL.md b/plugins/errortools/skills/rust-error-handling/SKILL.md new file mode 100644 index 0000000..aea9cd7 --- /dev/null +++ b/plugins/errortools/skills/rust-error-handling/SKILL.md @@ -0,0 +1,44 @@ +--- +name: rust-error-handling +description: > + Router for Rust error-handling skills. Use by default whenever a Rust task + touches errors in any form: Result, unwrap, expect, thiserror, anyhow, + Box, error logging, or the errortools crate. Routes to the right + sub-skill: structured-error-handling for designing error types and source + chains, using-errortools for MainResult / FormatError / WithContext / ManyErrors, + migrating-from-unstructured for moving away from anyhow or scattered unwraps. + When in doubt, load this skill first. +--- + +# Rust error-handling + +Three skills cover the full error-handling surface. Load the one that matches +your task; they cross-link where they overlap. + +| Task | Skill | +|---|---| +| Design error enums, chain sources, choose `#[source]` vs `#[from]`, `map_err` conventions | `structured-error-handling` | +| Use `MainResult`, `FormatError`, `WithContext`, `ManyErrors`, custom format strategies | `using-errortools` | +| Migrate from `anyhow` or scattered `unwrap`/`expect`/`Box` | `migrating-from-unstructured` | + +Most tasks touch more than one area, so load both if needed. + +## Adding the dependency + +If the project does not depend on `errortools` yet: + +```toml +[dependencies] +errortools = "0.3" +thiserror = "2" +``` + +`errortools` is `no_std`-capable; pass `default-features = false` for embedded +targets. The `alloc` feature (implied by `std`) gates `ManyErrors` and the +aggregate render shapes. + +## References + +- API docs: +- README: +- Runnable examples: diff --git a/plugins/errortools/skills/structured-error-handling/SKILL.md b/plugins/errortools/skills/structured-error-handling/SKILL.md new file mode 100644 index 0000000..db79f15 --- /dev/null +++ b/plugins/errortools/skills/structured-error-handling/SKILL.md @@ -0,0 +1,260 @@ +--- +name: structured-error-handling +description: > + Conventions for designing typed Rust error enums with thiserror. Use whenever + a task involves defining error types, choosing between #[source] and #[from], + structuring error variants with or without context fields, map_err call-site + patterns, or deciding when to use a tuple variant vs a struct variant vs + WithContext. Trigger on: thiserror, #[source], #[from], #[error(...)], error + enum, Error variant, map_err, impl From, transparent, even when the task + description doesn't name a skill explicitly. See using-errortools for rendering + and context-attachment at runtime. +--- + +# structured-error-handling + +This skill covers how to design error types in Rust: what shape variants should +take, when to chain vs wrap, and how to convert errors at call sites. It does +not cover formatting or the `errortools` crate runtime. See `using-errortools` +for that. + +## Defining the error type + +**1.** Derive `thiserror::Error` + `Debug`. One error type per module, named +`Error` (referenced as `feature::Error` from outside). + +```rust +// GOOD +#[derive(Debug, thiserror::Error)] +pub enum Error { /* ... */ } + +// BAD +#[derive(Debug)] +pub enum MyError { /* ... */ } // not named `Error` + +impl std::fmt::Display for MyError { /* ... */ } // hand-rolled +impl std::error::Error for MyError { /* ... */ } // hand-rolled (or absent) +``` + +**2.** Collapse single-variant enums to structs. + +```rust +// BAD +pub enum Error { ReadFile(#[source] io::Error) } +// GOOD +pub struct Error(#[source] io::Error); +``` + +**3.** Use a tuple variant when wrapping a foreign error with no extra context. + +```rust +// GOOD +#[error("Failed to open config")] +ConfigOpen(#[source] io::Error), +``` + +**4.** Use a struct variant when extra context is needed. Put context in named +fields, never inside the message via `format!`. + +```rust +// BAD +#[error("Render template {}", _0)] +Render(String, #[source] tera::Error), +// GOOD +#[error("Render template {name}")] +Render { name: String, #[source] source: tera::Error }, +``` + +**5.** Never print the source inside the variant message. `#[source]` already +chains it. `OneLine` / `Chain` (from `using-errortools`) walk `source()` and +join automatically. + +```rust +// VERY BAD -- causes double printing of the source error +#[error("Read failed: {0}")] Read(#[source] io::Error), +// GOOD +#[error("Read failed")] Read(#[source] io::Error), +``` +The only exception is when the source error is not `#[source]`-chained for some reason, e.g., a `Box` that is not `#[from]`-converted. In that case, you can include the source message in the variant text, but avoid it as much as possible. This can be the case due to serialization constraints, or when the underlying error type does not implement `std::error::Error`. + +**6.** Prefer specific variants over generic ones. Use `&'static str` payloads +only for truly one-off messages. + +```rust +// BAD +Other(String), +// GOOD +#[error("Failed to join task '{0}'")] +TokioJoin(&'static str), +``` + +**7.** For incidental context that callers will never match on (a file path, a +retry count, a record ID), prefer `WithContext` / `WithPath` over inventing a +single-variant wrapper. See `using-errortools` for details. + +```rust +// BAD -- wrapper variant exists only to attach a path +#[error("IO at {path}")] +IoAt { path: PathBuf, #[source] source: io::Error }, + +// GOOD -- no variant: WithPath carries the path and renders it in the chain +File::create(&path).map_err(|e| WithPath::new(path, e))?; + +// GOOD -- need a named variant (callers match on the operation)? Hold the +// WithPath and keep the path OUT of the message. WithPath takes the +// path type AND the error, and renders ": " in the chain. +enum Error { + #[error("IO error")] + IoAt(#[source] WithPath), +} + +File::create(&path).map_err(|e| Error::IoAt(WithPath::new(path, e)))?; +``` + +## Message text + +**Start every `#[error("...")]` message with a capital letter, and never end it +with punctuation.** Messages chain with `: ` under `OneLine` and stack in a +ladder under `Chain`, so each one is a fragment that has to read cleanly both at +the head and in the middle of a chain. + +```rust +// BAD +#[error("failed to load config")] // lowercase +#[error("Failed to load config.")] // trailing period +// GOOD +#[error("Failed to load config")] +``` + +Capitalize only your own text. Foreign error messages (`io::Error`, +`sqlx::Error`) render verbatim at the tail of the chain and keep whatever case +they ship with. + +## Converting at the call site + +**8.** Pass the variant constructor directly to `map_err` when the variant has no extra context fields. +Use a closure only when you need to fill in extra fields. + +```rust +// BAD +.map_err(|source| Error::Config { source })? +// GOOD +.map_err(Error::Config)? +``` + +**9.** Chain through existing variants rather than inventing new wrapper variants. + +```rust +// BAD -- new top-level variant just to wrap an inner one +#[error("Inner")] InnerWrap(#[source] inner::Error), +// GOOD -- reuse via From +.ok_or(top::Error::from(inner::Error::Foo(ctx)))? +``` + +**10.** Do not put `#[from]` on context-less variants. `#[from]` is only +appropriate when the source error already carries the operation context on its +own. + +```rust +// BAD -- every SQL error collapses to the same variant, losing context +#[error("DB error")] Db(#[from] sqlx::Error), +// GOOD +#[error("Load user {id}")] +LoadUser { id: UserId, #[source] source: sqlx::Error }, +``` + +**11.** Do not hand-write `impl From for Error`. Use `#[source]` +or `#[from]` only. + +**12.** `#[error(transparent)]` forwards the inner error's `Display` and +`source()` unchanged and adds no text of its own. Its job is **module-boundary +conversions**: when a parent module aggregates a child module's `Error` that +already carries full context, so a wrapping message would only add a redundant +layer. Pair it with `#[from]` so `?` lifts the child error with no `map_err`. + +```rust +// child module already produces a fully-contextualized error +mod config { + #[derive(Debug, thiserror::Error)] + pub enum Error { /* ... */ } +} + +#[derive(Debug, thiserror::Error)] +enum Error { + // GOOD -- pass config::Error straight through; `?` converts via #[from] + #[error(transparent)] + Config(#[from] config::Error), + + // a sibling variant that DOES add context stays a normal variant + #[error("Failed to bind {addr}")] + Bind { addr: SocketAddr, #[source] source: io::Error }, +} +``` + +When to reach for it: +- **Use it** when the inner error is the whole story and the outer variant would + add nothing -- the canonical case is re-exporting a child module's `Error` + across a module boundary. +- **Do NOT use it** when this layer adds context the caller needs (an operation + name, an ID, a path). Give that variant a real message and a `#[source]` + field instead; reserve `#[from]` for context-less pass-throughs (rule 10). +- `transparent` collapses `Display`/`source` but is **not** transparent to + `Suggest` -- the outer type's hint still wins. See + `using-errortools/references/suggestions.md`. + +## Panics + +**13.** Do not `unwrap()` or `expect()` in production or library code. If +genuinely unavoidable, document the invariant under `# Panics`. + +```rust +// BAD +let cfg = load().unwrap(); +// GOOD +let cfg = load().map_err(config::Error::Load)?; +``` + +## Batch operations + +**14.** Do not silently skip failed items unless the API contract says so. Either +fail fast or collect per-item errors with `ManyErrors` (see `using-errortools`). + +```rust +// BAD -- failures vanish +for item in items { let _ = process(item); } + +// GOOD +let mut errs = ManyErrors::new(); +for item in &items { + if let Err(e) = process(item) { errs.push(item.id, e); } +} +errs.into_result(())?; + +// BEST +items.into_iter() + .map(|item| process(item).map_err(|e| (item.id, e))) + .collect::>() + .into_result(())?; +``` + +## `anyhow` / `Box` + +**15.** Avoid `anyhow` or `Box` in production code or library code. Callers cannot +branch on variants, and the chain is opaque. Both are acceptable in tests and +temporary scripts. If the project currently uses `anyhow`, see +`migrating-from-unstructured`. + +## Tests + +**16.** Assert the exact error variant, not just `.is_err()`. + +```rust +// BAD +assert!(result.is_err()); +assert!(result.unwrap_err().to_string().contains("config file not found")); + +// GOOD +assert_eq!(result.unwrap_err().to_string(), "config file not found"); +// BEST +assert_matches!(result, Err(Error::Config(_))); +``` diff --git a/plugins/errortools/skills/using-errortools/SKILL.md b/plugins/errortools/skills/using-errortools/SKILL.md new file mode 100644 index 0000000..54b4d99 --- /dev/null +++ b/plugins/errortools/skills/using-errortools/SKILL.md @@ -0,0 +1,194 @@ +--- +name: using-errortools +description: > + How to use the errortools crate for formatting and context in Rust projects. + Use whenever a task involves MainResult, FormatError, WithContext, WithPath, + ManyErrors, custom Format strategies, or choosing how to render an error chain + for a terminal, log, or user message. Trigger on: MainResult, FormatError, + one_line(), chain(), formatted(), WithContext, WithPath, ManyErrors, Format + trait, OneLine, Chain, Suggestion -- even when only one of these is mentioned. + See structured-error-handling for designing the error types themselves. +--- + +# using-errortools + +This skill covers the runtime surface of the `errortools` crate: rendering error +chains, attaching incidental context without new variants, aggregating batch +failures, and defining custom format strategies. For designing error enums and +`#[source]`/`#[from]` conventions, see `structured-error-handling`. + +## Core API + +| Item | Purpose | +|---|---| +| `MainResult` | Return type for `fn main`. Renders `E` via strategy `F` instead of `Debug`. `T` lets `main` return `ExitCode`. | +| `OneLine` | Default strategy: error + sources joined with `": "`. | +| `Chain` | Per-error indented source-chain ladder (`└─`). | +| `Tree` / `List` / `Bullets` / `Joined` | Aggregate `ManyErrors` render shapes (needs `alloc`). | +| `FormatError` | Ext trait on any `&dyn Error`: `.one_line()`, `.chain()`, `.suggestion()`, `.formatted::()`. | +| `Format` trait | Implement on a unit type to define a custom strategy. | +| `chain(&dyn Error)` | Iterator over the error and its `source()` chain; use inside `Format` impls. | +| `Formatted` | Wrapper whose `Display` runs strategy `F` over `E`. | +| `WithContext` / `WithPath` | Tag an error with a context value (path, attempt, ID) without a wrapper variant. | +| `ManyErrors` | Aggregate of context-tagged failures; render as tree/list/bullets/joined. | +| `Suggest` / `Suggestion` | Per-error "did you mean..." hints via `.suggestion()`. | +| `Add` + `separator::*` | Compose two strategies, e.g. `WithNewLine`. | +| `DisplaySwapDebug` | Swaps `Debug`/`Display`; powers `MainResult`. | + +### Reference files + +| Need | File | +|---|---| +| Choosing/combining formats, custom `Format`, `MainResult` internals | `references/formatting.md` | +| Attaching a path / attempt / ID to an error | `references/with-context.md` | +| Collecting and reporting many failures at once | `references/many-errors.md` | +| "Did you mean..." recovery hints | `references/suggestions.md` | + +## Patterns + +### `MainResult` for binary entrypoints + +Replace the `if let Err(e) = run() { eprintln!(...); exit(1) }` dance with a +typed return. `MainResult` renders the chain via `Display`, so `Debug` never +reaches stderr. + +```rust +use errortools::MainResult; +use std::{fs, io}; + +#[derive(Debug, thiserror::Error)] +enum Error { + #[error("Failed to load config")] + Config(#[source] WithPath), +} + +fn main() -> MainResult { + let config_path = "missing.toml"; + fs::read_to_string(&config_path).map_err(|e| Error::Config(WithPath::new(config_path, e)))?; + Ok(()) +} +``` + +Output: + +```text +Error: Failed to load config: missing.toml: No such file or directory (os error 2) +``` +with exit code 1. + +For the indented ladder: `fn main() -> MainResult`. + +Switch the strategy globally by changing the type parameter on `MainResult`; +no call sites change. Details and composition in `references/formatting.md`. + +**Rules:** +- **MUST** use `fn main() -> MainResult`. Never `Result<(), Error>`; that + prints `Debug`. +- **MUST** confine `exit(1)` and `panic!()` to `main`. Business logic returns + `Result`. +- **MUST** do graceful shutdown in `main` (join threads, close connections). + Rely on scope for drop; avoid calling `drop(v)` manually. + +### Ad-hoc logging mid-function + +When you cannot propagate (inside a `tokio::spawn`, an event handler, a retry +loop), use `FormatError`. Never walk `source()` by hand. +Use **ONLY** if there's no way to return a `Result` to the caller. + +```rust +use errortools::FormatError; + +if let Err(e) = do_thing().await { + tracing::error!("do_thing failed: {}", e.one_line()); +} +``` + +```rust +eprintln!("{}", e.chain()); // indented ladder for terminals +// eprintln!("{}", e.formatted::()); // any custom strategy +``` + +### Custom format strategy + +Implement `Format` once per project, reuse everywhere. A chain-walking +strategy declares `E: Error` itself and walks with `chain`; never call +`source()` directly inside a `Format` impl. + +```rust +use core::{error::Error, fmt}; +use errortools::{Format, chain}; + +pub struct Arrow; +impl Format for Arrow { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, e) in chain(&error).enumerate() { + if i > 0 { f.write_str(" -> ")?; } + write!(f, "{e}")?; + } + Ok(()) + } +} + +// fn main() -> MainResult { ... } +// tracing::error!("{}", e.formatted::()); +``` + +See `references/formatting.md` for composing strategies with `Add`/separators +and for `Chain`/connectors. + +### Attaching incidental context + +When a path, retry count, or record ID needs to travel with an error but callers +will not branch on it, use `WithContext` / `WithPath` instead of a wrapper variant. +`WithContext`'s `source()` skips its own inner error, so the chain never doubles. + +```rust +// BAD -- variant exists only to staple a path on +#[error("IO at {path}")] +IoAt { path: PathBuf, #[source] source: io::Error }, + +// GOOD +File::create(&path).map_err(|e| WithPath::new(path, e))?; +``` + +Keep context in a real variant only when callers need to match on it. See +`references/with-context.md` for full usage. + +### Collecting batch failures + +Never silently skip failed items. Use `ManyErrors` to push `(context, error)` +pairs and report them all at once. + +```rust +let mut errs = ManyErrors::new(); +for item in &items { + if let Err(e) = process(item) { errs.push(item.id, e); } +} +errs.into_result(())?; + +// BEST +items.into_iter() + .map(|item| process(item).map_err(|e| (item.id, e))) + .collect::>() + .into_result(())?; +``` + +See `references/many-errors.md` for nesting with `push_group` and render options. + +## Choosing a format strategy + +| Context | Strategy | +|---|---| +| CLI tools, default | `OneLine` (single tidy line, greppable) | +| Interactive terminals, deep chains | `Chain` (or `Chain`) | +| Structured logs (JSON, OpenTelemetry) | `OneLine`, one log line per error | +| A batch of independent failures | `Tree` / `List` / `Bullets` / `Joined` (`ManyErrors`) | +| Error + recovery hint | `WithNewLine` | +| Project house style | custom `Format` impl, applied uniformly | + +## References + +- API docs: +- Runnable examples: + (`one_line`, `chain`, `format_error`, `custom_format`, `transparent`, + `with_context`, `many_errors`) diff --git a/plugins/errortools/skills/errortools/references/formatting.md b/plugins/errortools/skills/using-errortools/references/formatting.md similarity index 84% rename from plugins/errortools/skills/errortools/references/formatting.md rename to plugins/errortools/skills/using-errortools/references/formatting.md index bec0d4e..770a8cb 100644 --- a/plugins/errortools/skills/errortools/references/formatting.md +++ b/plugins/errortools/skills/using-errortools/references/formatting.md @@ -5,7 +5,7 @@ choosing how `main` reports an error, how you log a chain mid-flow, or when you need a project-specific format. The mental model: a **`Format` strategy** is a unit type that knows how to -write some value `E` to a `fmt::Formatter`. You rarely instantiate one — you +write some value `E` to a `fmt::Formatter`. You rarely instantiate one; you name it as a type parameter (`MainResult`) or hand it to a wrapper (`e.formatted::()`). `Formatted` is the wrapper whose `Display` runs strategy `F` over `E`. @@ -13,7 +13,7 @@ runs strategy `F` over `E`. ## Returning from `main`: `MainResult` `MainResult` is a drop-in `Result` for `fn main`. When -`main` returns `Err`, the runtime prints it via `Debug` — `MainResult` arranges +`main` returns `Err`, the runtime prints it via `Debug`, and `MainResult` arranges for that `Debug` print to emit `F`'s `Display`-style output instead of the raw derived `Debug`. `?` converts your error in automatically. @@ -23,13 +23,13 @@ use std::{fs, io}; #[derive(Debug, thiserror::Error)] enum AppError { - #[error("failed to load config")] + #[error("Failed to load config")] Config(#[source] ConfigError), } #[derive(Debug, thiserror::Error)] enum ConfigError { - #[error("failed to read file")] + #[error("Failed to read file")] Read(#[source] io::Error), } @@ -42,18 +42,18 @@ fn main() -> MainResult { ``` ```text -Error: failed to load config: failed to read file: No such file or directory (os error 2) +Error: Failed to load config: Failed to read file: No such file or directory (os error 2) ``` -Swap the strategy with the second type parameter — no call sites change: +Swap the strategy with the second type parameter; no call sites change: ```rust -fn main() -> errortools::MainResult { /* … */ } +fn main() -> errortools::MainResult { /* ... */ } ``` ```text -Error: failed to load config -└─ failed to read file +Error: Failed to load config +└─ Failed to read file └─ No such file or directory (os error 2) ``` @@ -63,14 +63,14 @@ The third parameter `T` is the success type (default `()`). Return an ```rust use std::process::ExitCode; fn main() -> errortools::MainResult { - // … on success: + // ... on success: Ok(ExitCode::SUCCESS) } ``` ## Logging mid-flow: `FormatError` -When you cannot return — inside a `tokio::spawn`, an event loop, a retry — the +When you cannot return (inside a `tokio::spawn`, an event loop, a retry), the `FormatError` extension trait renders any `&dyn Error` on the spot. Never walk `source()` by hand. @@ -89,7 +89,7 @@ if let Err(e) = do_thing() { |---|---|---| | `.one_line()` | `OneLine` | error + sources joined by `": "` | | `.chain()` | `Chain` | indented source-chain ladder (`└─`) | -| `.suggestion()` | `Suggestion` | top-level "did you mean…" hint (needs `Suggest`) — see `suggestions.md` | +| `.suggestion()` | `Suggestion` | top-level "did you mean..." hint (needs `Suggest`); see `suggestions.md` | | `.formatted::()` | any `F` | render with an arbitrary strategy | Pick a strategy inline: @@ -109,7 +109,7 @@ if let Err(e) = do_thing() { | Strategy | Shape | Glyphs | |---|---|---| -| `OneLine` | `outer: middle: inner` | — | +| `OneLine` | `outer: middle: inner` | -- | | `Chain` | indented ladder, one source per line | `└─ ` + ` ` (Unicode) | `Chain` is parameterised by a `Connectors` glyph set. Use `Ascii` where Unicode @@ -131,7 +131,7 @@ implement both, so `Chain` and `Tree` look consistent. Implement `Format` on a unit type. The trait imposes **no bound** on `E` (`Format`); a strategy that walks the source chain declares -`E: Error` itself. Walk the chain with `chain(&error)` — never call `source()` +`E: Error` itself. Walk the chain with `chain(&error)`; never call `source()` by hand. ```rust @@ -148,7 +148,7 @@ impl Format for Arrow { } } -// fn main() -> MainResult { … } +// fn main() -> MainResult { ... } println!("{}", my_error.formatted::()); // outer -> middle -> inner ``` @@ -165,12 +165,12 @@ fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { ``` Define a custom strategy once per project and reuse it everywhere via the type -parameter — there is one place to change the house style. +parameter, so there is one place to change the house style. ## Composing strategies: `Add` + separators `Add` runs two strategies against the same value, left then right. There -is no implicit separator — drop a separator strategy in the middle, or use the +is no implicit separator; drop a separator strategy in the middle, or use the `WithSep` alias so the separator reads in order: ```rust @@ -194,7 +194,7 @@ eprintln!("{}", Formatted::<_, Brief>::new(err)); Bounds compose automatically: `Add` only implements `Format` when `E: Error + Suggest`, because `Suggestion`'s own impl carries -the `Suggest` bound. `Add` writes both sides unconditionally — if `R` produces +the `Suggest` bound. `Add` writes both sides unconditionally; if `R` produces nothing (a `Suggestion` variant with no hint), the separator is still written. This same combinator powers defaults elsewhere: `WithContext`'s `Colon` is @@ -204,9 +204,9 @@ This same combinator powers defaults elsewhere: `WithContext`'s `Colon` is | Context | Strategy | |---|---| -| CLI tools, default | `OneLine` — single greppable line | +| CLI tools, default | `OneLine`, single greppable line | | Interactive terminals, deep chains | `Chain` (or `Chain`) | -| Structured logs (JSON, OTel) | `OneLine` — one log line per error | +| Structured logs (JSON, OTel) | `OneLine`, one log line per error | | Aggregate of many failures | `Tree` / `List` / `Bullets` / `Joined` (see `many-errors.md`) | | Error + fix hint | `WithNewLine` (see `suggestions.md`) | | Project house style | a custom `Format` impl, applied uniformly | diff --git a/plugins/errortools/skills/errortools/references/many-errors.md b/plugins/errortools/skills/using-errortools/references/many-errors.md similarity index 86% rename from plugins/errortools/skills/errortools/references/many-errors.md rename to plugins/errortools/skills/using-errortools/references/many-errors.md index 41e31c2..823db13 100644 --- a/plugins/errortools/skills/errortools/references/many-errors.md +++ b/plugins/errortools/skills/using-errortools/references/many-errors.md @@ -1,7 +1,7 @@ # Aggregating many failures: `ManyErrors` -Read this when an operation shouldn't stop at the first failure — validating a -whole config, deploying to every region, parsing a batch — and you want to +Read this when an operation shouldn't stop at the first failure (validating a +whole config, deploying to every region, parsing a batch) and you want to report all of them, grouped and readable, instead of just the first. (Requires the `alloc` feature, on by default.) @@ -26,12 +26,12 @@ errs.into_result(())?; // Ok(()) if empty, Err(ManyErrors) otherwise | `new()` / `is_empty()` / `len()` | construct / inspect | | `push(context, error)` | append a leaf, promoting `None → One → Many` | | `push_group(label, sub)` | append a named nested `ManyErrors` | -| `push_node(node)` | append anything `Into` — a `(C, E)` pair, `WithContext`, `Subgroup`, or `Node` | +| `push_node(node)` | append anything `Into`: a `(C, E)` pair, `WithContext`, `Subgroup`, or `Node` | | `into_result(ok)` | `Ok(ok)` if empty, else `Err(self)` | | `with_formats::()` | swap leaf + group-label strategies | You can also collect straight from an iterator of `(context, error)` pairs or -`WithContext` values — `ManyErrors` implements `FromIterator` and `Extend`, +`WithContext` values. `ManyErrors` implements `FromIterator` and `Extend`, which composes with itertools' `partition_result`: ```rust @@ -54,7 +54,7 @@ all.push("eu-west-1", RegionError::Refused); ## Rendering shapes -The shapes are **inherent helpers** — no turbofish — and they walk each leaf's +The shapes are **inherent helpers** (no turbofish) and they walk each leaf's source chain: ```rust @@ -74,15 +74,15 @@ println!("{}", all.joined()); // ;-separated single line, parens around group └─ eu-west-1: connection refused ``` -The default `Display` (`{all}`) is deliberately a **shallow one-line summary** — -each error's own text, no source chains — so it's safe to embed in a message or +The default `Display` (`{all}`) is deliberately a **shallow one-line summary** +(each error's own text, no source chains), so it's safe to embed in a message or log, following the Rust convention that an error's `Display` is its own message: ```text 2 errors: us-east-1 (2 errors: i-0a1: connection refused; i-0b2: connection refused); eu-west-1: connection refused ``` -For full control — ASCII connectors, no count header — go through the inherent +For full control (ASCII connectors, no count header) go through the inherent `formatted` with explicit generics. `Tree`: ```rust @@ -91,7 +91,7 @@ println!("{}", Formatted::<_, Tree>::new(&all)); // ASCII, no head println!("{}", all.formatted::>()); // same, shorter ``` -## Two `formatted` methods — use the inherent one +## Two `formatted` methods: use the inherent one `errs.formatted::()` resolves to an **inherent** method that is completely unbounded (and `const`): wrapping always compiles; whether the combination can @@ -101,7 +101,7 @@ The `FormatError::formatted` *trait* method also exists, but calling it requires `ManyErrors: Error`, which drags `C: Debug` and `GC: Debug` onto your context types (the `Error` supertrait) even though no strategy needs them to render. At a call site the inherent method wins automatically and produces the identical -value — so just write `errs.formatted::()`. A `PathBuf` context with +value, so just write `errs.formatted::()`. A `PathBuf` context with `PathColon` then renders in every shape even though `PathBuf` has no `Display`. The same rule runs through the whole path: no shape demands anything from a @@ -121,22 +121,22 @@ for node in &errs { ## Footgun: `one_line()` / `chain()` on an aggregate -A `ManyErrors` *is* an `Error`, so `errs.one_line()` and `errs.chain()` compile — +A `ManyErrors` *is* an `Error`, so `errs.one_line()` and `errs.chain()` compile, but they print only the shallow summary, because `Error::source()` is always `None` (an aggregate has no single linear cause). The deep versions are `joined()` (one line) and `tree()` (multi-line). Same logic in reverse: a `ManyErrors` buried in another error's `#[source]` chain shows up as **one summary line** under `OneLine`/`Chain`, and the walk -stops there — branching can't be recovered through `dyn Error`. If you want its +stops there; branching can't be recovered through `dyn Error`. If you want its branches rendered, lift it into a `push_group` of an outer `ManyErrors` instead of chaining it as a source. ## Custom aggregate shapes -Need your own layout? Implement `Format>` and match on the public +Need your own layout? Implement `Format>` and match on the public `ManyErrors` / `Node` variants, plus a small `&T` ref-forwarder so -`Formatted<&ManyErrors<…>, _>` works: +`Formatted<&ManyErrors<...>, _>` works: ```rust use core::fmt::{self, Display, Formatter}; @@ -172,5 +172,5 @@ module docs for a worked example. Group labels can differ from leaf contexts via the third parameter `ManyErrors`, but `GC` defaults to `C`, so the common case stays two params. Label decoration is a separate lever: the group-label strategy `GF` -(default `AsDisplay`) is label-only and never sees the nested errors — laying +(default `AsDisplay`) is label-only and never sees the nested errors; laying those out is the aggregate strategy's job. diff --git a/plugins/errortools/skills/errortools/references/suggestions.md b/plugins/errortools/skills/using-errortools/references/suggestions.md similarity index 89% rename from plugins/errortools/skills/errortools/references/suggestions.md rename to plugins/errortools/skills/using-errortools/references/suggestions.md index dd5e30f..b3ba1db 100644 --- a/plugins/errortools/skills/errortools/references/suggestions.md +++ b/plugins/errortools/skills/using-errortools/references/suggestions.md @@ -1,8 +1,8 @@ -# Suggestions: "Did you mean…" hints +# Suggestions: "Did you mean..." hints -Read this when an error should carry a recovery hint for the user — "copy +Read this when an error should carry a recovery hint for the user ("copy `config.example.toml` to `config.toml`", "pass `--help`", "check the path -exists" — separate from the error message itself. +exists") separate from the error message itself. Implement the `Suggest` trait on your error type to give per-variant hints, then render them with `error.suggestion()` (or the `Suggestion` `Format` strategy). @@ -36,14 +36,14 @@ eprintln!("{}\n{}", Error::NoConfig.one_line(), Error::NoConfig.suggestion()); `Suggest::fmt` defaults to writing nothing, so a type only needs arms for the variants that actually have a hint. -## Top-level only — by design +## Top-level only, by design `error.suggestion()` prints **only the top-level error's** hint; the source chain is not walked. This is intentional: an inner error's hint is often irrelevant once it has been wrapped, and printing it would just add noise. The convention is that each error that should suggest something implements `Suggest`, and a top-level error's hint can deliberately concatenate an inner hint when it's -relevant — with nesting that matches the error chain. +relevant, with nesting that matches the error chain. `Suggest` is dispatched on the concrete outer type, so it is **not** delegated through `#[error(transparent)]` either: `transparent` collapses `Display` and @@ -65,13 +65,13 @@ eprintln!("{}", Formatted::<_, Brief>::new(err)); ``` `Add` writes both sides unconditionally, so a variant with no hint still emits -the separator (a trailing newline) — predictable for line-oriented output. +the separator (a trailing newline), predictable for line-oriented output. ## In `main`: `MainResultWithSuggestion` To print the error and its hint straight out of `main`, return `MainResultWithSuggestion` instead of `MainResult`. It is -`MainResult>` — the error via `F` (default +`MainResult>`: the error via `F` (default `OneLine`), a newline, then the top-level suggestion: ```rust diff --git a/plugins/errortools/skills/errortools/references/with-context.md b/plugins/errortools/skills/using-errortools/references/with-context.md similarity index 89% rename from plugins/errortools/skills/errortools/references/with-context.md rename to plugins/errortools/skills/using-errortools/references/with-context.md index b2dcf73..89963c7 100644 --- a/plugins/errortools/skills/errortools/references/with-context.md +++ b/plugins/errortools/skills/using-errortools/references/with-context.md @@ -1,7 +1,7 @@ # Attaching context: `WithContext` -Read this when an error needs a value carried alongside it — a file path, a step -number, a retry attempt, a record ID — and inventing a one-off wrapper variant +Read this when an error needs a value carried alongside it (a file path, a step +number, a retry attempt, a record ID) and inventing a one-off wrapper variant just to hold that value would be noise. `WithContext` pairs a context value `C` with an error `E` and @@ -28,7 +28,7 @@ let ctx = WithContextColon::new("path/to/config", err); assert_eq!(ctx.one_line().to_string(), "path/to/config: file missing"); ``` -Any `Display` context works — a retry attempt number, for instance: +Any `Display` context works. A retry attempt number, for instance: ```rust use errortools::WithContext; @@ -58,7 +58,7 @@ use errortools::{MainResult, WithContext, with_context::WithPath}; use std::{fs::File, io, path::Path}; #[derive(Debug, thiserror::Error)] -#[error("failed to create file")] +#[error("Failed to create file")] struct Error(#[from] WithPath<&'static Path, io::Error>); fn main() -> MainResult { @@ -69,16 +69,16 @@ fn main() -> MainResult { ``` ```text -Error: failed to create file: no/such/dir/foo.txt: No such file or directory (os error 2) +Error: Failed to create file: no/such/dir/foo.txt: No such file or directory (os error 2) ``` `#[from]` is what pins the format parameter: `WithContext::new(path, e)` infers -`PathColon` from the target `WithPath<…>`, no turbofish needed. +`PathColon` from the target `WithPath<...>`, no turbofish needed. ## Nesting context layers Layers nest, and the chain reads outside-in. Wrap a -`WithContext` (attempt) inside a `WithPath<&Path, …>` (path) +`WithContext` (attempt) inside a `WithPath<&Path, ...>` (path) and you get `": : "`: ```rust @@ -99,7 +99,7 @@ change the look: **1. Compose field extractors with a separator.** `Colon` is just `WithColonSpace`. Swap the separator to change the -delimiter — every extractor/separator is a `Format` tag: +delimiter, since every extractor/separator is a `Format` tag: ```rust use errortools::{WithContext, separator::WithSpace, with_context::{ContextField, ErrorField}}; @@ -138,14 +138,14 @@ assert_eq!(w.to_string(), "1 -> boom"); `with_format::()` switches the strategy on an existing value without touching the stored fields. `From<(C, E)>` lets a `(context, error)` tuple -become a `WithContext` directly — handy with iterator adapters. +become a `WithContext` directly, handy with iterator adapters. ## When to reach for it (and when not) - **Reach for it** instead of a single-variant wrapper struct/enum whose only purpose is to attach a path/ID/attempt to a foreign error. This keeps the source chain honest and avoids variant sprawl. -- **Still use a real variant** when the context *is* the operation's identity +- **May use a real variant** when the context *is* the operation's identity and you'll match on it (e.g. `LoadUser { id, #[source] source }`). Context that callers branch on belongs in the enum; context that's purely for the message belongs in `WithContext`. From aee5f3ae157859be5ef410396e4b2df3b4991db7 Mon Sep 17 00:00:00 2001 From: Max Wase Date: Wed, 24 Jun 2026 00:04:25 +0300 Subject: [PATCH 5/5] Consolidate duplicates --- .../migrating-from-unstructured/SKILL.md | 37 +++++++------------ .../skills/structured-error-handling/SKILL.md | 32 ++++++---------- .../skills/using-errortools/SKILL.md | 6 +-- 3 files changed, 28 insertions(+), 47 deletions(-) diff --git a/plugins/errortools/skills/migrating-from-unstructured/SKILL.md b/plugins/errortools/skills/migrating-from-unstructured/SKILL.md index 78af7bb..7fdabc4 100644 --- a/plugins/errortools/skills/migrating-from-unstructured/SKILL.md +++ b/plugins/errortools/skills/migrating-from-unstructured/SKILL.md @@ -74,6 +74,9 @@ fs::read_to_string(path).map_err(Error::ReadConfig)? fs::read_to_string(path).map_err(|e| WithPath::new(path, e))? ``` +For `WithContext` / `WithPath` usage and rendering, see `using-errortools` → +"Attaching incidental context" and `references/with-context.md`. + **Step 4: Replace `bail!` / `ensure!`.** ```rust @@ -96,8 +99,11 @@ fn main() -> errortools::MainResult { ... } ``` **Step 6: Leave unstructured error handling in tests.** Tests may keep it as a -convenience return type. That is the one acceptable exception. This includes panics as well. -One note: The tests **MUST** match the exact error variant or message, not `assert!(res.is_err())`. +convenience return type, panics included -- that is the one acceptable exception. +Asserting still has rules, though: tests **MUST** match the exact error variant +or message, not `assert!(res.is_err())`, and once a variant is matched the +unhappy path **MUST** be tested too, not just the happy path. See +`structured-error-handling` → "Tests". --- @@ -199,27 +205,12 @@ let key = "key"; let val = map.get(key).ok_or(Error::MissingKey(key))?; ``` -**Step 6: Replace silent skips in loops.** +**Step 6: Replace silent skips in loops.** A `let _ = process(item)` in a loop +swallows failures. Collect them with `ManyErrors` instead -- the canonical +push/`collect`/`into_result` pattern and the nesting/render options live in +`using-errortools` → "Collecting batch failures" and `references/many-errors.md`. ```rust -// BAD -for item in items { - let _ = process(item); // failures vanish -} - -// GOOD -let mut errs = ManyErrors::new(); -for item in items { - if let Err(e) = process(item) { errs.push(item.id, e); } -} -errs.into_result(())?; - -// BEST -items.into_iter() - .map(|item| process(item).map_err(|e| (item.id, e))) - .collect::>() - .into_result(())?; +// BAD -- failures vanish +for item in items { let _ = process(item); } ``` - -See `using-errortools` → `references/many-errors.md` for nesting and rendering -options. diff --git a/plugins/errortools/skills/structured-error-handling/SKILL.md b/plugins/errortools/skills/structured-error-handling/SKILL.md index db79f15..20a0ac8 100644 --- a/plugins/errortools/skills/structured-error-handling/SKILL.md +++ b/plugins/errortools/skills/structured-error-handling/SKILL.md @@ -90,19 +90,18 @@ TokioJoin(&'static str), **7.** For incidental context that callers will never match on (a file path, a retry count, a record ID), prefer `WithContext` / `WithPath` over inventing a -single-variant wrapper. See `using-errortools` for details. +single-variant wrapper. Basic usage and rendering live in `using-errortools` → +"Attaching incidental context" and `references/with-context.md`; the design rule +below covers the case those don't -- needing a named variant *and* a path. ```rust // BAD -- wrapper variant exists only to attach a path #[error("IO at {path}")] IoAt { path: PathBuf, #[source] source: io::Error }, -// GOOD -- no variant: WithPath carries the path and renders it in the chain -File::create(&path).map_err(|e| WithPath::new(path, e))?; - // GOOD -- need a named variant (callers match on the operation)? Hold the -// WithPath and keep the path OUT of the message. WithPath takes the -// path type AND the error, and renders ": " in the chain. +// WithPath (path type AND error) and keep the path OUT of the message; +// it renders ": " in the chain. enum Error { #[error("IO error")] IoAt(#[source] WithPath), @@ -217,24 +216,13 @@ let cfg = load().map_err(config::Error::Load)?; ## Batch operations **14.** Do not silently skip failed items unless the API contract says so. Either -fail fast or collect per-item errors with `ManyErrors` (see `using-errortools`). +fail fast or collect per-item errors with `ManyErrors`. The canonical +`ManyErrors` pattern (push vs `collect`, `into_result`, render shapes) lives in +`using-errortools` → "Collecting batch failures" and `references/many-errors.md`. ```rust // BAD -- failures vanish for item in items { let _ = process(item); } - -// GOOD -let mut errs = ManyErrors::new(); -for item in &items { - if let Err(e) = process(item) { errs.push(item.id, e); } -} -errs.into_result(())?; - -// BEST -items.into_iter() - .map(|item| process(item).map_err(|e| (item.id, e))) - .collect::>() - .into_result(())?; ``` ## `anyhow` / `Box` @@ -246,7 +234,9 @@ temporary scripts. If the project currently uses `anyhow`, see ## Tests -**16.** Assert the exact error variant, not just `.is_err()`. +**16.** Assert the exact error variant, not just `.is_err()`. Once a variant is +worth matching, the unhappy path is worth testing: cover the error cases, not +only the happy path. ```rust // BAD diff --git a/plugins/errortools/skills/using-errortools/SKILL.md b/plugins/errortools/skills/using-errortools/SKILL.md index 54b4d99..7ed3459 100644 --- a/plugins/errortools/skills/using-errortools/SKILL.md +++ b/plugins/errortools/skills/using-errortools/SKILL.md @@ -53,18 +53,18 @@ typed return. `MainResult` renders the chain via `Display`, so `Debug` never reaches stderr. ```rust -use errortools::MainResult; +use errortools::{MainResult, with_context::WithPath}; use std::{fs, io}; #[derive(Debug, thiserror::Error)] enum Error { #[error("Failed to load config")] - Config(#[source] WithPath), + Config(#[source] WithPath<&'static str, io::Error>), } fn main() -> MainResult { let config_path = "missing.toml"; - fs::read_to_string(&config_path).map_err(|e| Error::Config(WithPath::new(config_path, e)))?; + fs::read_to_string(config_path).map_err(|e| Error::Config(WithPath::new(config_path, e)))?; Ok(()) } ```