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
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ thiserror = "2"
[features]
default = ["std"]
std = ["itertools/use_std"]

[[example]]
name = "with_context"
required-features = ["std"]
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Output:
Error: failed to load config: No such file or directory (os error 2)
```

The error and its full source chain are joined with `": "` — no boilerplate, no `run()` wrapper, no manual loop.
The error and its full source chain print joined with `": "`. No `run()` wrapper, no manual loop.

## Tree format

Expand All @@ -75,6 +75,66 @@ Error: failed to load config
└── No such file or directory (os error 2)
```

## Adding context

Ever needed to wrap `io::Error` just to attach a path? Or keep a retry attempt around? That's what `WithContext<C, E>` is for. No more ad-hoc single-variant wrappers that mess up error chains. `WithContext` holds a context value next to an error. The pair displays through whatever strategy you pick: `Colon` by default, `PathColon` if the context is a path. `FormatError` skips the wrapped error itself when it walks the chain, so it never shows up twice.

`PathColon` calls `Path::display` for you, so `&Path` and `PathBuf` go
straight in. The `WithPath` alias names the type:

```rust,no_run
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<Error> {
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)
```

Retry attempt numbers fit too. The default `Colon` strategy takes any
`Display` pair, and `usize` is `Display`:

```rust,ignore
fn create_with_retry(
path: &Path,
attempts: NonZeroUsize,
) -> Result<File, WithContext<usize, io::Error>> {
let last = attempts.get();
for _ in 1..last {
if let Ok(f) = File::create(path) { return Ok(f); }
}
File::create(path).map_err(|e| WithContext::new(last, e))
}
```

You can nest the two: wrap a `WithContext<usize, io::Error>` inside a `WithPath<&Path, WithContext<usize, io::Error>>` and the chain prints `<path>: <attempt>: <io error>`.
The [`with_context`](https://github.com/maxwase/errortools/blob/master/examples/with_context.rs) example shows that through `MainResult` end-to-end.

Need a different look? Implement `ContextFormat<C, E>` (re-exported as
`errortools::with_context::ContextFormat`) on a unit type and plug it in
with `WithContext<C, E, MyFmt>`. The bounds are yours: `Colon` asks for
`Display`, `PathColon` asks for `AsRef<Path>`, you ask for whatever you
need.

## But why?

Countless hours of debugging with unordered error and debug logs that *may* mention the needed context (such as a path), simply because it felt like too much effort to write a wrapper type just to add it.

### My strong point

**It must be possible to pinpoint the exact location of an error from a single, perhaps rather long but informative, error message.**


## Logging in place

Sometimes you cannot return and need to log the full source chain right where
Expand Down Expand Up @@ -131,7 +191,7 @@ use errortools::{DisplaySwapDebug, Formatted, OneLine};
pub type MainResult<E, F = OneLine, T = ()> = Result<T, DisplaySwapDebug<Formatted<E, F>>>;
```

`DisplaySwapDebug` swaps the `Debug` and `Display` impls of its inner type, so when `main` prints the error via `Debug`, you actually get its `Display` output formatted by the chosen strategy. `?` converts your error automatically via the blanket `From` impl.
`DisplaySwapDebug` swaps the `Debug` and `Display` impls of its inner type. When `main` prints the error via `Debug`, it ends up reaching the `Display` output instead, formatted by the chosen strategy. `?` converts your error automatically via the blanket `From` impl.

## Examples

Expand All @@ -144,6 +204,7 @@ Runnable examples in [`examples/`](https://github.com/maxwase/errortools/tree/ma
| [`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]` |

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

Expand Down
88 changes: 88 additions & 0 deletions examples/with_context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! Tagging file-operation errors with context via [`WithContext`].
//!
//! Errors are handled by two enums:
//!
//! - [`FsError`] distinguishes which step failed (create vs. write). The
//! `Create` variant nests two [`WithContext`] layers — the outer
//! [`WithPath`] tags the path, and an inner `WithContext<usize, io::Error>`
//! from the retry loop carries the attempt number — so the chain reports
//! `"<path>: <attempt>: <io error>"`.
//! - [`AppError`] is the top-level error in `main`. It routes [`FsError`]
//! into `MainResult`.
//!
//! Run: `cargo run --example with_context`
//!
//! Output (the exact io message is platform-dependent):
//!
//! ```text
//! Error: An FS error happened: Failed to create file: no/such/dir/output.txt: 3: No such file or directory (os error 2)
//! ```

use std::{
fs::File,
io::{self, Write},
num::NonZeroUsize,
path::{Path, PathBuf},
};

