From c5cd4ce6c6a54de295e8398ef59095db1d66e864 Mon Sep 17 00:00:00 2001 From: maxwase Date: Sat, 16 May 2026 22:25:01 +0300 Subject: [PATCH 1/2] Add WithContext --- src/with_context.rs | 279 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 src/with_context.rs diff --git a/src/with_context.rs b/src/with_context.rs new file mode 100644 index 0000000..e435661 --- /dev/null +++ b/src/with_context.rs @@ -0,0 +1,279 @@ +//! Context-tagged error pair. + +use core::{ + error::Error, + fmt::{self, Debug, Display, Formatter}, + marker::PhantomData, +}; + +pub use crate::with_context::format::{Colon, ContextFormat, WithContextColon}; + +/// A context value paired with an error, rendered through a static +/// [`ContextFormat`] strategy. +/// +/// `Display` delegates to `F`. [`Error::source`] returns the inner error's +/// source (skipping `error` itself, since the strategy already prints it), so +/// chain-walking strategies don't duplicate it. +/// +/// `F` is a type-level tag (never instantiated). The `fn() -> F` inside +/// [`PhantomData`] avoids drop-check ownership of `F` and keeps the wrapper +/// `Send + Sync` regardless of `F`. +/// +/// # Example +/// ``` +/// use errortools::{FormatError, WithContextColon}; +/// use std::io; +/// +/// let err = io::Error::new(io::ErrorKind::NotFound, "file missing"); +/// let ctx = WithContextColon::new("path/to/config", err); +/// assert_eq!(ctx.one_line().to_string(), "path/to/config: file missing"); +/// ``` +/// +/// # Custom strategy +/// ``` +/// use core::fmt::{self, Display, Formatter}; +/// use errortools::{ContextFormat, WithContext}; +/// +/// struct Arrow; +/// impl ContextFormat for Arrow { +/// fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { +/// write!(f, "{c} -> {e}") +/// } +/// } +/// +/// let w = WithContext::<_, _, Arrow>::new("step", "boom"); +/// assert_eq!(w.to_string(), "step -> boom"); +/// ``` +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct WithContext { + /// The context value tagging this error (e.g. a file path or step number). + pub context: C, + /// The underlying error. + pub error: E, + + _format: PhantomData F>, +} + +impl WithContext { + /// Creates a new [`WithContext`] pairing `context` with `error`. + /// + /// Use [`WithContextColon`] for the default `Colon` strategy and type inference on `new` without a turbofish. + pub const fn new(context: C, error: E) -> Self { + Self { + context, + error, + _format: PhantomData, + } + } + + /// Switches the formatting strategy without touching the stored values. + pub fn with_format(self) -> WithContext { + WithContext { + context: self.context, + error: self.error, + _format: PhantomData, + } + } +} + +impl From<(C, E)> for WithContext { + fn from((context, error): (C, E)) -> Self { + Self::new(context, error) + } +} + +/// Renders the pair via the strategy `F`. +impl Display for WithContext { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + F::fmt(&self.context, &self.error, f) + } +} + +/// Forwards to the fields' `Debug` rather than printing the `PhantomData` tag. +impl Debug for WithContext { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("WithContext") + .field("context", &self.context) + .field("error", &self.error) + .finish() + } +} + +impl Error for WithContext +where + C: Display + Debug, + E: Error + 'static, + F: ContextFormat, +{ + /// Returns the inner error's source, skipping the inner error itself + /// (already shown via [`Display`]) so chain-walking strategies don't + /// duplicate it. + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.error.source() + } +} + +mod format { + //! Formatting strategies for [`WithContext`]. + + use core::fmt::{self, Display, Formatter}; + + use super::WithContext; + + /// Convenience alias for [`WithContext`] with the default [`Colon`] strategy. + /// + /// Use this when you don't need a custom format and want type inference on + /// [`WithContext::new`] to work with a default strategy without a turbofish. + pub type WithContextColon = WithContext; + + /// A static strategy for combining a context value and an error into a single + /// [`Display`] line. + /// + /// Implement on a unit type to plug a custom rendering into + /// [`WithContext`]. The error's source chain is still walked by + /// [`FormatError`](crate::FormatError) strategies (`OneLine`, `Tree`, …); this + /// trait only controls how the `(context, error)` pair itself is printed. + pub trait ContextFormat { + /// Writes `context` and `error` to `f` using the strategy. + fn fmt( + context: &C, + error: &E, + f: &mut Formatter<'_>, + ) -> fmt::Result; + } + + /// Default [`ContextFormat`]: writes `"{context}: {error}"`. + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct Colon; + + impl ContextFormat for Colon { + fn fmt( + context: &C, + error: &E, + f: &mut Formatter<'_>, + ) -> fmt::Result { + write!(f, "{context}: {error}") + } + } +} + +#[cfg(test)] +mod tests { + use std::{error::Error as _, io}; + + use thiserror::Error; + + use super::*; + use crate::FormatError; + + #[derive(Error, Debug)] + #[error("leaf error")] + struct Leaf; + + #[derive(Error, Debug)] + #[error("middle")] + struct Middle(#[source] Leaf); + + struct Bracketed; + impl ContextFormat for Bracketed { + fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "[{c}] {e}") + } + } + + /// Caller-facing error in this test module. The `#[from]` impl is what + /// drives `F = Bracketed` inference at the `?` site in [`returning_error`]. + #[derive(Error, Debug)] + #[error("an error happened")] + pub struct Error(#[from] WithContext<&'static str, Middle, Bracketed>); + + fn returning_middle() -> Result<(), Middle> { + Err(Middle(Leaf)) + } + + /// Realistic use: a function tags an inner error with context via + /// `map_err`, then `?` routes it through `#[from]` into the caller's + /// error type. + /// Most importantly, `F` is inferred from the `From` impl on `Error`. + fn returning_error() -> Result<(), Error> { + returning_middle().map_err(|e| WithContext::new("context", e))?; + Ok(()) + } + + #[test] + fn test_new_and_fields() { + let w = format::WithContextColon::new("ctx", Leaf); + assert_eq!(w.context, "ctx"); + } + + #[test] + fn test_from_tuple() { + let w: format::WithContextColon<&str, Leaf> = ("ctx", Leaf).into(); + assert_eq!(w.context, "ctx"); + } + + #[test] + fn test_display_default_format() { + let w = format::WithContextColon::new("step 3", Leaf); + assert_eq!(w.to_string(), "step 3: leaf error"); + } + + #[test] + fn test_source_skips_inner_error() { + // Leaf has no source, so skipping it yields None. + let w = format::WithContextColon::new("ctx", Leaf); + assert!(w.source().is_none()); + + // For Middle(Leaf), source must be Leaf — not Middle (which Display already shows). + let w = format::WithContextColon::new("ctx", Middle(Leaf)); + let src = w.source().expect("source must be Some"); + assert_eq!(src.to_string(), "leaf error"); + } + + #[test] + fn test_one_line_walks_full_chain() { + let w = format::WithContextColon::new("ctx", Middle(Leaf)); + assert_eq!(w.one_line().to_string(), "ctx: middle: leaf error"); + } + + #[test] + fn test_io_error_chain() { + let io = io::Error::new(io::ErrorKind::NotFound, "file missing"); + let w = format::WithContextColon::new("config", io); + assert_eq!(w.one_line().to_string(), "config: file missing"); + } + + #[test] + fn test_custom_format_strategy() { + struct Arrow; + impl ContextFormat for Arrow { + fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{c} -> {e}") + } + } + + let w = WithContext::<_, _, Arrow>::new("step", Leaf); + assert_eq!(w.to_string(), "step -> leaf error"); + } + + #[test] + fn test_custom_format_affects_one_line() { + let w = WithContext::<_, _, Bracketed>::new("ctx", Middle(Leaf)); + // Display: "[ctx] middle" — then chain appends ": leaf error" via source. + assert_eq!(w.one_line().to_string(), "[ctx] middle: leaf error"); + } + + /// End-to-end: `map_err` wraps an inner error with [`WithContext`], `?` + /// fires `From> for Error` (and pins `F`), + /// and the full chain comes out via [`FormatError::one_line`] without any + /// duplication thanks to `source` skipping the inner error. + #[test] + fn test_propagation_via_question_mark() { + let err = returning_error().expect_err("returning_error must error"); + assert_eq!(err.to_string(), "an error happened"); + assert_eq!( + err.one_line().to_string(), + "an error happened: [context] middle: leaf error", + ); + } +} From a365f8e13bb1a2f640bd10c65d1c38426fd66f73 Mon Sep 17 00:00:00 2001 From: maxwase Date: Sun, 17 May 2026 00:16:40 +0300 Subject: [PATCH 2/2] WithPath + PathDisplay --- Cargo.toml | 4 ++ README.md | 65 ++++++++++++++++++++++- examples/with_context.rs | 88 ++++++++++++++++++++++++++++++ src/lib.rs | 12 ++++- src/path_display.rs | 33 ++++++++++++ src/with_context.rs | 112 ++++++++++++++++++++++++++------------- 6 files changed, 275 insertions(+), 39 deletions(-) create mode 100644 examples/with_context.rs create mode 100644 src/path_display.rs diff --git a/Cargo.toml b/Cargo.toml index 81b6a0f..bd5e78d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,7 @@ thiserror = "2" [features] default = ["std"] std = ["itertools/use_std"] + +[[example]] +name = "with_context" +required-features = ["std"] diff --git a/README.md b/README.md index 190e391..cc96eed 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` 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 { + 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> { + 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` inside a `WithPath<&Path, WithContext>` and the chain prints `: : `. +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` (re-exported as +`errortools::with_context::ContextFormat`) on a unit type and plug it in +with `WithContext`. The bounds are yours: `Colon` asks for +`Display`, `PathColon` asks for `AsRef`, 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 @@ -131,7 +191,7 @@ use errortools::{DisplaySwapDebug, Formatted, OneLine}; pub type MainResult = Result>>; ``` -`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 @@ -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 `. diff --git a/examples/with_context.rs b/examples/with_context.rs new file mode 100644 index 0000000..734ce54 --- /dev/null +++ b/examples/with_context.rs @@ -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` +//! from the retry loop carries the attempt number — so the chain reports +//! `": : "`. +//! - [`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>), + #[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`. The default `Colon` strategy renders that +/// as `": "` when it shows up in the chain. +fn create_with_retry( + path: &Path, + attempts: NonZeroUsize, +) -> Result> { + 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 { + // 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(()) +} diff --git a/src/lib.rs b/src/lib.rs index f4dd328..8deada5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,8 @@ -#![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)] @@ -6,11 +10,17 @@ 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. /// diff --git a/src/path_display.rs b/src/path_display.rs new file mode 100644 index 0000000..58b9dc2 --- /dev/null +++ b/src/path_display.rs @@ -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`](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); + +impl> fmt::Display for DisplayPath

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

