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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jobs:
matrix:
features:
- --all-features
- --no-default-features --features alloc
- --no-default-features
steps:
- uses: actions/checkout@v6
Expand Down
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.0] - 2026-05-17
## [0.2.0] - 2026-06-23

### Added

Expand All @@ -16,15 +16,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `Suggest` trait for per-variant "Did you mean…" hints, and the `Suggestion` `Format` strategy that renders them. `error.suggestion()` prints the top-level hint via `Display`. Default `Suggest::fmt` writes nothing, so types only implement it for variants that have a hint.
- `DisplayPath` wrapper in the `path_display` module — drops `&Path`/`PathBuf` into any `Display` context.
- `T` generic on `MainResult<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`, `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`.
- `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.

### 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:** `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 Down
39 changes: 37 additions & 2 deletions Cargo.lock

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

12 changes: 10 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,23 @@ categories = ["rust-patterns", "command-line-interface", "no-std"]
include = ["src/**/*.rs", "examples/**/*.rs", "README.md", "CHANGELOG.md", "LICENSE*"]

[dependencies]
itertools = { version = "0.14", default-features = false }
derive-where = "1.6.1"
itertools = { version = "0.15", default-features = false }

[dev-dependencies]
pretty_assertions = "1.4.1"
thiserror = "2"

[features]
default = ["std"]
std = ["itertools/use_std"]

std = ["alloc", "itertools/use_std"]
alloc = []

[[example]]
name = "with_context"
required-features = ["std"]

[[example]]
name = "many_errors"
required-features = ["std"]
92 changes: 80 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ 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.

## Tree format
## Chain format

Prefer a multi-line view? Swap the format strategy:
Prefer a multi-line indented view of the source chain? Swap the format strategy:

```rust,no_run
use errortools::{MainResult, Tree};
use errortools::{Chain, MainResult};
use std::{fs, io};

#[derive(Debug, thiserror::Error)]
Expand All @@ -64,15 +64,15 @@ enum AppError {
Config(#[source] io::Error),
}

fn main() -> MainResult<AppError, Tree> {
fn main() -> MainResult<AppError, Chain> {
let _ = fs::read_to_string("missing.toml").map_err(AppError::Config)?;
Ok(())
}
```

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