use errortools::{MainResult, WithContext, with_context::WithPath};

/// How many times `create_with_retry` will attempt `File::create` before
/// surfacing the last error.
const RETRY_ATTEMPTS: NonZeroUsize = NonZeroUsize::new(3).unwrap();

#[derive(Debug, thiserror::Error)]
enum FsError {
// Chain single contextualized errors with `WithContext` inline!
#[error("Failed to create file")]
Create(#[source] WithPath<PathBuf, WithContext<usize, io::Error>>),
#[error("Failed to write file")]
Write(#[source] WithPath<&'static Path, io::Error>),
}

#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("An FS error happened")]
Fs(#[source] FsError),
}

/// Retries `File::create` up to `attempts` times. On exhaustion, returns the
/// final attempt's error tagged with its attempt number via
/// `WithContext<usize, io::Error>`. The default `Colon` strategy renders that
/// as `"<attempt>: <io error>"` when it shows up in the chain.
fn create_with_retry(
path: &Path,
attempts: NonZeroUsize,
) -> Result<File, WithContext<usize, io::Error>> {
let last = attempts.get();
// First `last - 1` attempts: silently retry on failure.
for _ in 1..last {
if let Ok(file) = File::create(path) {
return Ok(file);
}
}
// Final attempt: surface the error tagged with the attempt number.
File::create(path).map_err(|e| WithContext::new(last, e))
}

fn write_file(path: &'static Path, contents: &[u8]) -> Result<(), FsError> {
// Single map_err: the retry-tagged error gets wrapped with the path and
// routed into `FsError::Create` in one closure.
let mut file = create_with_retry(path, RETRY_ATTEMPTS)
.map_err(|e| FsError::Create(WithContext::new(path.to_path_buf(), e)))?;
// Double map_err: tag with path first, then lift into the enum variant.
file.write_all(contents)
.map_err(|e| WithContext::new(path, e))
.map_err(FsError::Write)?;
Ok(())
}

fn main() -> MainResult<AppError> {
// Parent directory doesn't exist, so every retry of `File::create` fails.
// `WithContext::new(attempt, io_err)` tags the final attempt; the outer
// `WithContext::new(path, ...)` wraps that with the path; `?` routes the
// result through `FsError::Create` and `AppError::Fs` into `MainResult`.
write_file(Path::new("no/such/dir/output.txt"), b"hello, errortools\n")
.map_err(AppError::Fs)?;
Ok(())
}
12 changes: 11 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
#![doc = include_str!("../README.md")]
#![cfg_attr(feature = "std", doc = include_str!("../README.md"))]
#![cfg_attr(
not(feature = "std"),
doc = "Quality of life utilities for error handling in Rust."
)]
#![cfg_attr(not(any(feature = "std", test)), no_std)]
#![warn(missing_docs)]

use core::{error::Error, fmt, iter, marker::PhantomData};

mod main_result;
mod oneline;
#[cfg(feature = "std")]
pub mod path_display;
mod tree;
pub mod with_context;

pub use main_result::{DisplaySwapDebug, MainResult};
pub use oneline::OneLine;
#[cfg(feature = "std")]
pub use path_display::DisplayPath;
pub use tree::{Tree, TreeIndent, TreeMarker};
pub use with_context::WithContext;

/// A static strategy for formatting an error and its source chain.
///
Expand Down
33 changes: 33 additions & 0 deletions src/path_display.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Display-adapter wrapper for [`Path`]-like values.
//! This is an experimental helper module. Prefer defining printing strategies
//! that call `Path::display` directly, e.g. via [`ContextFormat<C, E>`](crate::with_context::ContextFormat) for [`WithContext`](crate::WithContext).
//! See [`WithPath`](crate::with_context::WithPath) to get the idea.

use core::fmt;
use std::path::Path;

/// Wrapper that gives a [`Path`]-like value a [`fmt::Display`] impl (via
/// [`Path::display`]) so it can be used in contexts that require `Display`,
/// e.g. as the context slot of [`WithContext`](crate::WithContext) under the
/// default [`Colon`](crate::with_context::Colon) strategy. Prefer
/// [`PathColon`](crate::with_context::PathColon) when you only need a path-aware strategy.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct DisplayPath<T>(T);

impl<P: AsRef<Path>> fmt::Display for DisplayPath<P> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.as_ref().display().fmt(f)
}
}

impl<P: AsRef<Path>> fmt::Debug for DisplayPath<P> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.as_ref().fmt(f)
}
}

impl<T> From<T> for DisplayPath<T> {
fn from(value: T) -> Self {
Self(value)
}
}
Loading