{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.as_ref().fmt(f) + } +} + +impl From for DisplayPath { + fn from(value: T) -> Self { + Self(value) + } +} diff --git a/src/with_context.rs b/src/with_context.rs index e435661..a294b98 100644 --- a/src/with_context.rs +++ b/src/with_context.rs @@ -7,6 +7,12 @@ use core::{ }; pub use crate::with_context::format::{Colon, ContextFormat, WithContextColon}; +#[cfg(feature = "std")] +pub use crate::with_context::format::{PathColon, WithContextPathColon}; + +/// Convenience alias for [`WithContext`] with the default [`PathColon`] strategy. +#[cfg(feature = "std")] +pub type WithPath = WithContext; /// A context value paired with an error, rendered through a static /// [`ContextFormat`] strategy. @@ -15,13 +21,9 @@ pub use crate::with_context::format::{Colon, ContextFormat, WithContextColon}; /// source (skipping `error` itself, since the strategy already prints it), so /// chain-walking strategies don't duplicate it. /// -/// `F` is a type-level tag (never instantiated). The `fn() -> F` inside -/// [`PhantomData`] avoids drop-check ownership of `F` and keeps the wrapper -/// `Send + Sync` regardless of `F`. -/// /// # Example /// ``` -/// use errortools::{FormatError, WithContextColon}; +/// use errortools::{FormatError, with_context::WithContextColon}; /// use std::io; /// /// let err = io::Error::new(io::ErrorKind::NotFound, "file missing"); @@ -32,17 +34,17 @@ pub use crate::with_context::format::{Colon, ContextFormat, WithContextColon}; /// # Custom strategy /// ``` /// use core::fmt::{self, Display, Formatter}; -/// use errortools::{ContextFormat, WithContext}; +/// use errortools::{WithContext, with_context::ContextFormat}; /// /// struct Arrow; -/// impl ContextFormat for Arrow { -/// fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { +/// impl ContextFormat for Arrow { +/// fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { /// write!(f, "{c} -> {e}") /// } /// } /// -/// let w = WithContext::<_, _, Arrow>::new("step", "boom"); -/// assert_eq!(w.to_string(), "step -> boom"); +/// let w = WithContext::<_, _, Arrow>::new(1, "boom"); +/// assert_eq!(w.to_string(), "1 -> boom"); /// ``` #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct WithContext { @@ -67,7 +69,7 @@ impl WithContext { } /// Switches the formatting strategy without touching the stored values. - pub fn with_format(self) -> WithContext { + pub fn with_format>(self) -> WithContext { WithContext { context: self.context, error: self.error, @@ -82,8 +84,9 @@ impl From<(C, E)> for WithContext { } } -/// Renders the pair via the strategy `F`. -impl Display for WithContext { +/// Renders the pair via the strategy `F`. `C` and `E` have no `Display` bound +/// here — the strategy decides what each must implement. +impl> Display for WithContext { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { F::fmt(&self.context, &self.error, f) } @@ -101,9 +104,9 @@ impl Debug for WithContext { impl Error for WithContext where - C: Display + Debug, + C: Debug, E: Error + 'static, - F: ContextFormat, + F: ContextFormat, { /// Returns the inner error's source, skipping the inner error itself /// (already shown via [`Display`]) so chain-walking strategies don't @@ -117,6 +120,8 @@ mod format { //! Formatting strategies for [`WithContext`]. use core::fmt::{self, Display, Formatter}; + #[cfg(feature = "std")] + use std::path::Path; use super::WithContext; @@ -126,35 +131,53 @@ mod format { /// [`WithContext::new`] to work with a default strategy without a turbofish. pub type WithContextColon = WithContext; + /// Convenience alias for [`WithContext`] with the [`PathColon`] strategy. + /// Use this when your context is a path and you want it rendered via `Path::display` + /// without needing to wrap it in [`DisplayPath`](crate::DisplayPath) or another newtype. + #[cfg(feature = "std")] + pub type WithContextPathColon = WithContext; + /// A static strategy for combining a context value and an error into a single /// [`Display`] line. /// - /// Implement on a unit type to plug a custom rendering into - /// [`WithContext`]. The error's source chain is still walked by - /// [`FormatError`](crate::FormatError) strategies (`OneLine`, `Tree`, …); this - /// trait only controls how the `(context, error)` pair itself is printed. - pub trait ContextFormat { + /// The trait is parameterized over `C` and `E` so each strategy can declare + /// its own bounds: [`Colon`] requires `Display` on both, [`PathColon`] + /// requires `AsRef` on the context, and custom strategies can require + /// whatever they need. The error's source chain is still walked by + /// [`FormatError`](crate::FormatError) strategies (`OneLine`, `Tree`, …); + /// this trait only controls how the `(context, error)` pair itself is printed. + pub trait ContextFormat { /// Writes `context` and `error` to `f` using the strategy. - fn fmt( - context: &C, - error: &E, - f: &mut Formatter<'_>, - ) -> fmt::Result; + fn fmt(context: &C, error: &E, f: &mut Formatter<'_>) -> fmt::Result; } - /// Default [`ContextFormat`]: writes `"{context}: {error}"`. + /// Default [`ContextFormat`]: writes `"{context}: {error}"` for any pair of + /// `Display` values. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Colon; - impl ContextFormat for Colon { - fn fmt( - context: &C, - error: &E, - f: &mut Formatter<'_>, - ) -> fmt::Result { + impl ContextFormat for Colon { + fn fmt(context: &C, error: &E, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{context}: {error}") } } + + /// Path-aware [`ContextFormat`]: writes `"{path}: {error}"` where `path` is + /// rendered via [`Path::display`]. + /// + /// `Path` and `PathBuf` don't implement [`Display`] (paths may not be valid + /// UTF-8), so [`Colon`] won't accept them. `PathColon` plugs that gap + /// without needing a wrapper newtype around the context value. + #[cfg(feature = "std")] + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct PathColon; + + #[cfg(feature = "std")] + impl + ?Sized, E: Display + ?Sized> ContextFormat for PathColon { + fn fmt(path: &P, error: &E, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}: {error}", path.as_ref().display()) + } + } } #[cfg(test)] @@ -175,8 +198,8 @@ mod tests { struct Middle(#[source] Leaf); struct Bracketed; - impl ContextFormat for Bracketed { - fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { + impl ContextFormat for Bracketed { + fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "[{c}] {e}") } } @@ -246,8 +269,8 @@ mod tests { #[test] fn test_custom_format_strategy() { struct Arrow; - impl ContextFormat for Arrow { - fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { + impl ContextFormat for Arrow { + fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{c} -> {e}") } } @@ -276,4 +299,21 @@ mod tests { "an error happened: [context] middle: leaf error", ); } + + /// `PathColon` formats path contexts directly, without a wrapper newtype. + #[cfg(feature = "std")] + #[test] + fn test_path_colon_strategy() { + use std::path::{Path, PathBuf}; + + let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing"); + let w = WithContext::<_, _, PathColon>::new(PathBuf::from("a/b/c.txt"), io_err); + assert_eq!(w.to_string(), "a/b/c.txt: file missing"); + + // Works for borrowed paths too. + let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing"); + let path: &Path = Path::new("a/b/c.txt"); + let w = WithContext::<_, _, PathColon>::new(path, io_err); + assert_eq!(w.to_string(), "a/b/c.txt: file missing"); + } }