Skip to content
Draft
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
23 changes: 23 additions & 0 deletions Cargo.lock

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

9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,19 @@ include = ["src/**/*.rs", "examples/**/*.rs", "README.md", "CHANGELOG.md", "LICE
itertools = { version = "0.14", 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"]
66 changes: 56 additions & 10 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 Down Expand Up @@ -262,6 +262,51 @@ 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::Timeout);

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: timed out: network partition
└─ 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: timed out); 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()); // 1. 1.1. 2.
println!("{}", errs.bullets()); // • bulleted
println!("{}", errs.joined()); // ;-separated one line, parens around groups
```

For full control — ASCII connectors, no count header — go through `formatted`: `Formatted::<_, Tree<Ascii, false>>::new(&errs)`.

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.

## How it works

`MainResult<E, F>` is a type alias:
Expand All @@ -281,11 +326,12 @@ 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 |
| [`tree`](https://github.com/maxwase/errortools/blob/master/examples/tree.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>`.

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}");
}
10 changes: 5 additions & 5 deletions examples/tree.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`
//!
//! 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
Loading