## Adding context
Expand Down Expand Up @@ -158,13 +158,13 @@ if let Err(e) = do_thing() {
For ad-hoc strategies, pick the format inline with `formatted::<F>()`:

```rust,ignore
use errortools::{FormatError, Tree};
use errortools::{Chain, FormatError};

if let Err(e) = do_thing() {
eprintln!("{}", e.formatted::<Tree>());
eprintln!("{}", e.formatted::<Chain>());
// outer
// └─ middle
// └── inner
// └─ middle
// ─ inner
}
```

Expand All @@ -189,7 +189,7 @@ println!("{}", my_error.formatted::<Arrow>()); // outer -> middle -> inner

## Combining strategies

`Add<L, R>` glues two `Format` strategies together. Both run against the same value, left then right. There's no built-in separator, drop a separator strategy (`NewLine`, `Space`, `Colon`, `ColonSpace`, `Empty`) in between, or reach for the three-arg `WithSep<L, Sep, R>` alias when you'd otherwise nest:
`Add<L, R>` glues two `Format` strategies together. Both run against the same value, left then right. There's no built-in separator, drop a separator strategy (`NewLine`, `Space`, `ColonChar`, `ColonSpace`, `Empty`) in between, or reach for the three-arg `WithSep<L, Sep, R>` alias when you'd otherwise nest:

```rust,ignore
use errortools::{Formatted, OneLine, Suggestion, separator::{NewLine, WithSep}};
Expand Down Expand Up @@ -262,6 +262,70 @@ Only the top-level error's hint is printed, the source chain isn't walked. This

The idea is that every error that is supposed to have a suggestion should implement `Suggest` and then later the top-level error's suggestion may concatenate the inner hint if it's relevant with nesting matching the error chain.

## 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.

```rust,ignore
use errortools::ManyErrors;

let mut errs = ManyErrors::new();
errs.push("eu-west-1", RegionError::Refused);
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`.

Group related failures with `push_group` and the shapes nest. `tree()` gives the Unicode tree, walking each error's source chain:

```text
2 errors:
├─ us-east-1 (2 errors):
│ ├─ i-0a1: connection refused
│ └─ i-0b2: connection refused
└─ 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:

```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:

```rust,ignore
println!("{}", errs.tree()); // Unicode tree (above)
println!("{}", errs.list()); // numbered outline
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.

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.

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.

A few more things it does:

```rust,ignore
// Build trees iteratively: push or collect whole nodes, groups included.
errs.push_node(Subgroup::new("us-east-1", regional));
let errs: ManyErrors<&str, io::Error> = nodes.into_iter().collect();

// Children are errors themselves: iterate and log, or stick one in #[source].
for node in &errs {
tracing::warn!("{}", node.one_line());
}
```

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

`MainResult<E, F>` is a type alias:
Expand All @@ -281,16 +345,20 @@ Runnable examples in [`examples/`](https://github.com/maxwase/errortools/tree/ma
| Example | What it shows |
|---|---|
| [`one_line`](https://github.com/maxwase/errortools/blob/master/examples/one_line.rs) | `MainResult` with default `OneLine` format |
| [`tree`](https://github.com/maxwase/errortools/blob/master/examples/tree.rs) | `MainResult<E, Tree>` for indented multi-line output |
| [`chain`](https://github.com/maxwase/errortools/blob/master/examples/chain.rs) | `MainResult<E, Chain>` for indented multi-line output |
| [`format_error`](https://github.com/maxwase/errortools/blob/master/examples/format_error.rs) | `FormatError` trait for ad-hoc formatting |
| [`custom_format`](https://github.com/maxwase/errortools/blob/master/examples/custom_format.rs) | A custom `Format` strategy |
| [`transparent`](https://github.com/maxwase/errortools/blob/master/examples/transparent.rs) | `#[error(transparent)]` pass-through with `#[from]` |
| [`with_context`](https://github.com/maxwase/errortools/blob/master/examples/with_context.rs) | `WithContext` tags an inner error with a context value, lifted via `#[from]` |
| [`many_errors`](https://github.com/maxwase/errortools/blob/master/examples/many_errors.rs) | `ManyErrors` collects nested, context-tagged failures and renders them as a tree |

Run with: `cargo run --example <name>`.

## Features

| Feature | Default | Effect |
|---|---|---|
| `std` | yes | Enables `itertools/use_std`. Disable for `no_std`. |
| `std` | yes | Path-aware strategies (`PathColon`, `DisplayPath`). Implies `alloc`. |
| `alloc` | via `std` | `ManyErrors` and the aggregate shapes (`Tree`, `List`, `Bullets`, `Joined`). |

Without either, the per-error core still works: `MainResult`, `OneLine`, `Chain`, `WithContext`, `Suggestion`, `Add`.
12 changes: 6 additions & 6 deletions examples/tree.rs → examples/chain.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
//! `MainResult` with the [`Tree`] format.
//! `MainResult` with the [`Chain`] format (per-error source-chain ladder).
//!
//! Run: `cargo run --example tree`
//! Run: `cargo run --example chain`
//!
//! Output:
//!
//! ```text
//! Error: failed to load config
//! └─ failed to read file
//! └── No such file or directory (os error 2)
//! └─ failed to read file
//! ─ No such file or directory (os error 2)
//! ```

use std::{fs, io};

use errortools::{MainResult, Tree};
use errortools::{Chain, MainResult};

#[derive(Debug, thiserror::Error)]
enum AppError {
Expand All @@ -26,7 +26,7 @@ enum ConfigError {
Read(#[source] io::Error),
}

fn main() -> MainResult<AppError, Tree> {
fn main() -> MainResult<AppError, Chain> {
fs::read_to_string("does-not-exist.toml")
.map_err(ConfigError::Read)
.map_err(AppError::Config)?;
Expand Down
2 changes: 1 addition & 1 deletion examples/format_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ fn main() {

println!("one line: {}", err.one_line());
println!();
println!("tree:\n{}", err.tree());
println!("chain:\n{}", err.chain());

let dyn_err: &dyn core::error::Error = &err;
println!();
Expand Down
66 changes: 66 additions & 0 deletions examples/many_errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//! Demonstrates `ManyErrors` with nested groups and multiple rendering shapes.
//!
//! Run: `cargo run --example many_errors`

use std::io;

use errortools::many_errors::Ascii;
use errortools::{Formatted, ManyErrors, many_errors::Tree};

#[derive(Debug, thiserror::Error)]
enum DeployError {
#[error("deploy failed")]
Failed(#[source] ManyErrors<&'static str, RegionError>),
}

#[derive(Debug, thiserror::Error)]
enum RegionError {
#[error("connection refused")]
Refused,
#[error("timed out")]
Timeout(#[source] io::Error),
}

fn main() {
// Build nested ManyErrors: two regions, one with two sub-errors.
let mut east = ManyErrors::new();
east.push("i-0a1", RegionError::Refused);
east.push(
"i-0b2",
RegionError::Timeout(io::Error::other("network partition")),
);

let mut all: ManyErrors<&str, RegionError> = ManyErrors::new();
all.push_group("us-east-1", east);
all.push("eu-west-1", RegionError::Refused);

// Default Display = shallow single-line summary (own text only, no source chains)
println!("=== Default (Summary / one-line) ===");
println!("{all}");

println!();
println!("=== Tree (Unicode) ===");
println!("{}", all.tree());

println!();
println!("=== List ===");
println!("{}", all.list());

println!();
println!("=== Bullets ===");
println!("{}", all.bullets());

println!();
println!("=== Joined (deep one-line) ===");
println!("{}", all.joined());

println!();
println!("=== ASCII connectors, no header ===");
println!("{}", Formatted::<_, Tree<Ascii, false>>::new(&all));

// Show it as a source in a top-level error
let err = DeployError::Failed(all);
println!();
println!("=== As thiserror source ===");
println!("{err}");
}
Loading