diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..881dc91 --- /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 the errortools and thiserror crates — error-type design, MainResult, context tagging, aggregation, and custom formatting" + } + ] +} \ 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/CHANGELOG.md b/CHANGELOG.md index 282df63..4355107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,30 +7,40 @@ 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 -- `WithContext` — tag any error with a context value (path, attempt number, etc.). `Colon` is the default strategy; `PathColon` handles +- `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 + +- `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`, `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`. -- `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. +- `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>`. -- **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 @@ -41,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::()`) @@ -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/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/.claude-plugin/plugin.json b/plugins/errortools/.claude-plugin/plugin.json new file mode 100644 index 0000000..5ef8597 --- /dev/null +++ b/plugins/errortools/.claude-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "rust-error-handling", + "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", + "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/migrating-from-unstructured/SKILL.md b/plugins/errortools/skills/migrating-from-unstructured/SKILL.md new file mode 100644 index 0000000..7fdabc4 --- /dev/null +++ b/plugins/errortools/skills/migrating-from-unstructured/SKILL.md @@ -0,0 +1,216 @@ +--- +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))? +``` + +For `WithContext` / `WithPath` usage and rendering, see `using-errortools` → +"Attaching incidental context" and `references/with-context.md`. + +**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, 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". + +--- + +## 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.** 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 -- failures vanish +for item in items { let _ = process(item); } +``` 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..20a0ac8 --- /dev/null +++ b/plugins/errortools/skills/structured-error-handling/SKILL.md @@ -0,0 +1,250 @@ +--- +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. 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 -- need a named variant (callers match on the operation)? Hold the +// 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), +} + +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`. 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); } +``` + +## `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()`. Once a variant is +worth matching, the unhappy path is worth testing: cover the error cases, not +only the happy path. + +```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..7ed3459 --- /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, with_context::WithPath}; +use std::{fs, io}; + +#[derive(Debug, thiserror::Error)] +enum Error { + #[error("Failed to load config")] + 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)))?; + 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/using-errortools/references/formatting.md b/plugins/errortools/skills/using-errortools/references/formatting.md new file mode 100644 index 0000000..770a8cb --- /dev/null +++ b/plugins/errortools/skills/using-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`, 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. + +```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, 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 +`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/using-errortools/references/many-errors.md b/plugins/errortools/skills/using-errortools/references/many-errors.md new file mode 100644 index 0000000..823db13 --- /dev/null +++ b/plugins/errortools/skills/using-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/using-errortools/references/suggestions.md b/plugins/errortools/skills/using-errortools/references/suggestions.md new file mode 100644 index 0000000..b3ba1db --- /dev/null +++ b/plugins/errortools/skills/using-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/using-errortools/references/with-context.md b/plugins/errortools/skills/using-errortools/references/with-context.md new file mode 100644 index 0000000..89963c7 --- /dev/null +++ b/plugins/errortools/skills/using-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, since 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. +- **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`.