diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c7a7f7..a9c16bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,7 @@ jobs: matrix: features: - --all-features + - --no-default-features --features alloc - --no-default-features steps: - uses: actions/checkout@v6 diff --git a/Cargo.toml b/Cargo.toml index bd5e78d..72653c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,10 @@ thiserror = "2" [features] default = ["std"] -std = ["itertools/use_std"] + +std = ["alloc", "itertools/use_std"] +alloc = [] [[example]] name = "with_context" -required-features = ["std"] +required-features = ["std"] \ No newline at end of file diff --git a/src/add.rs b/src/add.rs deleted file mode 100644 index 944d68c..0000000 --- a/src/add.rs +++ /dev/null @@ -1,290 +0,0 @@ -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/add/mod.rs b/src/add/mod.rs new file mode 100644 index 0000000..cdd60a8 --- /dev/null +++ b/src/add/mod.rs @@ -0,0 +1,125 @@ +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; + +#[cfg(test)] +mod tests { + use core::error::Error; + + use super::*; + use crate::{Formatted, OneLine, Suggestion, Tree, tests::Inner}; + use separator::*; + + 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(Inner::A); + assert_eq!( + Formatted::<_, Add>::new(error).to_string(), + "Two: InnerA\n" + ); + } + + #[test] + fn test_nested_oneline_newline_suggestion() { + let error = crate::tests::Error::One; + assert_eq!( + Formatted::<_, Add, Suggestion>>::new(error).to_string(), + "One\nTry passing --help to see available options." + ); + } + + #[test] + fn test_empty_rhs_keeps_separator() { + let error = crate::tests::Error::Two(Inner::A); + assert_eq!( + Formatted::<_, Add, Suggestion>>::new(error).to_string(), + "Two: InnerA\n" + ); + } + + #[test] + fn test_right_associated_nesting() { + let error = crate::tests::Error::Two(Inner::A); + assert_eq!( + Formatted::<_, Add>>::new(error).to_string(), + "Two: InnerA\nTwo: InnerA" + ); + } + + #[test] + fn test_debug_prints_inner() { + let add = Add::::default(); + assert_eq!(format!("{add:?}"), "Add(OneLine, NewLine)"); + } +} diff --git a/src/add/separator.rs b/src/add/separator.rs new file mode 100644 index 0000000..c6e5fc9 --- /dev/null +++ b/src/add/separator.rs @@ -0,0 +1,150 @@ +//! 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 super::*; + use crate::{ + Add, Formatted, OneLine, + tests::{Error, Inner}, + }; + + #[test] + fn test_space_between_repeats() { + let error = Error::One; + assert_eq!( + Formatted::<_, Add>>::new(error).to_string(), + "One One" + ); + } + + #[test] + fn test_colon_space_alias() { + // ColonSpace ignores the error and writes ": ". + let error = Error::One; + assert_eq!( + Formatted::<_, Add>>::new(error).to_string(), + "One: One" + ); + } + + #[test] + fn test_with_space_alias() { + let error = Error::One; + assert_eq!( + Formatted::<_, WithSpace>::new(error).to_string(), + "One One" + ); + } + + #[test] + fn test_with_newline_alias() { + let error = Error::Two(Inner::A); + assert_eq!( + Formatted::<_, WithNewLine>::new(error).to_string(), + "Two: InnerA\nTwo: InnerA" + ); + } + + #[test] + fn test_with_colon_space_alias() { + let error = Error::One; + assert_eq!( + Formatted::<_, WithColonSpace>::new(error).to_string(), + "One: One" + ); + } + + #[test] + fn test_add_sep_generic_alias() { + let error = Error::One; + assert_eq!( + Formatted::<_, WithSep>::new(error).to_string(), + "One:One" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index e8fa269..81fbbf4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,10 +6,15 @@ #![cfg_attr(not(any(feature = "std", test)), no_std)] #![warn(missing_docs)] +#[cfg(feature = "alloc")] +extern crate alloc; + use core::{error::Error, fmt, iter, marker::PhantomData}; mod add; mod main_result; +#[cfg(feature = "alloc")] +pub mod many_errors; mod oneline; #[cfg(feature = "std")] pub mod path_display; @@ -19,6 +24,8 @@ pub mod with_context; pub use add::{Add, separator}; pub use main_result::{DisplaySwapDebug, MainResult, MainResultWithSuggestion, WithSuggestion}; +#[cfg(feature = "alloc")] +pub use many_errors::{Listing, ManyErrors}; pub use oneline::OneLine; #[cfg(feature = "std")] pub use path_display::DisplayPath; @@ -31,7 +38,8 @@ pub use with_context::WithContext; /// 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 +/// 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. @@ -51,6 +59,23 @@ pub trait Format { fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result; } +/// Sentinel [`Format`] strategy that delegates to the value's own [`fmt::Display`] +/// impl. +/// +/// Useful as a default in strategy-aware wrappers (e.g. +#[cfg_attr(feature = "alloc", doc = "[`Listing`]")] +#[cfg_attr(not(feature = "alloc"), doc = "`Listing`")] +/// ) when per-item formatting should defer to each item's own `Display` (and +/// thus its own type-level strategy) rather than being overridden. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AsDisplay; + +impl Format for AsDisplay { + fn fmt(value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(value, f) + } +} + /// Iterator over an error and its source chain. /// /// The first item is `error` itself; subsequent items come from @@ -104,6 +129,9 @@ impl Formatted { } /// Renders the wrapped error via the strategy `F`. +/// These genetic bounds actually define whether a strategy can be used to format a given error type +/// Any error type can be put into a strategy, but not every can actually be formatted. +/// That's why it's possible to construct, but get a compiler error when trying to call [`fmt::Display`] on the combination. impl> fmt::Display for Formatted { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { F::fmt(&self.0, f) @@ -119,105 +147,4 @@ impl fmt::Debug for Formatted { } #[cfg(test)] -pub(crate) mod tests { - use std::io; - - use thiserror::Error; - - use super::*; - - fn _assert_derive_traits() { - #[derive(Clone, Copy, Default, PartialEq, Eq, Hash, Debug)] - struct DummyError; - impl fmt::Display for DummyError { - fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result { - Ok(()) - } - } - impl core::error::Error for DummyError {} - - fn assert_all< - T: Clone + Copy + Default + PartialEq + Eq + core::hash::Hash + Send + Sync, - >() { - } - assert_all::>(); - assert_all::>(); - assert_all::>(); - assert_all::(); - assert_all::(); - assert_all::(); - assert_all::(); - } - - #[derive(Error, Debug)] - pub enum Error { - #[error("One")] - One, - #[error("Two")] - Two(#[source] ErrorInner), - #[error("Three")] - Three(#[source] io::Error), - #[error(transparent)] - Four(#[from] ErrorInner), - } - - #[derive(Error, Debug)] - pub enum ErrorInner { - #[error("One")] - One, - #[error("Two")] - Two, - } - - #[test] - fn test_user_output() { - let error = Error::One; - assert_eq!(error.one_line().to_string(), "One"); - - let error = Error::Two(ErrorInner::One); - assert_eq!(error.one_line().to_string(), "Two: One"); - - let error = Error::Three(io::Error::new(io::ErrorKind::PermissionDenied, "test")); - assert_eq!(error.one_line().to_string(), "Three: test"); - - let error = Error::Four(ErrorInner::Two); - assert_eq!(error.one_line().to_string(), "Two"); - } - - #[test] - fn test_combined() { - let error = Error::One; - let io_error = Error::Three(io::Error::new(io::ErrorKind::PermissionDenied, "test")); - - assert_eq!(error.one_line().to_string(), "One"); - - assert_eq!(io_error.one_line().to_string(), "Three: test"); - } - - #[test] - fn test_dyn_error() { - let error = Error::Two(ErrorInner::One); - - let dyn_ref: &dyn core::error::Error = &error; - assert_eq!(dyn_ref.one_line().to_string(), "Two: One"); - - let boxed: Box = Box::new(Error::Two(ErrorInner::Two)); - assert_eq!(boxed.one_line().to_string(), "Two: Two"); - - let send_sync: &(dyn core::error::Error + Send + Sync) = &error; - assert_eq!(send_sync.one_line().to_string(), "Two: One"); - } - - #[test] - fn test_custom_format() { - struct Upper; - impl Format for Upper { - fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", error.to_string().to_uppercase()) - } - } - - let error = Error::Two(ErrorInner::One); - assert_eq!(error.formatted::().to_string(), "TWO"); - } -} +pub(crate) mod tests; diff --git a/src/main_result.rs b/src/main_result.rs index 3192e33..780b475 100644 --- a/src/main_result.rs +++ b/src/main_result.rs @@ -77,27 +77,11 @@ impl> From for DisplaySwapDebug> { #[cfg(test)] mod tests { - use thiserror::Error as ThisError; - use super::*; - 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(()), - } - } - } + use crate::{ + separator::Space, + tests::{Error, Inner}, + }; struct Foo; @@ -127,12 +111,12 @@ mod tests { #[test] fn test_swap_with_formatted() { - let inner = Formatted::<_, OneLine>::new(Error::Two(crate::tests::ErrorInner::One)); + let inner = Formatted::<_, OneLine>::new(Error::Two(Inner::A)); let wrapped = DisplaySwapDebug::new(inner); // Debug of DisplaySwapDebug = Display of inner = OneLine chain. - assert_eq!(format!("{wrapped:?}"), "Two: One"); + assert_eq!(format!("{wrapped:?}"), "Two: InnerA"); // Display of DisplaySwapDebug = Debug of inner = forwarded to error's Debug. - assert_eq!(wrapped.to_string(), "Two(One)"); + assert_eq!(wrapped.to_string(), "Two(A)"); } #[test] @@ -153,33 +137,33 @@ mod tests { #[test] fn test_with_suggestion_renders_error_then_hint() { - let formatted = Formatted::<_, WithSuggestion>::new(SugError::NoEnv); + let formatted = Formatted::<_, WithSuggestion>::new(Error::One); assert_eq!( formatted.to_string(), - "env file missing\nDid you mean rename the .env.example file to .env?" + "One\nTry passing --help to see available options." ); } #[test] fn test_with_suggestion_empty_hint_keeps_separator() { - let formatted = Formatted::<_, WithSuggestion>::new(SugError::Other); - assert_eq!(formatted.to_string(), "something else\n"); + let formatted = Formatted::<_, WithSuggestion>::new(Error::Two(Inner::A)); + assert_eq!(formatted.to_string(), "Two: InnerA\n"); } #[test] fn test_with_suggestion_custom_separator() { - let formatted = Formatted::<_, WithSuggestion>::new(SugError::NoEnv); + let formatted = Formatted::<_, WithSuggestion>::new(Error::One); assert_eq!( formatted.to_string(), - "env file missing Did you mean rename the .env.example file to .env?" + "One Try passing --help to see available options." ); } #[test] fn test_main_result_with_suggestion_question_mark() { - fn run(err: bool) -> MainResultWithSuggestion { + fn run(err: bool) -> MainResultWithSuggestion { if err { - Err(SugError::NoEnv)?; + Err(Error::One)?; } Ok(()) } @@ -189,20 +173,19 @@ mod tests { // 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?" + "One\nTry passing --help to see available options." ); - // 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"); + // Display of DisplaySwapDebug forwards to inner Debug = Error::One's Debug. + assert_eq!(wrapped.to_string(), "One"); } #[test] fn test_main_result_with_suggestion_exit_code() { use std::process::ExitCode; - fn main_with_error(err: bool) -> MainResultWithSuggestion { + fn main_with_error(err: bool) -> MainResultWithSuggestion { if err { - Err(SugError::NoEnv)?; + Err(Error::One)?; } Ok(ExitCode::SUCCESS) } @@ -211,7 +194,7 @@ mod tests { 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?" + "One\nTry passing --help to see available options." ); } diff --git a/src/many_errors/iter.rs b/src/many_errors/iter.rs new file mode 100644 index 0000000..fde4670 --- /dev/null +++ b/src/many_errors/iter.rs @@ -0,0 +1,282 @@ +// --- Iter --- + +use crate::{ManyErrors, WithContext, with_context::Colon}; + +impl ManyErrors { + /// Returns an iterator over references to each recorded [`WithContext`]. + pub fn iter(&self) -> Iter<'_, C, E, WithContextFormat> { + Iter::new(self) + } +} + +/// Iterator over references to each [`WithContext`] in a [`ManyErrors`]. +pub struct Iter<'a, C, E, WithContextFormat = Colon>(IterInner<'a, C, E, WithContextFormat>); + +enum IterInner<'a, C, E, WithContextFormat> { + Empty, + One(Option<&'a WithContext>), + Many(core::slice::Iter<'a, WithContext>), +} + +impl<'a, C, E, WithContextFormat> Iter<'a, C, E, WithContextFormat> { + fn new(many: &'a ManyErrors) -> Self { + Self(match many { + ManyErrors::None => IterInner::Empty, + ManyErrors::One(w) => IterInner::One(Some(w)), + ManyErrors::Many(v) => IterInner::Many(v.iter()), + }) + } +} + +impl<'a, C, E, WithContextFormat> Iterator for Iter<'a, C, E, WithContextFormat> { + type Item = &'a WithContext; + + fn next(&mut self) -> Option { + match &mut self.0 { + IterInner::Empty => None, + IterInner::One(slot) => slot.take(), + IterInner::Many(it) => it.next(), + } + } + + fn size_hint(&self) -> (usize, Option) { + match &self.0 { + IterInner::Empty => (0, Some(0)), + IterInner::One(slot) => { + let n = slot.is_some() as usize; + (n, Some(n)) + } + IterInner::Many(it) => it.size_hint(), + } + } +} + +mod from_iter { + use core::ops::ControlFlow; + + use super::*; + + impl FromIterator> + for ManyErrors + { + fn from_iter>>( + iter: I, + ) -> Self { + let mut me = Self::None; + me.extend(iter); + me + } + } + + impl FromIterator<(C, E)> for ManyErrors { + fn from_iter>(iter: I) -> Self { + iter.into_iter().map(WithContext::from).collect() + } + } + + impl + FromIterator< + ControlFlow, WithContext>, + > for ManyErrors + { + fn from_iter(iter: I) -> Self + where + I: IntoIterator< + Item = ControlFlow< + WithContext, + WithContext, + >, + >, + { + let mut me = Self::None; + me.extend(iter); + me + } + } + + impl FromIterator> + for ManyErrors + { + fn from_iter>>(iter: I) -> Self { + let mut me = Self::None; + me.extend(iter); + me + } + } + + // --- Extend --- + + impl Extend> + for ManyErrors + { + fn extend>>( + &mut self, + iter: I, + ) { + // TODO: Optimize + for item in iter { + self.push(item); + } + } + } + + impl Extend<(C, E)> for ManyErrors { + fn extend>(&mut self, iter: I) { + self.extend(iter.into_iter().map(WithContext::from)); + } + } + + /// `Continue(w)` records `w` and keeps iterating; `Break(w)` records `w` and stops. + impl + Extend< + ControlFlow, WithContext>, + > for ManyErrors + { + fn extend(&mut self, iter: I) + where + I: IntoIterator< + Item = ControlFlow< + WithContext, + WithContext, + >, + >, + { + for cf in iter { + let stop = matches!(cf, ControlFlow::Break(_)); + let w = match cf { + ControlFlow::Continue(w) | ControlFlow::Break(w) => w, + }; + self.push(w); + if stop { + break; + } + } + } + } + + impl Extend> + for ManyErrors + { + fn extend(&mut self, iter: I) + where + I: IntoIterator>, + { + self.extend(iter.into_iter().map(|cf| match cf { + ControlFlow::Continue(t) => ControlFlow::Continue(WithContext::from(t)), + ControlFlow::Break(t) => ControlFlow::Break(WithContext::from(t)), + })); + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ManyErrors, WithContext, tests::Inner}; + use itertools::Itertools as _; + use std::{io, ops::ControlFlow}; + + fn w(ctx: &'static str) -> WithContext<&'static str, Inner> { + WithContext::new(ctx, Inner::A) + } + + #[test] + fn test_collect_from_with_context() { + let errs: ManyErrors<&str, Inner> = [w("a"), w("b"), w("c")].into_iter().collect(); + assert_eq!(errs.len(), 3); + } + + #[test] + fn test_collect_from_tuples() { + let errs: ManyErrors<&str, Inner> = + [("a", Inner::A), ("b", Inner::A)].into_iter().collect(); + assert_eq!(errs.len(), 2); + } + + #[test] + fn test_extend_from_with_context() { + let mut e = ManyErrors::new(); + e.extend([w("a"), w("b")]); + assert_eq!(e.len(), 2); + } + + #[test] + fn test_extend_from_tuples_via_partition_result() { + let results: Vec> = + vec![Ok(1), Err(("a", Inner::A)), Ok(2), Err(("b", Inner::A))]; + let (oks, errs): (Vec, ManyErrors<&str, Inner>) = + results.into_iter().partition_result(); + assert_eq!(oks, [1, 2]); + assert_eq!(errs.len(), 2); + } + + #[test] + fn test_control_flow_all_continue() { + #[allow(clippy::type_complexity)] + let items: Vec, WithContext<&str, Inner>>> = + vec![ControlFlow::Continue(w("a")), ControlFlow::Continue(w("b"))]; + let errs: ManyErrors<&str, Inner> = items.into_iter().collect(); + assert_eq!(errs.len(), 2); + } + + #[test] + fn test_control_flow_break_stops_and_records() { + let mut count = 0usize; + let iter = ["a", "b", "c", "d"].iter().map(|s| { + count += 1; + if *s == "b" { + ControlFlow::Break(WithContext::new(*s, Inner::A)) + } else { + ControlFlow::Continue(WithContext::new(*s, Inner::A)) + } + }); + let errs: ManyErrors<&str, Inner> = iter.collect(); + // "a" (continue), "b" (break) → stops; "c","d" not consumed + assert_eq!(errs.len(), 2); + assert_eq!(count, 2); + } + + #[test] + fn test_control_flow_tuples() { + #[allow(clippy::type_complexity)] + let items: Vec> = vec![ + ControlFlow::Continue(("a", Inner::A)), + ControlFlow::Break(("b", Inner::A)), + ]; + let errs: ManyErrors<&str, Inner> = items.into_iter().collect(); + assert_eq!(errs.len(), 2); + } + + #[test] + fn test_iter_none() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.iter().count(), 0); + } + + #[test] + fn test_iter_one() { + let mut e = ManyErrors::new(); + e.push(w("a")); + let items: Vec<_> = e.iter().collect(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].context, "a"); + } + + #[test] + fn test_iter_many() { + let mut e = ManyErrors::new(); + e.push(w("a")); + e.push(w("b")); + let ctxs: Vec<_> = e.iter().map(|w| w.context).collect(); + assert_eq!(ctxs, ["a", "b"]); + } + + #[test] + fn test_io_errors_via_collect() { + let paths = ["missing.txt", "also_missing.txt"]; + let errs: ManyErrors<&str, io::Error> = paths + .iter() + .filter_map(|p| std::fs::read(p).err().map(|e| WithContext::new(*p, e))) + .collect(); + assert_eq!(errs.len(), 2); + } +} diff --git a/src/many_errors/listing.rs b/src/many_errors/listing.rs new file mode 100644 index 0000000..16f9bdd --- /dev/null +++ b/src/many_errors/listing.rs @@ -0,0 +1,150 @@ +use core::{ + fmt::{self, Formatter}, + marker::PhantomData, +}; + +use crate::{AsDisplay, Format}; + +use super::{ManyErrors, WithContext}; + +/// Aggregate strategy that renders each item in a [`ManyErrors`] on its own +/// line, formatting each via the per-item strategy `IndividualErrorFormat`. +/// +/// The default `G = AsDisplay` defers to each item's own [`fmt::Display`] (and +/// thus its own type-level strategy `WithContextFormat`). Pass a concrete `IndividualErrorFormat` (e.g. +/// [`OneLine`](crate::OneLine) or [`Tree`](crate::Tree)) to override per-item +/// rendering. +/// +/// Listing is implemented for both [`ManyErrors`] and +/// [`&ManyErrors`](crate::ManyErrors) so it can be used directly inside this module's +/// [`fmt::Display`] and via the [`Formatted`](crate::Formatted) wrapper (which holds +/// a reference) from [`FormatError::formatted`](crate::FormatError::formatted). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Listing(PhantomData IndividualErrorFormat>); + +impl Format> + for Listing +where + IndividualErrorFormat: Format>, +{ + fn fmt(error: &ManyErrors, f: &mut Formatter<'_>) -> fmt::Result { + let mut it = error.iter(); + let Some(first) = it.next() else { + return Ok(()); + }; + IndividualErrorFormat::fmt(first, f)?; + for p in it { + writeln!(f)?; + IndividualErrorFormat::fmt(p, f)?; + } + Ok(()) + } +} + +/// Trampoline so [`Formatted<&ManyErrors<_>, Listing>`](crate::Formatted) +/// (the type produced by `e.formatted::>()`) can dispatch through +/// the owned impl above. +impl Format<&ManyErrors> + for Listing +where + IndividualErrorFormat: Format>, +{ + fn fmt(error: &&ManyErrors, f: &mut Formatter<'_>) -> fmt::Result { + >>::fmt(error, f) + } +} + +#[cfg(test)] +mod tests { + use super::Listing; + use crate::{ + FormatError, ManyErrors, OneLine, Tree, WithContext, + tests::{Inner, Mid, WcArrow}, + }; + + #[test] + fn test_format_zero_errors() { + let e = ManyErrors::<&str, Inner>::new(); + + // Display (default Listing). + assert_eq!(e.to_string(), ""); + // Explicit Listing variants — all empty. + assert_eq!(e.formatted::().to_string(), ""); + assert_eq!(e.formatted::>().to_string(), ""); + assert_eq!(e.formatted::>().to_string(), ""); + } + + #[test] + fn test_format_one_error() { + // Mid → Inner so OneLine / Tree have a chain to walk. + let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); + e.push(WithContext::new("ctx", Mid::Inner(Inner::A))); + + // Default WithContextFormat = Colon → "{context}: {error}". + assert_eq!(e.to_string(), "ctx: mid"); + assert_eq!(e.formatted::().to_string(), "ctx: mid"); + // Listing walks the chain. + assert_eq!( + e.formatted::>().to_string(), + "ctx: mid: InnerA" + ); + assert_eq!( + e.formatted::>().to_string(), + "ctx: mid\n└── InnerA", + ); + + // Per-item WithContextFormat override (WcArrow) — affects items' own + // Display, which is what Listing defers to. + let mut a: ManyErrors<&str, Mid, _> = ManyErrors::new(); + a.push(WithContext::<_, _, WcArrow>::new( + "ctx", + Mid::Inner(Inner::A), + )); + assert_eq!(a.to_string(), "ctx -> mid"); + assert_eq!(a.formatted::().to_string(), "ctx -> mid"); + // Listing does NOT fully override: OneLine walks the Error + // chain, whose first element is the WithContext itself — and that + // WithContext's Display still fires its own F=WcArrow. Limitation. + assert_eq!( + a.formatted::>().to_string(), + "ctx -> mid: InnerA", + ); + assert_eq!( + a.formatted::>().to_string(), + "ctx -> mid\n└── InnerA", + ); + } + + #[test] + fn test_format_many_errors() { + let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); + e.push(WithContext::new("a", Mid::Inner(Inner::A))); + e.push(WithContext::new("b", Mid::Inner(Inner::A))); + e.push(WithContext::new("c", Mid::Inner(Inner::A))); + + assert_eq!(e.to_string(), "a: mid\nb: mid\nc: mid"); + assert_eq!(e.formatted::().to_string(), e.to_string()); + assert_eq!( + e.formatted::>().to_string(), + "a: mid: InnerA\nb: mid: InnerA\nc: mid: InnerA", + ); + assert_eq!( + e.formatted::>().to_string(), + "a: mid\n└── InnerA\nb: mid\n└── InnerA\nc: mid\n└── InnerA", + ); + + // WcArrow override on items. + let mut a: ManyErrors<&str, Mid, _> = ManyErrors::new(); + a.push(WithContext::<_, _, WcArrow>::new("a", Mid::Inner(Inner::A))); + a.push(WithContext::<_, _, WcArrow>::new("b", Mid::Inner(Inner::A))); + assert_eq!(a.to_string(), "a -> mid\nb -> mid"); + assert_eq!( + a.formatted::>().to_string(), + "a -> mid: InnerA\nb -> mid: InnerA", + ); + assert_eq!( + a.formatted::>().to_string(), + "a -> mid\n└── InnerA\nb -> mid\n└── InnerA", + ); + } +} diff --git a/src/many_errors/mod.rs b/src/many_errors/mod.rs new file mode 100644 index 0000000..b47e481 --- /dev/null +++ b/src/many_errors/mod.rs @@ -0,0 +1,238 @@ +//! Aggregated, context-tagged errors from iterator/fold-style operations. + +use core::{ + error::Error, + fmt::{self, Debug, Display, Formatter}, +}; + +use alloc::{vec, vec::Vec}; + +use crate::{ + AsDisplay, Format, + with_context::{Colon, WithContext}, +}; + +mod iter; +mod listing; + +pub use listing::Listing; + +/// Zero or more context-tagged errors collected during an iterator/fold operation. +/// +/// The three-variant split lets consumers pattern-match on the empty / single / +/// multiple cases. [`Display`] renders each recorded [`WithContext`] via the +/// strategy `WithContextFormat`, one per line — mirroring [`WithContext`]'s +/// strategy-dispatched Display. The default `WithContextFormat = Colon` produces +/// `"{context}: {error}"` per item. +/// +/// # Example +/// ``` +/// use errortools::{ManyErrors, WithContext}; +/// use std::path::PathBuf; +/// +/// let mut errs = ManyErrors::::new(); +/// assert!(errs.is_empty()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub enum ManyErrors { + /// No errors were recorded. + #[default] + None, + /// Exactly one error was recorded. + One(WithContext), + /// Two or more errors were recorded. + Many(Vec>), +} + +impl ManyErrors { + /// Creates an empty `ManyErrors`. + pub const fn new() -> Self { + Self::None + } + + /// Returns `true` if no errors have been recorded. + pub const fn is_empty(&self) -> bool { + matches!(self, Self::None) + } + + /// Returns the number of recorded errors. + pub fn len(&self) -> usize { + match self { + Self::None => 0, + Self::One(_) => 1, + Self::Many(v) => v.len(), + } + } + + /// Appends a tagged error, promoting `None → One → Many` as needed. + /// + /// # Example + /// ``` + /// use errortools::{ManyErrors, WithContext}; + /// + /// let mut errs = ManyErrors::<&str, std::io::Error>::new(); + /// errs.push(WithContext::new("step 1", std::io::Error::other("fail"))); + /// assert_eq!(errs.len(), 1); + /// ``` + pub fn push(&mut self, item: WithContext) { + let prev = core::mem::take(self); + *self = match prev { + Self::None => Self::One(item), + Self::One(first) => Self::Many(vec![first, item]), + Self::Many(mut v) => { + v.push(item); + Self::Many(v) + } + }; + } + + /// Returns `Ok(ok)` if no errors were recorded, otherwise `Err(self)`. + /// + /// # Example + /// ``` + /// use errortools::ManyErrors; + /// + /// let errs = ManyErrors::<&str, std::io::Error>::new(); + /// assert!(errs.into_result(42).is_ok()); + /// ``` + pub fn into_result(self, ok: T) -> Result { + match self { + Self::None => Ok(ok), + _ => Err(self), + } + } +} + +/// Renders each recorded error on its own line. Each item is rendered via its +/// own [`Display`] (and thus its own type-level strategy `WithContextFormat`), since this +/// Display impl routes through [`Listing`]. +impl Display for ManyErrors +where + WithContextFormat: Format>, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + as Format>::fmt(self, f) + } +} + +impl Error for ManyErrors +where + C: Debug, + E: Error + 'static, + WithContextFormat: Format> + Debug, +{ + /// For [`Self::One`], skips the inner error (already shown via Display) and + /// returns its source so chain-walking strategies don't duplicate it. + /// [`Self::Many`] has no single source — the chain ends here. + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::None | Self::Many(_) => None, + Self::One(p) => p.error.source(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + FormatError, + tests::{Inner, Mid}, + }; + + fn w(ctx: &'static str) -> WithContext<&'static str, Inner> { + WithContext::new(ctx, Inner::A) + } + + // --- push / variants --- + + #[test] + fn test_new_is_none() { + let e = ManyErrors::<&str, Inner>::new(); + assert!(matches!(e, ManyErrors::None)); + assert!(e.is_empty()); + assert_eq!(e.len(), 0); + } + + #[test] + fn test_push_none_to_one() { + let mut e = ManyErrors::new(); + e.push(w("a")); + assert!(matches!(e, ManyErrors::One(_))); + assert_eq!(e.len(), 1); + } + + #[test] + fn test_push_one_to_many() { + let mut e = ManyErrors::new(); + e.push(w("a")); + e.push(w("b")); + assert!(matches!(e, ManyErrors::Many(_))); + assert_eq!(e.len(), 2); + } + + #[test] + fn test_push_many_grows() { + let mut e: ManyErrors = ManyErrors::new(); + for i in 0..5u32 { + e.push(WithContext::new(i, Inner::A)); + } + assert_eq!(e.len(), 5); + } + + // --- into_result --- + + #[test] + fn test_into_result_none_ok() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.into_result(42), Ok(42)); + } + + #[test] + fn test_into_result_one_err() { + let mut e = ManyErrors::new(); + e.push(w("a")); + assert!(e.into_result(()).is_err()); + } + + #[test] + fn test_into_result_many_err() { + let mut e = ManyErrors::new(); + e.push(w("a")); + e.push(w("b")); + assert!(e.into_result(()).is_err()); + } + + // --- Display + Error --- + + #[test] + fn test_source_none() { + let e = ManyErrors::<&str, Inner>::new(); + assert!(e.source().is_none()); + } + + #[test] + fn test_source_one_skips_inner_error() { + let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); + e.push(WithContext::new("ctx", Mid::Inner(Inner::A))); + // Display already shows "ctx: mid"; source returns Mid's source (&Inner::A) + // so chain walkers don't repeat "mid". + let src = e.source().expect("should have source"); + assert_eq!(src.to_string(), "InnerA"); + } + + #[test] + fn test_source_many_is_none() { + let mut e = ManyErrors::new(); + e.push(w("a")); + e.push(w("b")); + assert!(e.source().is_none()); + } + + #[test] + fn test_one_line_one_walks_chain() { + let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); + e.push(WithContext::new("ctx", Mid::Inner(Inner::A))); + assert_eq!(e.one_line().to_string(), "ctx: mid: InnerA"); + } +} diff --git a/src/oneline.rs b/src/oneline.rs index 97a3f99..743ef19 100644 --- a/src/oneline.rs +++ b/src/oneline.rs @@ -20,14 +20,11 @@ impl Format for OneLine { #[cfg(test)] mod tests { - use core::fmt; use std::io; - use itertools::Itertools; - use crate::{ - Format, FormatError, Formatted, OneLine, chain, - tests::{Error, ErrorInner}, + FormatError, Formatted, OneLine, + tests::{Arrow, Error, Inner}, }; #[test] @@ -46,35 +43,24 @@ mod tests { assert_eq!(format!("{:?}", error.one_line()), "One"); assert_eq!(Formatted::<_, OneLine>::new(Error::One).to_string(), "One"); - let error = Error::Two(ErrorInner::One); - assert_eq!(error.one_line().to_string(), "Two: One"); - assert_eq!(format!("{:?}", error.one_line()), "Two(One)"); + let error = Error::Two(Inner::A); + assert_eq!(error.one_line().to_string(), "Two: InnerA"); + assert_eq!(format!("{:?}", error.one_line()), "Two(A)"); } #[test] fn test_from() { - let error = Error::Three(io::Error::other("test")); + // `#[from] io::Error` provides both the From impl and the source link. + let error: Error = io::Error::other("test").into(); assert_eq!(error.one_line().to_string(), "Three: test"); - assert_eq!( - format!("{:?}", error.one_line()), - "Three(Custom { kind: Other, error: \"test\" })" - ); - - let error = Error::Four(ErrorInner::Two); - assert_eq!(error.one_line().to_string(), "Two"); - assert_eq!(format!("{:?}", error.one_line()), "Four(Two)"); } #[test] fn test_custom_separator_via_format() { - struct Arrow; - impl Format for Arrow { - fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", chain(&error).format(" -> ")) - } - } - - let error = Error::Two(ErrorInner::One); - assert_eq!(Formatted::<_, Arrow>::new(error).to_string(), "Two -> One"); + let error = Error::Two(Inner::A); + assert_eq!( + Formatted::<_, Arrow>::new(error).to_string(), + "Two -> InnerA" + ); } } diff --git a/src/suggestion.rs b/src/suggestion.rs index 550123a..0cb8ab6 100644 --- a/src/suggestion.rs +++ b/src/suggestion.rs @@ -48,64 +48,169 @@ impl Format for Suggestion { #[cfg(test)] mod tests { - use thiserror::Error; + //! Tests are organized around one invariant: + //! + //! **`Suggest` is a caller-level annotation — it is never auto-delegated + //! through the source chain or through `#[error(transparent)]`.** + //! + //! `#[error(transparent)]` collapses `Display` and `source()`, but + //! `Suggest::fmt` is always dispatched on the *concrete outer type*. + //! Chain length, source depth, and transparent wrappers are all irrelevant. + + use core::error::Error as _; + use std::io; + + use crate::{ + Add, FormatError, + separator::NewLine, + tests::{Error, Inner, Mid, NoHint}, + }; + + // --- baseline: hint vs no-hint --- - use super::*; - use crate::FormatError; + #[test] + fn hint_variant_renders_message() { + // Error::One has a hint; Error::Three has a different hint. + assert_eq!( + Error::One.suggestion().to_string(), + "Try passing --help to see available options.", + ); + assert_eq!( + Error::Three(io::Error::other("x")).suggestion().to_string(), + "Check that the file path exists and permissions are correct.", + ); + } - #[derive(Error, Debug)] - pub enum SugError { - #[error("env file missing")] - NoEnv, - #[error("something else")] - Other, + #[test] + fn no_hint_variant_renders_empty_string() { + assert_eq!(Error::Two(Inner::A).suggestion().to_string(), ""); + assert_eq!( + Error::Transparent(Mid::Inner(Inner::A)) + .suggestion() + .to_string(), + "" + ); } - 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(()), - } - } + #[test] + fn default_impl_writes_nothing() { + // NoHint uses the default impl — suggestion() must be callable and empty. + assert_eq!(NoHint.suggestion().to_string(), ""); } - #[derive(Error, Debug)] - #[error("plain")] - struct NoHint; + #[test] + fn debug_of_formatted_suggestion_forwards_to_inner_debug() { + // Formatted<_, Suggestion> forwards Debug to the inner error's Debug, + // not to Suggestion (which is a zero-size tag type). + assert_eq!(format!("{:?}", Error::One.suggestion()), "One"); + assert_eq!(format!("{:?}", NoHint.suggestion()), "NoHint"); + } - impl Suggest for NoHint {} + // --- suggestion is orthogonal to Display --- #[test] - fn renders_variant_hint() { - let error = SugError::NoEnv; + fn one_line_and_suggestion_are_independent_strategies() { + let e = Error::One; + // one_line walks the source chain; suggestion ignores it. + assert_eq!(e.one_line().to_string(), "One"); + assert_eq!( + e.suggestion().to_string(), + "Try passing --help to see available options.", + ); + // They can be composed via Add. assert_eq!( - error.suggestion().to_string(), - "Did you mean rename the .env.example file to .env?" + e.formatted::>>() + .to_string(), + "One\nTry passing --help to see available options.", ); } + // --- suggestion does NOT walk the source chain --- + #[test] - fn renders_empty_for_variant_without_hint() { - let error = SugError::Other; - assert_eq!(error.suggestion().to_string(), ""); + fn suggestion_ignores_source_chain_depth() { + // Error::Two has a source (Inner::A); its Suggest arm returns "". + let with_source = Error::Two(Inner::A); + assert_eq!(with_source.one_line().to_string(), "Two: InnerA"); + assert_eq!(with_source.suggestion().to_string(), ""); + + // Longer chain: Error::Transparent → Mid::Inner → Inner::A. + let with_chain = Error::Transparent(Mid::Inner(Inner::A)); + assert_eq!(with_chain.one_line().to_string(), "mid: InnerA"); + assert_eq!(with_chain.suggestion().to_string(), ""); } #[test] - fn default_impl_writes_nothing() { - let error = NoHint; - assert_eq!(error.suggestion().to_string(), ""); + fn suggestion_fires_even_with_no_source() { + // Error::One has no source — prove chain depth is not required. + assert!(Error::One.source().is_none()); + assert_ne!(Error::One.suggestion().to_string(), ""); + } + + // --- suggestion is NOT delegated through transparent --- + + #[test] + fn transparent_collapses_display_but_not_suggestion() { + // Error::Transparent is #[error(transparent)] — display collapses to Mid's. + // But Suggest::fmt is dispatched on Error, not on Mid. + // Error's Transparent arm returns "". + let with_inner = Error::Transparent(Mid::Inner(Inner::A)); + let with_io = Error::Transparent(Mid::Io(io::Error::other("io error"))); + + // Display collapsed through transparent. + assert_eq!(with_inner.to_string(), "mid"); + assert_eq!(with_io.to_string(), "io error"); + + // Suggestion is NOT collapsed — outer type's impl always wins. + assert_eq!(with_inner.suggestion().to_string(), ""); + assert_eq!(with_io.suggestion().to_string(), ""); } #[test] - fn debug_forwards_to_inner() { - let error = SugError::NoEnv; - assert_eq!(format!("{:?}", error.suggestion()), "NoEnv"); + fn double_transparent_display_collapses_suggestion_stays_at_outermost() { + // Error::Transparent(Mid::Io(io_err)): + // display = io message (two transparent layers) + // source = None (io::Error::other has none) + // suggestion = "" (Error's Transparent arm, not delegated) + let e = Error::Transparent(Mid::Io(io::Error::other("deep io"))); + assert_eq!(e.to_string(), "deep io"); + assert!(e.source().is_none()); + assert_eq!(e.suggestion().to_string(), ""); + // one_line has no chain to walk — just the one display string. + assert_eq!(e.one_line().to_string(), "deep io"); } #[test] - fn one_line_still_works_on_suggestion_types() { - let error = SugError::NoEnv; - assert_eq!(error.one_line().to_string(), "env file missing"); + fn hint_and_no_hint_variants_coexist_in_same_type() { + // Error has both hint-bearing (One, Three) and silent (Two, Transparent) variants. + // The match arm in Suggest controls everything — no cross-variant leakage. + assert_ne!(Error::One.suggestion().to_string(), ""); + assert_eq!(Error::Two(Inner::A).suggestion().to_string(), ""); + assert_ne!( + Error::Three(io::Error::other("x")).suggestion().to_string(), + "", + ); + assert_eq!( + Error::Transparent(Mid::Inner(Inner::A)) + .suggestion() + .to_string(), + "", + ); + } + + // --- ref delegation: impl Suggest for &T --- + + #[test] + fn suggest_blanket_impl_works_on_shared_ref() { + // impl Suggest for &T delegates to T. + let e = Error::One; + let r: &Error = &e; + // &Error: core::error::Error (via blanket) + Suggest (via blanket on &T). + assert_eq!( + r.suggestion().to_string(), + "Try passing --help to see available options.", + ); + let no: &NoHint = &NoHint; + assert_eq!(no.suggestion().to_string(), ""); } } diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..9abb242 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,302 @@ +//! Shared test fixtures: error types and reusable [`Format`] strategies. +//! +//! Each module's `#[cfg(test)] mod tests` pulls types and formatters from +//! here so per-module tests stay focused on the unit under test rather than +//! re-declaring boilerplate. +#![cfg(test)] + +use core::{ + error::Error as _, + fmt::{self, Display, Formatter}, + hash::Hash, +}; +use std::io; + +use itertools::Itertools as _; +use thiserror::Error; + +use super::*; + +/// Inner leaf error used as the source for [`Error::Two`] / [`Error::Three`] and chain tests. +#[derive(Error, Debug, Clone, PartialEq, Eq, Hash)] +pub enum Inner { + #[error("InnerA")] + A, + #[error("InnerB")] + B, +} + +/// Middle error with two variants: +/// - [`Mid::Inner`]: wraps [`Inner`] as a source, display `"mid"`. +/// - [`Mid::Io`]: transparent wrapper around [`io::Error`], `From` provided. +#[derive(Error, Debug)] +pub enum Mid { + #[error("mid")] + Inner(#[source] Inner), + #[error(transparent)] + Io(#[from] io::Error), +} + +/// Top-level error covering all `#[source]`/`#[from]`/`#[error(transparent)]`/none combinations: +/// - [`Error::One`]: plain unit variant, no source. +/// - [`Error::Two`]: explicit `#[source]`; Display prints `"Two"` only. +/// - [`Error::Three`]: `#[from] io::Error`; Display prints `"Three"`. +/// - [`Error::WithCtx`]: wraps a [`WithContext`] as a source. +/// - [`Error::Many`]: wraps a [`ManyErrors`] as a source (alloc only). +/// - [`Error::Transparent`]: `#[error(transparent)]`; delegates Display and source to [`Mid`]. +#[derive(Error, Debug)] +pub enum Error { + #[error("One")] + One, + #[error("Two")] + Two(#[source] Inner), + #[error("Three")] + Three(#[from] io::Error), + #[error("WithCtx")] + WithCtx(#[source] WithContext<&'static str, Inner>), + #[cfg(feature = "alloc")] + #[error("Many")] + Many(#[source] ManyErrors<&'static str, Inner>), + #[error(transparent)] + Transparent(#[from] Mid), +} + +impl Suggest for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::One => f.write_str("Try passing --help to see available options."), + Self::Three(_) => { + f.write_str("Check that the file path exists and permissions are correct.") + } + Self::Two(_) | Self::WithCtx(_) | Self::Transparent(_) => Ok(()), + #[cfg(feature = "alloc")] + Self::Many(_) => Ok(()), + } + } +} + +/// Error with no suggestion, exercises the default [`Suggest`] impl. +#[derive(Error, Debug)] +#[error("plain")] +pub struct NoHint; + +impl Suggest for NoHint {} + +/// Reusable [`Format`] strategy: joins error chain with `" -> "`. +#[derive(Debug, Default)] +pub struct Arrow; +impl Format for Arrow { + fn fmt(error: &E, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", chain(error).format(" -> ")) + } +} + +/// Reusable [`Format`] strategy: uppercases the top-level error's `Display`. +#[derive(Debug, Default)] +pub struct Upper; +impl Format for Upper { + fn fmt(error: &E, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", error.to_string().to_uppercase()) + } +} + +/// [`WithContext`] formatter producing `"[ctx] err"`. +#[derive(Debug, Default)] +pub struct Bracketed; +impl Format> for Bracketed { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {}", w.context, w.error) + } +} + +/// [`WithContext`] formatter producing `"ctx -> err"`. +#[derive(Debug, Default)] +pub struct WcArrow; +impl Format> for WcArrow { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{} -> {}", w.context, w.error) + } +} + +// --- lib-level integration tests --- + +fn _assert_derive_traits() { + #[derive(Clone, Copy, Default, PartialEq, Eq, Hash, Debug)] + struct DummyError; + impl fmt::Display for DummyError { + fn fmt(&self, _: &mut Formatter<'_>) -> fmt::Result { + Ok(()) + } + } + impl core::error::Error for DummyError {} + + fn assert_all() {} + assert_all::>(); + assert_all::>(); + assert_all::>(); + assert_all::(); + assert_all::(); + assert_all::(); + assert_all::(); +} + +#[test] +fn test_user_output() { + assert_eq!(Error::One.one_line().to_string(), "One"); + assert_eq!(Error::Two(Inner::A).one_line().to_string(), "Two: InnerA"); + assert_eq!( + Error::Three(io::Error::new(io::ErrorKind::PermissionDenied, "test")) + .one_line() + .to_string(), + "Three: test" + ); + // `#[from]` generates the conversion used at `?` boundaries. + let from_io: Error = io::Error::other("boom").into(); + assert_eq!(from_io.one_line().to_string(), "Three: boom"); +} + +#[test] +fn test_dyn_error() { + let error = Error::Two(Inner::A); + + let dyn_ref: &dyn core::error::Error = &error; + assert_eq!(dyn_ref.one_line().to_string(), "Two: InnerA"); + + let boxed: Box = Box::new(Error::Two(Inner::B)); + assert_eq!(boxed.one_line().to_string(), "Two: InnerB"); + + let send_sync: &(dyn core::error::Error + Send + Sync) = &error; + assert_eq!(send_sync.one_line().to_string(), "Two: InnerA"); +} + +#[test] +fn test_custom_format() { + assert_eq!(Error::Two(Inner::A).formatted::().to_string(), "TWO"); +} + +#[test] +fn test_with_ctx_variant() { + let e = Error::WithCtx(WithContext::new("step", Inner::A)); + assert_eq!(e.to_string(), "WithCtx"); + assert_eq!(e.one_line().to_string(), "WithCtx: step: InnerA"); +} + +#[cfg(feature = "alloc")] +#[test] +fn test_many_variant() { + let mut errs = ManyErrors::new(); + errs.push(WithContext::new("a", Inner::A)); + errs.push(WithContext::new("b", Inner::B)); + let e = Error::Many(errs); + assert_eq!(e.to_string(), "Many"); + // ManyErrors is the source; its Display renders all items. + assert_eq!(e.one_line().to_string(), "Many: a: InnerA\nb: InnerB"); +} + +// --- transparent --- + +#[test] +fn test_transparent_display_collapses_wrapper() { + // The word "Transparent" never appears in rendered output. + assert_eq!(Error::Transparent(Mid::Inner(Inner::A)).to_string(), "mid",); + assert_eq!( + Error::Transparent(Mid::Io(io::Error::other("disk full"))).to_string(), + "disk full", + ); + // Two transparent layers: Error::Transparent(Mid::Io(...)) drills to io message. + assert_eq!( + Error::Transparent(Mid::Io(io::Error::other("deep"))).to_string(), + "deep", + ); +} + +#[test] +fn test_transparent_source_delegates_not_wraps() { + // source() is delegated, not wrapped — the transparent variant itself is + // NOT a node in the source chain. + + // Mid::Inner(Inner::A).source() = Some(Inner::A). + // Error::Transparent(Mid::Inner(Inner::A)).source() follows Mid's source. + let e = Error::Transparent(Mid::Inner(Inner::A)); + let src = e.source().expect("Inner::A must be the source"); + assert_eq!(src.to_string(), "InnerA"); + + // io::Error::other has no source; Mid::Io delegates, so None. + let e2 = Error::Transparent(Mid::Io(io::Error::other("boom"))); + assert!(e2.source().is_none()); + + // Double-transparent: Error::Transparent(Mid::Io) → mid.source() → None. + let e3 = Error::Transparent(Mid::Io(io::Error::other("deep"))); + assert!(e3.source().is_none()); +} + +#[test] +fn test_transparent_chain_never_shows_wrapper_name() { + // one_line and tree walk the chain. "Transparent" must not appear. + let e = Error::Transparent(Mid::Inner(Inner::A)); + let one = e.one_line().to_string(); + let tree = e.tree().to_string(); + assert!( + !one.contains("Transparent"), + "one_line should not contain 'Transparent': {one}" + ); + assert!( + !tree.contains("Transparent"), + "tree should not contain 'Transparent': {tree}" + ); + assert_eq!(one, "mid: InnerA"); + assert_eq!(tree, "mid\n└── InnerA"); +} + +#[test] +fn test_transparent_two_vs_transparent_same_source_different_display() { + // Error::Two shows its own label; Error::Transparent(Mid::Inner) shows Mid's. + // Same source (Inner::A), different top-level display. + let two = Error::Two(Inner::A); + let transp = Error::Transparent(Mid::Inner(Inner::A)); + + assert_eq!(two.to_string(), "Two"); + assert_eq!(transp.to_string(), "mid"); + + assert_eq!(two.one_line().to_string(), "Two: InnerA"); + assert_eq!(transp.one_line().to_string(), "mid: InnerA"); + + assert_eq!( + two.source().unwrap().to_string(), + transp.source().unwrap().to_string(), + ); +} + +#[test] +fn test_from_io_routes_differ_by_variant() { + // io::Error -> Error via #[from] on Three: direct route. + let via_three: Error = io::Error::other("direct").into(); + assert!(matches!(via_three, Error::Three(_))); + assert_eq!(via_three.to_string(), "Three"); // NOT transparent — own display + + // io::Error -> Mid -> Error: two-hop From, lands in Transparent. + let via_mid: Error = Mid::from(io::Error::other("via mid")).into(); + assert!(matches!(via_mid, Error::Transparent(Mid::Io(_)))); + assert_eq!(via_mid.to_string(), "via mid"); // transparent — io message + + // The two routes coexist; neither hides the other. +} + +#[test] +fn test_suggest_not_delegated_through_transparent() { + // #[error(transparent)] delegates Display + source — but NOT Suggest. + // Suggest::fmt is always dispatched on the concrete outer type. + let e = Error::Transparent(Mid::Inner(Inner::A)); + + // Display is transparent (collapses to Mid's message). + assert_eq!(e.to_string(), "mid"); + // Suggestion uses Error's Transparent arm — returns "". + assert_eq!(e.suggestion().to_string(), ""); + + // Control: hint-bearing variants still work. + assert_ne!(Error::One.suggestion().to_string(), ""); + assert_ne!( + Error::Three(io::Error::other("x")).suggestion().to_string(), + "", + ); +} diff --git a/src/tree.rs b/src/tree.rs index 3bd7b8b..d6415ce 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -85,7 +85,7 @@ mod tests { use crate::{ Format, FormatError, Formatted, Tree, TreeIndent, TreeMarker, chain, - tests::{Error, ErrorInner}, + tests::{Error, Inner}, }; #[test] @@ -96,21 +96,21 @@ mod tests { #[test] fn test_tree_one_source() { - let error = Error::Two(ErrorInner::One); - assert_eq!(error.tree().to_string(), "Two\n└── One"); + let error = Error::Two(Inner::A); + assert_eq!(error.tree().to_string(), "Two\n└── InnerA"); } #[test] fn test_tree_nested() { - let error = Error::Two(ErrorInner::Two); - assert_eq!(error.tree().to_string(), "Two\n└── Two"); + let error = Error::Two(Inner::B); + assert_eq!(error.tree().to_string(), "Two\n└── InnerB"); } #[test] fn test_tree_custom_marker_and_indent() { #[derive(Default)] - struct Arrow; - impl fmt::Display for Arrow { + struct ArrowMarker; + impl fmt::Display for ArrowMarker { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("|-> ") } @@ -124,10 +124,10 @@ mod tests { } } - let error = Error::Two(ErrorInner::One); + let error = Error::Two(Inner::A); assert_eq!( - Formatted::<_, Tree>::new(error).to_string(), - "Two\n|-> One" + Formatted::<_, Tree>::new(error).to_string(), + "Two\n|-> InnerA" ); } @@ -168,10 +168,10 @@ mod tests { } } - let error = Error::Two(ErrorInner::One); + let error = Error::Two(Inner::A); assert_eq!( Formatted::<_, AsciiTree>::new(error).to_string(), - "Two\n|-- One" + "Two\n|-- InnerA" ); } } diff --git a/src/with_context.rs b/src/with_context.rs deleted file mode 100644 index 816655e..0000000 --- a/src/with_context.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! Context-tagged error pair. - -use core::{ - error::Error, - fmt::{self, Debug, Display, Formatter}, - marker::PhantomData, -}; - -use crate::Format; - -pub use crate::with_context::format::{Colon, ContextField, ErrorField, WithContextColon}; -#[cfg(feature = "std")] -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 -/// [`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). -/// -/// [`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 -/// ``` -/// use errortools::{FormatError, with_context::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 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}}; -/// -/// // 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::{Format, WithContext}; -/// -/// struct Arrow; -/// impl Format> for Arrow { -/// fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { -/// write!(f, "{} -> {}", w.context, w.error) -/// } -/// } -/// -/// let w = WithContext::<_, _, Arrow>::new(1, "boom"); -/// assert_eq!(w.to_string(), "1 -> 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 - where - G: Format>, - { - 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`. `C` and `E` have no `Display` bound -/// here — the strategy decides what each must implement. -impl Display for WithContext -where - F: Format, -{ - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - F::fmt(self, 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: Debug, - E: Error + 'static, - F: Format, -{ - /// 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 { - //! 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. - /// - /// 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; - - /// 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; - - /// [`Format`] extractor that prints the `context` field via `Display`. - /// - /// 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) - } - } - - /// [`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 ErrorField; - - impl Format> for ErrorField { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { - Display::fmt(&w.error, f) - } - } - - /// [`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 [`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 ContextPath; - - #[cfg(feature = "std")] - 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)] -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); - - /// Custom one-shot strategy: `[ctx] err`. - struct Bracketed; - impl Format> for Bracketed { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "[{}] {}", w.context, w.error) - } - } - - /// 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 = WithContextColon::new("ctx", Leaf); - assert_eq!(w.context, "ctx"); - } - - #[test] - fn test_from_tuple() { - let w: WithContextColon<&str, Leaf> = ("ctx", Leaf).into(); - assert_eq!(w.context, "ctx"); - } - - #[test] - fn test_display_default_format() { - 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 = WithContextColon::new("ctx", Leaf); - assert!(w.source().is_none()); - - // For Middle(Leaf), source must be Leaf — not Middle (which Display already shows). - 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 = 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 = WithContextColon::new("config", io); - assert_eq!(w.one_line().to_string(), "config: file missing"); - } - - #[test] - fn test_custom_format_strategy() { - struct Arrow; - impl Format> for Arrow { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{} -> {}", w.context, w.error) - } - } - - 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", - ); - } - - /// `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"); - } - - /// 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"); - } -} diff --git a/src/with_context/format.rs b/src/with_context/format.rs new file mode 100644 index 0000000..6a0de50 --- /dev/null +++ b/src/with_context/format.rs @@ -0,0 +1,114 @@ +//! 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. +/// +/// 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; + +/// 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; + +/// [`Format`] extractor that prints the `context` field via `Display`. +/// +/// 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) + } +} + +/// [`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 ErrorField; + +impl Format> for ErrorField { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&w.error, f) + } +} + +/// [`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 [`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 ContextPath; + +#[cfg(feature = "std")] +impl, E, WithContextFormat> 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)] +mod tests { + use super::*; + use crate::{WithContext, separator::WithSpace, tests::Inner}; + use std::io; + + /// `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"); + } + + /// Compose a custom delimiter without writing a new Format impl. + #[test] + fn test_composed_separator() { + type SpacePair = WithSpace; + + let w = WithContext::<_, _, SpacePair>::new("ctx", Inner::A); + assert_eq!(w.to_string(), "ctx InnerA"); + } +} diff --git a/src/with_context/mod.rs b/src/with_context/mod.rs new file mode 100644 index 0000000..9b316cd --- /dev/null +++ b/src/with_context/mod.rs @@ -0,0 +1,250 @@ +//! Context-tagged error pair. + +use core::{ + error::Error, + fmt::{self, Debug, Display, Formatter}, + marker::PhantomData, +}; + +use crate::Format; + +mod format; + +pub use crate::with_context::format::{Colon, ContextField, ErrorField, WithContextColon}; +#[cfg(feature = "std")] +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 +/// [`Format`] strategy. +/// +/// `Display` delegates to `WithContextFormat::fmt(self, f)`, so any +/// `WithContextFormat: Format>` +/// can format the pair. Strategies are usually built by composing the field +/// extractors [`ContextField`] / [`ErrorField`] (or `ContextPath` when +/// `C: AsRef`, requires `std`) with separator strategies via +/// [`Add`](crate::Add) / [`WithSep`](crate::separator::WithSep), e.g. the default +/// [`Colon`] is [`WithColonSpace`](crate::separator::WithColonSpace). +/// +/// [`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 +/// ``` +/// use errortools::{FormatError, with_context::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 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}}; +/// +/// // 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::{Format, WithContext}; +/// +/// struct Arrow; +/// impl Format> for Arrow { +/// fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { +/// write!(f, "{} -> {}", w.context, w.error) +/// } +/// } +/// +/// let w = WithContext::<_, _, Arrow>::new(1, "boom"); +/// assert_eq!(w.to_string(), "1 -> 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 WithContextFormat>, +} + +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 + where + NewSelfFormat: Format>, + { + 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 `WithContextFormat`. `C` and `E` have +/// no `Display` bound here — the strategy decides what each must implement. +impl Display for WithContext +where + WithContextFormat: Format, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + WithContextFormat::fmt(self, 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: Debug, + E: Error + 'static, + WithContextFormat: Format, +{ + /// 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() + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use thiserror::Error; + + use super::*; + use crate::{ + FormatError, + tests::{Bracketed, Inner, Mid, WcArrow}, + }; + + /// Caller-facing error in this test module. The `#[from]` impl is what + /// drives `WithContextFormat = Bracketed` inference at the `?` site in [`returning_error`]. + #[derive(Error, Debug)] + #[error("an error happened")] + pub struct PropError(#[from] WithContext<&'static str, Mid, Bracketed>); + + fn returning_middle() -> Result<(), Mid> { + Err(Mid::Inner(Inner::A)) + } + + /// 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, `WithContextFormat` is inferred from the `From` impl on `Error`. + fn returning_error() -> Result<(), PropError> { + returning_middle().map_err(|e| WithContext::new("context", e))?; + Ok(()) + } + + #[test] + fn test_new_and_fields() { + let w = WithContextColon::new("ctx", Inner::A); + assert_eq!(w.context, "ctx"); + } + + #[test] + fn test_from_tuple() { + let w: WithContextColon<&str, Inner> = ("ctx", Inner::A).into(); + assert_eq!(w.context, "ctx"); + } + + #[test] + fn test_display_default_format() { + let w = WithContextColon::new("step 3", Inner::A); + assert_eq!(w.to_string(), "step 3: InnerA"); + } + + #[test] + fn test_source_skips_inner_error() { + // Inner::A has no source, so skipping it yields None. + let w = WithContextColon::new("ctx", Inner::A); + assert!(w.source().is_none()); + + // For Mid::Inner(Inner::A), source must be Inner — not Mid (which Display already shows). + let w = WithContextColon::new("ctx", Mid::Inner(Inner::A)); + let src = w.source().expect("source must be Some"); + assert_eq!(src.to_string(), "InnerA"); + } + + #[test] + fn test_one_line_walks_full_chain() { + let w = WithContextColon::new("ctx", Mid::Inner(Inner::A)); + assert_eq!(w.one_line().to_string(), "ctx: mid: InnerA"); + } + + #[test] + fn test_io_error_chain() { + let io = io::Error::new(io::ErrorKind::NotFound, "file missing"); + let w = WithContextColon::new("config", io); + assert_eq!(w.one_line().to_string(), "config: file missing"); + } + + #[test] + fn test_custom_format_strategy() { + let w = WithContext::<_, _, WcArrow>::new("step", Inner::A); + assert_eq!(w.to_string(), "step -> InnerA"); + } + + #[test] + fn test_custom_format_affects_one_line() { + let w = WithContext::<_, _, Bracketed>::new("ctx", Mid::Inner(Inner::A)); + // Display: "[ctx] mid" — then chain appends ": InnerA" via source. + assert_eq!(w.one_line().to_string(), "[ctx] mid: InnerA"); + } + + /// End-to-end: `map_err` wraps an inner error with [`WithContext`], `?` + /// fires `From> for PropError` (and pins + /// `WithContextFormat`), 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] mid: InnerA", + ); + } +}