Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/target
.claude/
.agents/
.codex
.agents
.claude
.codex
47 changes: 29 additions & 18 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<C, E, F>` — tag any error with a context value (path, attempt number, etc.). `Colon` is the default strategy; `PathColon` handles
- `ManyErrors<C, E, GC, F, GF>` (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<C, E, GC, F, GF>` and `Subgroup<C, E, GC, F, GF>` (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<Conn, HEADER>`, `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::<F>()` for full generic control.
- `Connectors` / `TreeConnectors` traits with the `Unicode` and `Ascii` glyph sets, shared by `Chain` and the aggregate `Tree` (so `Chain<Ascii>` and `Tree<Ascii>` look consistent).

### Changed

- **Breaking:** `Tree<M, I>` (the per-error source-chain ladder) renamed to `Chain<C>`. The type-parameter API changed too: the old `Marker + Indent` pair is replaced by a single `Connectors` impl (e.g. `Chain<Ascii>`). 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<C, E, F>` -- 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<C, E>` 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<E, F, T = ()>` so `main` can return `ExitCode` or any `Termination` type.
- `Add<L, R>` — 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<Add<OneLine, NewLine>, Suggestion>` without writing a new impl. Bounds compose automatically — using `Suggestion` in the chain still requires `E: Suggest`.
- `WithSep<L, Sep, R>` alias — sugar for `Add<Add<L, Sep>, R>` so the separator reads in the middle. Plus pre-baked variants in `separator`: `WithSpace<L, R>`, `WithNewLine<L, R>`, `WithColonSpace<L, R>`.
- `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<ContextField, Space, ErrorField>` for a space-delimited pair.
- `Chain<C>` — per-error source-chain ladder renderer (renamed from `Tree<M, I>`, see breaking changes). Uses the same [`Connectors`] glyph vocabulary as the new aggregate `Tree`.
- `ManyErrors<C, E, GC, F, GF>` (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<C, E, GC, F, GF>` and `Subgroup<C, E, GC, F, GF>` (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<Conn, HEADER>`, `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::<F>()` for full generic control.
- `Add<L, R>` -- 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<Add<OneLine, NewLine>, Suggestion>` without writing a new impl. Bounds compose automatically -- using `Suggestion` in the chain still requires `E: Suggest`.
- `WithSep<L, Sep, R>` alias -- sugar for `Add<Add<L, Sep>, R>` so the separator reads in the middle. Plus pre-baked variants in `separator`: `WithSpace<L, R>`, `WithNewLine<L, R>`, `WithColonSpace<L, R>`.
- `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<ContextField, Space, ErrorField>` for a space-delimited pair.

### Changed

- **Breaking:** `Format` is now `Format<E: ?Sized>` (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<E: ?Sized>` (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<Add<ContextField, ColonSpace>, 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<C, E>` to `F: Format<WithContext<C, E, F>>`.
- **Breaking:** `Tree<M, I>` (the 0.1.0 per-error source-chain ladder) renamed to `Chain<C>`. The type-parameter API changed: the old `Marker + Indent` pair is replaced by a single `Connectors` impl (e.g. `Chain<Ascii>`). The `Tree` name is now used for the `ManyErrors` aggregate branching renderer.

### Removed

Expand All @@ -41,17 +51,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `MainResult<E, F>` drop-in `Result` for `fn main` that prints errors via
- `MainResult<E, F>` -- 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<M, I>` format multi-line indented tree, marker and indent
- `OneLine` format -- joins the error chain with `": "`.
- `Tree<M, I>` 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::<F>()`)
on any `Error`, including `&dyn Error`.
- `DisplaySwapDebug<E>` 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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "errortools"
authors = ["Max Wase <max.vvase@gmail.com>"]
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"
Expand Down
36 changes: 18 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand All @@ -45,7 +45,7 @@ fn main() -> MainResult<Error> {
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.
Expand All @@ -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),
}

Expand All @@ -71,7 +71,7 @@ fn main() -> MainResult<AppError, Chain> {
```

```text
Error: failed to load config
Error: Failed to load config
└─ No such file or directory (os error 2)
```

Expand All @@ -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<Error> {
Expand All @@ -98,7 +98,7 @@ fn main() -> MainResult<Error> {
```

```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
Expand Down Expand Up @@ -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<C: Display, E: Display, F> Format<WithContext<C, E, F>> 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<Path>`, you ask for whatever you need.

## But why?
Expand Down Expand Up @@ -201,8 +201,8 @@ type Brief = WithSep<OneLine, NewLine, Suggestion>;
eprintln!("{}", Formatted::<_, Brief>::new(err));
```

For the common separators there are zero-think aliases `WithSpace<L, R>`,
`WithNewLine<L, R>`, `WithColonSpace<L, R>` all in `errortools::separator`:
For the common separators there are zero-think aliases -- `WithSpace<L, R>`,
`WithNewLine<L, R>`, `WithColonSpace<L, R>` -- all in `errortools::separator`:

```rust,ignore
use errortools::{Formatted, OneLine, Suggestion, separator::WithNewLine};
Expand All @@ -214,7 +214,7 @@ eprintln!("{}", Formatted::<_, Brief>::new(err));
Bounds compose: `Add<OneLine, Suggestion>` only implements `Format<E>` 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<ContextField, ErrorField>`, where
`ContextField`/`ErrorField` are extractor strategies that read the pair's
fields. To get a different delimiter, swap one piece:
Expand All @@ -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
Expand Down Expand Up @@ -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<C, E>`: 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<C, E>`: a context-tagged collection you can render as a tree, list, or single line.

```rust,ignore
use errortools::ManyErrors;
Expand All @@ -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:

Expand All @@ -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)
Expand All @@ -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::<Tree<Ascii, false>>()`. 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::<Tree<Ascii, false>>()`. 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<ManyErrors<>>` 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<ManyErrors<...>>` 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<C, E, GC>`, but `GC` defaults to `C`, so the common case stays two params.

Expand All @@ -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

Expand Down
Loading