diff --git a/CHANGELOG.md b/CHANGELOG.md index 6503d80..b10d1dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `WithContext` — tag any error with a context value (path, attempt number, etc.). `Colon` is the default strategy; `PathColon` handles + `Path`/`PathBuf` directly without a newtype. `WithPath` aliases the path case. The wrapper's `Error::source` skips its inner error so chain walkers don't print it twice. +- `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` so `main` can return `ExitCode` or any `Termination` type. +- `Add` — 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, Suggestion>` without writing a new impl. Bounds compose automatically — using `Suggestion` in the chain still requires `E: Suggest`. +- `WithSep` alias — sugar for `Add, R>` so the separator reads in the middle. Plus pre-baked variants in `separator`: `WithSpace`, `WithNewLine`, `WithColonSpace`. +- `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` for a space-delimited pair. + +### Changed + +- **Breaking:** `Format` is now `Format` (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, 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` to `F: Format>`. + +### Removed + +- **Breaking:** `FormatOneLine` type alias. Use `Formatted`. +- **Breaking:** `with_context::ContextFormat` trait. Custom pair strategies now `impl Format> for MyFmt` and pull fields off `&WithContext` directly. The pre-composed `Colon` / `PathColon` aliases cover the previous defaults. + ## [0.1.0] - 2025-01-01 ### Added diff --git a/README.md b/README.md index cc96eed..8aba8eb 100644 --- a/README.md +++ b/README.md @@ -120,11 +120,17 @@ fn create_with_retry( 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. +Need a different look? `WithContext` formats through any `F: Format>`, +so there are two ways to customize it: + +1. **Compose** with the built-in field extractors and separators: + `type SpacePair = WithSpace;` swaps `": "` + for a single space. Same recipe for any delimiter you can write as a + `Format` tag. +2. **Write a one-shot impl** when the layout is unusual: + `impl Format> for MyFmt { ... }`. + You declare your own bounds — `Colon` asks for `Display`, `PathColon` asks + for `AsRef`, you ask for whatever you need. ## But why? @@ -164,7 +170,7 @@ if let Err(e) = do_thing() { ## Custom formats -Implement the `Format` trait on a unit type: +Implement the `Format` trait on a unit type. `E` is generic so your strategy can require extra bounds on the error type (e.g. `Suggest` for the suggestion strategy): ```rust,ignore use core::{error::Error, fmt}; @@ -172,15 +178,90 @@ use errortools::{Format, FormatError, chain}; use itertools::Itertools; struct Arrow; -impl Format for Arrow { - fn fmt(error: &dyn Error, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", chain(error).format(" -> ")) +impl Format for Arrow { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", chain(&error).format(" -> ")) } } println!("{}", my_error.formatted::()); // outer -> middle -> inner ``` +## Combining strategies + +`Add` 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` alias when you'd otherwise nest: + +```rust,ignore +use errortools::{Formatted, OneLine, Suggestion, separator::{NewLine, WithSep}}; + +// Same as Add, Suggestion>. Renders: +// "\n" +type Brief = WithSep; + +eprintln!("{}", Formatted::<_, Brief>::new(err)); +``` + +For the common separators there are zero-think aliases — `WithSpace`, +`WithNewLine`, `WithColonSpace` — all in `errortools::separator`: + +```rust,ignore +use errortools::{Formatted, OneLine, Suggestion, separator::WithNewLine}; + +type Brief = WithNewLine; +eprintln!("{}", Formatted::<_, Brief>::new(err)); +``` + +Bounds compose: `Add` only implements `Format` when +`E: Error + Suggest`, because `Suggestion`'s impl carries that bound. + +The same combinator powers the `WithContext` default — `Colon` is just a type +alias for `WithColonSpace`, where +`ContextField`/`ErrorField` are extractor strategies that read the pair's +fields. To get a different delimiter, swap one piece: + +```rust,ignore +use errortools::{WithContext, separator::WithSpace, with_context::{ContextField, ErrorField}}; + +type SpacePair = WithSpace; +let w = WithContext::<_, _, SpacePair>::new("step", "boom"); +assert_eq!(w.to_string(), "step boom"); +``` + +## Suggestions + +For "Did you mean…" hints, implement `Suggest` on your error type and call +`error.suggestion()`: + +```rust,ignore +use core::fmt; +use errortools::{FormatError, Suggest}; + +#[derive(Debug, thiserror::Error)] +enum Error { + #[error("Config file missing")] + NoConfig, + #[error("Network down")] + Network, +} + +impl Suggest for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoConfig => f.write_str("Did you copy config.example.toml to config.toml?"), + Self::Network => Ok(()), + } + } +} + +eprintln!("{}\n{}", Error::NoConfig.one_line(), Error::NoConfig.suggestion()); +// Config file missing +// Did you copy config.example.toml to config.toml? +``` + +Only the top-level error's hint is printed, the source chain isn't walked. This decision is intentional: The underlying hint may be irrelevant in the context of the top-level error, and printing it may just add noise. + +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. + ## How it works `MainResult` is a type alias: diff --git a/examples/custom_format.rs b/examples/custom_format.rs index 61474cf..8234149 100644 --- a/examples/custom_format.rs +++ b/examples/custom_format.rs @@ -12,9 +12,9 @@ use itertools::Itertools; struct Arrow; -impl Format for Arrow { - fn fmt(error: &dyn Error, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", chain(error).format(" -> ")) +impl Format for Arrow { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", chain(&error).format(" -> ")) } } diff --git a/src/add.rs b/src/add.rs new file mode 100644 index 0000000..944d68c --- /dev/null +++ b/src/add.rs @@ -0,0 +1,290 @@ +use core::{fmt, marker::PhantomData}; + +use crate::Format; + +/// Combines two [`Format`] strategies, rendering `L` then `R` against the same value. +/// +/// `Add` is a type-level combinator: both strategies are tag types, never +/// instantiated. The combined strategy implements [`Format`] when both +/// `L` and `R` do. Bounds compose automatically, so `Add` +/// requires `E: Suggest` because [`Suggestion`](crate::Suggestion) does. +/// +/// There is no built-in separator. Use [`NewLine`](separator::NewLine) or +/// [`Space`](separator::Space) (or any custom [`Format`] tag) as the middle term: +/// +/// ```text +/// Add, Suggestion> +/// ``` +/// +/// renders the one-line chain, a newline, then the top-level suggestion hint. +/// +/// `Add` writes both sides unconditionally — if `R` produces no output (e.g. +/// a [`Suggestion`](crate::Suggestion) variant without a hint), the separator +/// is still written. +#[derive(Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Add(PhantomData (L, R)>); + +/// Prints the inner strategy values (instantiated via [`Default`]) instead of +/// `Add(PhantomData)`. +impl fmt::Debug for Add { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Add") + .field(&L::default()) + .field(&R::default()) + .finish() + } +} + +impl Format for Add +where + E: ?Sized, + L: Format, + R: Format, +{ + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + L::fmt(error, f)?; + R::fmt(error, f) + } +} + +pub mod separator { + //! Separator strategies for [`Add`]. + //! + //! Each is a [`Format`] tag that ignores its input and writes a fixed + //! string. Because [`Format`] no longer requires `E: Error`, these + //! separators also compose with non-error formatters (e.g. the field + //! extractors used by [`WithContext`](crate::WithContext)). + //! + //! This may be worth extending and separating into its own crate in + //! future, but for now it just has a few simple built-in separators. + + use crate::{Add, Format}; + + use core::fmt; + + /// [`Format`] strategy that writes a single line feed and ignores the input. + /// + /// Designed as a separator term inside [`Add`], e.g. + /// `Add>`. + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct NewLine; + + impl Format for NewLine { + fn fmt(_: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("\n") + } + } + + /// [`Format`] strategy that writes a single space and ignores the input. + /// + /// Designed as a separator term inside [`Add`]. + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct Space; + + impl Format for Space { + fn fmt(_: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(" ") + } + } + + /// [`Format`] strategy that writes nothing. + /// + /// Useful as a no-op identity element when composing strategies with [`Add`]. + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct Empty; + + impl Format for Empty { + fn fmt(_: &E, _: &mut fmt::Formatter<'_>) -> fmt::Result { + Ok(()) + } + } + + /// [`Format`] strategy that writes a colon (`":"`) and ignores the input. + /// + /// Pair with [`Space`] via [`ColonSpace`] for the common `": "` separator. + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct Colon; + + impl Format for Colon { + fn fmt(_: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(":") + } + } + + /// Convenience alias for `Add` — writes `": "`. + pub type ColonSpace = Add; + + /// [`Add`] with an explicit separator slot: writes `L`, then `Sep`, then `R`. + /// + /// Equivalent to `Add, R>` — a thin convenience over manual + /// nesting. Pair with the separators in this module: + /// [`WithSpace`](WithSpace) is `WithSep`, + /// [`WithNewLine`](WithNewLine) is `WithSep`, + /// [`WithColonSpace`](WithColonSpace) is `WithSep`. + pub type WithSep = Add, R>; + + /// `Add` of `L` and `R` with a [`Space`] between — equivalent to + /// [`WithSep`](WithSep). + pub type WithSpace = WithSep; + + /// `Add` of `L` and `R` with a [`NewLine`] between — equivalent to + /// [`WithSep`](WithSep). + pub type WithNewLine = WithSep; + + /// `Add` of `L` and `R` with [`ColonSpace`] between — equivalent to + /// [`WithSep`](WithSep). + pub type WithColonSpace = WithSep; +} + +#[cfg(test)] +mod tests { + use core::error::Error; + + use thiserror::Error; + + use super::*; + use crate::{Formatted, OneLine, Suggest, Suggestion, Tree, tests::ErrorInner}; + use separator::*; + + #[derive(Error, Debug)] + enum SugError { + #[error("env file missing")] + NoEnv, + #[error("something else")] + Other, + } + + impl Suggest for SugError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoEnv => f.write_str("Did you mean rename the .env.example file to .env?"), + Self::Other => Ok(()), + } + } + } + + fn _assert_traits() { + fn assert_all< + T: Clone + Copy + Default + PartialEq + Eq + core::hash::Hash + Send + Sync, + >() { + } + assert_all::>(); + assert_all::, Suggestion>>(); + assert_all::(); + assert_all::(); + + fn assert_format>() {} + assert_format::>(); + assert_format::>(); + assert_format::, Suggestion>>(); + + // Confirm Error bound still gates the leaf strategy, just not the trait. + fn assert_oneline() + where + OneLine: Format, + { + } + assert_oneline::(); + } + + #[test] + fn test_one_line_plus_newline() { + let error = crate::tests::Error::Two(ErrorInner::One); + assert_eq!( + Formatted::<_, Add>::new(error).to_string(), + "Two: One\n" + ); + } + + #[test] + fn test_nested_oneline_newline_suggestion() { + let error = SugError::NoEnv; + assert_eq!( + Formatted::<_, Add, Suggestion>>::new(error).to_string(), + "env file missing\nDid you mean rename the .env.example file to .env?" + ); + } + + #[test] + fn test_empty_rhs_keeps_separator() { + let error = SugError::Other; + assert_eq!( + Formatted::<_, Add, Suggestion>>::new(error).to_string(), + "something else\n" + ); + } + + #[test] + fn test_right_associated_nesting() { + let error = crate::tests::Error::Two(ErrorInner::One); + assert_eq!( + Formatted::<_, Add>>::new(error).to_string(), + "Two: One\nTwo: One" + ); + } + + #[test] + fn test_space_between_repeats() { + let error = crate::tests::Error::One; + assert_eq!( + Formatted::<_, Add>>::new(error).to_string(), + "One One" + ); + } + + #[test] + fn test_debug_prints_inner() { + let add = Add::::default(); + assert_eq!(format!("{add:?}"), "Add(OneLine, NewLine)"); + } + + #[test] + fn test_colon_space_alias() { + // ColonSpace ignores the error and writes ": ". + let error = crate::tests::Error::One; + assert_eq!( + Formatted::<_, Add>>::new(error).to_string(), + "One: One" + ); + } + + #[test] + fn test_with_space_alias() { + use separator::WithSpace; + let error = crate::tests::Error::One; + assert_eq!( + Formatted::<_, WithSpace>::new(error).to_string(), + "One One" + ); + } + + #[test] + fn test_with_newline_alias() { + use separator::WithNewLine; + let error = crate::tests::Error::Two(ErrorInner::One); + assert_eq!( + Formatted::<_, WithNewLine>::new(error).to_string(), + "Two: One\nTwo: One" + ); + } + + #[test] + fn test_with_colon_space_alias() { + use separator::WithColonSpace; + let error = crate::tests::Error::One; + assert_eq!( + Formatted::<_, WithColonSpace>::new(error).to_string(), + "One: One" + ); + } + + #[test] + fn test_add_sep_generic_alias() { + use separator::Colon; + let error = crate::tests::Error::One; + assert_eq!( + Formatted::<_, WithSep>::new(error).to_string(), + "One:One" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8deada5..e8fa269 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,30 +8,47 @@ use core::{error::Error, fmt, iter, marker::PhantomData}; +mod add; mod main_result; mod oneline; #[cfg(feature = "std")] pub mod path_display; +mod suggestion; mod tree; pub mod with_context; -pub use main_result::{DisplaySwapDebug, MainResult}; +pub use add::{Add, separator}; +pub use main_result::{DisplaySwapDebug, MainResult, MainResultWithSuggestion, WithSuggestion}; pub use oneline::OneLine; #[cfg(feature = "std")] pub use path_display::DisplayPath; +pub use suggestion::{Suggest, Suggestion}; pub use tree::{Tree, TreeIndent, TreeMarker}; pub use with_context::WithContext; -/// A static strategy for formatting an error and its source chain. +/// A static strategy for formatting a value to a [`fmt::Formatter`]. +/// +/// Usually, the error is traversed via [`chain`] to format the entire source chain, +/// but this is not required — the strategy can choose to ignore the chain or format +/// non-error types as well. +/// For example, an implementation of [`Format>`] can format the context +/// and error fields of [`WithContext`] with field extractors like +/// [`ContextField`](crate::with_context::ContextField) and [`ErrorField`](crate::with_context::ErrorField) +/// without walking the source chain at all. +/// +/// `E` is the value being formatted; each strategy declares its own bounds: +/// [`OneLine`] and [`Tree`] require `E: Error`, [`Suggestion`] additionally +/// requires [`Suggest`], and field extractors like +/// [`ContextField`](crate::with_context::ContextField) require `E` to be a +/// specific shape. The trait itself imposes nothing beyond `?Sized` so +/// strategies can format non-error pairs (e.g. [`WithContext`]). /// -/// Implement on a unit type to define a custom format. Use [`chain`] to walk -/// the error and its sources. /// We cannot rely on `fmt::*` traits because: -/// 1. They have accept &self -/// 1. `Error` is already bound by it -pub trait Format { +/// 1. They accept &self +/// 1. `Error` already bounds `Display` as a supertrait, which would block composing strategies through types like [`WithContext`]. +pub trait Format { /// Writes `error` and its source chain to `f` using the strategy. - fn fmt(error: &dyn Error, f: &mut fmt::Formatter<'_>) -> fmt::Result; + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result; } /// Iterator over an error and its source chain. @@ -54,8 +71,17 @@ pub trait FormatError { self.formatted::() } + /// Renders the error's [`Suggestion`] hint. Only the top-level error is + /// printed; the source chain is not walked. + fn suggestion(&self) -> Formatted<&Self, Suggestion> + where + Self: Suggest, + { + self.formatted::() + } + /// Formats the error using a custom [`Format`] strategy. - fn formatted(&self) -> Formatted<&Self, F> { + fn formatted(&self) -> Formatted<&Self, F> { Formatted::new(self) } } @@ -70,15 +96,15 @@ impl FormatError for E {} #[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] pub struct Formatted(E, PhantomData F>); -impl Formatted { +impl Formatted { /// Wraps `error` so its `Display` impl uses the [`Format`] strategy `F`. - pub fn new(error: E) -> Self { + pub const fn new(error: E) -> Self { Formatted(error, PhantomData) } } /// Renders the wrapped error via the strategy `F`. -impl fmt::Display for Formatted { +impl> fmt::Display for Formatted { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { F::fmt(&self.0, f) } @@ -185,8 +211,8 @@ pub(crate) mod tests { #[test] fn test_custom_format() { struct Upper; - impl Format for Upper { - fn fmt(error: &dyn core::error::Error, f: &mut fmt::Formatter<'_>) -> fmt::Result { + impl Format for Upper { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", error.to_string().to_uppercase()) } } diff --git a/src/main_result.rs b/src/main_result.rs index 28b5e92..3192e33 100644 --- a/src/main_result.rs +++ b/src/main_result.rs @@ -1,6 +1,8 @@ use core::error::Error; use core::fmt; +use crate::separator::{NewLine, WithSep}; + use super::{Format, Formatted, OneLine}; /// A result type that wraps an error with [Formatted] and [DisplaySwapDebug] to output from the `main` function. @@ -11,6 +13,22 @@ use super::{Format, Formatted, OneLine}; pub type MainResult = core::result::Result>>; +/// A result type that wraps an error with [Formatted] and [DisplaySwapDebug] to output from the `main` function, with an additional suggestion. +/// +/// See [`MainResult`] for details on the type parameters. +/// The suggestion is rendered after the error, separated by a newline. To customize the separator, use `MainResult` with a custom `Format` that combines the error and suggestion as desired. +/// If [`Suggestion::fmt`](crate::Suggestion::fmt) produces an empty string, the separator is still printed. +pub type MainResultWithSuggestion = + core::result::Result>>>; + +/// A helper type to combine an error format strategy `F` with a suggestion, separated by `Sep`. +/// Used by `MainResultWithSuggestion` to render the error and suggestion together. +/// `F` defaults to [`OneLine`] and `Sep` defaults to a newline, but you can customize both to achieve different layouts. +/// +/// Equivalent to [`WithSep`]. +/// If [`Suggestion::fmt`](crate::Suggestion::fmt) produces an empty string, the separator is still printed. +pub type WithSuggestion = WithSep; + /// Wrapper that swaps an inner type's [`fmt::Debug`] and [`fmt::Display`] impls. /// /// @@ -51,7 +69,7 @@ impl fmt::Debug for DisplaySwapDebug { } } -impl From for DisplaySwapDebug> { +impl> From for DisplaySwapDebug> { fn from(value: E) -> Self { DisplaySwapDebug::new(Formatted::new(value)) } @@ -59,8 +77,27 @@ impl From for DisplaySwapDebug> { #[cfg(test)] mod tests { + use thiserror::Error as ThisError; + use super::*; - use crate::tests::Error; + use crate::{Suggest, separator::Space, tests::Error}; + + #[derive(ThisError, Debug)] + enum SugError { + #[error("env file missing")] + NoEnv, + #[error("something else")] + Other, + } + + impl Suggest for SugError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoEnv => f.write_str("Did you mean rename the .env.example file to .env?"), + Self::Other => Ok(()), + } + } + } struct Foo; @@ -114,6 +151,70 @@ mod tests { ); } + #[test] + fn test_with_suggestion_renders_error_then_hint() { + let formatted = Formatted::<_, WithSuggestion>::new(SugError::NoEnv); + assert_eq!( + formatted.to_string(), + "env file missing\nDid you mean rename the .env.example file to .env?" + ); + } + + #[test] + fn test_with_suggestion_empty_hint_keeps_separator() { + let formatted = Formatted::<_, WithSuggestion>::new(SugError::Other); + assert_eq!(formatted.to_string(), "something else\n"); + } + + #[test] + fn test_with_suggestion_custom_separator() { + let formatted = Formatted::<_, WithSuggestion>::new(SugError::NoEnv); + assert_eq!( + formatted.to_string(), + "env file missing Did you mean rename the .env.example file to .env?" + ); + } + + #[test] + fn test_main_result_with_suggestion_question_mark() { + fn run(err: bool) -> MainResultWithSuggestion { + if err { + Err(SugError::NoEnv)?; + } + Ok(()) + } + + run(false).unwrap(); + let wrapped = run(true).unwrap_err(); + // Debug of DisplaySwapDebug forwards to inner Display = error chain + \n + hint. + assert_eq!( + format!("{wrapped:?}"), + "env file missing\nDid you mean rename the .env.example file to .env?" + ); + // Display of DisplaySwapDebug forwards to inner Debug = forwarded to the + // wrapped error's Debug (i.e. the original `SugError` Debug derive). + assert_eq!(wrapped.to_string(), "NoEnv"); + } + + #[test] + fn test_main_result_with_suggestion_exit_code() { + use std::process::ExitCode; + + fn main_with_error(err: bool) -> MainResultWithSuggestion { + if err { + Err(SugError::NoEnv)?; + } + Ok(ExitCode::SUCCESS) + } + + assert_eq!(main_with_error(false).unwrap(), ExitCode::SUCCESS); + let wrapped = main_with_error(true).unwrap_err(); + assert_eq!( + wrapped.0.to_string(), + "env file missing\nDid you mean rename the .env.example file to .env?" + ); + } + #[test] fn test_main_result_with_exit_code() { use std::process::ExitCode; diff --git a/src/oneline.rs b/src/oneline.rs index 8261cf4..97a3f99 100644 --- a/src/oneline.rs +++ b/src/oneline.rs @@ -12,9 +12,9 @@ use crate::{Format, chain}; pub struct OneLine; /// Walks the source chain and joins each error's `Display` output with `": "`. -impl Format for OneLine { - fn fmt(error: &dyn Error, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", chain(error).format(": ")) +impl Format for OneLine { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", chain(&error).format(": ")) } } @@ -68,9 +68,9 @@ mod tests { #[test] fn test_custom_separator_via_format() { struct Arrow; - impl Format for Arrow { - fn fmt(error: &dyn core::error::Error, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", chain(error).format(" -> ")) + impl Format for Arrow { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", chain(&error).format(" -> ")) } } diff --git a/src/path_display.rs b/src/path_display.rs index 58b9dc2..fc9928d 100644 --- a/src/path_display.rs +++ b/src/path_display.rs @@ -1,7 +1,9 @@ //! 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. +//! This is an experimental helper module. Prefer composing a path-aware +//! [`Format`](crate::Format) strategy for [`WithContext`](crate::WithContext) +//! via [`ContextPath`](crate::with_context::ContextPath) — see +//! [`PathColon`](crate::with_context::PathColon) and +//! [`WithPath`](crate::with_context::WithPath) for the canonical use. use core::fmt; use std::path::Path; diff --git a/src/suggestion.rs b/src/suggestion.rs new file mode 100644 index 0000000..550123a --- /dev/null +++ b/src/suggestion.rs @@ -0,0 +1,111 @@ +use core::{error::Error, fmt}; + +use crate::Format; + +/// A suggestion for how to fix an error. +/// +/// Only the top-level error's hint is printed, the source chain isn't walked. +/// This decision is intentional: The underlying hint may be irrelevant in the context of the top-level error, and printing it may just add noise. +/// 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. +/// +/// Implement on an error type to provide a per-variant hint (e.g. +/// `"Did you mean to rename .env.example to .env?"`). The default impl writes +/// nothing, so types only need to implement it for the variants that have a +/// hint. +/// +/// Render via [`FormatError::suggestion`](crate::FormatError::suggestion), +/// which dispatches through the [`Suggestion`] strategy. +pub trait Suggest { + /// Writes the suggestion text for `self` to `f`. The default writes + /// nothing. + fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { + Ok(()) + } +} + +/// Similar blanket impl as in fmt::Display +impl Suggest for &T { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Suggest::fmt(*self, f) + } +} + +/// [`Format`] strategy that renders the top-level error's [`Suggestion`] hint. +/// +/// The source chain is not walked — only the wrapped error's own hint is +/// printed. Pair with [`FormatError::suggestion`](crate::FormatError::suggestion) +/// (returns `Formatted<&Self, Suggestion>`). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Suggestion; + +impl Format for Suggestion { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Suggest::fmt(error, f) + } +} + +#[cfg(test)] +mod tests { + use thiserror::Error; + + use super::*; + use crate::FormatError; + + #[derive(Error, Debug)] + pub enum SugError { + #[error("env file missing")] + NoEnv, + #[error("something else")] + Other, + } + + impl Suggest for SugError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoEnv => f.write_str("Did you mean rename the .env.example file to .env?"), + Self::Other => Ok(()), + } + } + } + + #[derive(Error, Debug)] + #[error("plain")] + struct NoHint; + + impl Suggest for NoHint {} + + #[test] + fn renders_variant_hint() { + let error = SugError::NoEnv; + assert_eq!( + error.suggestion().to_string(), + "Did you mean rename the .env.example file to .env?" + ); + } + + #[test] + fn renders_empty_for_variant_without_hint() { + let error = SugError::Other; + assert_eq!(error.suggestion().to_string(), ""); + } + + #[test] + fn default_impl_writes_nothing() { + let error = NoHint; + assert_eq!(error.suggestion().to_string(), ""); + } + + #[test] + fn debug_forwards_to_inner() { + let error = SugError::NoEnv; + assert_eq!(format!("{:?}", error.suggestion()), "NoEnv"); + } + + #[test] + fn one_line_still_works_on_suggestion_types() { + let error = SugError::NoEnv; + assert_eq!(error.one_line().to_string(), "env file missing"); + } +} diff --git a/src/tree.rs b/src/tree.rs index 62bc9d0..3bd7b8b 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -44,16 +44,16 @@ pub struct Tree(PhantomData (M, I)>); /// Walks the source chain. Prints the top error on its own line, then each /// source on a new line preceded by `(depth - 1)` repetitions of `I` followed /// by `M`. -impl Format for Tree +impl Format for Tree where M: fmt::Display + Default, I: fmt::Display + Default, { - fn fmt(error: &dyn Error, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { let marker = M::default(); let indent = I::default(); let formatted = - chain(error) + chain(&error) .enumerate() .format_with("\n", |(depth, e), write| match depth { 0 => write(&format_args!("{e}")), @@ -156,9 +156,9 @@ mod tests { #[test] fn test_custom_tree_via_format() { struct AsciiTree; - impl Format for AsciiTree { - fn fmt(error: &dyn core::error::Error, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let formatted = chain(error) + impl Format for AsciiTree { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let formatted = chain(&error) .enumerate() .format_with("\n", |(depth, e), write| match depth { 0 => write(&format_args!("{e}")), diff --git a/src/with_context.rs b/src/with_context.rs index a294b98..816655e 100644 --- a/src/with_context.rs +++ b/src/with_context.rs @@ -6,20 +6,29 @@ use core::{ marker::PhantomData, }; -pub use crate::with_context::format::{Colon, ContextFormat, WithContextColon}; +use crate::Format; + +pub use crate::with_context::format::{Colon, ContextField, ErrorField, WithContextColon}; #[cfg(feature = "std")] -pub use crate::with_context::format::{PathColon, WithContextPathColon}; +pub use crate::with_context::format::{ContextPath, 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. +/// [`Format`] strategy. +/// +/// `Display` delegates to `F::fmt(self, f)`, so any `F: Format>` +/// can format the pair. Strategies are usually built by composing the field +/// extractors [`ContextField`] / [`ErrorField`] (or [`ContextPath`] when +/// `C: AsRef`) with separator strategies via +/// [`Add`](crate::Add) / [`WithSep`](crate::separator::WithSep), e.g. the default +/// [`Colon`] is [`WithColonSpace`](crate::separator::WithColonSpace). /// -/// `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. +/// [`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. /// /// # Example /// ``` @@ -30,16 +39,28 @@ pub type WithPath = WithContext; /// let ctx = WithContextColon::new("path/to/config", err); /// assert_eq!(ctx.one_line().to_string(), "path/to/config: file missing"); /// ``` +/// # Custom formatting +/// There are 2 ways to customize the formatting strategy: +/// +/// ## Custom strategy via composition of field extractors and separators +/// ``` +/// use errortools::{WithContext, separator::WithSpace, with_context::{ContextField, ErrorField}}; /// -/// # Custom strategy +/// // Same as `Colon` but uses a single space instead of ": ". +/// type SpacePair = WithSpace; +/// let w = WithContext::<_, _, SpacePair>::new("step", "boom"); +/// assert_eq!(w.to_string(), "step boom"); +/// ``` +/// +/// ## Custom strategy via an impl of `Format> for YourStrategy` /// ``` /// use core::fmt::{self, Display, Formatter}; -/// use errortools::{WithContext, with_context::ContextFormat}; +/// use errortools::{Format, WithContext}; /// /// struct Arrow; -/// impl ContextFormat for Arrow { -/// fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { -/// write!(f, "{c} -> {e}") +/// impl Format> for Arrow { +/// fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { +/// write!(f, "{} -> {}", w.context, w.error) /// } /// } /// @@ -47,7 +68,7 @@ pub type WithPath = WithContext; /// assert_eq!(w.to_string(), "1 -> boom"); /// ``` #[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub struct WithContext { +pub struct WithContext { /// The context value tagging this error (e.g. a file path or step number). pub context: C, /// The underlying error. @@ -69,7 +90,10 @@ impl WithContext { } /// Switches the formatting strategy without touching the stored values. - pub fn with_format>(self) -> WithContext { + pub fn with_format(self) -> WithContext + where + G: Format>, + { WithContext { context: self.context, error: self.error, @@ -86,9 +110,12 @@ impl From<(C, E)> 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 { +impl Display for WithContext +where + F: Format, +{ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - F::fmt(&self.context, &self.error, f) + F::fmt(self, f) } } @@ -106,7 +133,7 @@ impl Error for WithContext where C: Debug, E: Error + 'static, - F: ContextFormat, + F: Format, { /// Returns the inner error's source, skipping the inner error itself /// (already shown via [`Display`]) so chain-walking strategies don't @@ -117,12 +144,16 @@ where } mod format { - //! Formatting strategies for [`WithContext`]. + //! Field extractors and pre-composed strategies for [`WithContext`]. use core::fmt::{self, Display, Formatter}; #[cfg(feature = "std")] use std::path::Path; + #[allow(unused_imports)] // referenced from doc links + use crate::add::separator::{ColonSpace, WithSep}; + use crate::{Format, add::separator::WithColonSpace}; + use super::WithContext; /// Convenience alias for [`WithContext`] with the default [`Colon`] strategy. @@ -137,47 +168,59 @@ mod format { #[cfg(feature = "std")] pub type WithContextPathColon = WithContext; - /// A static strategy for combining a context value and an error into a single - /// [`Display`] line. + /// [`Format`] extractor that prints the `context` field via `Display`. /// - /// 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; + /// Compose with [`ErrorField`] and a separator to build pair strategies: + /// [`WithSep`](WithSep) is exactly [`Colon`]. + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct ContextField; + + impl Format> for ContextField { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&w.context, f) + } } - /// Default [`ContextFormat`]: writes `"{context}: {error}"` for any pair of - /// `Display` values. + /// [`Format`] extractor that prints the `error` field via `Display`. + /// + /// Counterpart to [`ContextField`]. See [`Colon`] for the canonical use. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub struct Colon; + pub struct ErrorField; - impl ContextFormat for Colon { - fn fmt(context: &C, error: &E, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{context}: {error}") + impl Format> for ErrorField { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&w.error, f) } } - /// Path-aware [`ContextFormat`]: writes `"{path}: {error}"` where `path` is - /// rendered via [`Path::display`]. + /// [`Format`] extractor that prints the `context` field 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. + /// UTF-8), so [`ContextField`] won't accept them. `ContextPath` 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; + pub struct ContextPath; #[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()) + impl, E, F> Format> for ContextPath { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + w.context.as_ref().display().fmt(f) } } + + /// Default pair strategy: writes `"{context}: {error}"` for any pair of + /// `Display` values. + /// + /// Equivalent to [`WithColonSpace`](WithColonSpace). + pub type Colon = WithColonSpace; + + /// Path-aware pair strategy: writes `"{path}: {error}"` where `path` is + /// rendered via [`Path::display`]. + /// + /// Equivalent to [`WithColonSpace`](WithColonSpace). + #[cfg(feature = "std")] + pub type PathColon = WithColonSpace; } #[cfg(test)] @@ -197,10 +240,11 @@ mod tests { #[error("middle")] struct Middle(#[source] Leaf); + /// Custom one-shot strategy: `[ctx] err`. struct Bracketed; - impl ContextFormat for Bracketed { - fn fmt(c: &C, e: &E, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "[{c}] {e}") + impl Format> for Bracketed { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {}", w.context, w.error) } } @@ -225,53 +269,53 @@ mod tests { #[test] fn test_new_and_fields() { - let w = format::WithContextColon::new("ctx", Leaf); + let w = WithContextColon::new("ctx", Leaf); assert_eq!(w.context, "ctx"); } #[test] fn test_from_tuple() { - let w: format::WithContextColon<&str, Leaf> = ("ctx", Leaf).into(); + let w: 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); + let w = 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); + let w = 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 w = 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)); + let w = 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); + let w = 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}") + impl Format> for Arrow { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{} -> {}", w.context, w.error) } } @@ -316,4 +360,14 @@ mod tests { let w = WithContext::<_, _, PathColon>::new(path, io_err); assert_eq!(w.to_string(), "a/b/c.txt: file missing"); } + + /// Compose a custom delimiter without writing a new Format impl. + #[test] + fn test_composed_separator() { + use crate::separator::WithSpace; + type SpacePair = WithSpace; + + let w = WithContext::<_, _, SpacePair>::new("ctx", Leaf); + assert_eq!(w.to_string(), "ctx leaf error"); + } }