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
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.3.0"
version = "0.3.1"
edition = "2024"
description = "Quality of life utilities for error handling in Rust."
repository = "https://github.com/maxwase/errortools"
Expand Down
50 changes: 43 additions & 7 deletions plugins/errortools/skills/structured-error-handling/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,26 @@ 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.
**2.** Collapse single-variant enums to structs — but first check the struct
earns its existence. If its **only** job is to staple one incidental value (an
index, ID, key, attempt, path) onto a foreign error and no caller will `match`
on it, don't write a struct at all: use `WithContext` / `WithPath` (rule 7).

```rust
// BAD
pub enum Error { ReadFile(#[source] io::Error) }
// GOOD
pub struct Error(#[source] io::Error);

// ALSO BAD -- a struct whose only purpose is to carry the offending value
#[derive(thiserror::Error)]
#[error("Index {index} exceeds u16 range")]
pub struct Error { index: u32, #[source] source: TryFromIntError }
let id = u16::try_from(value).map_err(|source| Error { index: value, source })?;

// GOOD -- tag the value onto the source; renders "<value>: <error>"
pub type Error = errortools::WithContext<u32, TryFromIntError>;
let id = u16::try_from(value).map_err(|source| WithContext::new(value, source))?;
```

**3.** Use a tuple variant when wrapping a foreign error with no extra context.
Expand All @@ -53,8 +66,10 @@ pub struct Error(#[source] io::Error);
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!`.
**4.** Use a struct variant when extra context is needed **and a caller will
match on that context**. Put context in named fields, never inside the message
via `format!`. If the context is only ever rendered (never matched), don't add a
field — layer `WithContext` over the variant instead (rule 7).

```rust
// BAD
Expand Down Expand Up @@ -89,8 +104,12 @@ 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` →
retry count, a record ID, the offending value being converted), prefer
`WithContext` / `WithPath` over inventing a single-variant wrapper. The trigger
is **"will anyone branch on it?"**, never *where the value came from* — a value
produced inside the function (e.g. the element being converted in a loop) is
just as incidental as one passed in as a parameter. 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.

Expand Down Expand Up @@ -225,16 +244,33 @@ fail fast or collect per-item errors with `ManyErrors`. The canonical
for item in items { let _ = process(item); }
```

## Large source errors

**15.** Box large source errors to keep the variant size small.

```rust
// BAD -- variant is as large as the biggest source error
#[error("Render failed")]
Render(#[source] SomeLargeError),

// GOOD
#[error("Render failed")]
Render(#[source] Box<SomeLargeError>),
```

`Box<E>` implements `std::error::Error` when `E: Error`, so `#[source]` chains
through the box transparently and `OneLine` / `Chain` still walk the full chain.

## `anyhow` / `Box<dyn Error>`

**15.** Avoid `anyhow` or `Box<dyn Error>` in production code or library code. Callers cannot
**16.** Avoid `anyhow` or `Box<dyn Error>` 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
**17.** 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.

Expand Down
17 changes: 17 additions & 0 deletions plugins/errortools/skills/using-errortools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,23 @@ items.into_iter()

See `references/many-errors.md` for nesting with `push_group` and render options.

Two `ManyErrors` instances can be merged with `+` (the `Add` impl). This is
useful when parallel work produces independent error sets that need to be
reported together.

```rust
let errs = errs_a + errs_b;
errs.into_result(())?;
```

Merge of Results is also supported:

```rust
let result_a: Result<(), Error> = ...;
let result_b: Result<(), Error> = ...;
let merged: ManyErrors<_, _> = result_a + result_b;
```

## Choosing a format strategy

| Context | Strategy |
Expand Down
228 changes: 228 additions & 0 deletions src/many_errors/add.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
use crate::ManyErrors;

/// Merges all top-level nodes from `rhs` into `self`.
impl<C, E, GC, F, GF> core::ops::Add for ManyErrors<C, E, GC, F, GF> {
type Output = Self;

fn add(mut self, rhs: Self) -> Self {
self.extend(rhs);
self
}
}

/// Appends a single leaf `(context, error)`.
impl<C, E, GC, F, GF> core::ops::Add<(C, E)> for ManyErrors<C, E, GC, F, GF> {
type Output = Self;

fn add(mut self, (context, error): (C, E)) -> Self {
self.push(context, error);
self
}
}

/// Appends `error` if `result` is `Err`; leaves `self` unchanged on `Ok`.
impl<C, E, GC, F, GF, T> core::ops::Add<(C, Result<T, E>)> for ManyErrors<C, E, GC, F, GF> {
type Output = Self;

fn add(mut self, (context, result): (C, Result<T, E>)) -> Self {
if let Err(error) = result {
self.push(context, error);
}
self
}
}

/// Appends `item` if `option` is `Some`; leaves `self` unchanged on `None`.
///
/// `I` can be anything that `ManyErrors` already accepts via `Add`:
/// `(C, E)`, `(C, Result<T, E>)`, or another `ManyErrors`.
impl<C, E, GC, F, GF, I> core::ops::Add<Option<I>> for ManyErrors<C, E, GC, F, GF>
where
ManyErrors<C, E, GC, F, GF>: core::ops::Add<I, Output = ManyErrors<C, E, GC, F, GF>>,
{
type Output = Self;

fn add(self, option: Option<I>) -> Self {
match option {
Some(item) => self + item,
None => self,
}
}
}

impl<C, E, GC, F, GF> core::ops::AddAssign for ManyErrors<C, E, GC, F, GF> {
fn add_assign(&mut self, rhs: Self) {
self.extend(rhs);
}
}

impl<C, E, GC, F, GF> core::ops::AddAssign<(C, E)> for ManyErrors<C, E, GC, F, GF> {
fn add_assign(&mut self, (context, error): (C, E)) {
self.push(context, error);
}
}

impl<C, E, GC, F, GF, T> core::ops::AddAssign<(C, Result<T, E>)> for ManyErrors<C, E, GC, F, GF> {
fn add_assign(&mut self, (context, result): (C, Result<T, E>)) {
if let Err(error) = result {
self.push(context, error);
}
}
}

/// Appends `item` if `option` is `Some`; leaves `self` unchanged on `None`.
impl<C, E, GC, F, GF, I> core::ops::AddAssign<Option<I>> for ManyErrors<C, E, GC, F, GF>
where
Self: core::ops::AddAssign<I>,
{
fn add_assign(&mut self, option: Option<I>) {
if let Some(item) = option {
*self += item;
}
}
}

#[cfg(test)]
mod tests {
use crate::tests::Inner;

use super::*;

// --- Add ---

#[test]
fn test_add_many_errors_merges_nodes() {
let mut a = ManyErrors::<&str, Inner>::new();
a.push("a", Inner::A);
let mut b = ManyErrors::<&str, Inner>::new();
b.push("b", Inner::B);
b.push("c", Inner::A);
let merged = a + b;
assert_eq!(merged.len(), 3);
}

#[test]
fn test_add_many_errors_with_empty() {
let mut a = ManyErrors::<&str, Inner>::new();
a.push("a", Inner::A);
let merged = a + ManyErrors::new();
assert_eq!(merged.len(), 1);
}

#[test]
fn test_add_tuple_pushes_leaf() {
let errs = ManyErrors::<&str, Inner>::new() + ("ctx", Inner::A);
assert_eq!(errs.len(), 1);
}

#[test]
fn test_add_result_err_pushes() {
let errs = ManyErrors::<&str, Inner>::new() + ("ctx", Err::<(), _>(Inner::A));
assert_eq!(errs.len(), 1);
}

#[test]
fn test_add_result_ok_skips() {
let errs = ManyErrors::<&str, Inner>::new() + ("ctx", Ok::<(), _>(()));
assert!(errs.is_empty());
}

#[test]
fn test_add_result_chain() {
let results: alloc::vec::Vec<Result<(), Inner>> =
alloc::vec![Ok(()), Err(Inner::A), Ok(()), Err(Inner::B)];
let errs = results
.into_iter()
.enumerate()
.fold(ManyErrors::<usize, Inner>::new(), |acc, (i, r)| {
acc + (i, r)
});
assert_eq!(errs.len(), 2);
}

#[test]
fn test_add_option_tuple_some_pushes() {
let errs = ManyErrors::<&str, Inner>::new() + Some(("ctx", Inner::A));
assert_eq!(errs.len(), 1);
}

#[test]
fn test_add_option_tuple_none_skips() {
let errs = ManyErrors::<&str, Inner>::new() + None::<(&str, Inner)>;
assert!(errs.is_empty());
}

#[test]
fn test_add_option_result_some_err_pushes() {
let errs = ManyErrors::<&str, Inner>::new() + Some(("ctx", Err::<(), _>(Inner::A)));
assert_eq!(errs.len(), 1);
}

#[test]
fn test_add_option_result_some_ok_skips() {
let errs = ManyErrors::<&str, Inner>::new() + Some(("ctx", Ok::<(), Inner>(())));
assert!(errs.is_empty());
}

#[test]
fn test_add_option_many_errors_some_merges() {
let mut other = ManyErrors::<&str, Inner>::new();
other.push("b", Inner::B);
let errs = ManyErrors::<&str, Inner>::new() + Some(other);
assert_eq!(errs.len(), 1);
}

#[test]
fn test_add_option_many_errors_none_skips() {
let errs = ManyErrors::<&str, Inner>::new() + None::<ManyErrors<&str, Inner>>;
assert!(errs.is_empty());
}

// --- AddAssign ---

#[test]
fn test_add_assign_many_errors() {
let mut a = ManyErrors::<&str, Inner>::new();
a.push("a", Inner::A);
let mut b = ManyErrors::<&str, Inner>::new();
b.push("b", Inner::B);
b.push("c", Inner::A);
a += b;
assert_eq!(a.len(), 3);
}

#[test]
fn test_add_assign_tuple() {
let mut errs = ManyErrors::<&str, Inner>::new();
errs += ("ctx", Inner::A);
assert_eq!(errs.len(), 1);
}

#[test]
fn test_add_assign_result_err() {
let mut errs = ManyErrors::<&str, Inner>::new();
errs += ("ctx", Err::<(), _>(Inner::A));
assert_eq!(errs.len(), 1);
}

#[test]
fn test_add_assign_result_ok() {
let mut errs = ManyErrors::<&str, Inner>::new();
errs += ("ctx", Ok::<(), Inner>(()));
assert!(errs.is_empty());
}

#[test]
fn test_add_assign_option_some_pushes() {
let mut errs = ManyErrors::<&str, Inner>::new();
errs += Some(("ctx", Inner::A));
assert_eq!(errs.len(), 1);
}

#[test]
fn test_add_assign_option_none_skips() {
let mut errs = ManyErrors::<&str, Inner>::new();
errs += None::<(&str, Inner)>;
assert!(errs.is_empty());
}
}
Loading