From 678e2fb35c8bf3e5f396ab2dc90d956cce2b5efe Mon Sep 17 00:00:00 2001 From: maxwase Date: Sun, 17 May 2026 03:53:14 +0300 Subject: [PATCH 1/9] ManyErrors prototype --- .github/workflows/ci.yml | 1 + Cargo.toml | 6 +- src/lib.rs | 5 + src/many_errors.rs | 582 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 src/many_errors.rs 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/lib.rs b/src/lib.rs index e8fa269..f38bb88 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; diff --git a/src/many_errors.rs b/src/many_errors.rs new file mode 100644 index 0000000..e60d935 --- /dev/null +++ b/src/many_errors.rs @@ -0,0 +1,582 @@ +//! Aggregated, context-tagged errors from iterator/fold-style operations. + +use core::{ + error::Error, + fmt::{self, Debug, Display, Formatter}, + marker::PhantomData, + ops::ControlFlow, +}; + +use alloc::{vec, vec::Vec}; + +use crate::{Format, with_context::WithContext}; + +/// Zero or more context-tagged errors collected during an iterator/fold operation. +/// +/// The three-variant split lets consumers pattern-match and render each case +/// differently — for example, printing a single error with its full chain or +/// listing all errors line-by-line with [`ManyErrors::list`]. +/// +/// # 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), + } + } + + /// Returns an iterator over references to each recorded [`WithContext`]. + pub fn iter(&self) -> Iter<'_, C, E> { + Iter::new(self) + } + + /// Returns a [`Display`](fmt::Display) adapter that renders each error on its + /// own line using format strategy `F`. + /// + /// # Example + /// ``` + /// use errortools::{ManyErrors, OneLine, WithContext}; + /// + /// let mut errs = ManyErrors::<&str, std::io::Error>::new(); + /// errs.push(WithContext::new("a", std::io::Error::other("err a"))); + /// errs.push(WithContext::new("b", std::io::Error::other("err b"))); + /// let output = errs.list::().to_string(); + /// assert!(output.contains("a: err a")); + /// assert!(output.contains("b: err b")); + /// ``` + pub fn list>>(&self) -> Listing<'_, C, E, F> + where + C: Display + Debug, + E: Error + 'static, + { + Listing(self, PhantomData) + } +} + +// --- FromIterator --- + +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, WithContext>> for ManyErrors { + fn from_iter(iter: I) -> Self + where + I: IntoIterator, 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) { + 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, WithContext>> for ManyErrors { + fn extend(&mut self, iter: I) + where + I: IntoIterator, 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)), + })); + } +} + +// --- Display + Error --- + +impl Display for ManyErrors { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::None => Ok(()), + Self::One(p) => Display::fmt(&p.context, f), + Self::Many(v) => write!(f, "{} errors", v.len()), + } + } +} + +impl Error for ManyErrors +where + C: Display + Debug, + E: Error + 'static, +{ + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::None => None, + // Skip the WithContext wrapper to avoid repeating the context in the chain. + Self::One(p) => Some(&p.error), + Self::Many(_) => None, + } + } +} + +// --- Iter --- + +/// Iterator over references to each [`WithContext`] in a [`ManyErrors`]. +pub struct Iter<'a, C, E>(IterInner<'a, C, E>); + +enum IterInner<'a, C, E> { + Empty, + One(Option<&'a WithContext>), + Many(core::slice::Iter<'a, WithContext>), +} + +impl<'a, C, E> Iter<'a, C, E> { + 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> Iterator for Iter<'a, C, E> { + 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(), + } + } +} + +// --- Listing --- + +/// Renders all errors in a [`ManyErrors`], one per line, each via strategy `F`. +/// +/// Obtained from [`ManyErrors::list`]. +pub struct Listing<'a, C, E, F = crate::OneLine>(&'a ManyErrors, PhantomData F>); + +impl Display for Listing<'_, C, E, F> +where + C: Display + Debug, + E: Error + 'static, + F: Format>, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let mut it = self.0.iter(); + let Some(first) = it.next() else { + return Ok(()); + }; + F::fmt(first, f)?; + for p in it { + writeln!(f)?; + F::fmt(p, f)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use thiserror::Error; + + use super::*; + use crate::{FormatError, OneLine, Tree}; + + #[derive(Error, Debug, Clone, PartialEq, Eq)] + #[error("leaf")] + struct Leaf; + + #[derive(Error, Debug, Clone, PartialEq, Eq)] + #[error("mid")] + struct Mid(#[source] Leaf); + + fn w(ctx: &'static str) -> WithContext<&'static str, Leaf> { + WithContext::new(ctx, Leaf) + } + + // --- push / variants --- + + #[test] + fn test_new_is_none() { + let e = ManyErrors::<&str, Leaf>::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::new(); + for i in 0..5u32 { + e.push(WithContext::new(i, Leaf)); + } + assert_eq!(e.len(), 5); + } + + // --- into_result --- + + #[test] + fn test_into_result_none_ok() { + let e = ManyErrors::<&str, Leaf>::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()); + } + + // --- FromIterator / Extend --- + + #[test] + fn test_collect_from_with_context() { + let errs: ManyErrors<&str, Leaf> = [w("a"), w("b"), w("c")].into_iter().collect(); + assert_eq!(errs.len(), 3); + } + + #[test] + fn test_collect_from_tuples() { + let errs: ManyErrors<&str, Leaf> = [("a", Leaf), ("b", Leaf)].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() { + use itertools::Itertools as _; + + let results: Vec> = + vec![Ok(1), Err(("a", Leaf)), Ok(2), Err(("b", Leaf))]; + let (oks, errs): (Vec, ManyErrors<&str, Leaf>) = + results.into_iter().partition_result(); + assert_eq!(oks, [1, 2]); + assert_eq!(errs.len(), 2); + } + + // --- ControlFlow --- + + #[test] + fn test_control_flow_all_continue() { + #[allow(clippy::type_complexity)] + let items: Vec, WithContext<&str, Leaf>>> = + vec![ControlFlow::Continue(w("a")), ControlFlow::Continue(w("b"))]; + let errs: ManyErrors<&str, Leaf> = 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, Leaf)) + } else { + ControlFlow::Continue(WithContext::new(*s, Leaf)) + } + }); + let errs: ManyErrors<&str, Leaf> = 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", Leaf)), + ControlFlow::Break(("b", Leaf)), + ]; + let errs: ManyErrors<&str, Leaf> = items.into_iter().collect(); + assert_eq!(errs.len(), 2); + } + + // --- Display + Error --- + + #[test] + fn test_display_none_is_empty() { + let e = ManyErrors::<&str, Leaf>::new(); + assert_eq!(e.to_string(), ""); + } + + #[test] + fn test_display_one_writes_context() { + let mut e = ManyErrors::new(); + e.push(w("step 1")); + assert_eq!(e.to_string(), "step 1"); + } + + #[test] + fn test_display_many_writes_count() { + let mut e = ManyErrors::new(); + e.push(w("a")); + e.push(w("b")); + e.push(w("c")); + assert_eq!(e.to_string(), "3 errors"); + } + + #[test] + fn test_source_none() { + let e = ManyErrors::<&str, Leaf>::new(); + assert!(e.source().is_none()); + } + + #[test] + fn test_source_one_skips_wrapper() { + let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); + e.push(WithContext::new("ctx", Mid(Leaf))); + // source should be &Mid, not &WithContext + let src = e.source().expect("should have source"); + assert_eq!(src.to_string(), "mid"); + } + + #[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(Leaf))); + assert_eq!(e.one_line().to_string(), "ctx: mid: leaf"); + } + + // --- iter --- + + #[test] + fn test_iter_none() { + let e = ManyErrors::<&str, Leaf>::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"]); + } + + // --- Listing --- + + #[test] + fn test_listing_none_empty() { + let e = ManyErrors::<&str, Leaf>::new(); + assert_eq!(e.list::().to_string(), ""); + } + + #[test] + fn test_listing_one_formats_chain() { + let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); + e.push(WithContext::new("ctx", Mid(Leaf))); + assert_eq!(e.list::().to_string(), "ctx: mid: leaf"); + } + + #[test] + fn test_listing_many_one_per_line() { + let errs: ManyErrors<&str, Leaf> = [("a", Leaf), ("b", Leaf), ("c", Leaf)] + .into_iter() + .collect(); + let out = errs.list::().to_string(); + assert_eq!(out, "a: leaf\nb: leaf\nc: leaf"); + } + + #[test] + fn test_listing_tree_strategy() { + let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); + e.push(WithContext::new("ctx", Mid(Leaf))); + let out = e.list::().to_string(); + assert!(out.contains("ctx")); + assert!(out.contains("mid")); + assert!(out.contains("leaf")); + } + + // --- io::Error integration --- + + #[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); + } +} From eaa4e4f3c52315b8ae0a1a14f2656d2676475169 Mon Sep 17 00:00:00 2001 From: Max Wase Date: Sun, 17 May 2026 22:36:10 +0300 Subject: [PATCH 2/9] Refactor ManyErrors --- src/lib.rs | 23 +- src/many_errors/iter.rs | 170 +++++++++ src/{many_errors.rs => many_errors/mod.rs} | 409 ++++++++------------- src/with_context.rs | 74 ++-- 4 files changed, 396 insertions(+), 280 deletions(-) create mode 100644 src/many_errors/iter.rs rename src/{many_errors.rs => many_errors/mod.rs} (54%) diff --git a/src/lib.rs b/src/lib.rs index f38bb88..63fbf9d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,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; @@ -36,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. @@ -56,6 +59,21 @@ pub trait Format { fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result; } +/// Sentinel [`Format`] strategy that delegates to the value's own [`Display`] +/// impl. +/// +/// Useful as a default in strategy-aware wrappers (e.g. [`Listing`](crate::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 @@ -109,6 +127,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) diff --git a/src/many_errors/iter.rs b/src/many_errors/iter.rs new file mode 100644 index 0000000..50aaa99 --- /dev/null +++ b/src/many_errors/iter.rs @@ -0,0 +1,170 @@ +// --- 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 std::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)), + })); + } + } +} diff --git a/src/many_errors.rs b/src/many_errors/mod.rs similarity index 54% rename from src/many_errors.rs rename to src/many_errors/mod.rs index e60d935..62e1355 100644 --- a/src/many_errors.rs +++ b/src/many_errors/mod.rs @@ -4,18 +4,24 @@ use core::{ error::Error, fmt::{self, Debug, Display, Formatter}, marker::PhantomData, - ops::ControlFlow, }; use alloc::{vec, vec::Vec}; -use crate::{Format, with_context::WithContext}; +use crate::{ + AsDisplay, Format, + with_context::{Colon, WithContext}, +}; + +mod iter; /// Zero or more context-tagged errors collected during an iterator/fold operation. /// -/// The three-variant split lets consumers pattern-match and render each case -/// differently — for example, printing a single error with its full chain or -/// listing all errors line-by-line with [`ManyErrors::list`]. +/// 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 /// ``` @@ -26,17 +32,17 @@ use crate::{Format, with_context::WithContext}; /// assert!(errs.is_empty()); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] -pub enum ManyErrors { +pub enum ManyErrors { /// No errors were recorded. #[default] None, /// Exactly one error was recorded. - One(WithContext), + One(WithContext), /// Two or more errors were recorded. - Many(Vec>), + Many(Vec>), } -impl ManyErrors { +impl ManyErrors { /// Creates an empty `ManyErrors`. pub const fn new() -> Self { Self::None @@ -66,7 +72,7 @@ impl ManyErrors { /// errs.push(WithContext::new("step 1", std::io::Error::other("fail"))); /// assert_eq!(errs.len(), 1); /// ``` - pub fn push(&mut self, item: WithContext) { + pub fn push(&mut self, item: WithContext) { let prev = core::mem::take(self); *self = match prev { Self::None => Self::One(item), @@ -93,218 +99,87 @@ impl ManyErrors { _ => Err(self), } } - - /// Returns an iterator over references to each recorded [`WithContext`]. - pub fn iter(&self) -> Iter<'_, C, E> { - Iter::new(self) - } - - /// Returns a [`Display`](fmt::Display) adapter that renders each error on its - /// own line using format strategy `F`. - /// - /// # Example - /// ``` - /// use errortools::{ManyErrors, OneLine, WithContext}; - /// - /// let mut errs = ManyErrors::<&str, std::io::Error>::new(); - /// errs.push(WithContext::new("a", std::io::Error::other("err a"))); - /// errs.push(WithContext::new("b", std::io::Error::other("err b"))); - /// let output = errs.list::().to_string(); - /// assert!(output.contains("a: err a")); - /// assert!(output.contains("b: err b")); - /// ``` - pub fn list>>(&self) -> Listing<'_, C, E, F> - where - C: Display + Debug, - E: Error + 'static, - { - Listing(self, PhantomData) - } } -// --- FromIterator --- - -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, WithContext>> for ManyErrors { - fn from_iter(iter: I) -> Self - where - I: IntoIterator, 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) { - 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, WithContext>> for ManyErrors { - fn extend(&mut self, iter: I) - where - I: IntoIterator, 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)), - })); - } -} - -// --- Display + Error --- - -impl Display for ManyErrors { +/// 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 { - match self { - Self::None => Ok(()), - Self::One(p) => Display::fmt(&p.context, f), - Self::Many(v) => write!(f, "{} errors", v.len()), - } + as Format>::fmt(self, f) } } -impl Error for ManyErrors +/// Aggregate strategy that renders each item in a [`ManyErrors`] on its own +/// line, formatting each via the per-item strategy `G`. +/// +/// The default `G = AsDisplay` defers to each item's own [`Display`] (and +/// thus its own type-level strategy `WithContextFormat`). Pass a concrete `G` (e.g. +/// [`OneLine`](crate::OneLine) or [`Tree`](crate::Tree)) to override per-item +/// rendering. +/// +/// Listing is implemented for both `ManyErrors` and +/// `&ManyErrors` so it can be used directly inside this module's +/// `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 - C: Display + Debug, - E: Error + 'static, + IndividualErrorFormat: Format>, { - fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::None => None, - // Skip the WithContext wrapper to avoid repeating the context in the chain. - Self::One(p) => Some(&p.error), - Self::Many(_) => None, + 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(()) } } -// --- Iter --- - -/// Iterator over references to each [`WithContext`] in a [`ManyErrors`]. -pub struct Iter<'a, C, E>(IterInner<'a, C, E>); - -enum IterInner<'a, C, E> { - Empty, - One(Option<&'a WithContext>), - Many(core::slice::Iter<'a, WithContext>), -} - -impl<'a, C, E> Iter<'a, C, E> { - 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> Iterator for Iter<'a, C, E> { - 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(), - } +/// 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) } } -// --- Listing --- - -/// Renders all errors in a [`ManyErrors`], one per line, each via strategy `F`. -/// -/// Obtained from [`ManyErrors::list`]. -pub struct Listing<'a, C, E, F = crate::OneLine>(&'a ManyErrors, PhantomData F>); - -impl Display for Listing<'_, C, E, F> +impl Error for ManyErrors where - C: Display + Debug, + C: Debug, E: Error + 'static, - F: Format>, + WithContextFormat: Format> + Debug, { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let mut it = self.0.iter(); - let Some(first) = it.next() else { - return Ok(()); - }; - F::fmt(first, f)?; - for p in it { - writeln!(f)?; - F::fmt(p, f)?; + /// 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(), } - Ok(()) } } #[cfg(test)] mod tests { - use std::io; + use std::{io, ops::ControlFlow}; use thiserror::Error; @@ -352,7 +227,7 @@ mod tests { #[test] fn test_push_many_grows() { - let mut e = ManyErrors::new(); + let mut e: ManyErrors = ManyErrors::new(); for i in 0..5u32 { e.push(WithContext::new(i, Leaf)); } @@ -456,26 +331,99 @@ mod tests { // --- Display + Error --- + /// Per-item override used by formatter tests to verify + /// `Listing` dispatches to `G` instead of each item's own Display. + #[derive(Debug)] + struct Arrow; + impl Format> + for Arrow + { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{} -> {}", w.context, w.error) + } + } + #[test] - fn test_display_none_is_empty() { + fn test_format_zero_errors() { let e = ManyErrors::<&str, Leaf>::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_display_one_writes_context() { - let mut e = ManyErrors::new(); - e.push(w("step 1")); - assert_eq!(e.to_string(), "step 1"); + fn test_format_one_error() { + // Mid → Leaf so OneLine / Tree have a chain to walk. + let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); + e.push(WithContext::new("ctx", Mid(Leaf))); + + // 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: leaf" + ); + assert_eq!( + e.formatted::>().to_string(), + "ctx: mid\n└── leaf", + ); + + // Per-item WithContextFormat override (Arrow) — affects items' own + // Display, which is what Listing defers to. + let mut a: ManyErrors<&str, Mid, _> = ManyErrors::new(); + a.push(WithContext::<_, _, Arrow>::new("ctx", Mid(Leaf))); + 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=Arrow. Limitation. + assert_eq!( + a.formatted::>().to_string(), + "ctx -> mid: leaf", + ); + assert_eq!( + a.formatted::>().to_string(), + "ctx -> mid\n└── leaf", + ); } #[test] - fn test_display_many_writes_count() { - let mut e = ManyErrors::new(); - e.push(w("a")); - e.push(w("b")); - e.push(w("c")); - assert_eq!(e.to_string(), "3 errors"); + fn test_format_many_errors() { + let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); + e.push(WithContext::new("a", Mid(Leaf))); + e.push(WithContext::new("b", Mid(Leaf))); + e.push(WithContext::new("c", Mid(Leaf))); + + 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: leaf\nb: mid: leaf\nc: mid: leaf", + ); + assert_eq!( + e.formatted::>().to_string(), + "a: mid\n└── leaf\nb: mid\n└── leaf\nc: mid\n└── leaf", + ); + + // Arrow override on items. + let mut a: ManyErrors<&str, Mid, _> = ManyErrors::new(); + a.push(WithContext::<_, _, Arrow>::new("a", Mid(Leaf))); + a.push(WithContext::<_, _, Arrow>::new("b", Mid(Leaf))); + assert_eq!(a.to_string(), "a -> mid\nb -> mid"); + assert_eq!( + a.formatted::>().to_string(), + "a -> mid: leaf\nb -> mid: leaf", + ); + assert_eq!( + a.formatted::>().to_string(), + "a -> mid\n└── leaf\nb -> mid\n└── leaf", + ); } #[test] @@ -485,12 +433,13 @@ mod tests { } #[test] - fn test_source_one_skips_wrapper() { + fn test_source_one_skips_inner_error() { let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); e.push(WithContext::new("ctx", Mid(Leaf))); - // source should be &Mid, not &WithContext + // Display already shows "ctx: mid"; source returns Mid's source (&Leaf) + // so chain walkers don't repeat "mid". let src = e.source().expect("should have source"); - assert_eq!(src.to_string(), "mid"); + assert_eq!(src.to_string(), "leaf"); } #[test] @@ -534,40 +483,6 @@ mod tests { assert_eq!(ctxs, ["a", "b"]); } - // --- Listing --- - - #[test] - fn test_listing_none_empty() { - let e = ManyErrors::<&str, Leaf>::new(); - assert_eq!(e.list::().to_string(), ""); - } - - #[test] - fn test_listing_one_formats_chain() { - let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); - e.push(WithContext::new("ctx", Mid(Leaf))); - assert_eq!(e.list::().to_string(), "ctx: mid: leaf"); - } - - #[test] - fn test_listing_many_one_per_line() { - let errs: ManyErrors<&str, Leaf> = [("a", Leaf), ("b", Leaf), ("c", Leaf)] - .into_iter() - .collect(); - let out = errs.list::().to_string(); - assert_eq!(out, "a: leaf\nb: leaf\nc: leaf"); - } - - #[test] - fn test_listing_tree_strategy() { - let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); - e.push(WithContext::new("ctx", Mid(Leaf))); - let out = e.list::().to_string(); - assert!(out.contains("ctx")); - assert!(out.contains("mid")); - assert!(out.contains("leaf")); - } - // --- io::Error integration --- #[test] diff --git a/src/with_context.rs b/src/with_context.rs index 816655e..34b1e5c 100644 --- a/src/with_context.rs +++ b/src/with_context.rs @@ -19,7 +19,8 @@ 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>` +/// `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`) with separator strategies via @@ -58,8 +59,8 @@ pub type WithPath = WithContext; /// use errortools::{Format, WithContext}; /// /// struct Arrow; -/// impl Format> for Arrow { -/// fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { +/// impl Format> for Arrow { +/// fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { /// write!(f, "{} -> {}", w.context, w.error) /// } /// } @@ -68,16 +69,16 @@ 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. pub error: E, - _format: PhantomData F>, + _format: PhantomData WithContextFormat>, } -impl WithContext { +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. @@ -90,9 +91,9 @@ 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>, + NewSelfFormat: Format>, { WithContext { context: self.context, @@ -102,25 +103,25 @@ impl WithContext { } } -impl From<(C, E)> for WithContext { +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 +/// 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 - F: Format, + WithContextFormat: Format, { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - F::fmt(self, f) + WithContextFormat::fmt(self, f) } } /// Forwards to the fields' `Debug` rather than printing the `PhantomData` tag. -impl Debug for WithContext { +impl Debug for WithContext { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { f.debug_struct("WithContext") .field("context", &self.context) @@ -129,11 +130,11 @@ impl Debug for WithContext { } } -impl Error for WithContext +impl Error for WithContext where C: Debug, E: Error + 'static, - F: Format, + WithContextFormat: Format, { /// Returns the inner error's source, skipping the inner error itself /// (already shown via [`Display`]) so chain-walking strategies don't @@ -175,8 +176,10 @@ mod format { #[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 { + impl Format> + for ContextField + { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { Display::fmt(&w.context, f) } } @@ -187,8 +190,8 @@ mod format { #[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 { + impl Format> for ErrorField { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { Display::fmt(&w.error, f) } } @@ -203,8 +206,10 @@ mod format { pub struct ContextPath; #[cfg(feature = "std")] - impl, E, F> Format> for ContextPath { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + impl, E, WithContextFormat> Format> + for ContextPath + { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { w.context.as_ref().display().fmt(f) } } @@ -242,14 +247,16 @@ mod tests { /// Custom one-shot strategy: `[ctx] err`. struct Bracketed; - impl Format> for Bracketed { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + 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`]. + /// drives `WithContextFormat = Bracketed` inference at the `?` site in [`returning_error`]. #[derive(Error, Debug)] #[error("an error happened")] pub struct Error(#[from] WithContext<&'static str, Middle, Bracketed>); @@ -261,7 +268,7 @@ mod tests { /// 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`. + /// Most importantly, `WithContextFormat` is inferred from the `From` impl on `Error`. fn returning_error() -> Result<(), Error> { returning_middle().map_err(|e| WithContext::new("context", e))?; Ok(()) @@ -313,8 +320,10 @@ mod tests { #[test] fn test_custom_format_strategy() { struct Arrow; - impl Format> for Arrow { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + impl Format> + for Arrow + { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{} -> {}", w.context, w.error) } } @@ -331,9 +340,10 @@ mod tests { } /// 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. + /// fires `From> for Error` (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"); From 87da558137dfcaec82a226ddeebb3ab82620cd88 Mon Sep 17 00:00:00 2001 From: Max Wase Date: Sun, 17 May 2026 23:37:18 +0300 Subject: [PATCH 3/9] Refactor tests --- src/add.rs | 43 ++---- src/lib.rs | 103 +------------- src/main_result.rs | 59 +++----- src/many_errors/mod.rs | 125 ++++++++--------- src/oneline.rs | 38 ++---- src/suggestion.rs | 177 +++++++++++++++++++----- src/tests.rs | 302 +++++++++++++++++++++++++++++++++++++++++ src/tree.rs | 24 ++-- src/with_context.rs | 82 ++++------- 9 files changed, 585 insertions(+), 368 deletions(-) create mode 100644 src/tests.rs diff --git a/src/add.rs b/src/add.rs index 944d68c..ba3c4c5 100644 --- a/src/add.rs +++ b/src/add.rs @@ -140,29 +140,10 @@ pub mod separator { mod tests { use core::error::Error; - use thiserror::Error; - use super::*; - use crate::{Formatted, OneLine, Suggest, Suggestion, Tree, tests::ErrorInner}; + use crate::{Formatted, OneLine, Suggestion, Tree, tests::Inner}; 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, @@ -176,7 +157,7 @@ mod tests { fn assert_format>() {} assert_format::>(); assert_format::>(); - assert_format::, Suggestion>>(); + assert_format::, Suggestion>>(); // Confirm Error bound still gates the leaf strategy, just not the trait. fn assert_oneline() @@ -189,37 +170,37 @@ mod tests { #[test] fn test_one_line_plus_newline() { - let error = crate::tests::Error::Two(ErrorInner::One); + let error = crate::tests::Error::Two(Inner::A); assert_eq!( Formatted::<_, Add>::new(error).to_string(), - "Two: One\n" + "Two: InnerA\n" ); } #[test] fn test_nested_oneline_newline_suggestion() { - let error = SugError::NoEnv; + let error = crate::tests::Error::One; assert_eq!( Formatted::<_, Add, Suggestion>>::new(error).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_empty_rhs_keeps_separator() { - let error = SugError::Other; + let error = crate::tests::Error::Two(Inner::A); assert_eq!( Formatted::<_, Add, Suggestion>>::new(error).to_string(), - "something else\n" + "Two: InnerA\n" ); } #[test] fn test_right_associated_nesting() { - let error = crate::tests::Error::Two(ErrorInner::One); + let error = crate::tests::Error::Two(Inner::A); assert_eq!( Formatted::<_, Add>>::new(error).to_string(), - "Two: One\nTwo: One" + "Two: InnerA\nTwo: InnerA" ); } @@ -261,10 +242,10 @@ mod tests { #[test] fn test_with_newline_alias() { use separator::WithNewLine; - let error = crate::tests::Error::Two(ErrorInner::One); + let error = crate::tests::Error::Two(Inner::A); assert_eq!( Formatted::<_, WithNewLine>::new(error).to_string(), - "Two: One\nTwo: One" + "Two: InnerA\nTwo: InnerA" ); } diff --git a/src/lib.rs b/src/lib.rs index 63fbf9d..0b6f985 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -145,105 +145,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/mod.rs b/src/many_errors/mod.rs index 62e1355..801209b 100644 --- a/src/many_errors/mod.rs +++ b/src/many_errors/mod.rs @@ -181,28 +181,21 @@ where mod tests { use std::{io, ops::ControlFlow}; - use thiserror::Error; - use super::*; - use crate::{FormatError, OneLine, Tree}; - - #[derive(Error, Debug, Clone, PartialEq, Eq)] - #[error("leaf")] - struct Leaf; - - #[derive(Error, Debug, Clone, PartialEq, Eq)] - #[error("mid")] - struct Mid(#[source] Leaf); + use crate::{ + FormatError, OneLine, Tree, + tests::{Inner, Mid, WcArrow}, + }; - fn w(ctx: &'static str) -> WithContext<&'static str, Leaf> { - WithContext::new(ctx, Leaf) + 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, Leaf>::new(); + let e = ManyErrors::<&str, Inner>::new(); assert!(matches!(e, ManyErrors::None)); assert!(e.is_empty()); assert_eq!(e.len(), 0); @@ -227,9 +220,9 @@ mod tests { #[test] fn test_push_many_grows() { - let mut e: ManyErrors = ManyErrors::new(); + let mut e: ManyErrors = ManyErrors::new(); for i in 0..5u32 { - e.push(WithContext::new(i, Leaf)); + e.push(WithContext::new(i, Inner::A)); } assert_eq!(e.len(), 5); } @@ -238,7 +231,7 @@ mod tests { #[test] fn test_into_result_none_ok() { - let e = ManyErrors::<&str, Leaf>::new(); + let e = ManyErrors::<&str, Inner>::new(); assert_eq!(e.into_result(42), Ok(42)); } @@ -261,13 +254,14 @@ mod tests { #[test] fn test_collect_from_with_context() { - let errs: ManyErrors<&str, Leaf> = [w("a"), w("b"), w("c")].into_iter().collect(); + 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, Leaf> = [("a", Leaf), ("b", Leaf)].into_iter().collect(); + let errs: ManyErrors<&str, Inner> = + [("a", Inner::A), ("b", Inner::A)].into_iter().collect(); assert_eq!(errs.len(), 2); } @@ -282,9 +276,9 @@ mod tests { fn test_extend_from_tuples_via_partition_result() { use itertools::Itertools as _; - let results: Vec> = - vec![Ok(1), Err(("a", Leaf)), Ok(2), Err(("b", Leaf))]; - let (oks, errs): (Vec, ManyErrors<&str, Leaf>) = + 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); @@ -295,9 +289,9 @@ mod tests { #[test] fn test_control_flow_all_continue() { #[allow(clippy::type_complexity)] - let items: Vec, WithContext<&str, Leaf>>> = + let items: Vec, WithContext<&str, Inner>>> = vec![ControlFlow::Continue(w("a")), ControlFlow::Continue(w("b"))]; - let errs: ManyErrors<&str, Leaf> = items.into_iter().collect(); + let errs: ManyErrors<&str, Inner> = items.into_iter().collect(); assert_eq!(errs.len(), 2); } @@ -307,12 +301,12 @@ mod tests { let iter = ["a", "b", "c", "d"].iter().map(|s| { count += 1; if *s == "b" { - ControlFlow::Break(WithContext::new(*s, Leaf)) + ControlFlow::Break(WithContext::new(*s, Inner::A)) } else { - ControlFlow::Continue(WithContext::new(*s, Leaf)) + ControlFlow::Continue(WithContext::new(*s, Inner::A)) } }); - let errs: ManyErrors<&str, Leaf> = iter.collect(); + 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); @@ -321,31 +315,19 @@ mod tests { #[test] fn test_control_flow_tuples() { #[allow(clippy::type_complexity)] - let items: Vec> = vec![ - ControlFlow::Continue(("a", Leaf)), - ControlFlow::Break(("b", Leaf)), + let items: Vec> = vec![ + ControlFlow::Continue(("a", Inner::A)), + ControlFlow::Break(("b", Inner::A)), ]; - let errs: ManyErrors<&str, Leaf> = items.into_iter().collect(); + let errs: ManyErrors<&str, Inner> = items.into_iter().collect(); assert_eq!(errs.len(), 2); } // --- Display + Error --- - /// Per-item override used by formatter tests to verify - /// `Listing` dispatches to `G` instead of each item's own Display. - #[derive(Debug)] - struct Arrow; - impl Format> - for Arrow - { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{} -> {}", w.context, w.error) - } - } - #[test] fn test_format_zero_errors() { - let e = ManyErrors::<&str, Leaf>::new(); + let e = ManyErrors::<&str, Inner>::new(); // Display (default Listing). assert_eq!(e.to_string(), ""); @@ -357,9 +339,9 @@ mod tests { #[test] fn test_format_one_error() { - // Mid → Leaf so OneLine / Tree have a chain to walk. + // Mid → Inner so OneLine / Tree have a chain to walk. let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); - e.push(WithContext::new("ctx", Mid(Leaf))); + e.push(WithContext::new("ctx", Mid::Inner(Inner::A))); // Default WithContextFormat = Colon → "{context}: {error}". assert_eq!(e.to_string(), "ctx: mid"); @@ -367,79 +349,82 @@ mod tests { // Listing walks the chain. assert_eq!( e.formatted::>().to_string(), - "ctx: mid: leaf" + "ctx: mid: InnerA" ); assert_eq!( e.formatted::>().to_string(), - "ctx: mid\n└── leaf", + "ctx: mid\n└── InnerA", ); - // Per-item WithContextFormat override (Arrow) — affects items' own + // 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::<_, _, Arrow>::new("ctx", Mid(Leaf))); + 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=Arrow. Limitation. + // WithContext's Display still fires its own F=WcArrow. Limitation. assert_eq!( a.formatted::>().to_string(), - "ctx -> mid: leaf", + "ctx -> mid: InnerA", ); assert_eq!( a.formatted::>().to_string(), - "ctx -> mid\n└── leaf", + "ctx -> mid\n└── InnerA", ); } #[test] fn test_format_many_errors() { let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); - e.push(WithContext::new("a", Mid(Leaf))); - e.push(WithContext::new("b", Mid(Leaf))); - e.push(WithContext::new("c", Mid(Leaf))); + 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: leaf\nb: mid: leaf\nc: mid: leaf", + "a: mid: InnerA\nb: mid: InnerA\nc: mid: InnerA", ); assert_eq!( e.formatted::>().to_string(), - "a: mid\n└── leaf\nb: mid\n└── leaf\nc: mid\n└── leaf", + "a: mid\n└── InnerA\nb: mid\n└── InnerA\nc: mid\n└── InnerA", ); - // Arrow override on items. + // WcArrow override on items. let mut a: ManyErrors<&str, Mid, _> = ManyErrors::new(); - a.push(WithContext::<_, _, Arrow>::new("a", Mid(Leaf))); - a.push(WithContext::<_, _, Arrow>::new("b", Mid(Leaf))); + 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: leaf\nb -> mid: leaf", + "a -> mid: InnerA\nb -> mid: InnerA", ); assert_eq!( a.formatted::>().to_string(), - "a -> mid\n└── leaf\nb -> mid\n└── leaf", + "a -> mid\n└── InnerA\nb -> mid\n└── InnerA", ); } #[test] fn test_source_none() { - let e = ManyErrors::<&str, Leaf>::new(); + 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(Leaf))); - // Display already shows "ctx: mid"; source returns Mid's source (&Leaf) + 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(), "leaf"); + assert_eq!(src.to_string(), "InnerA"); } #[test] @@ -453,15 +438,15 @@ mod tests { #[test] fn test_one_line_one_walks_chain() { let mut e: ManyErrors<&str, Mid> = ManyErrors::new(); - e.push(WithContext::new("ctx", Mid(Leaf))); - assert_eq!(e.one_line().to_string(), "ctx: mid: leaf"); + e.push(WithContext::new("ctx", Mid::Inner(Inner::A))); + assert_eq!(e.one_line().to_string(), "ctx: mid: InnerA"); } // --- iter --- #[test] fn test_iter_none() { - let e = ManyErrors::<&str, Leaf>::new(); + let e = ManyErrors::<&str, Inner>::new(); assert_eq!(e.iter().count(), 0); } 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 index 34b1e5c..0ace451 100644 --- a/src/with_context.rs +++ b/src/with_context.rs @@ -230,84 +230,69 @@ mod format { #[cfg(test)] mod tests { - use std::{error::Error as _, io}; + use std::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) - } - } + 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 Error(#[from] WithContext<&'static str, Middle, Bracketed>); + pub struct PropError(#[from] WithContext<&'static str, Mid, Bracketed>); - fn returning_middle() -> Result<(), Middle> { - Err(Middle(Leaf)) + 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<(), 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", Leaf); + let w = WithContextColon::new("ctx", Inner::A); assert_eq!(w.context, "ctx"); } #[test] fn test_from_tuple() { - let w: WithContextColon<&str, Leaf> = ("ctx", Leaf).into(); + 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", Leaf); - assert_eq!(w.to_string(), "step 3: leaf error"); + let w = WithContextColon::new("step 3", Inner::A); + assert_eq!(w.to_string(), "step 3: InnerA"); } #[test] fn test_source_skips_inner_error() { - // Leaf has no source, so skipping it yields None. - let w = WithContextColon::new("ctx", Leaf); + // Inner::A has no source, so skipping it yields None. + let w = WithContextColon::new("ctx", Inner::A); 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)); + // 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(), "leaf error"); + assert_eq!(src.to_string(), "InnerA"); } #[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"); + let w = WithContextColon::new("ctx", Mid::Inner(Inner::A)); + assert_eq!(w.one_line().to_string(), "ctx: mid: InnerA"); } #[test] @@ -319,28 +304,19 @@ mod tests { #[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"); + 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", Middle(Leaf)); - // Display: "[ctx] middle" — then chain appends ": leaf error" via source. - assert_eq!(w.one_line().to_string(), "[ctx] middle: leaf error"); + 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 Error` (and pins + /// 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. @@ -350,7 +326,7 @@ mod tests { assert_eq!(err.to_string(), "an error happened"); assert_eq!( err.one_line().to_string(), - "an error happened: [context] middle: leaf error", + "an error happened: [context] mid: InnerA", ); } @@ -377,7 +353,7 @@ mod tests { use crate::separator::WithSpace; type SpacePair = WithSpace; - let w = WithContext::<_, _, SpacePair>::new("ctx", Leaf); - assert_eq!(w.to_string(), "ctx leaf error"); + let w = WithContext::<_, _, SpacePair>::new("ctx", Inner::A); + assert_eq!(w.to_string(), "ctx InnerA"); } } From 694fec7b13e4baadb0edf90bc1c767c37a55d884 Mon Sep 17 00:00:00 2001 From: Max Wase Date: Sun, 17 May 2026 23:43:04 +0300 Subject: [PATCH 4/9] FIx doc --- src/lib.rs | 8 +++++--- src/many_errors/iter.rs | 2 +- src/with_context.rs | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0b6f985..1bb94a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,11 +59,13 @@ pub trait Format { fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result; } -/// Sentinel [`Format`] strategy that delegates to the value's own [`Display`] +/// Sentinel [`Format`] strategy that delegates to the value's own [`fmt::Display`] /// impl. /// -/// Useful as a default in strategy-aware wrappers (e.g. [`Listing`](crate::Listing)) -/// when per-item formatting should defer to each item's own `Display` (and +/// Useful as a default in strategy-aware wrappers (e.g. +#[cfg_attr(feature = "alloc", doc = "[`Listing`](crate::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; diff --git a/src/many_errors/iter.rs b/src/many_errors/iter.rs index 50aaa99..f8d7665 100644 --- a/src/many_errors/iter.rs +++ b/src/many_errors/iter.rs @@ -52,7 +52,7 @@ impl<'a, C, E, WithContextFormat> Iterator for Iter<'a, C, E, WithContextFormat> } mod from_iter { - use std::ops::ControlFlow; + use core::ops::ControlFlow; use super::*; diff --git a/src/with_context.rs b/src/with_context.rs index 0ace451..53996b6 100644 --- a/src/with_context.rs +++ b/src/with_context.rs @@ -22,8 +22,8 @@ pub type WithPath = WithContext; /// `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`) with separator strategies via +/// 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). /// From 31c07df8d97fa4fbb488b79c93642faba357f51d Mon Sep 17 00:00:00 2001 From: Max Wase Date: Mon, 18 May 2026 00:07:31 +0300 Subject: [PATCH 5/9] Decompose modules --- src/add.rs | 271 ------------------- src/add/mod.rs | 125 +++++++++ src/add/separator.rs | 150 ++++++++++ src/lib.rs | 2 +- src/many_errors/iter.rs | 112 ++++++++ src/many_errors/listing.rs | 150 ++++++++++ src/many_errors/mod.rs | 254 +---------------- src/with_context/format.rs | 114 ++++++++ src/{with_context.rs => with_context/mod.rs} | 113 +------- 9 files changed, 659 insertions(+), 632 deletions(-) delete mode 100644 src/add.rs create mode 100644 src/add/mod.rs create mode 100644 src/add/separator.rs create mode 100644 src/many_errors/listing.rs create mode 100644 src/with_context/format.rs rename src/{with_context.rs => with_context/mod.rs} (65%) diff --git a/src/add.rs b/src/add.rs deleted file mode 100644 index ba3c4c5..0000000 --- a/src/add.rs +++ /dev/null @@ -1,271 +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 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_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(Inner::A); - assert_eq!( - Formatted::<_, WithNewLine>::new(error).to_string(), - "Two: InnerA\nTwo: InnerA" - ); - } - - #[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 1bb94a3..81fbbf4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,7 +63,7 @@ pub trait Format { /// impl. /// /// Useful as a default in strategy-aware wrappers (e.g. -#[cfg_attr(feature = "alloc", doc = "[`Listing`](crate::Listing)")] +#[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. diff --git a/src/many_errors/iter.rs b/src/many_errors/iter.rs index f8d7665..fde4670 100644 --- a/src/many_errors/iter.rs +++ b/src/many_errors/iter.rs @@ -168,3 +168,115 @@ mod from_iter { } } } + +#[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 index 801209b..b47e481 100644 --- a/src/many_errors/mod.rs +++ b/src/many_errors/mod.rs @@ -3,7 +3,6 @@ use core::{ error::Error, fmt::{self, Debug, Display, Formatter}, - marker::PhantomData, }; use alloc::{vec, vec::Vec}; @@ -14,6 +13,9 @@ use crate::{ }; mod iter; +mod listing; + +pub use listing::Listing; /// Zero or more context-tagged errors collected during an iterator/fold operation. /// @@ -113,53 +115,6 @@ where } } -/// Aggregate strategy that renders each item in a [`ManyErrors`] on its own -/// line, formatting each via the per-item strategy `G`. -/// -/// The default `G = AsDisplay` defers to each item's own [`Display`] (and -/// thus its own type-level strategy `WithContextFormat`). Pass a concrete `G` (e.g. -/// [`OneLine`](crate::OneLine) or [`Tree`](crate::Tree)) to override per-item -/// rendering. -/// -/// Listing is implemented for both `ManyErrors` and -/// `&ManyErrors` so it can be used directly inside this module's -/// `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) - } -} - impl Error for ManyErrors where C: Debug, @@ -179,12 +134,10 @@ where #[cfg(test)] mod tests { - use std::{io, ops::ControlFlow}; - use super::*; use crate::{ - FormatError, OneLine, Tree, - tests::{Inner, Mid, WcArrow}, + FormatError, + tests::{Inner, Mid}, }; fn w(ctx: &'static str) -> WithContext<&'static str, Inner> { @@ -250,167 +203,8 @@ mod tests { assert!(e.into_result(()).is_err()); } - // --- FromIterator / Extend --- - - #[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() { - use itertools::Itertools as _; - - 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); - } - - // --- ControlFlow --- - - #[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); - } - // --- Display + Error --- - #[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", - ); - } - #[test] fn test_source_none() { let e = ManyErrors::<&str, Inner>::new(); @@ -441,42 +235,4 @@ mod tests { e.push(WithContext::new("ctx", Mid::Inner(Inner::A))); assert_eq!(e.one_line().to_string(), "ctx: mid: InnerA"); } - - // --- iter --- - - #[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"]); - } - - // --- io::Error integration --- - - #[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/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.rs b/src/with_context/mod.rs similarity index 65% rename from src/with_context.rs rename to src/with_context/mod.rs index 53996b6..9b316cd 100644 --- a/src/with_context.rs +++ b/src/with_context/mod.rs @@ -8,6 +8,8 @@ use core::{ 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}; @@ -144,90 +146,6 @@ where } } -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, 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 std::io; @@ -329,31 +247,4 @@ mod tests { "an error happened: [context] mid: InnerA", ); } - - /// `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", Inner::A); - assert_eq!(w.to_string(), "ctx InnerA"); - } } From 610bf7b4d5dd3d702d876450037ed8c214c01c36 Mon Sep 17 00:00:00 2001 From: Max Wase Date: Sun, 31 May 2026 10:34:40 +0200 Subject: [PATCH 6/9] Fix formatting of ManyErrors --- Cargo.lock | 23 ++ Cargo.toml | 3 +- README.md | 77 ++++-- examples/format_error.rs | 2 +- examples/many_errors.rs | 62 +++++ examples/tree.rs | 10 +- src/add/mod.rs | 30 +- src/add/separator.rs | 16 +- src/chain.rs | 157 +++++++++++ src/connectors.rs | 80 ++++++ src/lib.rs | 35 +-- src/main_result.rs | 24 +- src/many_errors/iter.rs | 412 +++++++++++++++++++--------- src/many_errors/listing.rs | 150 ---------- src/many_errors/mod.rs | 344 +++++++++++++++++------ src/many_errors/node.rs | 180 ++++++++++++ src/many_errors/strategy/bullets.rs | 129 +++++++++ src/many_errors/strategy/inline.rs | 123 +++++++++ src/many_errors/strategy/list.rs | 134 +++++++++ src/many_errors/strategy/mod.rs | 96 +++++++ src/many_errors/strategy/tree.rs | 371 +++++++++++++++++++++++++ src/oneline.rs | 8 +- src/suggestion.rs | 2 +- src/tests.rs | 31 ++- src/tree.rs | 177 ------------ src/with_context/mod.rs | 36 ++- tests/max_custom.rs | 258 +++++++++++++++++ 27 files changed, 2341 insertions(+), 629 deletions(-) create mode 100644 examples/many_errors.rs create mode 100644 src/chain.rs create mode 100644 src/connectors.rs delete mode 100644 src/many_errors/listing.rs create mode 100644 src/many_errors/node.rs create mode 100644 src/many_errors/strategy/bullets.rs create mode 100644 src/many_errors/strategy/inline.rs create mode 100644 src/many_errors/strategy/list.rs create mode 100644 src/many_errors/strategy/mod.rs create mode 100644 src/many_errors/strategy/tree.rs delete mode 100644 src/tree.rs create mode 100644 tests/max_custom.rs diff --git a/Cargo.lock b/Cargo.lock index 77053d3..073ab4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "either" version = "1.15.0" @@ -13,6 +19,7 @@ name = "errortools" version = "0.1.0" dependencies = [ "itertools", + "pretty_assertions", "thiserror", ] @@ -25,6 +32,16 @@ dependencies = [ "either", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -79,3 +96,9 @@ name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml index 72653c5..ac7bc0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ include = ["src/**/*.rs", "examples/**/*.rs", "README.md", "CHANGELOG.md", "LICE itertools = { version = "0.14", default-features = false } [dev-dependencies] +pretty_assertions = "1.4.1" thiserror = "2" [features] @@ -27,4 +28,4 @@ alloc = [] [[example]] name = "with_context" -required-features = ["std"] \ No newline at end of file +required-features = ["std"] diff --git a/README.md b/README.md index 8aba8eb..863ebfa 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,12 @@ Error: failed to load config: No such file or directory (os error 2) The error and its full source chain print joined with `": "`. No `run()` wrapper, no manual loop. -## Tree format +## Chain format -Prefer a multi-line view? Swap the format strategy: +Prefer a multi-line indented view of the source chain? Swap the format strategy: ```rust,no_run -use errortools::{MainResult, Tree}; +use errortools::{Chain, MainResult}; use std::{fs, io}; #[derive(Debug, thiserror::Error)] @@ -64,7 +64,7 @@ enum AppError { Config(#[source] io::Error), } -fn main() -> MainResult { +fn main() -> MainResult { let _ = fs::read_to_string("missing.toml").map_err(AppError::Config)?; Ok(()) } @@ -72,7 +72,7 @@ fn main() -> MainResult { ```text Error: failed to load config -└── No such file or directory (os error 2) +└─ No such file or directory (os error 2) ``` ## Adding context @@ -158,13 +158,13 @@ if let Err(e) = do_thing() { For ad-hoc strategies, pick the format inline with `formatted::()`: ```rust,ignore -use errortools::{FormatError, Tree}; +use errortools::{Chain, FormatError}; if let Err(e) = do_thing() { - eprintln!("{}", e.formatted::()); + eprintln!("{}", e.formatted::()); // outer - // └── middle - // └── inner + // └─ middle + // └─ inner } ``` @@ -192,11 +192,11 @@ println!("{}", my_error.formatted::()); // outer -> middle -> inner `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}}; +use errortools::{Formatted, Flat, Suggestion, separator::{NewLine, WithSep}}; -// Same as Add, Suggestion>. Renders: +// Same as Add, Suggestion>. Renders: // "\n" -type Brief = WithSep; +type Brief = WithSep; eprintln!("{}", Formatted::<_, Brief>::new(err)); ``` @@ -205,13 +205,13 @@ 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}; +use errortools::{Formatted, Flat, Suggestion, separator::WithNewLine}; -type Brief = WithNewLine; +type Brief = WithNewLine; eprintln!("{}", Formatted::<_, Brief>::new(err)); ``` -Bounds compose: `Add` only implements `Format` when +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 @@ -262,14 +262,52 @@ Only the top-level error's hint is printed, the source chain isn't walked. This 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. +## Many errors at once + +Some operations shouldn't stop at the first failure — validating a config, deploying to every region, parsing a batch. You want all of them, grouped and readable. That's `ManyErrors`: a context-tagged collection that renders as a tree. + +```rust,ignore +use errortools::ManyErrors; + +let mut errs = ManyErrors::new(); +errs.push("eu-west-1", RegionError::Refused); +errs.push("us-east-1", RegionError::Timeout); + +errs.into_result(())?; // Ok if empty, Err(ManyErrors) otherwise +``` + +It costs nothing until it has to: `None` while empty, one inline slot for the first error, a `Vec` only once a second arrives. You can also collect straight from an iterator of `(context, error)` pairs or `WithContext` values — including itertools' `partition_result`. + +Group related failures with `push_group` and the tree nests: + +```text +2 errors: +├─ us-east-1 (2 errors): +│ ├─ i-0a1: connection refused +│ └─ i-0b2: timed out: network partition +└─ eu-west-1: connection refused +``` + +The default `Display` is the Unicode tree above. Want another shape? `list()`, `bullets()`, and `one_line()` are inherent helpers, no turbofish: + +```rust,ignore +println!("{}", errs.list()); // 1. 1.1. 2. +println!("{}", errs.bullets()); // • bulleted +println!("{}", errs.one_line()); // ;-separated, parens around groups +``` + +For full control — ASCII connectors, no count header — go through `formatted`: `Formatted::<_, Tree>::new(&errs)`. + +Group labels can differ from leaf contexts via the third parameter, `ManyErrors`, but `GC` defaults to `C`, so the common case stays two params. + ## How it works `MainResult` is a type alias: ```rust -use errortools::{DisplaySwapDebug, Formatted, OneLine}; +use errortools::{DisplaySwapDebug, Formatted, Flat}; -pub type MainResult = Result>>; +pub type MainResult = Result>>; ``` `DisplaySwapDebug` swaps the `Debug` and `Display` impls of its inner type. When `main` prints the error via `Debug`, it ends up reaching the `Display` output instead, formatted by the chosen strategy. `?` converts your error automatically via the blanket `From` impl. @@ -280,12 +318,13 @@ Runnable examples in [`examples/`](https://github.com/maxwase/errortools/tree/ma | Example | What it shows | |---|---| -| [`one_line`](https://github.com/maxwase/errortools/blob/master/examples/one_line.rs) | `MainResult` with default `OneLine` format | -| [`tree`](https://github.com/maxwase/errortools/blob/master/examples/tree.rs) | `MainResult` for indented multi-line output | +| [`one_line`](https://github.com/maxwase/errortools/blob/master/examples/one_line.rs) | `MainResult` with default `Flat` format | +| [`tree`](https://github.com/maxwase/errortools/blob/master/examples/tree.rs) | `MainResult` for indented multi-line output | | [`format_error`](https://github.com/maxwase/errortools/blob/master/examples/format_error.rs) | `FormatError` trait for ad-hoc formatting | | [`custom_format`](https://github.com/maxwase/errortools/blob/master/examples/custom_format.rs) | A custom `Format` strategy | | [`transparent`](https://github.com/maxwase/errortools/blob/master/examples/transparent.rs) | `#[error(transparent)]` pass-through with `#[from]` | | [`with_context`](https://github.com/maxwase/errortools/blob/master/examples/with_context.rs) | `WithContext` tags an inner error with a context value, lifted via `#[from]` | +| [`many_errors`](https://github.com/maxwase/errortools/blob/master/examples/many_errors.rs) | `ManyErrors` collects nested, context-tagged failures and renders them as a tree | Run with: `cargo run --example `. diff --git a/examples/format_error.rs b/examples/format_error.rs index 2cb509a..8008f0d 100644 --- a/examples/format_error.rs +++ b/examples/format_error.rs @@ -17,7 +17,7 @@ fn main() { println!("one line: {}", err.one_line()); println!(); - println!("tree:\n{}", err.tree()); + println!("chain:\n{}", err.chain()); let dyn_err: &dyn core::error::Error = &err; println!(); diff --git a/examples/many_errors.rs b/examples/many_errors.rs new file mode 100644 index 0000000..86d79ae --- /dev/null +++ b/examples/many_errors.rs @@ -0,0 +1,62 @@ +//! Demonstrates `ManyErrors` with nested groups and multiple rendering shapes. +//! +//! Run: `cargo run --example many_errors` + +use std::io; + +use errortools::many_errors::Ascii; +use errortools::{Formatted, ManyErrors, many_errors::Tree}; + +#[derive(Debug, thiserror::Error)] +enum DeployError { + #[error("deploy failed")] + Failed(#[source] ManyErrors<&'static str, RegionError>), +} + +#[derive(Debug, thiserror::Error)] +enum RegionError { + #[error("connection refused")] + Refused, + #[error("timed out")] + Timeout(#[source] io::Error), +} + +fn main() { + // Build nested ManyErrors: two regions, one with two sub-errors. + let mut east = ManyErrors::new(); + east.push("i-0a1", RegionError::Refused); + east.push( + "i-0b2", + RegionError::Timeout(io::Error::other("network partition")), + ); + + let mut all: ManyErrors<&str, RegionError> = ManyErrors::new(); + all.push_group("us-east-1", east); + all.push("eu-west-1", RegionError::Refused); + + // Default Display = Tree with Unicode connectors and count header + println!("=== Default (Tree / Unicode) ==="); + println!("{all}"); + + println!(); + println!("=== List ==="); + println!("{}", all.list()); + + println!(); + println!("=== Bullets ==="); + println!("{}", all.bullets()); + + println!(); + println!("=== Inline ==="); + println!("{}", all.one_line()); + + println!(); + println!("=== ASCII connectors, no header ==="); + println!("{}", Formatted::<_, Tree>::new(&all)); + + // Show it as a source in a top-level error + let err = DeployError::Failed(all); + println!(); + println!("=== As thiserror source ==="); + println!("{err}"); +} diff --git a/examples/tree.rs b/examples/tree.rs index 522bf56..1f76e33 100644 --- a/examples/tree.rs +++ b/examples/tree.rs @@ -1,4 +1,4 @@ -//! `MainResult` with the [`Tree`] format. +//! `MainResult` with the [`Chain`] format (per-error source-chain ladder). //! //! Run: `cargo run --example tree` //! @@ -6,13 +6,13 @@ //! //! ```text //! Error: failed to load config -//! └── failed to read file -//! └── No such file or directory (os error 2) +//! └─ failed to read file +//! └─ No such file or directory (os error 2) //! ``` use std::{fs, io}; -use errortools::{MainResult, Tree}; +use errortools::{Chain, MainResult}; #[derive(Debug, thiserror::Error)] enum AppError { @@ -26,7 +26,7 @@ enum ConfigError { Read(#[source] io::Error), } -fn main() -> MainResult { +fn main() -> MainResult { fs::read_to_string("does-not-exist.toml") .map_err(ConfigError::Read) .map_err(AppError::Config)?; diff --git a/src/add/mod.rs b/src/add/mod.rs index cdd60a8..363e3be 100644 --- a/src/add/mod.rs +++ b/src/add/mod.rs @@ -6,14 +6,14 @@ use crate::Format; /// /// `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` +/// `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> +/// Add, Suggestion> /// ``` /// /// renders the one-line chain, a newline, then the top-level suggestion hint. @@ -54,7 +54,7 @@ mod tests { use core::error::Error; use super::*; - use crate::{Formatted, OneLine, Suggestion, Tree, tests::Inner}; + use crate::{Chain, Flat, Formatted, Suggestion, tests::Inner}; use separator::*; fn _assert_traits() { @@ -62,20 +62,20 @@ mod tests { T: Clone + Copy + Default + PartialEq + Eq + core::hash::Hash + Send + Sync, >() { } - assert_all::>(); - assert_all::, Suggestion>>(); + assert_all::>(); + assert_all::, Suggestion>>(); assert_all::(); assert_all::(); fn assert_format>() {} - assert_format::>(); - assert_format::>(); - assert_format::, Suggestion>>(); + 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, + Flat: Format, { } assert_oneline::(); @@ -85,7 +85,7 @@ mod tests { fn test_one_line_plus_newline() { let error = crate::tests::Error::Two(Inner::A); assert_eq!( - Formatted::<_, Add>::new(error).to_string(), + Formatted::<_, Add>::new(error).to_string(), "Two: InnerA\n" ); } @@ -94,7 +94,7 @@ mod tests { fn test_nested_oneline_newline_suggestion() { let error = crate::tests::Error::One; assert_eq!( - Formatted::<_, Add, Suggestion>>::new(error).to_string(), + Formatted::<_, Add, Suggestion>>::new(error).to_string(), "One\nTry passing --help to see available options." ); } @@ -103,7 +103,7 @@ mod tests { fn test_empty_rhs_keeps_separator() { let error = crate::tests::Error::Two(Inner::A); assert_eq!( - Formatted::<_, Add, Suggestion>>::new(error).to_string(), + Formatted::<_, Add, Suggestion>>::new(error).to_string(), "Two: InnerA\n" ); } @@ -112,14 +112,14 @@ mod tests { fn test_right_associated_nesting() { let error = crate::tests::Error::Two(Inner::A); assert_eq!( - Formatted::<_, Add>>::new(error).to_string(), + 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)"); + let add = Add::::default(); + assert_eq!(format!("{add:?}"), "Add(Flat, NewLine)"); } } diff --git a/src/add/separator.rs b/src/add/separator.rs index c6e5fc9..d7e1f41 100644 --- a/src/add/separator.rs +++ b/src/add/separator.rs @@ -15,7 +15,7 @@ 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>`. +/// `Add>`. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct NewLine; @@ -89,7 +89,7 @@ pub type WithColonSpace = WithSep; mod tests { use super::*; use crate::{ - Add, Formatted, OneLine, + Add, Flat, Formatted, tests::{Error, Inner}, }; @@ -97,7 +97,7 @@ mod tests { fn test_space_between_repeats() { let error = Error::One; assert_eq!( - Formatted::<_, Add>>::new(error).to_string(), + Formatted::<_, Add>>::new(error).to_string(), "One One" ); } @@ -107,7 +107,7 @@ mod tests { // ColonSpace ignores the error and writes ": ". let error = Error::One; assert_eq!( - Formatted::<_, Add>>::new(error).to_string(), + Formatted::<_, Add>>::new(error).to_string(), "One: One" ); } @@ -116,7 +116,7 @@ mod tests { fn test_with_space_alias() { let error = Error::One; assert_eq!( - Formatted::<_, WithSpace>::new(error).to_string(), + Formatted::<_, WithSpace>::new(error).to_string(), "One One" ); } @@ -125,7 +125,7 @@ mod tests { fn test_with_newline_alias() { let error = Error::Two(Inner::A); assert_eq!( - Formatted::<_, WithNewLine>::new(error).to_string(), + Formatted::<_, WithNewLine>::new(error).to_string(), "Two: InnerA\nTwo: InnerA" ); } @@ -134,7 +134,7 @@ mod tests { fn test_with_colon_space_alias() { let error = Error::One; assert_eq!( - Formatted::<_, WithColonSpace>::new(error).to_string(), + Formatted::<_, WithColonSpace>::new(error).to_string(), "One: One" ); } @@ -143,7 +143,7 @@ mod tests { fn test_add_sep_generic_alias() { let error = Error::One; assert_eq!( - Formatted::<_, WithSep>::new(error).to_string(), + Formatted::<_, WithSep>::new(error).to_string(), "One:One" ); } diff --git a/src/chain.rs b/src/chain.rs new file mode 100644 index 0000000..d3bc525 --- /dev/null +++ b/src/chain.rs @@ -0,0 +1,157 @@ +//! Per-error source-chain ladder renderer ([`Chain`]). +//! +//! This is distinct from [`Tree`](crate::many_errors::Tree), which renders +//! a branching *aggregate* of many errors. `Chain` renders a *single* error's +//! linear source chain as an indented ladder: +//! +//! ```text +//! top error +//! └─ source 1 +//! └─ source 2 +//! ``` + +use core::{error::Error, fmt, iter, marker::PhantomData}; + +use itertools::Itertools; + +use crate::{ + Format, chain, + connectors::{Connectors, Unicode}, +}; + +/// Per-error source-chain ladder format, drawn with a [`Connectors`] glyph set. +/// +/// ```text +/// top error +/// └─ source 1 +/// └─ source 2 +/// ``` +/// +/// A linear chain is a degenerate tree — every node is an only-child — so it +/// uses only the "last child" branch glyph ([`Connectors::LAST`]) and the blank +/// continuation ([`Connectors::GAP`]). The marker is printed before each source +/// and the continuation is repeated `depth - 1` times. Swap the glyph set with +/// [`Ascii`](crate::Ascii) (or any custom [`Connectors`] impl) the same way +/// [`Tree`](crate::many_errors::Tree) does — one vocabulary serves both. +/// +/// Use [`FormatError::chain`](crate::FormatError::chain) for the most common case. +/// For aggregate many-error rendering see [`Tree`](crate::many_errors::Tree). +#[derive(Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Chain(PhantomData C>); + +/// 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 +/// [`Connectors::GAP`] followed by [`Connectors::LAST`]. +impl Format for Chain { + fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // &error: &&E; &&E coerces to &dyn Error via the blanket `impl Error for &T`. + let formatted = + chain(&error) + .enumerate() + .format_with("\n", |(depth, e), write| match depth { + 0 => write(&format_args!("{e}")), + n => { + let pad = iter::repeat_n(C::GAP, n - 1).format(""); + write(&format_args!("{pad}{}{e}", C::LAST)) + } + }); + write!(f, "{formatted}") + } +} + +/// Prints the connector type (instantiated via [`Default`]) instead of +/// `Chain(PhantomData)`. +impl fmt::Debug for Chain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Chain").field(&C::default()).finish() + } +} + +#[cfg(test)] +mod tests { + use core::fmt; + + use itertools::Itertools; + + use crate::{ + Chain, Format, FormatError, Formatted, chain, + connectors::{Ascii, Connectors, Unicode}, + tests::{Error, Inner}, + }; + + #[test] + fn test_chain_no_source() { + let error = Error::One; + assert_eq!(error.chain().to_string(), "One"); + } + + #[test] + fn test_chain_one_source() { + let error = Error::Two(Inner::A); + assert_eq!(error.chain().to_string(), "Two\n└─ InnerA"); + } + + #[test] + fn test_chain_nested() { + let error = Error::Two(Inner::B); + assert_eq!(error.chain().to_string(), "Two\n└─ InnerB"); + } + + #[test] + fn test_chain_ascii() { + let error = Error::Two(Inner::A); + assert_eq!( + Formatted::<_, Chain>::new(error).to_string(), + "Two\n`- InnerA" + ); + } + + #[test] + fn test_chain_custom_connectors() { + struct Arrow; + impl Connectors for Arrow { + const LAST: &'static str = "|-> "; + const GAP: &'static str = " "; + } + + let error = Error::Two(Inner::A); + assert_eq!( + Formatted::<_, Chain>::new(error).to_string(), + "Two\n|-> InnerA" + ); + } + + #[test] + fn test_chain_debug_default_params() { + let c = Chain::::default(); + assert_eq!(format!("{c:?}"), "Chain(Unicode)"); + } + + #[test] + fn test_chain_debug_custom_params() { + let c = Chain::::default(); + assert_eq!(format!("{c:?}"), "Chain(Ascii)"); + } + + #[test] + fn test_custom_chain_via_format() { + struct AsciiChain; + impl Format for AsciiChain { + 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}")), + n => write(&format_args!("{:width$}|-- {e}", "", width = (n - 1) * 2)), + }); + write!(f, "{formatted}") + } + } + + let error = Error::Two(Inner::A); + assert_eq!( + Formatted::<_, AsciiChain>::new(error).to_string(), + "Two\n|-- InnerA" + ); + } +} diff --git a/src/connectors.rs b/src/connectors.rs new file mode 100644 index 0000000..3159024 --- /dev/null +++ b/src/connectors.rs @@ -0,0 +1,80 @@ +//! Box-drawing glyph sets shared by the [`Chain`](crate::Chain) source-chain +//! ladder and the [`Tree`](crate::many_errors::Tree) aggregate renderer. +//! +//! A linear chain is a degenerate tree: every node is an only-child, so it +//! always renders as a "last" child with a blank continuation under it. That's +//! exactly the [`Connectors`] pair. A branching tree additionally needs the +//! sibling glyphs, which live on the [`TreeConnectors`] supertrait. One glyph +//! type ([`Unicode`], [`Ascii`]) implements both, so `Chain` and +//! `Tree` share a single vocabulary. + +/// The two glyphs a linear source-chain ladder needs: the branch prefix before +/// each source, and the blank continuation under it. +/// +/// [`Chain`](crate::Chain) renders every node as an only-child, so it only ever +/// uses [`LAST`](Connectors::LAST) and [`GAP`](Connectors::GAP). Branching +/// trees pick up the sibling glyphs via the [`TreeConnectors`] supertrait. +pub trait Connectors { + /// Prefix for a last (or only) child: `"└─ "` (Unicode). + const LAST: &'static str; + /// Blank continuation under a last child: `" "`. + const GAP: &'static str; +} + +/// The full box-drawing set a branching [`Tree`](crate::many_errors::Tree) +/// needs: the [`Connectors`] pair plus the sibling glyphs for non-last children. +pub trait TreeConnectors: Connectors { + /// Prefix for a non-last child: `"├─ "` (Unicode). + const BRANCH: &'static str; + /// Continuation bar under a non-last child: `"│ "` (Unicode). + const VERT: &'static str; +} + +/// Unicode box-drawing connectors (default). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Unicode; + +impl Connectors for Unicode { + const LAST: &'static str = "└─ "; + const GAP: &'static str = " "; +} + +impl TreeConnectors for Unicode { + const BRANCH: &'static str = "├─ "; + const VERT: &'static str = "│ "; +} + +/// ASCII-only connectors for environments that can't render Unicode box art. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Ascii; + +impl Connectors for Ascii { + const LAST: &'static str = "`- "; + const GAP: &'static str = " "; +} + +impl TreeConnectors for Ascii { + const BRANCH: &'static str = "|- "; + const VERT: &'static str = "| "; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unicode_glyphs() { + assert_eq!(Unicode::BRANCH, "├─ "); + assert_eq!(Unicode::LAST, "└─ "); + assert_eq!(Unicode::VERT, "│ "); + assert_eq!(Unicode::GAP, " "); + } + + #[test] + fn test_ascii_glyphs() { + assert_eq!(Ascii::BRANCH, "|- "); + assert_eq!(Ascii::LAST, "`- "); + assert_eq!(Ascii::VERT, "| "); + assert_eq!(Ascii::GAP, " "); + } +} diff --git a/src/lib.rs b/src/lib.rs index 81fbbf4..14c8dc7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,8 @@ extern crate alloc; use core::{error::Error, fmt, iter, marker::PhantomData}; mod add; +mod chain; +mod connectors; mod main_result; #[cfg(feature = "alloc")] pub mod many_errors; @@ -19,18 +21,18 @@ mod oneline; #[cfg(feature = "std")] pub mod path_display; mod suggestion; -mod tree; pub mod with_context; pub use add::{Add, separator}; +pub use chain::Chain; +pub use connectors::{Ascii, Connectors, TreeConnectors, Unicode}; pub use main_result::{DisplaySwapDebug, MainResult, MainResultWithSuggestion, WithSuggestion}; #[cfg(feature = "alloc")] -pub use many_errors::{Listing, ManyErrors}; -pub use oneline::OneLine; +pub use many_errors::{Bullets, Inline, List, ManyErrors, Node, Subgroup, Tree}; +pub use oneline::Flat; #[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 a value to a [`fmt::Formatter`]. @@ -45,7 +47,7 @@ pub use with_context::WithContext; /// 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 +/// [`Flat`] and [`Chain`] 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 @@ -62,11 +64,9 @@ pub trait Format { /// 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. +/// Useful as a default in strategy-aware wrappers 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; @@ -87,13 +87,16 @@ pub fn chain<'a>(error: &'a dyn Error) -> impl Iterator + /// A helper trait to format errors. pub trait FormatError { /// Formats the error in a single line concatenated by `: `. - fn one_line(&self) -> Formatted<&Self, OneLine> { - self.formatted::() + fn one_line(&self) -> Formatted<&Self, Flat> { + self.formatted::() } - /// Formats the error as an indented tree of sources. - fn tree(&self) -> Formatted<&Self, Tree> { - self.formatted::() + /// Formats the error as an indented source-chain ladder. + /// + /// For aggregate many-error rendering (branching tree) see + /// [`ManyErrors::tree`](crate::many_errors::ManyErrors::tree). + fn chain(&self) -> Formatted<&Self, Chain> { + self.formatted::() } /// Renders the error's [`Suggestion`] hint. Only the top-level error is @@ -119,7 +122,7 @@ impl FormatError for E {} /// [`PhantomData`] avoids drop-check ownership of `F` and makes the wrapper /// `Send + Sync` regardless of `F`. #[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] -pub struct Formatted(E, PhantomData F>); +pub struct Formatted(E, PhantomData F>); impl Formatted { /// Wraps `error` so its `Display` impl uses the [`Format`] strategy `F`. diff --git a/src/main_result.rs b/src/main_result.rs index 780b475..2cc9895 100644 --- a/src/main_result.rs +++ b/src/main_result.rs @@ -3,14 +3,14 @@ use core::fmt; use crate::separator::{NewLine, WithSep}; -use super::{Format, Formatted, OneLine}; +use super::{Flat, Format, Formatted}; /// A result type that wraps an error with [Formatted] and [DisplaySwapDebug] to output from the `main` function. /// -/// The format strategy `F` defaults to [`OneLine`]; pass [`crate::Tree`] or a custom [`Format`] +/// The format strategy `F` defaults to [`Flat`]; pass [`crate::Tree`] or a custom [`Format`] /// to change how the error is rendered when `main` returns `Err`. /// The success type `T` defaults to `()`; pass `ExitCode` or another type to return from `main`. -pub type MainResult = +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. @@ -18,16 +18,16 @@ pub type MainResult = /// 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 = +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. +/// `F` defaults to [`Flat`] 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; +pub type WithSuggestion = WithSep; /// Wrapper that swaps an inner type's [`fmt::Debug`] and [`fmt::Display`] impls. /// @@ -111,9 +111,9 @@ mod tests { #[test] fn test_swap_with_formatted() { - let inner = Formatted::<_, OneLine>::new(Error::Two(Inner::A)); + let inner = Formatted::<_, Flat>::new(Error::Two(Inner::A)); let wrapped = DisplaySwapDebug::new(inner); - // Debug of DisplaySwapDebug = Display of inner = OneLine chain. + // Debug of DisplaySwapDebug = Display of inner = Flat chain. assert_eq!(format!("{wrapped:?}"), "Two: InnerA"); // Display of DisplaySwapDebug = Debug of inner = forwarded to error's Debug. assert_eq!(wrapped.to_string(), "Two(A)"); @@ -127,7 +127,7 @@ mod tests { ); assert_eq!( - DisplaySwapDebug::new(&DisplaySwapDebug::new(Formatted::<_, OneLine>::new( + DisplaySwapDebug::new(&DisplaySwapDebug::new(Formatted::<_, Flat>::new( Error::One ))) .to_string(), @@ -152,7 +152,7 @@ mod tests { #[test] fn test_with_suggestion_custom_separator() { - let formatted = Formatted::<_, WithSuggestion>::new(Error::One); + let formatted = Formatted::<_, WithSuggestion>::new(Error::One); assert_eq!( formatted.to_string(), "One Try passing --help to see available options." @@ -183,7 +183,7 @@ mod tests { 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(Error::One)?; } @@ -202,7 +202,7 @@ mod tests { fn test_main_result_with_exit_code() { use std::process::ExitCode; - fn main_with_error(err: bool) -> MainResult { + fn main_with_error(err: bool) -> MainResult { if err { Err(Error::One)?; } diff --git a/src/many_errors/iter.rs b/src/many_errors/iter.rs index fde4670..7918817 100644 --- a/src/many_errors/iter.rs +++ b/src/many_errors/iter.rs @@ -1,35 +1,51 @@ // --- Iter --- -use crate::{ManyErrors, WithContext, with_context::Colon}; +use core::ops::ControlFlow; -impl ManyErrors { - /// Returns an iterator over references to each recorded [`WithContext`]. - pub fn iter(&self) -> Iter<'_, C, E, WithContextFormat> { +use crate::{ + ManyErrors, + with_context::{Colon, ContextField, WithContext}, +}; + +use super::Node; + +impl ManyErrors { + /// Returns an iterator over references to each direct [`Node`] child. + pub fn iter(&self) -> Iter<'_, C, E, GC, F, GF> { 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>); +impl<'a, C, E, GC, F, GF> IntoIterator for &'a ManyErrors { + type Item = &'a Node; + type IntoIter = Iter<'a, C, E, GC, F, GF>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +/// Iterator over references to the direct [`Node`] children of a [`ManyErrors`]. +pub struct Iter<'a, C, E, GC = C, F = Colon, GF = ContextField>(IterInner<'a, C, E, GC, F, GF>); -enum IterInner<'a, C, E, WithContextFormat> { +enum IterInner<'a, C, E, GC, F, GF> { Empty, - One(Option<&'a WithContext>), - Many(core::slice::Iter<'a, WithContext>), + One(Option<&'a Node>), + Many(core::slice::Iter<'a, Node>), } -impl<'a, C, E, WithContextFormat> Iter<'a, C, E, WithContextFormat> { - fn new(many: &'a ManyErrors) -> Self { +impl<'a, C, E, GC, F, GF> Iter<'a, C, E, GC, F, GF> { + fn new(many: &'a ManyErrors) -> Self { Self(match many { ManyErrors::None => IterInner::Empty, - ManyErrors::One(w) => IterInner::One(Some(w)), + ManyErrors::One(n) => IterInner::One(Some(n)), ManyErrors::Many(v) => IterInner::Many(v.iter()), }) } } -impl<'a, C, E, WithContextFormat> Iterator for Iter<'a, C, E, WithContextFormat> { - type Item = &'a WithContext; +impl<'a, C, E, GC, F, GF> Iterator for Iter<'a, C, E, GC, F, GF> { + type Item = &'a Node; fn next(&mut self) -> Option { match &mut self.0 { @@ -51,137 +67,223 @@ impl<'a, C, E, WithContextFormat> Iterator for Iter<'a, C, E, WithContextFormat> } } -mod from_iter { - use core::ops::ControlFlow; +// --- IterMut --- - use super::*; +impl ManyErrors { + /// Returns an iterator over mutable references to each direct [`Node`] child. + pub fn iter_mut(&mut self) -> IterMut<'_, C, E, GC, F, GF> { + IterMut::new(self) + } +} - impl FromIterator> - for ManyErrors - { - fn from_iter>>( - iter: I, - ) -> Self { - let mut me = Self::None; - me.extend(iter); - me +impl<'a, C, E, GC, F, GF> IntoIterator for &'a mut ManyErrors { + type Item = &'a mut Node; + type IntoIter = IterMut<'a, C, E, GC, F, GF>; + + fn into_iter(self) -> Self::IntoIter { + self.iter_mut() + } +} + +/// Iterator over mutable references to the direct [`Node`] children of a [`ManyErrors`]. +pub struct IterMut<'a, C, E, GC = C, F = Colon, GF = ContextField>( + IterMutInner<'a, C, E, GC, F, GF>, +); + +enum IterMutInner<'a, C, E, GC, F, GF> { + Empty, + One(Option<&'a mut Node>), + Many(core::slice::IterMut<'a, Node>), +} + +impl<'a, C, E, GC, F, GF> IterMut<'a, C, E, GC, F, GF> { + fn new(many: &'a mut ManyErrors) -> Self { + Self(match many { + ManyErrors::None => IterMutInner::Empty, + ManyErrors::One(n) => IterMutInner::One(Some(n)), + ManyErrors::Many(v) => IterMutInner::Many(v.iter_mut()), + }) + } +} + +impl<'a, C, E, GC, F, GF> Iterator for IterMut<'a, C, E, GC, F, GF> { + type Item = &'a mut Node; + + fn next(&mut self) -> Option { + match &mut self.0 { + IterMutInner::Empty => None, + IterMutInner::One(slot) => slot.take(), + IterMutInner::Many(it) => it.next(), } } - impl FromIterator<(C, E)> for ManyErrors { - fn from_iter>(iter: I) -> Self { - iter.into_iter().map(WithContext::from).collect() + fn size_hint(&self) -> (usize, Option) { + match &self.0 { + IterMutInner::Empty => (0, Some(0)), + IterMutInner::One(slot) => { + let n = slot.is_some() as usize; + (n, Some(n)) + } + IterMutInner::Many(it) => it.size_hint(), } } +} - 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 +// --- IntoIter (owned) --- + +impl IntoIterator for ManyErrors { + type Item = Node; + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + IntoIter(match self { + ManyErrors::None => IntoIterInner::Empty, + ManyErrors::One(n) => IntoIterInner::One(Some(n)), + ManyErrors::Many(v) => IntoIterInner::Many(v.into_iter()), + }) + } +} + +/// Owning iterator over the direct [`Node`] children of a [`ManyErrors`], +/// produced by `into_iter` (moves each child out). +pub struct IntoIter(IntoIterInner); + +enum IntoIterInner { + Empty, + One(Option>), + Many(alloc::vec::IntoIter>), +} + +impl Iterator for IntoIter { + type Item = Node; + + fn next(&mut self) -> Option { + match &mut self.0 { + IntoIterInner::Empty => None, + IntoIterInner::One(slot) => slot.take(), + IntoIterInner::Many(it) => it.next(), } } - impl FromIterator> - for ManyErrors - { - fn from_iter>>(iter: I) -> Self { - let mut me = Self::None; - me.extend(iter); - me + fn size_hint(&self) -> (usize, Option) { + match &self.0 { + IntoIterInner::Empty => (0, Some(0)), + IntoIterInner::One(slot) => { + let n = slot.is_some() as usize; + (n, Some(n)) + } + IntoIterInner::Many(it) => it.size_hint(), } } +} + +// --- FromIterator / Extend --- + +impl FromIterator> for ManyErrors { + fn from_iter>>(iter: I) -> Self { + let mut me = Self::None; + me.extend(iter); + me + } +} - // --- Extend --- +impl FromIterator<(C, E)> for ManyErrors { + fn from_iter>(iter: I) -> Self { + let mut me = Self::None; + me.extend(iter); + me + } +} - impl Extend> - for ManyErrors +impl FromIterator, WithContext>> + for ManyErrors +{ + fn from_iter(iter: I) -> Self + where + I: IntoIterator, WithContext>>, { - fn extend>>( - &mut self, - iter: I, - ) { - // TODO: Optimize - for item in iter { - self.push(item); - } + 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) { + for item in iter { + self.push_node(Node::Leaf(item)); } } +} - impl Extend<(C, E)> for ManyErrors { - fn extend>(&mut self, iter: I) { - self.extend(iter.into_iter().map(WithContext::from)); +impl Extend<(C, E)> for ManyErrors { + fn extend>(&mut self, iter: I) { + for (context, error) in iter { + self.push(context, error); } } +} - /// `Continue(w)` records `w` and keeps iterating; `Break(w)` records `w` and stops. - impl - Extend< - ControlFlow, WithContext>, - > for ManyErrors +/// `Continue(w)` records `w` and keeps iterating; `Break(w)` records `w` and stops. +impl Extend, WithContext>> + for ManyErrors +{ + fn extend(&mut self, iter: I) + where + I: IntoIterator, WithContext>>, { - 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; - } + for cf in iter { + let stop = matches!(cf, ControlFlow::Break(_)); + let w = match cf { + ControlFlow::Continue(w) | ControlFlow::Break(w) => w, + }; + self.push_node(Node::Leaf(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)), - })); +impl Extend> for ManyErrors { + fn extend>>(&mut self, iter: I) { + for cf in iter { + let stop = matches!(cf, ControlFlow::Break(_)); + let (context, error) = match cf { + ControlFlow::Continue(t) | ControlFlow::Break(t) => t, + }; + self.push(context, error); + if stop { + break; + } } } } #[cfg(test)] mod tests { - use crate::{ManyErrors, WithContext, tests::Inner}; + use crate::{ManyErrors, Node, 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(); + let wcs = [ + WithContext::<_, _, _>::new("a", Inner::A), + WithContext::new("b", Inner::A), + WithContext::new("c", Inner::A), + ]; + let errs: ManyErrors<&str, Inner> = wcs.into_iter().collect(); assert_eq!(errs.len(), 3); } @@ -194,26 +296,33 @@ mod tests { #[test] fn test_extend_from_with_context() { - let mut e = ManyErrors::new(); - e.extend([w("a"), w("b")]); + let mut e = ManyErrors::<&str, Inner>::new(); + e.extend([ + WithContext::new("a", Inner::A), + WithContext::new("b", Inner::A), + ]); 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>) = + let results: alloc::vec::Vec> = + alloc::vec![Ok(1), Err(("a", Inner::A)), Ok(2), Err(("b", Inner::A))]; + let (oks, errs): (alloc::vec::Vec, ManyErrors<&str, Inner>) = results.into_iter().partition_result(); assert_eq!(oks, [1, 2]); assert_eq!(errs.len(), 2); } + type WcFlow = ControlFlow, WithContext<&'static str, Inner>>; + type TupleFlow = ControlFlow<(&'static str, Inner), (&'static str, Inner)>; + #[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 items: alloc::vec::Vec = alloc::vec![ + ControlFlow::Continue(WithContext::new("a", Inner::A)), + ControlFlow::Continue(WithContext::new("b", Inner::A)), + ]; let errs: ManyErrors<&str, Inner> = items.into_iter().collect(); assert_eq!(errs.len(), 2); } @@ -237,8 +346,7 @@ mod tests { #[test] fn test_control_flow_tuples() { - #[allow(clippy::type_complexity)] - let items: Vec> = vec![ + let items: alloc::vec::Vec = alloc::vec![ ControlFlow::Continue(("a", Inner::A)), ControlFlow::Break(("b", Inner::A)), ]; @@ -254,19 +362,31 @@ mod tests { #[test] fn test_iter_one() { - let mut e = ManyErrors::new(); - e.push(w("a")); - let items: Vec<_> = e.iter().collect(); + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + let items: alloc::vec::Vec<_> = e.iter().collect(); assert_eq!(items.len(), 1); - assert_eq!(items[0].context, "a"); + assert_eq!(items[0].as_leaf().unwrap().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(); + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::A); + let ctxs: alloc::vec::Vec<_> = e.iter().map(|n| n.as_leaf().unwrap().context).collect(); + assert_eq!(ctxs, ["a", "b"]); + } + + #[test] + fn test_into_iter_ref() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::A); + let mut ctxs = alloc::vec::Vec::new(); + for n in &e { + ctxs.push(n.as_leaf().unwrap().context); + } assert_eq!(ctxs, ["a", "b"]); } @@ -279,4 +399,46 @@ mod tests { .collect(); assert_eq!(errs.len(), 2); } + + #[test] + fn test_into_iter_owned() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::B); + // Moves each node out — no borrow of `e` afterwards. + let ctxs: alloc::vec::Vec<_> = e + .into_iter() + .map(|n| n.as_leaf().unwrap().context) + .collect(); + assert_eq!(ctxs, ["a", "b"]); + } + + #[test] + fn test_into_iter_owned_one_and_none() { + let one = { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("solo", Inner::A); + e + }; + assert_eq!(one.into_iter().count(), 1); + + let none = ManyErrors::<&str, Inner>::new(); + assert_eq!(none.into_iter().count(), 0); + } + + #[test] + fn test_iter_mut_mutates_in_place() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::A); + + for node in &mut e { + if let Node::Leaf(w) = node { + w.context = "patched"; + } + } + + let ctxs: alloc::vec::Vec<_> = e.iter().map(|n| n.as_leaf().unwrap().context).collect(); + assert_eq!(ctxs, ["patched", "patched"]); + } } diff --git a/src/many_errors/listing.rs b/src/many_errors/listing.rs deleted file mode 100644 index 16f9bdd..0000000 --- a/src/many_errors/listing.rs +++ /dev/null @@ -1,150 +0,0 @@ -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 index b47e481..63b375a 100644 --- a/src/many_errors/mod.rs +++ b/src/many_errors/mod.rs @@ -2,49 +2,123 @@ use core::{ error::Error, - fmt::{self, Debug, Display, Formatter}, + fmt::{self, Display, Formatter}, + hash::{Hash, Hasher}, }; -use alloc::{vec, vec::Vec}; +use alloc::{boxed::Box, vec, vec::Vec}; use crate::{ - AsDisplay, Format, - with_context::{Colon, WithContext}, + Format, + with_context::{Colon, ContextField, WithContext}, }; mod iter; -mod listing; +mod node; +mod strategy; -pub use listing::Listing; +pub use crate::connectors::{Ascii, Connectors, TreeConnectors, Unicode}; +pub use node::{Node, Subgroup}; +pub use strategy::{Bullets, Inline, List, Tree}; -/// Zero or more context-tagged errors collected during an iterator/fold operation. +/// Zero or more context-tagged errors, arranged as a rose tree. /// -/// 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. +/// Each child is a [`Node`]: either a leaf [`WithContext`] pair or a labeled +/// sub-group (another `ManyErrors`). The three-variant split avoids heap +/// allocation until a second error arrives. +/// +/// [`Display`] renders via [`Tree`] (branching Unicode tree with a count +/// header). Other shapes — [`List`], [`Bullets`], [`Inline`] — are available +/// via the inherent helpers [`tree`](ManyErrors::tree), +/// [`list`](ManyErrors::list), [`bullets`](ManyErrors::bullets), +/// [`one_line`](ManyErrors::one_line), or via +/// [`FormatError::formatted`](crate::FormatError::formatted) for full generic +/// control (e.g. `Tree`). +/// +/// All standard-trait impls are written manually so they do **not** add +/// `F: Trait` bounds (mirroring [`WithContext`]'s `PhantomData F>`). +/// +/// # Context bounds +/// To put a `ManyErrors` in an [`Error`] position (e.g. as a `#[source]`, or to +/// render it via [`Display`]/[`Formatted`](crate::Formatted)), the leaf context +/// `C` **and** the group context `GC` must implement [`Debug`](core::fmt::Debug) +/// — not for display, but because [`Error`] requires `Debug` as a supertrait and +/// that bound propagates through the manual `Debug` impl. A custom group-context +/// type therefore needs a `Debug` derive even though only its [`Display`] is +/// printed. /// /// # Example /// ``` -/// use errortools::{ManyErrors, WithContext}; -/// use std::path::PathBuf; +/// use errortools::ManyErrors; +/// use std::io; /// -/// let mut errs = ManyErrors::::new(); +/// let mut errs = ManyErrors::<&str, io::Error>::new(); /// assert!(errs.is_empty()); +/// errs.push("step 1", io::Error::other("fail")); +/// assert_eq!(errs.len(), 1); /// ``` -#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] -pub enum ManyErrors { - /// No errors were recorded. +#[derive(Default)] +pub enum ManyErrors { + /// No errors recorded. #[default] None, - /// Exactly one error was recorded. - One(WithContext), - /// Two or more errors were recorded. - Many(Vec>), + /// Exactly one child. + One(Node), + /// Two or more children. + Many(Vec>), +} + +// Manual trait impls so F/GF get no extra Trait bounds from derives. + +impl core::fmt::Debug + for ManyErrors +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::None => write!(f, "None"), + Self::One(n) => f.debug_tuple("One").field(n).finish(), + Self::Many(v) => f.debug_list().entries(v.iter()).finish(), + } + } } -impl ManyErrors { +impl Clone for ManyErrors { + fn clone(&self) -> Self { + match self { + Self::None => Self::None, + Self::One(n) => Self::One(n.clone()), + Self::Many(v) => Self::Many(v.clone()), + } + } +} + +impl PartialEq for ManyErrors { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::None, Self::None) => true, + (Self::One(a), Self::One(b)) => a == b, + (Self::Many(a), Self::Many(b)) => a == b, + _ => false, + } + } +} + +impl Eq for ManyErrors {} + +impl Hash for ManyErrors { + fn hash(&self, state: &mut H) { + core::mem::discriminant(self).hash(state); + match self { + Self::None => {} + Self::One(n) => n.hash(state), + Self::Many(v) => v.hash(state), + } + } +} + +// --- Core API --- + +impl ManyErrors { /// Creates an empty `ManyErrors`. pub const fn new() -> Self { Self::None @@ -55,8 +129,8 @@ impl ManyErrors { matches!(self, Self::None) } - /// Returns the number of recorded errors. - pub fn len(&self) -> usize { + /// Returns the number of direct children (leaves + sub-groups). + pub const fn len(&self) -> usize { match self { Self::None => 0, Self::One(_) => 1, @@ -64,23 +138,45 @@ impl ManyErrors { } } - /// Appends a tagged error, promoting `None → One → Many` as needed. + /// Appends a leaf error with context, promoting `None → One → Many`. /// /// # Example /// ``` - /// use errortools::{ManyErrors, WithContext}; + /// use errortools::ManyErrors; /// /// let mut errs = ManyErrors::<&str, std::io::Error>::new(); - /// errs.push(WithContext::new("step 1", std::io::Error::other("fail"))); + /// errs.push("step 1", std::io::Error::other("fail")); /// assert_eq!(errs.len(), 1); /// ``` - pub fn push(&mut self, item: WithContext) { + pub fn push(&mut self, context: C, error: E) { + self.push_node(Node::Leaf(WithContext::new(context, error))); + } + + /// Appends a named sub-group of errors. + /// + /// # Example + /// ``` + /// use errortools::ManyErrors; + /// use std::io; + /// + /// let mut inner = ManyErrors::<&str, io::Error>::new(); + /// inner.push("a", io::Error::other("x")); + /// + /// let mut outer = ManyErrors::new(); + /// outer.push_group("region", inner); + /// assert_eq!(outer.len(), 1); + /// ``` + pub fn push_group(&mut self, context: GC, errors: Self) { + self.push_node(Node::Group(WithContext::new(context, Box::new(errors)))); + } + + pub(crate) fn push_node(&mut self, node: Node) { let prev = core::mem::take(self); *self = match prev { - Self::None => Self::One(item), - Self::One(first) => Self::Many(vec![first, item]), + Self::None => Self::One(node), + Self::One(first) => Self::Many(vec![first, node]), Self::Many(mut v) => { - v.push(item); + v.push(node); Self::Many(v) } }; @@ -103,31 +199,59 @@ impl ManyErrors { } } -/// 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 +// --- Inherent formatting helpers (no turbofish needed for common shapes) --- + +impl ManyErrors { + /// Renders as a branching Unicode tree with a count header (same as default [`Display`]). + pub fn tree(&self) -> crate::Formatted<&Self, Tree> { + crate::Formatted::new(self) + } + + /// Renders as a dotted numbered list (`1. 1.1. 1.2. 2.`). + pub fn list(&self) -> crate::Formatted<&Self, List> { + crate::Formatted::new(self) + } + + /// Renders as a bulleted list with `•` markers. + pub fn bullets(&self) -> crate::Formatted<&Self, Bullets> { + crate::Formatted::new(self) + } + + /// Renders on a single line: `;`-separated siblings, parens around groups. + pub fn one_line(&self) -> crate::Formatted<&Self, Inline> { + crate::Formatted::new(self) + } +} + +/// Renders each error as a branching Unicode tree with a count header. +impl Display for ManyErrors where - WithContextFormat: Format>, + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - as Format>::fmt(self, f) + >::fmt(self, f) } } -impl Error for ManyErrors +impl Error for ManyErrors where - C: Debug, - E: Error + 'static, - WithContextFormat: Format> + Debug, + C: Display + core::fmt::Debug, + GC: core::fmt::Debug, + E: Error + Display + 'static, + F: Format>, + GF: Format>, { - /// 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. + /// For [`Self::One`] holding a leaf, returns the inner error's source so + /// chain-walking strategies don't duplicate it. Groups and `Many` variants + /// have no single source. fn source(&self) -> Option<&(dyn Error + 'static)> { match self { Self::None | Self::Many(_) => None, - Self::One(p) => p.error.source(), + Self::One(Node::Leaf(w)) => w.error.source(), + Self::One(Node::Group(_)) => None, } } } @@ -135,16 +259,9 @@ where #[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) - } + use crate::tests::{Inner, Mid}; - // --- push / variants --- + // --- push / push_group / variants --- #[test] fn test_new_is_none() { @@ -156,30 +273,52 @@ mod tests { #[test] fn test_push_none_to_one() { - let mut e = ManyErrors::new(); - e.push(w("a")); + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::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")); + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::A); assert!(matches!(e, ManyErrors::Many(_))); assert_eq!(e.len(), 2); } #[test] fn test_push_many_grows() { - let mut e: ManyErrors = ManyErrors::new(); + let mut e = ManyErrors::::new(); for i in 0..5u32 { - e.push(WithContext::new(i, Inner::A)); + e.push(i, Inner::A); } assert_eq!(e.len(), 5); } + #[test] + fn test_push_group() { + let mut inner = ManyErrors::<&str, Inner>::new(); + inner.push("x", Inner::A); + inner.push("y", Inner::B); + + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push_group("region", inner); + assert_eq!(outer.len(), 1); + assert!(matches!(outer, ManyErrors::One(Node::Group(_)))); + } + + #[test] + fn test_push_leaf_and_group() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("leaf", Inner::A); + let mut sub = ManyErrors::new(); + sub.push("sub-leaf", Inner::B); + e.push_group("group", sub); + assert_eq!(e.len(), 2); + } + // --- into_result --- #[test] @@ -190,20 +329,20 @@ mod tests { #[test] fn test_into_result_one_err() { - let mut e = ManyErrors::new(); - e.push(w("a")); + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::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")); + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::A); assert!(e.into_result(()).is_err()); } - // --- Display + Error --- + // --- Error::source --- #[test] fn test_source_none() { @@ -212,27 +351,74 @@ mod tests { } #[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". + fn test_source_one_leaf_skips_inner_error() { + let mut e = ManyErrors::<&str, Mid>::new(); + e.push("ctx", Mid::Inner(Inner::A)); let src = e.source().expect("should have source"); assert_eq!(src.to_string(), "InnerA"); } + #[test] + fn test_source_one_group_is_none() { + let mut e = ManyErrors::<&str, Inner>::new(); + let mut sub = ManyErrors::new(); + sub.push("x", Inner::A); + e.push_group("g", sub); + assert!(e.source().is_none()); + } + #[test] fn test_source_many_is_none() { - let mut e = ManyErrors::new(); - e.push(w("a")); - e.push(w("b")); + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::A); assert!(e.source().is_none()); } + // --- Display (Tree) --- + + #[test] + fn test_display_empty() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.to_string(), ""); + } + + #[test] + fn test_display_single_leaf_no_header() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("ctx", Inner::A); + // Single item: no count header + assert_eq!(e.to_string(), "ctx: InnerA"); + } + + #[test] + fn test_display_two_leaves_with_header() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::B); + assert_eq!(e.to_string(), "2 errors:\n├─ a: InnerA\n└─ b: InnerB"); + } + + #[test] + fn test_display_nested_group() { + let mut inner = ManyErrors::<&str, Inner>::new(); + inner.push("x", Inner::A); + inner.push("y", Inner::B); + + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push_group("region", inner); + outer.push("leaf", Inner::A); + + let s = outer.to_string(); + assert!(s.contains("2 errors:"), "got: {s}"); + assert!(s.contains("region (2 errors):"), "got: {s}"); + assert!(s.contains("leaf: InnerA"), "got: {s}"); + } + #[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))); + fn test_one_line_single_leaf_walks_chain() { + let mut e = ManyErrors::<&str, Mid>::new(); + e.push("ctx", Mid::Inner(Inner::A)); assert_eq!(e.one_line().to_string(), "ctx: mid: InnerA"); } } diff --git a/src/many_errors/node.rs b/src/many_errors/node.rs new file mode 100644 index 0000000..7b7c156 --- /dev/null +++ b/src/many_errors/node.rs @@ -0,0 +1,180 @@ +//! A single child of a [`ManyErrors`]: a leaf error-with-context, or a named sub-group. + +use core::{ + fmt::{self, Debug}, + hash::{Hash, Hasher}, +}; + +use alloc::boxed::Box; + +use crate::with_context::{Colon, ContextField, WithContext}; + +use super::ManyErrors; + +/// The payload of a [`Node::Group`]: a label `GC` paired with the boxed nested +/// [`ManyErrors`], rendered through the label strategy `GF`. +pub type Subgroup = WithContext>, GF>; + +/// A child of a [`ManyErrors`]: either a leaf error paired with context, or a +/// named sub-group of further errors. +/// +/// Both variants reuse [`WithContext`], so leaves and groups are symmetric and +/// each renders through its own [`Format`](crate::Format) strategy: +/// - [`Leaf`](Node::Leaf): a leaf context `C` paired with error `E`, formatted +/// by `F` (default [`Colon`]: `"{context}: {error}"`). +/// - [`Group`](Node::Group): a label `GC` paired with a boxed nested +/// [`ManyErrors`], formatted by `GF` (default [`ContextField`]: label only). +/// +/// The group's `errors` are boxed to break the mutual recursion with +/// [`ManyErrors`]. All standard-trait impls (`Clone`, `PartialEq`, `Eq`, +/// `Hash`, `Debug`) are written manually — not derived — so they do **not** add +/// `F`/`GF`: `Trait` bounds (mirroring [`WithContext`]'s `PhantomData F>`). +pub enum Node { + /// A leaf: one context-tagged error. + Leaf(WithContext), + /// A named sub-group: a label paired with a boxed nested [`ManyErrors`]. + Group(Subgroup), +} + +// --- Manual trait impls (no F/GF: Trait bound) --- + +impl Clone for Node { + fn clone(&self) -> Self { + match self { + Node::Leaf(w) => Node::Leaf(w.clone()), + Node::Group(w) => Node::Group(w.clone()), + } + } +} + +impl PartialEq for Node { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Node::Leaf(a), Node::Leaf(b)) => a == b, + (Node::Group(a), Node::Group(b)) => a == b, + _ => false, + } + } +} + +impl Eq for Node {} + +impl Hash for Node { + fn hash(&self, state: &mut H) { + core::mem::discriminant(self).hash(state); + match self { + Node::Leaf(w) => w.hash(state), + Node::Group(w) => w.hash(state), + } + } +} + +impl Debug for Node { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Node::Leaf(w) => f.debug_tuple("Leaf").field(w).finish(), + Node::Group(w) => f.debug_tuple("Group").field(w).finish(), + } + } +} + +// --- Conversions --- + +impl From> for Node { + fn from(w: WithContext) -> Self { + Node::Leaf(w) + } +} + +impl From<(C, E)> for Node { + fn from((context, error): (C, E)) -> Self { + Node::Leaf(WithContext::new(context, error)) + } +} + +// --- Methods --- + +impl Node { + /// Returns `true` if this is a [`Node::Leaf`]. + pub fn is_leaf(&self) -> bool { + matches!(self, Node::Leaf(_)) + } + + /// Returns the leaf's [`WithContext`] pair, or `None` for a group. + pub fn as_leaf(&self) -> Option<&WithContext> { + match self { + Node::Leaf(w) => Some(w), + Node::Group(_) => None, + } + } + + /// Returns the group's labeled [`WithContext`], or `None` for a leaf. + /// + /// The label is `&self.context`; the nested errors are `&*self.error`. + pub fn as_group(&self) -> Option<&Subgroup> { + match self { + Node::Group(w) => Some(w), + Node::Leaf(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::Inner; + + type N = Node<&'static str, Inner, &'static str, Colon, ContextField>; + + #[test] + fn test_leaf_from_with_context() { + let w = WithContext::<_, _, Colon>::new("ctx", Inner::A); + let node: N = Node::from(w); + assert!(node.is_leaf()); + assert_eq!(node.as_leaf().unwrap().context, "ctx"); + } + + #[test] + fn test_leaf_from_tuple() { + let node: N = Node::from(("ctx", Inner::A)); + assert!(node.is_leaf()); + assert_eq!(node.as_leaf().unwrap().context, "ctx"); + } + + #[test] + fn test_group_context() { + let node: N = Node::Group(WithContext::new("region", Box::new(ManyErrors::new()))); + assert!(!node.is_leaf()); + assert_eq!(node.as_group().unwrap().context, "region"); + } + + #[test] + fn test_clone_leaf() { + let node: N = Node::from(("ctx", Inner::A)); + let cloned = node.clone(); + assert_eq!(node, cloned); + } + + #[test] + fn test_clone_group() { + let node: N = Node::Group(WithContext::new("grp", Box::new(ManyErrors::new()))); + let cloned = node.clone(); + assert_eq!(node, cloned); + } + + #[test] + fn test_debug_leaf() { + let node: N = Node::from(("ctx", Inner::A)); + let s = format!("{node:?}"); + assert!(s.contains("Leaf")); + assert!(s.contains("ctx")); + } + + #[test] + fn test_debug_group() { + let node: N = Node::Group(WithContext::new("grp", Box::new(ManyErrors::new()))); + let s = format!("{node:?}"); + assert!(s.contains("Group")); + assert!(s.contains("grp")); + } +} diff --git a/src/many_errors/strategy/bullets.rs b/src/many_errors/strategy/bullets.rs new file mode 100644 index 0000000..4f315d4 --- /dev/null +++ b/src/many_errors/strategy/bullets.rs @@ -0,0 +1,129 @@ +//! [`Bullets`]: render a [`ManyErrors`] as a bulleted (`•`) list. +//! +//! `depth: usize` carries the nesting level; the visual indent is reconstructed +//! lazily with `repeat_n(" ", depth).format("")` — no `String` allocation. + +use core::{ + error::Error, + fmt::{self, Display}, + iter, +}; + +use itertools::Itertools; + +use crate::{ + Format, + many_errors::{ManyErrors, Node, Subgroup}, + with_context::WithContext, +}; + +use super::{impl_aggregate_format, inline_sources}; + +/// Aggregate strategy that renders a [`ManyErrors`] as a bulleted (`•`) list. +/// +/// # Output example +/// ```text +/// 3 errors: +/// • a: InnerA +/// • b: InnerB +/// • c: InnerC +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Bullets; + +impl_aggregate_format!(Bullets, |errors, f| draw_bullets_many::( + errors, 0, f +)); + +/// Render `errors` as a bulleted list at nesting `depth`. +/// +/// - `None` writes nothing. +/// - `One` delegates to [`draw_bullets_node`] with `with_bullet = false`: a lone +/// error is printed flush, without a leading `•`. +/// - `Many` writes the `"N errors:"` header, then recurses into each child at +/// `depth + 1` with `with_bullet = true` so every child gets its own bullet. +fn draw_bullets_many( + errors: &ManyErrors, + depth: usize, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, +{ + match errors { + ManyErrors::None => Ok(()), + ManyErrors::One(node) => draw_bullets_node::(node, depth, false, f), + ManyErrors::Many(nodes) => { + write!(f, "{} errors:", nodes.len())?; + for node in nodes { + draw_bullets_node::(node, depth + 1, true, f)?; + } + Ok(()) + } + } +} + +/// Render a single node, optionally prefixed with its own bullet. +/// +/// When `with_bullet` is set, first writes `"\n{indent}• "` where `indent` is +/// `depth` copies of `" "` (lazy `repeat_n`, no allocation). Then: +/// - `Leaf` → `{w}` plus the inline source chain via [`inline_sources`]; +/// - `Group` empty → `"{w}: (no errors)"`; +/// - `Group` single child → `"{w}: "` then recurse at the same `depth` with +/// `with_bullet = false`, so the child sits inline after the label; +/// - `Group` many children → `"{w} (N errors):"` header, then each child +/// recurses at `depth + 1` with its own bullet. +fn draw_bullets_node( + node: &Node, + depth: usize, + with_bullet: bool, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, +{ + if with_bullet { + let indent = iter::repeat_n(" ", depth).format(""); + write!(f, "\n{indent}• ")?; + } + match node { + Node::Leaf(w) => { + write!(f, "{w}")?; + inline_sources(w.error.source(), f) + } + Node::Group(w) => match w.error.as_ref() { + ManyErrors::None => write!(f, "{w}: (no errors)"), + ManyErrors::One(inner) => { + write!(f, "{w}: ")?; + draw_bullets_node::(inner, depth, false, f) + } + ManyErrors::Many(nodes) => { + write!(f, "{w} ({} errors):", nodes.len())?; + for node in nodes { + draw_bullets_node::(node, depth + 1, true, f)?; + } + Ok(()) + } + }, + } +} + +#[cfg(test)] +mod tests { + use crate::many_errors::strategy::test_helpers::two_leaves; + + #[test] + fn test_bullets_two_leaves() { + let e = two_leaves(); + assert_eq!( + e.bullets().to_string(), + "2 errors:\n • a: InnerA\n • b: InnerB" + ); + } +} diff --git a/src/many_errors/strategy/inline.rs b/src/many_errors/strategy/inline.rs new file mode 100644 index 0000000..9fc153f --- /dev/null +++ b/src/many_errors/strategy/inline.rs @@ -0,0 +1,123 @@ +//! [`Inline`]: render a [`ManyErrors`] on a single line. + +use core::{ + error::Error, + fmt::{self, Display}, +}; + +use crate::{ + Format, + many_errors::{ManyErrors, Node, Subgroup}, + with_context::WithContext, +}; + +use super::{impl_aggregate_format, inline_sources}; + +/// Aggregate strategy that renders a [`ManyErrors`] on a single line. +/// +/// Siblings are separated by `"; "`. Nested groups are wrapped in parens. +/// +/// # Output example +/// ```text +/// 3 errors: a: InnerA; b: InnerB; c: InnerC +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Inline; + +impl_aggregate_format!(Inline, |errors, f| draw_inline_many::( + errors, f +)); + +/// Render `errors` on the current line, no newlines. +/// +/// - `None` writes nothing. +/// - `One` delegates to [`draw_inline_node`] with no header. +/// - `Many` writes the `"N errors: "` header, then each child separated by +/// `"; "`. A `first` flag suppresses the separator before the first child so +/// it isn't led by a stray `"; "`. +fn draw_inline_many( + errors: &ManyErrors, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, +{ + match errors { + ManyErrors::None => Ok(()), + ManyErrors::One(node) => draw_inline_node::(node, f), + ManyErrors::Many(nodes) => { + write!(f, "{} errors: ", nodes.len())?; + let mut first = true; + for node in nodes { + if !first { + write!(f, "; ")?; + } + first = false; + draw_inline_node::(node, f)?; + } + Ok(()) + } + } +} + +/// Render a single node inline. +/// +/// - `Leaf` → `{w}` plus its source chain via [`inline_sources`]. +/// - `Group` → `"{w} ("`, the nested aggregate rendered recursively by +/// [`draw_inline_many`], then a closing `")"`. Parens bracket each nested +/// group so depth stays unambiguous on one line. +fn draw_inline_node( + node: &Node, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, +{ + match node { + Node::Leaf(w) => { + write!(f, "{w}")?; + inline_sources(w.error.source(), f) + } + Node::Group(w) => { + write!(f, "{w} (")?; + draw_inline_many::(w.error.as_ref(), f)?; + write!(f, ")") + } + } +} + +#[cfg(test)] +mod tests { + use crate::ManyErrors; + use crate::many_errors::strategy::test_helpers::two_leaves; + use crate::tests::{Inner, Mid}; + + #[test] + fn test_inline_empty() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.one_line().to_string(), ""); + } + + #[test] + fn test_inline_single() { + let mut e = ManyErrors::<&str, Mid>::new(); + e.push("ctx", Mid::Inner(Inner::A)); + assert_eq!(e.one_line().to_string(), "ctx: mid: InnerA"); + } + + #[test] + fn test_inline_two() { + let e = two_leaves(); + let s = e.one_line().to_string(); + assert!(s.contains("2 errors:"), "got: {s}"); + assert!(s.contains("a: InnerA"), "got: {s}"); + assert!(s.contains("b: InnerB"), "got: {s}"); + assert!(s.contains("; "), "got: {s}"); + } +} diff --git a/src/many_errors/strategy/list.rs b/src/many_errors/strategy/list.rs new file mode 100644 index 0000000..5d6482c --- /dev/null +++ b/src/many_errors/strategy/list.rs @@ -0,0 +1,134 @@ +//! [`List`]: render a [`ManyErrors`] as a numbered list. +//! +//! `depth: usize` carries the nesting level; the visual indent is reconstructed +//! lazily with `repeat_n(" ", depth).format("")` — no `String` allocation. + +use core::{ + error::Error, + fmt::{self, Display}, + iter, +}; + +use itertools::Itertools; + +use crate::{ + Format, + many_errors::{ManyErrors, Node, Subgroup}, + with_context::WithContext, +}; + +use super::{impl_aggregate_format, inline_sources}; + +/// Aggregate strategy that renders a [`ManyErrors`] as a numbered list. +/// +/// # Output example +/// ```text +/// 3 errors: +/// 1. a: InnerA +/// 2. b: InnerB +/// 3. c: InnerC +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct List; + +impl_aggregate_format!(List, |errors, f| draw_list_many::( + errors, 0, f +)); + +/// Render `errors` as a numbered list at nesting `depth`. +/// +/// - `None` writes nothing. +/// - `One` delegates straight to [`draw_list_node`] with no header or number +/// (a lone error reads better inline than as `1. ...`). +/// - `Many` writes the `"N errors:"` header, then one `"{indent}{i}. "` prefix +/// per child before recursing. `indent` is `depth` copies of `" "`, built +/// lazily via `repeat_n` so no `String` is allocated. +/// +/// Children recurse at `depth + 1` so their own nested groups indent one step +/// further than this level's numbers. +fn draw_list_many( + errors: &ManyErrors, + depth: usize, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, +{ + match errors { + ManyErrors::None => Ok(()), + ManyErrors::One(node) => draw_list_node::(node, depth, f), + ManyErrors::Many(nodes) => { + write!(f, "{} errors:", nodes.len())?; + for (i, node) in nodes.iter().enumerate() { + let indent = iter::repeat_n(" ", depth).format(""); + write!(f, "\n{indent}{}. ", i + 1)?; + draw_list_node::(node, depth + 1, f)?; + } + Ok(()) + } + } +} + +/// Render a single node; the `"{i}. "` prefix has already been written by the +/// caller. +/// +/// - `Leaf` writes the context/error pair (`{w}`), then appends its source +/// chain inline via [`inline_sources`] (`": src1: src2"`) — lists keep each +/// entry on one logical line. +/// - `Group` writes the label, then: +/// - empty group → `"{w}: (no errors)"`; +/// - single child → `"{w}: "` and recurse at the *same* `depth` (the child +/// is rendered inline after the colon, not as a new numbered row); +/// - many children → `"{w} (N errors):"` header, then a fresh numbered list +/// at `depth + 1`, recursing into children at `depth + 2`. +fn draw_list_node( + node: &Node, + depth: usize, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, +{ + match node { + Node::Leaf(w) => { + write!(f, "{w}")?; + inline_sources(w.error.source(), f) + } + Node::Group(w) => match w.error.as_ref() { + ManyErrors::None => write!(f, "{w}: (no errors)"), + ManyErrors::One(inner) => { + write!(f, "{w}: ")?; + draw_list_node::(inner, depth, f) + } + ManyErrors::Many(nodes) => { + write!(f, "{w} ({} errors):", nodes.len())?; + for (i, node) in nodes.iter().enumerate() { + let indent = iter::repeat_n(" ", depth + 1).format(""); + write!(f, "\n{indent}{}. ", i + 1)?; + draw_list_node::(node, depth + 2, f)?; + } + Ok(()) + } + }, + } +} + +#[cfg(test)] +mod tests { + use crate::many_errors::strategy::test_helpers::two_leaves; + + #[test] + fn test_list_two_leaves() { + let e = two_leaves(); + let s = e.list().to_string(); + assert!(s.contains("2 errors:"), "got: {s}"); + assert!(s.contains("1. a: InnerA"), "got: {s}"); + assert!(s.contains("2. b: InnerB"), "got: {s}"); + } +} diff --git a/src/many_errors/strategy/mod.rs b/src/many_errors/strategy/mod.rs new file mode 100644 index 0000000..af3e092 --- /dev/null +++ b/src/many_errors/strategy/mod.rs @@ -0,0 +1,96 @@ +//! Aggregate format strategies for [`ManyErrors`]: [`Tree`], [`List`], [`Bullets`], [`Inline`]. +//! +//! All strategies implement [`Format>`] (and the ref trampoline +//! [`Format<&ManyErrors<…>>`]) so they work with both `Display` and +//! [`Formatted`](crate::Formatted) wrappers. +//! +//! Group headers are rendered through the group's own label strategy `GF` via +//! `write!(f, "{w}")` (default [`ContextField`](crate::with_context::ContextField): +//! the label only); the structural ` (N errors):` / `: ` and children are added +//! by the aggregate strategy itself. + +use core::{error::Error, fmt}; + +mod bullets; +mod inline; +mod list; +mod tree; + +pub use bullets::Bullets; +pub use inline::Inline; +pub use list::List; +pub use tree::Tree; + +/// Emits the `Format>` impl and its `Format<&ManyErrors<…>>` ref +/// trampoline for an aggregate strategy with no extra generic parameters. The +/// closure-like argument names the entry-point `draw_*_many` call. +macro_rules! impl_aggregate_format { + ($strategy:ident, |$errors:ident, $f:ident| $call:expr) => { + impl $crate::Format<$crate::ManyErrors> for $strategy + where + C: ::core::fmt::Display, + E: ::core::error::Error + ::core::fmt::Display + 'static, + F: $crate::Format<$crate::with_context::WithContext>, + GF: $crate::Format<$crate::many_errors::Subgroup>, + { + fn fmt( + $errors: &$crate::ManyErrors, + $f: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { + $call + } + } + + impl $crate::Format<&$crate::ManyErrors> for $strategy + where + C: ::core::fmt::Display, + E: ::core::error::Error + ::core::fmt::Display + 'static, + F: $crate::Format<$crate::with_context::WithContext>, + GF: $crate::Format<$crate::many_errors::Subgroup>, + { + fn fmt( + errors: &&$crate::ManyErrors, + f: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { + >>::fmt(errors, f) + } + } + }; +} + +pub(crate) use impl_aggregate_format; + +/// Write the source chain as `": {src1}: {src2}: ..."` on the current line. +/// +/// Shared by the [`List`], [`Bullets`], and [`Inline`] leaf renderers. +pub(super) fn inline_sources( + source: Option<&dyn Error>, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result { + let mut opt_src = source; + while let Some(src) = opt_src { + write!(f, ": {src}")?; + opt_src = src.source(); + } + Ok(()) +} + +#[cfg(test)] +pub(super) mod test_helpers { + use crate::ManyErrors; + use crate::tests::{Inner, Mid}; + + pub fn two_leaves() -> ManyErrors<&'static str, Inner> { + let mut e = ManyErrors::new(); + e.push("a", Inner::A); + e.push("b", Inner::B); + e + } + + pub fn with_chain() -> ManyErrors<&'static str, Mid> { + let mut e = ManyErrors::new(); + e.push("a", Mid::Inner(Inner::A)); + e.push("b", Mid::Inner(Inner::B)); + e + } +} diff --git a/src/many_errors/strategy/tree.rs b/src/many_errors/strategy/tree.rs new file mode 100644 index 0000000..678992f --- /dev/null +++ b/src/many_errors/strategy/tree.rs @@ -0,0 +1,371 @@ +//! [`Tree`]: render a [`ManyErrors`] as a branching box-drawing tree. +//! +//! No `String` allocations. The ancestry path is encoded as `levels: Vec`, +//! one bool per ancestor depth — `true` if that ancestor was the last child, +//! `false` otherwise. At each write the VERT/GAP prefix is reconstructed from +//! `levels` via an itertools lazy format — O(depth) work, zero heap per line. + +use core::{ + error::Error, + fmt::{self, Display}, + marker::PhantomData, +}; + +use alloc::vec::Vec; + +use itertools::Itertools; + +use crate::{ + Format, + connectors::{TreeConnectors, Unicode}, + many_errors::{ManyErrors, Node, Subgroup}, + with_context::WithContext, +}; + +/// Aggregate strategy that renders a [`ManyErrors`] as a branching tree. +/// +/// Generic parameters: +/// - `Conn`: box-drawing character set ([`Unicode`] by default). +/// - `HEADER`: whether to print `"N errors:"` for levels with 2+ children (`true` by default). +/// +/// # Output example (defaults) +/// ```text +/// 2 errors: +/// ├─ us-east-1 (2 errors): +/// │ ├─ i-0a1: connection timed out +/// │ └─ i-0b2: connection refused +/// └─ eu-west-1: quota exceeded +/// ``` +#[derive(Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Tree(PhantomData Conn>); + +impl fmt::Debug for Tree { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Tree") + .field("connectors", &Conn::default()) + .field("header", &HEADER) + .finish() + } +} + +impl Format> + for Tree +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, + Conn: TreeConnectors, +{ + fn fmt(errors: &ManyErrors, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // One Vec allocation per fmt call, shared across all recursive descent. + let mut levels = Vec::new(); + draw_many::(errors, &mut levels, HEADER, f) + } +} + +impl Format<&ManyErrors> + for Tree +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, + Conn: TreeConnectors, +{ + fn fmt(errors: &&ManyErrors, f: &mut fmt::Formatter<'_>) -> fmt::Result { + >>::fmt(errors, f) + } +} + +/// Lazily renders an ancestry prefix: one `VERT`/`GAP` per `levels` entry (a +/// bar for ancestors with siblings below, blank otherwise), then `extra` +/// trailing `GAP`s. Reusable and allocation-free. +struct Pad<'a, Conn> { + levels: &'a [bool], + extra: usize, + _conn: PhantomData Conn>, +} + +impl Display for Pad<'_, Conn> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for &last in self.levels { + f.write_str(if last { Conn::GAP } else { Conn::VERT })?; + } + for _ in 0..self.extra { + f.write_str(Conn::GAP)?; + } + Ok(()) + } +} + +/// A [`fmt::Write`] adapter that re-emits `prefix` after every newline, so a +/// node whose rendered content spans multiple physical lines keeps the tree +/// indent instead of spilling flush-left. Streams line-by-line — no allocation. +struct Indented<'a, 'b, P: Display> { + inner: &'a mut fmt::Formatter<'b>, + prefix: P, +} + +impl fmt::Write for Indented<'_, '_, P> { + fn write_str(&mut self, s: &str) -> fmt::Result { + let mut lines = s.split('\n'); + if let Some(first) = lines.next() { + self.inner.write_str(first)?; + } + for line in lines { + write!(self.inner, "\n{}", self.prefix)?; + self.inner.write_str(line)?; + } + Ok(()) + } +} + +/// Writes `content` to `f`, re-indenting any embedded newlines to the prefix +/// `Pad { levels, extra }` so multi-line content stays under the tree. +fn indented( + f: &mut fmt::Formatter<'_>, + levels: &[bool], + extra: usize, + content: impl Display, +) -> fmt::Result { + use fmt::Write as _; + let prefix = Pad:: { + levels, + extra, + _conn: PhantomData, + }; + write!(Indented { inner: f, prefix }, "{content}") +} + +/// Draw `errors` at the current indentation level. +fn draw_many( + errors: &ManyErrors, + levels: &mut Vec, + show_header: bool, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, + Conn: TreeConnectors, +{ + match errors { + ManyErrors::None => Ok(()), + ManyErrors::One(node) => draw_node::(node, levels, f), + ManyErrors::Many(nodes) => { + let pre_first = if show_header { + write!(f, "{} errors:", nodes.len())?; + "\n" + } else { + "" + }; + draw_children::(nodes, levels, pre_first, f) + } + } +} + +/// Draw a slice of 2+ nodes, reconstructing each visual prefix lazily from `levels`. +fn draw_children( + nodes: &[Node], + levels: &mut Vec, + pre_first: &str, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, + Conn: TreeConnectors, +{ + for (i, node) in nodes.iter().enumerate() { + let is_last = i == nodes.len() - 1; + let connector = if is_last { Conn::LAST } else { Conn::BRANCH }; + let sep = if i == 0 { pre_first } else { "\n" }; + // Reconstruct ancestor prefix lazily — no allocation. + let pad = levels + .iter() + .map(|&l| if l { Conn::GAP } else { Conn::VERT }) + .format(""); + write!(f, "{sep}{pad}{connector}")?; + levels.push(is_last); + draw_node::(node, levels, f)?; + levels.pop(); + } + Ok(()) +} + +/// Draw a single node (content after the connector has already been written). +fn draw_node( + node: &Node, + levels: &mut Vec, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + Display + 'static, + F: Format>, + GF: Format>, + Conn: TreeConnectors, +{ + match node { + Node::Leaf(w) => { + indented::(f, levels, 0, w)?; + draw_error_chain::(w.error.source(), levels, f) + } + Node::Group(w) => match w.error.as_ref() { + ManyErrors::None => indented::(f, levels, 0, format_args!("{w}: (no errors)")), + ManyErrors::One(inner) => { + indented::(f, levels, 0, format_args!("{w}: "))?; + draw_node::(inner, levels, f) + } + ManyErrors::Many(nodes) => { + indented::(f, levels, 0, format_args!("{w} ({} errors):", nodes.len()))?; + draw_children::(nodes, levels, "\n", f) + } + }, + } +} + +/// Walk a single error's source chain, drawing each source below `levels` prefix. +fn draw_error_chain( + source: Option<&dyn Error>, + levels: &[bool], + f: &mut fmt::Formatter<'_>, +) -> fmt::Result { + let mut opt_src = source; + let mut depth = 0usize; + while let Some(src) = opt_src { + let pad = Pad:: { + levels, + extra: depth, + _conn: PhantomData, + }; + write!(f, "\n{pad}{}", Conn::LAST)?; + // Source content aligns one connector-width past `pad`; re-indent any + // embedded newlines to that column. + indented::(f, levels, depth + 1, src)?; + depth += 1; + opt_src = src.source(); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + Formatted, ManyErrors, + connectors::{Ascii, Unicode}, + many_errors::strategy::test_helpers::{two_leaves, with_chain}, + tests::Inner, + }; + + #[test] + fn test_tree_empty() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.tree().to_string(), ""); + } + + #[test] + fn test_tree_single_leaf() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("ctx", Inner::A); + assert_eq!(e.tree().to_string(), "ctx: InnerA"); + } + + #[test] + fn test_tree_two_leaves_unicode() { + let e = two_leaves(); + assert_eq!( + e.tree().to_string(), + "2 errors:\n├─ a: InnerA\n└─ b: InnerB" + ); + } + + #[test] + fn test_tree_ascii() { + let e = two_leaves(); + assert_eq!( + Formatted::<_, Tree>::new(&e).to_string(), + "2 errors:\n|- a: InnerA\n`- b: InnerB" + ); + } + + #[test] + fn test_tree_no_header() { + let e = two_leaves(); + assert_eq!( + Formatted::<_, Tree>::new(&e).to_string(), + "├─ a: InnerA\n└─ b: InnerB" + ); + } + + #[test] + fn test_tree_with_source_chain() { + let e = with_chain(); + let s = e.tree().to_string(); + assert!(s.contains("├─ a: mid"), "got: {s}"); + assert!(s.contains("│ └─ InnerA"), "got: {s}"); + assert!(s.contains("└─ b: mid"), "got: {s}"); + assert!(s.contains(" └─ InnerB"), "got: {s}"); + } + + #[test] + fn test_tree_nested_group() { + let mut inner = ManyErrors::<&str, Inner>::new(); + inner.push("x", Inner::A); + inner.push("y", Inner::B); + + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push_group("region", inner); + outer.push("leaf", Inner::A); + + let s = outer.tree().to_string(); + assert!(s.contains("2 errors:"), "got: {s}"); + assert!(s.contains("region (2 errors):"), "got: {s}"); + assert!(s.contains("x: InnerA"), "got: {s}"); + assert!(s.contains("y: InnerB"), "got: {s}"); + assert!(s.contains("leaf: InnerA"), "got: {s}"); + } + + /// Heterogeneous split: group labels are `usize`, leaf contexts are `&str`. + #[test] + fn test_tree_heterogeneous_group_label() { + let mut inner = ManyErrors::<&str, Inner, usize>::new(); + inner.push("x", Inner::A); + + let mut outer = ManyErrors::<&str, Inner, usize>::new(); + outer.push_group(7, inner); + outer.push("leaf", Inner::B); + + let s = outer.tree().to_string(); + assert!(s.contains("7: x: InnerA"), "got: {s}"); + assert!(s.contains("leaf: InnerB"), "got: {s}"); + } + + /// A custom `GF` is actually applied to group labels. + #[test] + fn test_tree_custom_group_format() { + use crate::with_context::WithContext; + + // Brackets the group label, ignoring the nested errors field. + struct Bracket; + impl Format> for Bracket { + fn fmt(w: &WithContext, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}]", w.context) + } + } + + let mut inner = ManyErrors::<&str, Inner, &str, crate::with_context::Colon, Bracket>::new(); + inner.push("x", Inner::A); + + let mut outer = ManyErrors::<&str, Inner, &str, crate::with_context::Colon, Bracket>::new(); + outer.push_group("region", inner); + + assert_eq!(outer.tree().to_string(), "[region]: x: InnerA"); + } +} diff --git a/src/oneline.rs b/src/oneline.rs index 743ef19..799fa60 100644 --- a/src/oneline.rs +++ b/src/oneline.rs @@ -9,10 +9,10 @@ use crate::{Format, chain}; /// For a different separator (or any per-element formatting), implement /// [`Format`] yourself using [`chain`]. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct OneLine; +pub struct Flat; /// Walks the source chain and joins each error's `Display` output with `": "`. -impl Format for OneLine { +impl Format for Flat { fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", chain(&error).format(": ")) } @@ -23,7 +23,7 @@ mod tests { use std::io; use crate::{ - FormatError, Formatted, OneLine, + Flat, FormatError, Formatted, tests::{Arrow, Error, Inner}, }; @@ -41,7 +41,7 @@ mod tests { let error = Error::One; assert_eq!(error.one_line().to_string(), "One"); assert_eq!(format!("{:?}", error.one_line()), "One"); - assert_eq!(Formatted::<_, OneLine>::new(Error::One).to_string(), "One"); + assert_eq!(Formatted::<_, Flat>::new(Error::One).to_string(), "One"); let error = Error::Two(Inner::A); assert_eq!(error.one_line().to_string(), "Two: InnerA"); diff --git a/src/suggestion.rs b/src/suggestion.rs index 0cb8ab6..e6572b3 100644 --- a/src/suggestion.rs +++ b/src/suggestion.rs @@ -119,7 +119,7 @@ mod tests { ); // They can be composed via Add. assert_eq!( - e.formatted::>>() + e.formatted::>>() .to_string(), "One\nTry passing --help to see available options.", ); diff --git a/src/tests.rs b/src/tests.rs index 9abb242..ab6fb20 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -131,13 +131,11 @@ fn _assert_derive_traits() { impl core::error::Error for DummyError {} fn assert_all() {} - assert_all::>(); - assert_all::>(); + assert_all::>(); + assert_all::>(); assert_all::>(); - assert_all::(); - assert_all::(); - assert_all::(); - assert_all::(); + assert_all::(); + assert_all::(); } #[test] @@ -185,12 +183,15 @@ fn test_with_ctx_variant() { #[test] fn test_many_variant() { let mut errs = ManyErrors::new(); - errs.push(WithContext::new("a", Inner::A)); - errs.push(WithContext::new("b", Inner::B)); + errs.push("a", Inner::A); + errs.push("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"); + // ManyErrors is the source; one_line walks the chain and embeds ManyErrors::Display (Tree). + assert_eq!( + e.one_line().to_string(), + "Many: 2 errors:\n├─ a: InnerA\n└─ b: InnerB" + ); } // --- transparent --- @@ -232,20 +233,20 @@ fn test_transparent_source_delegates_not_wraps() { #[test] fn test_transparent_chain_never_shows_wrapper_name() { - // one_line and tree walk the chain. "Transparent" must not appear. + // one_line and chain 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(); + let chain_out = e.chain().to_string(); assert!( !one.contains("Transparent"), "one_line should not contain 'Transparent': {one}" ); assert!( - !tree.contains("Transparent"), - "tree should not contain 'Transparent': {tree}" + !chain_out.contains("Transparent"), + "chain should not contain 'Transparent': {chain_out}" ); assert_eq!(one, "mid: InnerA"); - assert_eq!(tree, "mid\n└── InnerA"); + assert_eq!(chain_out, "mid\n└─ InnerA"); } #[test] diff --git a/src/tree.rs b/src/tree.rs deleted file mode 100644 index d6415ce..0000000 --- a/src/tree.rs +++ /dev/null @@ -1,177 +0,0 @@ -use core::{error::Error, fmt, iter, marker::PhantomData}; - -use itertools::Itertools; - -use crate::{Format, chain}; - -/// Default tree branch marker: `"└── "`. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct TreeMarker; - -/// Writes the literal `"└── "`. -impl fmt::Display for TreeMarker { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("└── ") - } -} - -/// Default tree indent: four spaces. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct TreeIndent; - -/// Writes four spaces. -impl fmt::Display for TreeIndent { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(" ") - } -} - -/// Tree format with a configurable marker and indent. -/// -/// ```text -/// top error -/// └── source 1 -/// └── source 2 -/// ``` -/// -/// The marker (`└── ` by default) is printed before each source, and the -/// indent (four spaces by default) is repeated `depth - 1` times. Any types -/// implementing [`Display`](fmt::Display) and [`Default`] can be substituted -/// to customize the rendering. -#[derive(Default, Clone, Copy, PartialEq, Eq, Hash)] -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 -where - M: fmt::Display + Default, - I: fmt::Display + Default, -{ - fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let marker = M::default(); - let indent = I::default(); - let formatted = - chain(&error) - .enumerate() - .format_with("\n", |(depth, e), write| match depth { - 0 => write(&format_args!("{e}")), - n => { - let pad = iter::repeat_n(&indent, n - 1).format(""); - write(&format_args!("{pad}{marker}{e}")) - } - }); - write!(f, "{formatted}") - } -} - -/// Prints the marker/indent values (instantiated via [`Default`]) instead of -/// `Tree(PhantomData)`. -impl fmt::Debug for Tree { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("Tree") - .field(&M::default()) - .field(&I::default()) - .finish() - } -} - -#[cfg(test)] -mod tests { - use core::fmt; - - use itertools::Itertools; - - use crate::{ - Format, FormatError, Formatted, Tree, TreeIndent, TreeMarker, chain, - tests::{Error, Inner}, - }; - - #[test] - fn test_tree_no_source() { - let error = Error::One; - assert_eq!(error.tree().to_string(), "One"); - } - - #[test] - fn test_tree_one_source() { - let error = Error::Two(Inner::A); - assert_eq!(error.tree().to_string(), "Two\n└── InnerA"); - } - - #[test] - fn test_tree_nested() { - 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 ArrowMarker; - impl fmt::Display for ArrowMarker { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("|-> ") - } - } - - #[derive(Default)] - struct TwoSpace; - impl fmt::Display for TwoSpace { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(" ") - } - } - - let error = Error::Two(Inner::A); - assert_eq!( - Formatted::<_, Tree>::new(error).to_string(), - "Two\n|-> InnerA" - ); - } - - #[test] - fn test_tree_marker_debug() { - assert_eq!(format!("{:?}", TreeMarker), "TreeMarker"); - assert_eq!(format!("{:?}", TreeIndent), "TreeIndent"); - } - - #[test] - fn test_tree_debug_default_params() { - let tree = Tree::::default(); - assert_eq!(format!("{tree:?}"), "Tree(TreeMarker, TreeIndent)"); - } - - #[test] - fn test_tree_debug_custom_params() { - #[derive(Debug, Default)] - struct Arrow; - #[derive(Debug, Default)] - struct TwoSpace; - let tree = Tree::::default(); - assert_eq!(format!("{tree:?}"), "Tree(Arrow, TwoSpace)"); - } - - #[test] - fn test_custom_tree_via_format() { - struct AsciiTree; - 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}")), - n => write(&format_args!("{:width$}|-- {e}", "", width = (n - 1) * 2)), - }); - write!(f, "{formatted}") - } - } - - let error = Error::Two(Inner::A); - assert_eq!( - Formatted::<_, AsciiTree>::new(error).to_string(), - "Two\n|-- InnerA" - ); - } -} diff --git a/src/with_context/mod.rs b/src/with_context/mod.rs index 9b316cd..b69535f 100644 --- a/src/with_context/mod.rs +++ b/src/with_context/mod.rs @@ -33,6 +33,11 @@ pub type WithPath = WithContext; /// since the strategy already prints it), so chain-walking strategies don't /// duplicate it. /// +/// All standard-trait impls (`Clone`, `PartialEq`, `Eq`, `Hash`) are written +/// manually — not derived — so they do **not** impose `WithContextFormat: Trait` +/// bounds. `Copy` is still derived (works unconditionally via +/// `PhantomData F>: Copy`). +/// /// # Example /// ``` /// use errortools::{FormatError, with_context::WithContextColon}; @@ -70,7 +75,7 @@ pub type WithPath = WithContext; /// let w = WithContext::<_, _, Arrow>::new(1, "boom"); /// assert_eq!(w.to_string(), "1 -> boom"); /// ``` -#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Copy)] pub struct WithContext { /// The context value tagging this error (e.g. a file path or step number). pub context: C, @@ -111,6 +116,35 @@ impl From<(C, E)> for WithContext WithContextFormat>, which is always +// Clone/PartialEq/Eq/Hash/Copy — but derive adds the F: Trait bound regardless. + +impl Clone for WithContext { + fn clone(&self) -> Self { + Self { + context: self.context.clone(), + error: self.error.clone(), + _format: PhantomData, + } + } +} + +impl PartialEq for WithContext { + fn eq(&self, other: &Self) -> bool { + self.context == other.context && self.error == other.error + } +} + +impl Eq for WithContext {} + +impl core::hash::Hash for WithContext { + fn hash(&self, state: &mut H) { + self.context.hash(state); + self.error.hash(state); + } +} + /// 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 diff --git a/tests/max_custom.rs b/tests/max_custom.rs new file mode 100644 index 0000000..5da3c36 --- /dev/null +++ b/tests/max_custom.rs @@ -0,0 +1,258 @@ +//! Maximum-customization integration test for the `ManyErrors` rendering path. +//! +//! Nothing here relies on a crate-provided strategy or a defaulted generic: +//! +//! - every `ManyErrors` / `WithContext` type parameter is spelled out; +//! - the leaf error carries a **2-level** source chain; +//! - the leaf's own `WithContext` uses one custom strategy (`TopFmt`) while the +//! `WithContext` one level deeper in the chain uses a *different* one +//! (`InnerFmt`); +//! - group labels use a third custom strategy (`GroupFmt`); +//! - the tree is drawn with a hand-rolled `TreeConnectors` glyph set (`Pipes`), +//! not `Unicode`/`Ascii`. + +use core::fmt::{self, Display, Formatter}; + +use errortools::{Connectors, Format, FormatError, ManyErrors, Tree, TreeConnectors, WithContext}; +use pretty_assertions::assert_eq; +use thiserror::Error; + +// ── Error types: a 2-level source chain sits under every leaf ────────────────── + +#[derive(Debug, Error)] +#[error("disk full")] +struct Bottom; + +#[derive(Debug, Error)] +#[error("write failed")] +struct MidErr(#[source] Bottom); + +/// Top-level leaf error. Its source is an *inner* [`WithContext`] tagged with a +/// strategy (`InnerFmt`) different from the leaf's own (`TopFmt`). +#[derive(Debug, Error)] +#[error("operation failed")] +struct TopErr(#[source] WithContext<&'static str, MidErr, InnerFmt>); + +// ── Three distinct, fully custom Format strategies ───────────────────────────── + +/// Leaf `WithContext` strategy (top level inside `ManyErrors`): `"ctx ▸ err"`. +struct TopFmt; +impl Format> for TopFmt { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{} ▸ {}", w.context, w.error) + } +} + +/// Inner `WithContext` strategy (one level deeper in the source chain): +/// `"ctx « err"`. Deliberately unlike `TopFmt` so the two are distinguishable +/// in the output. +struct InnerFmt; +impl Format> for InnerFmt { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{} « {}", w.context, w.error) + } +} + +/// A custom group-context type, distinct from the leaf context (`&str`). +#[derive(Debug)] +struct Region { + code: &'static str, + zone: u8, +} +impl Display for Region { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}#{}", self.code, self.zone) + } +} + +/// Group-label strategy: prints only the label, wrapped in braces. +struct GroupFmt; +impl Format> for GroupFmt { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{{{}}}", w.context) + } +} + +// ── Custom tree connectors (no Unicode / Ascii) ──────────────────────────────── + +struct Pipes; +impl Connectors for Pipes { + const LAST: &'static str = "\\__ "; + const GAP: &'static str = " "; +} +impl TreeConnectors for Pipes { + const BRANCH: &'static str = "|__ "; + const VERT: &'static str = "| "; +} + +// ── The fully-spelled aggregate type ─────────────────────────────────────────── + +// Heterogeneous: leaf context is `&str`, group context is the custom `Region`. +type Many = ManyErrors<&'static str, TopErr, Region, TopFmt, GroupFmt>; + +/// A leaf error whose source chain is `TopErr → WithContext(InnerFmt) → MidErr → Bottom`. +fn nested(inner_ctx: &'static str) -> TopErr { + TopErr(WithContext::new(inner_ctx, MidErr(Bottom))) +} + +#[test] +fn fully_custom_tree() { + // A group of two deep leaves, plus a sibling deep leaf at the top level. + let mut inner: Many = ManyErrors::new(); + inner.push("config", nested("fsync")); + inner.push("network", nested("connect")); + + let mut outer: Many = ManyErrors::new(); + outer.push_group( + Region { + code: "us-east", + zone: 3, + }, + inner, + ); + outer.push("startup", nested("load")); + + // Custom connectors + explicit HEADER, no Display defaulting. + let rendered = outer.formatted::>().to_string(); + + let expected = "\ +2 errors: +|__ {us-east#3} (2 errors): +| |__ config ▸ operation failed +| | \\__ fsync « write failed +| | \\__ disk full +| \\__ network ▸ operation failed +| \\__ connect « write failed +| \\__ disk full +\\__ startup ▸ operation failed + \\__ load « write failed + \\__ disk full"; + + assert_eq!(rendered, expected); + + assert_eq!( + outer.one_line().to_string(), + "2 errors: {us-east#3} (2 errors: config ▸ operation failed: fsync « write failed: disk full; network ▸ operation failed: connect « write failed: disk full); startup ▸ operation failed: load « write failed: disk full" + ); +} + +// ── Malformed variant: error messages and strategies embed `\n` / `\t` ───────── +// +// The tree renderer re-indents every physical line of a node's content (and of +// each source) to its tree column, so embedded `\n`s no longer spill flush-left +// — continuation lines carry the ancestry prefix. Embedded `\t`s are passed +// through verbatim (no display-width handling). The expected strings are written +// multi-line (real newlines for the structural `\n`, `\t` escapes for the tabs) +// so the garbled layout is legible in source. + +#[derive(Debug, Error)] +#[error("disk\n\tfull")] // newline + tab inside the message +struct BadBottom; + +#[derive(Debug, Error)] +#[error("write\tfailed")] // tab inside the message +struct BadMid(#[source] BadBottom); + +#[derive(Debug, Error)] +#[error("op\nfailed")] // newline inside the message +struct BadTop(#[source] WithContext<&'static str, BadMid, BadInnerFmt>); + +/// Leaf strategy that injects a newline + tab between context and error. +struct BadTopFmt; +impl Format> for BadTopFmt { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}\n\t-> {}", w.context, w.error) + } +} + +/// Inner strategy that injects a tab between context and error. +struct BadInnerFmt; +impl Format> for BadInnerFmt { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}\t=> {}", w.context, w.error) + } +} + +/// Group strategy that leaves a trailing newline after the label. +struct BadGroupFmt; +impl Format> for BadGroupFmt { + fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "[{}]\n ", w.context) + } +} + +type BadMany = ManyErrors<&'static str, BadTop, Region, BadTopFmt, BadGroupFmt>; + +fn bad_nested(inner_ctx: &'static str) -> BadTop { + BadTop(WithContext::new(inner_ctx, BadMid(BadBottom))) +} + +#[test] +fn malformed_messages_and_strategies() { + let mut inner: BadMany = ManyErrors::new(); + inner.push("conf\tig", bad_nested("fsync")); + inner.push("net\nwork", bad_nested("connect")); + + let mut outer: BadMany = ManyErrors::new(); + outer.push_group( + Region { + code: "us\teast", + zone: 9, + }, + inner, + ); + outer.push("start\nup", bad_nested("load")); + + let rendered = outer.formatted::>().to_string(); + + // Manual print: shows the actual garbled layout (run with `--nocapture`). + println!("--- tree ---\n{rendered}"); + println!("--- one line ---\n{}", outer.one_line()); + + let expected_tree = "\ +2 errors: +|__ [us\teast#9] +| (2 errors): +| |__ conf\tig +| | \t-> op +| | failed +| | \\__ fsync\t=> write\tfailed +| | \\__ disk +| | \tfull +| \\__ net +| work +| \t-> op +| failed +| \\__ connect\t=> write\tfailed +| \\__ disk +| \tfull +\\__ start + up + \t-> op + failed + \\__ load\t=> write\tfailed + \\__ disk + \tfull"; + + assert_eq!(rendered, expected_tree); + + // `one_line` (the Inline strategy) is single-line by design: it keeps its own + // `; ` / `: ` separators and passes embedded control chars through untouched + // (re-indentation only applies to the structural tree renderer). + let expected_one_line = "\ +2 errors: [us\teast#9] + (2 errors: conf\tig +\t-> op +failed: fsync\t=> write\tfailed: disk +\tfull; net +work +\t-> op +failed: connect\t=> write\tfailed: disk +\tfull); start +up +\t-> op +failed: load\t=> write\tfailed: disk +\tfull"; + + assert_eq!(outer.one_line().to_string(), expected_one_line); +} From 8bbe6c03f8ca9879477371b4f9a2ad9504770781 Mon Sep 17 00:00:00 2001 From: Max Wase Date: Sun, 31 May 2026 10:50:56 +0200 Subject: [PATCH 7/9] Fix auto derived bounds --- src/add/mod.rs | 30 ++++++++++++++-- src/chain.rs | 32 +++++++++++++++-- src/lib.rs | 60 ++++++++++++++++++++++++++++---- src/main_result.rs | 14 +++++--- src/many_errors/strategy/tree.rs | 25 ++++++++++++- src/oneline.rs | 11 ++++-- src/suggestion.rs | 16 ++++++--- src/tests.rs | 13 +++++++ src/with_context/mod.rs | 13 ++++--- 9 files changed, 187 insertions(+), 27 deletions(-) diff --git a/src/add/mod.rs b/src/add/mod.rs index 363e3be..1d7cce5 100644 --- a/src/add/mod.rs +++ b/src/add/mod.rs @@ -1,4 +1,8 @@ -use core::{fmt, marker::PhantomData}; +use core::{ + fmt, + hash::{Hash, Hasher}, + marker::PhantomData, +}; use crate::Format; @@ -21,9 +25,31 @@ use crate::Format; /// `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)>); +// Manual impls so the phantom strategies `L`/`R` get no `Trait` bound from +// derives (the `_format`-style doctrine; see `WithContext`). +impl Default for Add { + fn default() -> Self { + Self(PhantomData) + } +} +impl Clone for Add { + fn clone(&self) -> Self { + *self + } +} +impl Copy for Add {} +impl PartialEq for Add { + fn eq(&self, _: &Self) -> bool { + true + } +} +impl Eq for Add {} +impl Hash for Add { + fn hash(&self, _: &mut H) {} +} + /// Prints the inner strategy values (instantiated via [`Default`]) instead of /// `Add(PhantomData)`. impl fmt::Debug for Add { diff --git a/src/chain.rs b/src/chain.rs index d3bc525..84c31f5 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -10,7 +10,13 @@ //! └─ source 2 //! ``` -use core::{error::Error, fmt, iter, marker::PhantomData}; +use core::{ + error::Error, + fmt, + hash::{Hash, Hasher}, + iter, + marker::PhantomData, +}; use itertools::Itertools; @@ -36,9 +42,31 @@ use crate::{ /// /// Use [`FormatError::chain`](crate::FormatError::chain) for the most common case. /// For aggregate many-error rendering see [`Tree`](crate::many_errors::Tree). -#[derive(Default, Clone, Copy, PartialEq, Eq, Hash)] pub struct Chain(PhantomData C>); +// Manual impls so the phantom connector `C` gets no `C: Trait` bound from +// derives (the `_format`-style doctrine; see `WithContext`). +impl Default for Chain { + fn default() -> Self { + Self(PhantomData) + } +} +impl Clone for Chain { + fn clone(&self) -> Self { + *self + } +} +impl Copy for Chain {} +impl PartialEq for Chain { + fn eq(&self, _: &Self) -> bool { + true + } +} +impl Eq for Chain {} +impl Hash for Chain { + fn hash(&self, _: &mut H) {} +} + /// 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 /// [`Connectors::GAP`] followed by [`Connectors::LAST`]. diff --git a/src/lib.rs b/src/lib.rs index 14c8dc7..fb33d9c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,13 @@ #[cfg(feature = "alloc")] extern crate alloc; -use core::{error::Error, fmt, iter, marker::PhantomData}; +use core::{ + error::Error, + fmt, + hash::{Hash, Hasher}, + iter, + marker::PhantomData, +}; mod add; mod chain; @@ -56,6 +62,19 @@ pub use with_context::WithContext; /// We cannot rely on `fmt::*` traits because: /// 1. They accept &self /// 1. `Error` already bounds `Display` as a supertrait, which would block composing strategies through types like [`WithContext`]. +/// +/// # `Debug` convention across this crate +/// Strategy tags carry their configuration only at the type level (in a phantom +/// `PhantomData _>`), so their `Debug` is hand-written: +/// - **Pure-strategy types** ([`Chain`], [`Add`], [`Tree`], and [`Formatted`]) +/// materialize the phantom marker via [`Default`] and print its configuration +/// — these impls bound the marker `…: Debug + Default`, while their +/// auto-traits ([`Clone`]/[`Copy`]/[`PartialEq`]/[`Eq`]/[`Hash`]) stay free of +/// any marker bound. +/// - **Payload types** ([`WithContext`], [`ManyErrors`](crate::ManyErrors), +/// [`Node`](crate::Node)) print their own name and fields, hiding the phantom +/// strategy. Thin display adapters ([`DisplayPath`]) instead stay transparent +/// to mirror their target's `Debug`. pub trait Format { /// Writes `error` and its source chain to `f` using the strategy. fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result; @@ -121,9 +140,33 @@ impl FormatError for E {} /// `F` is a type-level tag (never instantiated). The `fn() -> F` inside /// [`PhantomData`] avoids drop-check ownership of `F` and makes the wrapper /// `Send + Sync` regardless of `F`. -#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] pub struct Formatted(E, PhantomData F>); +// Manual impls bounding only the real `E`, never the phantom strategy `F` +// (the `_format`-style doctrine; see `WithContext`). +impl Clone for Formatted { + fn clone(&self) -> Self { + Formatted(self.0.clone(), PhantomData) + } +} +impl Copy for Formatted {} +impl Default for Formatted { + fn default() -> Self { + Formatted(E::default(), PhantomData) + } +} +impl PartialEq for Formatted { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} +impl Eq for Formatted {} +impl Hash for Formatted { + fn hash(&self, state: &mut H) { + self.0.hash(state) + } +} + impl Formatted { /// Wraps `error` so its `Display` impl uses the [`Format`] strategy `F`. pub const fn new(error: E) -> Self { @@ -141,11 +184,16 @@ impl> fmt::Display for Formatted { } } -/// Forwards to the inner error's `Debug` rather than printing -/// `Formatted(.., PhantomData)`. Keeps `{:?}` output of wrapped errors readable. -impl fmt::Debug for Formatted { +/// Surfaces both the wrapped error and the active strategy (materialized via +/// [`Default`], like [`Chain`]/[`Add`]/[`Tree`]) rather than printing +/// `Formatted(.., PhantomData)`. The `F: Debug + Default` bound applies to this +/// `Debug` impl only — the auto-trait impls above stay free of any `F` bound. +impl fmt::Debug for Formatted { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Debug::fmt(&self.0, f) + f.debug_struct("Formatted") + .field("error", &self.0) + .field("format", &F::default()) + .finish() } } diff --git a/src/main_result.rs b/src/main_result.rs index 2cc9895..e1492bb 100644 --- a/src/main_result.rs +++ b/src/main_result.rs @@ -115,8 +115,11 @@ mod tests { let wrapped = DisplaySwapDebug::new(inner); // Debug of DisplaySwapDebug = Display of inner = Flat chain. assert_eq!(format!("{wrapped:?}"), "Two: InnerA"); - // Display of DisplaySwapDebug = Debug of inner = forwarded to error's Debug. - assert_eq!(wrapped.to_string(), "Two(A)"); + // Display of DisplaySwapDebug = Debug of inner Formatted = error + strategy. + assert_eq!( + wrapped.to_string(), + "Formatted { error: Two(A), format: Flat }" + ); } #[test] @@ -175,8 +178,11 @@ mod tests { format!("{wrapped:?}"), "One\nTry passing --help to see available options." ); - // Display of DisplaySwapDebug forwards to inner Debug = Error::One's Debug. - assert_eq!(wrapped.to_string(), "One"); + // Display of DisplaySwapDebug = Debug of inner Formatted = error + strategy. + assert_eq!( + wrapped.to_string(), + "Formatted { error: One, format: Add(Add(Flat, NewLine), Suggestion) }" + ); } #[test] diff --git a/src/many_errors/strategy/tree.rs b/src/many_errors/strategy/tree.rs index 678992f..6d73c79 100644 --- a/src/many_errors/strategy/tree.rs +++ b/src/many_errors/strategy/tree.rs @@ -8,6 +8,7 @@ use core::{ error::Error, fmt::{self, Display}, + hash::{Hash, Hasher}, marker::PhantomData, }; @@ -36,9 +37,31 @@ use crate::{ /// │ └─ i-0b2: connection refused /// └─ eu-west-1: quota exceeded /// ``` -#[derive(Default, Clone, Copy, PartialEq, Eq, Hash)] pub struct Tree(PhantomData Conn>); +// Manual impls so the phantom connector `Conn` gets no `Conn: Trait` bound from +// derives (the `_format`-style doctrine; see `WithContext`). +impl Default for Tree { + fn default() -> Self { + Self(PhantomData) + } +} +impl Clone for Tree { + fn clone(&self) -> Self { + *self + } +} +impl Copy for Tree {} +impl PartialEq for Tree { + fn eq(&self, _: &Self) -> bool { + true + } +} +impl Eq for Tree {} +impl Hash for Tree { + fn hash(&self, _: &mut H) {} +} + impl fmt::Debug for Tree { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Tree") diff --git a/src/oneline.rs b/src/oneline.rs index 799fa60..a795639 100644 --- a/src/oneline.rs +++ b/src/oneline.rs @@ -40,12 +40,19 @@ mod tests { fn test_one_line_variants() { let error = Error::One; assert_eq!(error.one_line().to_string(), "One"); - assert_eq!(format!("{:?}", error.one_line()), "One"); + // Debug surfaces the wrapped error and the active strategy. + assert_eq!( + format!("{:?}", error.one_line()), + "Formatted { error: One, format: Flat }" + ); assert_eq!(Formatted::<_, Flat>::new(Error::One).to_string(), "One"); let error = Error::Two(Inner::A); assert_eq!(error.one_line().to_string(), "Two: InnerA"); - assert_eq!(format!("{:?}", error.one_line()), "Two(A)"); + assert_eq!( + format!("{:?}", error.one_line()), + "Formatted { error: Two(A), format: Flat }" + ); } #[test] diff --git a/src/suggestion.rs b/src/suggestion.rs index e6572b3..a39c1f6 100644 --- a/src/suggestion.rs +++ b/src/suggestion.rs @@ -99,11 +99,17 @@ mod tests { } #[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"); + fn debug_of_formatted_suggestion_surfaces_error_and_strategy() { + // Formatted<_, Suggestion> Debug surfaces both the inner error and the + // materialized strategy tag. + assert_eq!( + format!("{:?}", Error::One.suggestion()), + "Formatted { error: One, format: Suggestion }" + ); + assert_eq!( + format!("{:?}", NoHint.suggestion()), + "Formatted { error: NoHint, format: Suggestion }" + ); } // --- suggestion is orthogonal to Display --- diff --git a/src/tests.rs b/src/tests.rs index ab6fb20..2f7f5f9 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -136,6 +136,19 @@ fn _assert_derive_traits() { assert_all::>(); assert_all::(); assert_all::(); + + // The phantom strategy param must NOT leak a `Trait` bound: these must + // compile even though `NoTraits` implements nothing. + struct NoTraits; + assert_all::>(); + assert_all::>(); + assert_all::>(); + assert_all::>(); + + // `WithContext` has no `Default`, but its other auto-traits must still be + // `F`-free. + fn assert_no_default() {} + assert_no_default::>(); } #[test] diff --git a/src/with_context/mod.rs b/src/with_context/mod.rs index b69535f..c031a31 100644 --- a/src/with_context/mod.rs +++ b/src/with_context/mod.rs @@ -33,10 +33,12 @@ pub type WithPath = WithContext; /// since the strategy already prints it), so chain-walking strategies don't /// duplicate it. /// -/// All standard-trait impls (`Clone`, `PartialEq`, `Eq`, `Hash`) are written -/// manually — not derived — so they do **not** impose `WithContextFormat: Trait` -/// bounds. `Copy` is still derived (works unconditionally via -/// `PhantomData F>: Copy`). +/// All standard-trait impls (`Clone`, `Copy`, `PartialEq`, `Eq`, `Hash`) are +/// written manually — not derived — so they do **not** impose +/// `WithContextFormat: Trait` bounds. (`Copy` cannot be derived for this: derive +/// emits `impl`, which would leak `F: Copy` and make +/// `WithContext` `Copy` only when the phantom strategy is — so it is hand-written +/// as `impl` to match the `F`-free `Clone`.) /// /// # Example /// ``` @@ -75,7 +77,6 @@ pub type WithPath = WithContext; /// let w = WithContext::<_, _, Arrow>::new(1, "boom"); /// assert_eq!(w.to_string(), "1 -> boom"); /// ``` -#[derive(Copy)] pub struct WithContext { /// The context value tagging this error (e.g. a file path or step number). pub context: C, @@ -130,6 +131,8 @@ impl Clone for WithContext { } } +impl Copy for WithContext {} + impl PartialEq for WithContext { fn eq(&self, other: &Self) -> bool { self.context == other.context && self.error == other.error From b03e65511ff495fccbed4f0acc60c387906265c1 Mon Sep 17 00:00:00 2001 From: Max Wase Date: Sun, 31 May 2026 12:14:59 +0200 Subject: [PATCH 8/9] OneLine naming + None print --- README.md | 33 +-- examples/many_errors.rs | 12 +- src/add/mod.rs | 30 +-- src/add/separator.rs | 16 +- src/lib.rs | 12 +- src/main_result.rs | 28 +-- src/many_errors/mod.rs | 76 ++++--- src/many_errors/strategy/bullets.rs | 90 ++++++-- src/many_errors/strategy/inline.rs | 123 ----------- src/many_errors/strategy/list.rs | 100 ++++++--- src/many_errors/strategy/mod.rs | 42 ++-- src/many_errors/strategy/one_line.rs | 295 +++++++++++++++++++++++++++ src/many_errors/strategy/tree.rs | 23 ++- src/oneline.rs | 12 +- src/suggestion.rs | 2 +- src/tests.rs | 9 +- src/with_context/format.rs | 1 + tests/max_custom.rs | 10 +- 18 files changed, 601 insertions(+), 313 deletions(-) delete mode 100644 src/many_errors/strategy/inline.rs create mode 100644 src/many_errors/strategy/one_line.rs diff --git a/README.md b/README.md index 863ebfa..a601e61 100644 --- a/README.md +++ b/README.md @@ -192,11 +192,11 @@ println!("{}", my_error.formatted::()); // outer -> middle -> inner `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, Flat, Suggestion, separator::{NewLine, WithSep}}; +use errortools::{Formatted, OneLine, Suggestion, separator::{NewLine, WithSep}}; -// Same as Add, Suggestion>. Renders: +// Same as Add, Suggestion>. Renders: // "\n" -type Brief = WithSep; +type Brief = WithSep; eprintln!("{}", Formatted::<_, Brief>::new(err)); ``` @@ -205,13 +205,13 @@ For the common separators there are zero-think aliases — `WithSpace`, `WithNewLine`, `WithColonSpace` — all in `errortools::separator`: ```rust,ignore -use errortools::{Formatted, Flat, Suggestion, separator::WithNewLine}; +use errortools::{Formatted, OneLine, Suggestion, separator::WithNewLine}; -type Brief = WithNewLine; +type Brief = WithNewLine; eprintln!("{}", Formatted::<_, Brief>::new(err)); ``` -Bounds compose: `Add` only implements `Format` when +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 @@ -264,7 +264,7 @@ The idea is that every error that is supposed to have a suggestion should implem ## Many errors at once -Some operations shouldn't stop at the first failure — validating a config, deploying to every region, parsing a batch. You want all of them, grouped and readable. That's `ManyErrors`: a context-tagged collection that renders as a tree. +Some operations shouldn't stop at the first failure — validating a config, deploying to every region, parsing a batch. You want all of them, grouped and readable. That's `ManyErrors`: a context-tagged collection you can render as a tree, list, or single line. ```rust,ignore use errortools::ManyErrors; @@ -278,7 +278,7 @@ errs.into_result(())?; // Ok if empty, Err(ManyErrors) otherwise It costs nothing until it has to: `None` while empty, one inline slot for the first error, a `Vec` only once a second arrives. You can also collect straight from an iterator of `(context, error)` pairs or `WithContext` values — including itertools' `partition_result`. -Group related failures with `push_group` and the tree nests: +Group related failures with `push_group` and the shapes nest. `tree()` gives the Unicode tree, walking each error's source chain: ```text 2 errors: @@ -288,12 +288,19 @@ Group related failures with `push_group` and the tree nests: └─ eu-west-1: connection refused ``` -The default `Display` is the Unicode tree above. Want another shape? `list()`, `bullets()`, and `one_line()` are inherent helpers, no turbofish: +The default `Display` (`{errs}`) is deliberately a shallow one-line *summary* — each error's own text, no source chains — so it's safe to embed in a message or log, following the Rust convention that an error's `Display` is its own message: + +```text +2 errors: us-east-1 (2 errors: i-0a1: connection refused; i-0b2: timed out); eu-west-1: connection refused +``` + +For the full picture, the shapes are inherent helpers, no turbofish — `tree()` and `joined()` walk the source chains, `list()` and `bullets()` too: ```rust,ignore +println!("{}", errs.tree()); // Unicode tree (above) println!("{}", errs.list()); // 1. 1.1. 2. println!("{}", errs.bullets()); // • bulleted -println!("{}", errs.one_line()); // ;-separated, parens around groups +println!("{}", errs.joined()); // ;-separated one line, parens around groups ``` For full control — ASCII connectors, no count header — go through `formatted`: `Formatted::<_, Tree>::new(&errs)`. @@ -305,9 +312,9 @@ Group labels can differ from leaf contexts via the third parameter, `ManyErrors< `MainResult` is a type alias: ```rust -use errortools::{DisplaySwapDebug, Formatted, Flat}; +use errortools::{DisplaySwapDebug, Formatted, OneLine}; -pub type MainResult = Result>>; +pub type MainResult = Result>>; ``` `DisplaySwapDebug` swaps the `Debug` and `Display` impls of its inner type. When `main` prints the error via `Debug`, it ends up reaching the `Display` output instead, formatted by the chosen strategy. `?` converts your error automatically via the blanket `From` impl. @@ -318,7 +325,7 @@ Runnable examples in [`examples/`](https://github.com/maxwase/errortools/tree/ma | Example | What it shows | |---|---| -| [`one_line`](https://github.com/maxwase/errortools/blob/master/examples/one_line.rs) | `MainResult` with default `Flat` format | +| [`one_line`](https://github.com/maxwase/errortools/blob/master/examples/one_line.rs) | `MainResult` with default `OneLine` format | | [`tree`](https://github.com/maxwase/errortools/blob/master/examples/tree.rs) | `MainResult` for indented multi-line output | | [`format_error`](https://github.com/maxwase/errortools/blob/master/examples/format_error.rs) | `FormatError` trait for ad-hoc formatting | | [`custom_format`](https://github.com/maxwase/errortools/blob/master/examples/custom_format.rs) | A custom `Format` strategy | diff --git a/examples/many_errors.rs b/examples/many_errors.rs index 86d79ae..58e82bc 100644 --- a/examples/many_errors.rs +++ b/examples/many_errors.rs @@ -34,10 +34,14 @@ fn main() { all.push_group("us-east-1", east); all.push("eu-west-1", RegionError::Refused); - // Default Display = Tree with Unicode connectors and count header - println!("=== Default (Tree / Unicode) ==="); + // Default Display = shallow single-line summary (own text only, no source chains) + println!("=== Default (Summary / one-line) ==="); println!("{all}"); + println!(); + println!("=== Tree (Unicode) ==="); + println!("{}", all.tree()); + println!(); println!("=== List ==="); println!("{}", all.list()); @@ -47,8 +51,8 @@ fn main() { println!("{}", all.bullets()); println!(); - println!("=== Inline ==="); - println!("{}", all.one_line()); + println!("=== Joined (deep one-line) ==="); + println!("{}", all.joined()); println!(); println!("=== ASCII connectors, no header ==="); diff --git a/src/add/mod.rs b/src/add/mod.rs index 1d7cce5..9eb6596 100644 --- a/src/add/mod.rs +++ b/src/add/mod.rs @@ -10,14 +10,14 @@ use crate::Format; /// /// `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` +/// `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> +/// Add, Suggestion> /// ``` /// /// renders the one-line chain, a newline, then the top-level suggestion hint. @@ -80,7 +80,7 @@ mod tests { use core::error::Error; use super::*; - use crate::{Chain, Flat, Formatted, Suggestion, tests::Inner}; + use crate::{Chain, Formatted, OneLine, Suggestion, tests::Inner}; use separator::*; fn _assert_traits() { @@ -88,20 +88,20 @@ mod tests { T: Clone + Copy + Default + PartialEq + Eq + core::hash::Hash + Send + Sync, >() { } - assert_all::>(); - assert_all::, Suggestion>>(); + assert_all::>(); + assert_all::, Suggestion>>(); assert_all::(); assert_all::(); fn assert_format>() {} - assert_format::>(); - assert_format::>(); - assert_format::, Suggestion>>(); + assert_format::>(); + assert_format::>(); + assert_format::, Suggestion>>(); // Confirm Error bound still gates the leaf strategy, just not the trait. fn assert_oneline() where - Flat: Format, + OneLine: Format, { } assert_oneline::(); @@ -111,7 +111,7 @@ mod tests { fn test_one_line_plus_newline() { let error = crate::tests::Error::Two(Inner::A); assert_eq!( - Formatted::<_, Add>::new(error).to_string(), + Formatted::<_, Add>::new(error).to_string(), "Two: InnerA\n" ); } @@ -120,7 +120,7 @@ mod tests { fn test_nested_oneline_newline_suggestion() { let error = crate::tests::Error::One; assert_eq!( - Formatted::<_, Add, Suggestion>>::new(error).to_string(), + Formatted::<_, Add, Suggestion>>::new(error).to_string(), "One\nTry passing --help to see available options." ); } @@ -129,7 +129,7 @@ mod tests { fn test_empty_rhs_keeps_separator() { let error = crate::tests::Error::Two(Inner::A); assert_eq!( - Formatted::<_, Add, Suggestion>>::new(error).to_string(), + Formatted::<_, Add, Suggestion>>::new(error).to_string(), "Two: InnerA\n" ); } @@ -138,14 +138,14 @@ mod tests { fn test_right_associated_nesting() { let error = crate::tests::Error::Two(Inner::A); assert_eq!( - Formatted::<_, Add>>::new(error).to_string(), + 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(Flat, NewLine)"); + let add = Add::::default(); + assert_eq!(format!("{add:?}"), "Add(OneLine, NewLine)"); } } diff --git a/src/add/separator.rs b/src/add/separator.rs index d7e1f41..c6e5fc9 100644 --- a/src/add/separator.rs +++ b/src/add/separator.rs @@ -15,7 +15,7 @@ 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>`. +/// `Add>`. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct NewLine; @@ -89,7 +89,7 @@ pub type WithColonSpace = WithSep; mod tests { use super::*; use crate::{ - Add, Flat, Formatted, + Add, Formatted, OneLine, tests::{Error, Inner}, }; @@ -97,7 +97,7 @@ mod tests { fn test_space_between_repeats() { let error = Error::One; assert_eq!( - Formatted::<_, Add>>::new(error).to_string(), + Formatted::<_, Add>>::new(error).to_string(), "One One" ); } @@ -107,7 +107,7 @@ mod tests { // ColonSpace ignores the error and writes ": ". let error = Error::One; assert_eq!( - Formatted::<_, Add>>::new(error).to_string(), + Formatted::<_, Add>>::new(error).to_string(), "One: One" ); } @@ -116,7 +116,7 @@ mod tests { fn test_with_space_alias() { let error = Error::One; assert_eq!( - Formatted::<_, WithSpace>::new(error).to_string(), + Formatted::<_, WithSpace>::new(error).to_string(), "One One" ); } @@ -125,7 +125,7 @@ mod tests { fn test_with_newline_alias() { let error = Error::Two(Inner::A); assert_eq!( - Formatted::<_, WithNewLine>::new(error).to_string(), + Formatted::<_, WithNewLine>::new(error).to_string(), "Two: InnerA\nTwo: InnerA" ); } @@ -134,7 +134,7 @@ mod tests { fn test_with_colon_space_alias() { let error = Error::One; assert_eq!( - Formatted::<_, WithColonSpace>::new(error).to_string(), + Formatted::<_, WithColonSpace>::new(error).to_string(), "One: One" ); } @@ -143,7 +143,7 @@ mod tests { fn test_add_sep_generic_alias() { let error = Error::One; assert_eq!( - Formatted::<_, WithSep>::new(error).to_string(), + Formatted::<_, WithSep>::new(error).to_string(), "One:One" ); } diff --git a/src/lib.rs b/src/lib.rs index fb33d9c..b31f9e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,8 +34,8 @@ pub use chain::Chain; pub use connectors::{Ascii, Connectors, TreeConnectors, Unicode}; pub use main_result::{DisplaySwapDebug, MainResult, MainResultWithSuggestion, WithSuggestion}; #[cfg(feature = "alloc")] -pub use many_errors::{Bullets, Inline, List, ManyErrors, Node, Subgroup, Tree}; -pub use oneline::Flat; +pub use many_errors::{Bullets, Joined, List, ManyErrors, Node, Subgroup, Tree}; +pub use oneline::OneLine; #[cfg(feature = "std")] pub use path_display::DisplayPath; pub use suggestion::{Suggest, Suggestion}; @@ -53,7 +53,7 @@ pub use with_context::WithContext; /// without walking the source chain at all. /// /// `E` is the value being formatted; each strategy declares its own bounds: -/// [`Flat`] and [`Chain`] require `E: Error`, [`Suggestion`] additionally +/// [`OneLine`] and [`Chain`] 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 @@ -106,8 +106,8 @@ pub fn chain<'a>(error: &'a dyn Error) -> impl Iterator + /// A helper trait to format errors. pub trait FormatError { /// Formats the error in a single line concatenated by `: `. - fn one_line(&self) -> Formatted<&Self, Flat> { - self.formatted::() + fn one_line(&self) -> Formatted<&Self, OneLine> { + self.formatted::() } /// Formats the error as an indented source-chain ladder. @@ -140,7 +140,7 @@ impl FormatError for E {} /// `F` is a type-level tag (never instantiated). The `fn() -> F` inside /// [`PhantomData`] avoids drop-check ownership of `F` and makes the wrapper /// `Send + Sync` regardless of `F`. -pub struct Formatted(E, PhantomData F>); +pub struct Formatted(E, PhantomData F>); // Manual impls bounding only the real `E`, never the phantom strategy `F` // (the `_format`-style doctrine; see `WithContext`). diff --git a/src/main_result.rs b/src/main_result.rs index e1492bb..0893bd6 100644 --- a/src/main_result.rs +++ b/src/main_result.rs @@ -3,14 +3,14 @@ use core::fmt; use crate::separator::{NewLine, WithSep}; -use super::{Flat, Format, Formatted}; +use super::{Format, Formatted, OneLine}; /// A result type that wraps an error with [Formatted] and [DisplaySwapDebug] to output from the `main` function. /// -/// The format strategy `F` defaults to [`Flat`]; pass [`crate::Tree`] or a custom [`Format`] +/// The format strategy `F` defaults to [`OneLine`]; pass [`crate::Tree`] or a custom [`Format`] /// to change how the error is rendered when `main` returns `Err`. /// The success type `T` defaults to `()`; pass `ExitCode` or another type to return from `main`. -pub type MainResult = +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. @@ -18,16 +18,16 @@ pub type MainResult = /// 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 = +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 [`Flat`] and `Sep` defaults to a newline, but you can customize both to achieve different layouts. +/// `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; +pub type WithSuggestion = WithSep; /// Wrapper that swaps an inner type's [`fmt::Debug`] and [`fmt::Display`] impls. /// @@ -111,14 +111,14 @@ mod tests { #[test] fn test_swap_with_formatted() { - let inner = Formatted::<_, Flat>::new(Error::Two(Inner::A)); + let inner = Formatted::<_, OneLine>::new(Error::Two(Inner::A)); let wrapped = DisplaySwapDebug::new(inner); - // Debug of DisplaySwapDebug = Display of inner = Flat chain. + // Debug of DisplaySwapDebug = Display of inner = OneLine chain. assert_eq!(format!("{wrapped:?}"), "Two: InnerA"); // Display of DisplaySwapDebug = Debug of inner Formatted = error + strategy. assert_eq!( wrapped.to_string(), - "Formatted { error: Two(A), format: Flat }" + "Formatted { error: Two(A), format: OneLine }" ); } @@ -130,7 +130,7 @@ mod tests { ); assert_eq!( - DisplaySwapDebug::new(&DisplaySwapDebug::new(Formatted::<_, Flat>::new( + DisplaySwapDebug::new(&DisplaySwapDebug::new(Formatted::<_, OneLine>::new( Error::One ))) .to_string(), @@ -155,7 +155,7 @@ mod tests { #[test] fn test_with_suggestion_custom_separator() { - let formatted = Formatted::<_, WithSuggestion>::new(Error::One); + let formatted = Formatted::<_, WithSuggestion>::new(Error::One); assert_eq!( formatted.to_string(), "One Try passing --help to see available options." @@ -181,7 +181,7 @@ mod tests { // Display of DisplaySwapDebug = Debug of inner Formatted = error + strategy. assert_eq!( wrapped.to_string(), - "Formatted { error: One, format: Add(Add(Flat, NewLine), Suggestion) }" + "Formatted { error: One, format: Add(Add(OneLine, NewLine), Suggestion) }" ); } @@ -189,7 +189,7 @@ mod tests { 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(Error::One)?; } @@ -208,7 +208,7 @@ mod tests { fn test_main_result_with_exit_code() { use std::process::ExitCode; - fn main_with_error(err: bool) -> MainResult { + fn main_with_error(err: bool) -> MainResult { if err { Err(Error::One)?; } diff --git a/src/many_errors/mod.rs b/src/many_errors/mod.rs index 63b375a..01c8534 100644 --- a/src/many_errors/mod.rs +++ b/src/many_errors/mod.rs @@ -19,7 +19,7 @@ mod strategy; pub use crate::connectors::{Ascii, Connectors, TreeConnectors, Unicode}; pub use node::{Node, Subgroup}; -pub use strategy::{Bullets, Inline, List, Tree}; +pub use strategy::{Bullets, Joined, List, Tree}; /// Zero or more context-tagged errors, arranged as a rose tree. /// @@ -27,11 +27,11 @@ pub use strategy::{Bullets, Inline, List, Tree}; /// sub-group (another `ManyErrors`). The three-variant split avoids heap /// allocation until a second error arrives. /// -/// [`Display`] renders via [`Tree`] (branching Unicode tree with a count -/// header). Other shapes — [`List`], [`Bullets`], [`Inline`] — are available -/// via the inherent helpers [`tree`](ManyErrors::tree), -/// [`list`](ManyErrors::list), [`bullets`](ManyErrors::bullets), -/// [`one_line`](ManyErrors::one_line), or via +/// [`Display`] renders a shallow single-line summary (each error's own text, no +/// source chains). Source-walking shapes — [`Tree`], [`List`], [`Bullets`], +/// [`Joined`] — are available via the inherent helpers +/// [`tree`](ManyErrors::tree), [`list`](ManyErrors::list), +/// [`bullets`](ManyErrors::bullets), [`joined`](ManyErrors::joined), or via /// [`FormatError::formatted`](crate::FormatError::formatted) for full generic /// control (e.g. `Tree`). /// @@ -218,21 +218,25 @@ impl ManyErrors { } /// Renders on a single line: `;`-separated siblings, parens around groups. - pub fn one_line(&self) -> crate::Formatted<&Self, Inline> { + pub fn joined(&self) -> crate::Formatted<&Self, Joined> { crate::Formatted::new(self) } } -/// Renders each error as a branching Unicode tree with a count header. +/// Renders a shallow, single-line summary: `"N errors: child1; child2; …"`, +/// each child's own text only (no source chains). This is the Rust-convention +/// error message; for source-walking shapes use [`tree`](ManyErrors::tree), +/// [`joined`](ManyErrors::joined), [`list`](ManyErrors::list), or +/// [`bullets`](ManyErrors::bullets). impl Display for ManyErrors where C: Display, - E: Error + Display + 'static, + E: Error + 'static, F: Format>, GF: Format>, { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - >::fmt(self, f) + >::fmt(self, f) } } @@ -240,19 +244,16 @@ impl Error for ManyErrors where C: Display + core::fmt::Debug, GC: core::fmt::Debug, - E: Error + Display + 'static, + E: Error + 'static, F: Format>, GF: Format>, { - /// For [`Self::One`] holding a leaf, returns the inner error's source so - /// chain-walking strategies don't duplicate it. Groups and `Many` variants - /// have no single source. + /// Always `None`: an aggregate of independent sibling errors has no single + /// linear cause, so it exposes nothing through [`Error::source`]. Inspect + /// the children directly, or render the full chains via a strategy + /// ([`tree`](Self::tree), [`joined`](Self::joined), …). fn source(&self) -> Option<&(dyn Error + 'static)> { - match self { - Self::None | Self::Many(_) => None, - Self::One(Node::Leaf(w)) => w.error.source(), - Self::One(Node::Group(_)) => None, - } + None } } @@ -351,11 +352,11 @@ mod tests { } #[test] - fn test_source_one_leaf_skips_inner_error() { + fn test_source_one_leaf_is_none() { + // An aggregate has no single linear cause, even with one leaf. let mut e = ManyErrors::<&str, Mid>::new(); e.push("ctx", Mid::Inner(Inner::A)); - let src = e.source().expect("should have source"); - assert_eq!(src.to_string(), "InnerA"); + assert!(e.source().is_none()); } #[test] @@ -375,12 +376,12 @@ mod tests { assert!(e.source().is_none()); } - // --- Display (Tree) --- + // --- Display (Summary: shallow, single-line, no source chains) --- #[test] fn test_display_empty() { let e = ManyErrors::<&str, Inner>::new(); - assert_eq!(e.to_string(), ""); + assert_eq!(e.to_string(), "no errors"); } #[test] @@ -392,11 +393,22 @@ mod tests { } #[test] - fn test_display_two_leaves_with_header() { + fn test_display_two_leaves() { let mut e = ManyErrors::<&str, Inner>::new(); e.push("a", Inner::A); e.push("b", Inner::B); - assert_eq!(e.to_string(), "2 errors:\n├─ a: InnerA\n└─ b: InnerB"); + assert_eq!(e.to_string(), "2 errors: a: InnerA; b: InnerB"); + } + + /// Default Display does not walk a leaf's source chain. + #[test] + fn test_display_does_not_walk_source() { + let mut e = ManyErrors::<&str, Mid>::new(); + e.push("a", Mid::Inner(Inner::A)); + e.push("b", Mid::Inner(Inner::B)); + let s = e.to_string(); + assert_eq!(s, "2 errors: a: mid; b: mid"); + assert!(!s.contains("InnerA"), "source must not be walked: {s}"); } #[test] @@ -406,19 +418,19 @@ mod tests { inner.push("y", Inner::B); let mut outer = ManyErrors::<&str, Inner>::new(); - outer.push_group("region", inner); outer.push("leaf", Inner::A); + outer.push_group("region", inner); - let s = outer.to_string(); - assert!(s.contains("2 errors:"), "got: {s}"); - assert!(s.contains("region (2 errors):"), "got: {s}"); - assert!(s.contains("leaf: InnerA"), "got: {s}"); + assert_eq!( + outer.to_string(), + "2 errors: leaf: InnerA; region (2 errors: x: InnerA; y: InnerB)" + ); } #[test] fn test_one_line_single_leaf_walks_chain() { let mut e = ManyErrors::<&str, Mid>::new(); e.push("ctx", Mid::Inner(Inner::A)); - assert_eq!(e.one_line().to_string(), "ctx: mid: InnerA"); + assert_eq!(e.joined().to_string(), "ctx: mid: InnerA"); } } diff --git a/src/many_errors/strategy/bullets.rs b/src/many_errors/strategy/bullets.rs index 4f315d4..5a8ecf7 100644 --- a/src/many_errors/strategy/bullets.rs +++ b/src/many_errors/strategy/bullets.rs @@ -5,19 +5,19 @@ use core::{ error::Error, - fmt::{self, Display}, + fmt::{self, Debug, Display}, iter, }; use itertools::Itertools; use crate::{ - Format, + Format, OneLine, many_errors::{ManyErrors, Node, Subgroup}, with_context::WithContext, }; -use super::{impl_aggregate_format, inline_sources}; +use super::impl_aggregate_format; /// Aggregate strategy that renders a [`ManyErrors`] as a bulleted (`•`) list. /// @@ -31,13 +31,17 @@ use super::{impl_aggregate_format, inline_sources}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] pub struct Bullets; -impl_aggregate_format!(Bullets, |errors, f| draw_bullets_many::( - errors, 0, f -)); +impl_aggregate_format!(Bullets, [+ ::core::fmt::Debug], |errors, f| draw_bullets_many::< + C, + E, + GC, + F, + GF, +>(errors, 0, f)); /// Render `errors` as a bulleted list at nesting `depth`. /// -/// - `None` writes nothing. +/// - `None` writes `"no errors"`. /// - `One` delegates to [`draw_bullets_node`] with `with_bullet = false`: a lone /// error is printed flush, without a leading `•`. /// - `Many` writes the `"N errors:"` header, then recurses into each child at @@ -48,13 +52,13 @@ fn draw_bullets_many( f: &mut fmt::Formatter<'_>, ) -> fmt::Result where - C: Display, - E: Error + Display + 'static, + C: Display + Debug, + E: Error + 'static, F: Format>, GF: Format>, { match errors { - ManyErrors::None => Ok(()), + ManyErrors::None => write!(f, "no errors"), ManyErrors::One(node) => draw_bullets_node::(node, depth, false, f), ManyErrors::Many(nodes) => { write!(f, "{} errors:", nodes.len())?; @@ -70,8 +74,9 @@ where /// /// When `with_bullet` is set, first writes `"\n{indent}• "` where `indent` is /// `depth` copies of `" "` (lazy `repeat_n`, no allocation). Then: -/// - `Leaf` → `{w}` plus the inline source chain via [`inline_sources`]; -/// - `Group` empty → `"{w}: (no errors)"`; +/// - `Leaf` → the whole pair on one line via the [`OneLine`] strategy (`{w}` plus +/// its `": "`-joined source chain); +/// - `Group` empty → `"{w}: no errors"`; /// - `Group` single child → `"{w}: "` then recurse at the same `depth` with /// `with_bullet = false`, so the child sits inline after the label; /// - `Group` many children → `"{w} (N errors):"` header, then each child @@ -83,8 +88,8 @@ fn draw_bullets_node( f: &mut fmt::Formatter<'_>, ) -> fmt::Result where - C: Display, - E: Error + Display + 'static, + C: Display + Debug, + E: Error + 'static, F: Format>, GF: Format>, { @@ -93,12 +98,9 @@ where write!(f, "\n{indent}• ")?; } match node { - Node::Leaf(w) => { - write!(f, "{w}")?; - inline_sources(w.error.source(), f) - } + Node::Leaf(w) => >::fmt(w, f), Node::Group(w) => match w.error.as_ref() { - ManyErrors::None => write!(f, "{w}: (no errors)"), + ManyErrors::None => write!(f, "{w}: no errors"), ManyErrors::One(inner) => { write!(f, "{w}: ")?; draw_bullets_node::(inner, depth, false, f) @@ -116,14 +118,58 @@ where #[cfg(test)] mod tests { - use crate::many_errors::strategy::test_helpers::two_leaves; + use crate::ManyErrors; + use crate::many_errors::strategy::test_helpers::{two_leaves, with_chain}; + use crate::tests::Inner; + + #[test] + fn test_bullets_empty() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.bullets().to_string(), "no errors"); + } + + #[test] + fn test_bullets_single_leaf_no_bullet() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("ctx", Inner::A); + assert_eq!(e.bullets().to_string(), "ctx: InnerA"); + } #[test] fn test_bullets_two_leaves() { - let e = two_leaves(); assert_eq!( - e.bullets().to_string(), + two_leaves().bullets().to_string(), "2 errors:\n • a: InnerA\n • b: InnerB" ); } + + /// Leaves walk their source chain via `OneLine`. + #[test] + fn test_bullets_walks_source_chain() { + let s = with_chain().bullets().to_string(); + assert!(s.contains("• a: mid: InnerA"), "got: {s}"); + assert!(s.contains("• b: mid: InnerB"), "got: {s}"); + } + + #[test] + fn test_bullets_nested_group() { + let mut inner = ManyErrors::<&str, Inner>::new(); + inner.push("x", Inner::A); + inner.push("y", Inner::B); + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push("leaf", Inner::A); + outer.push_group("region", inner); + + assert_eq!( + outer.bullets().to_string(), + "2 errors:\n • leaf: InnerA\n • region (2 errors):\n • x: InnerA\n • y: InnerB" + ); + } + + #[test] + fn test_bullets_empty_group() { + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push_group("g", ManyErrors::new()); + assert_eq!(outer.bullets().to_string(), "g: no errors"); + } } diff --git a/src/many_errors/strategy/inline.rs b/src/many_errors/strategy/inline.rs deleted file mode 100644 index 9fc153f..0000000 --- a/src/many_errors/strategy/inline.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! [`Inline`]: render a [`ManyErrors`] on a single line. - -use core::{ - error::Error, - fmt::{self, Display}, -}; - -use crate::{ - Format, - many_errors::{ManyErrors, Node, Subgroup}, - with_context::WithContext, -}; - -use super::{impl_aggregate_format, inline_sources}; - -/// Aggregate strategy that renders a [`ManyErrors`] on a single line. -/// -/// Siblings are separated by `"; "`. Nested groups are wrapped in parens. -/// -/// # Output example -/// ```text -/// 3 errors: a: InnerA; b: InnerB; c: InnerC -/// ``` -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Inline; - -impl_aggregate_format!(Inline, |errors, f| draw_inline_many::( - errors, f -)); - -/// Render `errors` on the current line, no newlines. -/// -/// - `None` writes nothing. -/// - `One` delegates to [`draw_inline_node`] with no header. -/// - `Many` writes the `"N errors: "` header, then each child separated by -/// `"; "`. A `first` flag suppresses the separator before the first child so -/// it isn't led by a stray `"; "`. -fn draw_inline_many( - errors: &ManyErrors, - f: &mut fmt::Formatter<'_>, -) -> fmt::Result -where - C: Display, - E: Error + Display + 'static, - F: Format>, - GF: Format>, -{ - match errors { - ManyErrors::None => Ok(()), - ManyErrors::One(node) => draw_inline_node::(node, f), - ManyErrors::Many(nodes) => { - write!(f, "{} errors: ", nodes.len())?; - let mut first = true; - for node in nodes { - if !first { - write!(f, "; ")?; - } - first = false; - draw_inline_node::(node, f)?; - } - Ok(()) - } - } -} - -/// Render a single node inline. -/// -/// - `Leaf` → `{w}` plus its source chain via [`inline_sources`]. -/// - `Group` → `"{w} ("`, the nested aggregate rendered recursively by -/// [`draw_inline_many`], then a closing `")"`. Parens bracket each nested -/// group so depth stays unambiguous on one line. -fn draw_inline_node( - node: &Node, - f: &mut fmt::Formatter<'_>, -) -> fmt::Result -where - C: Display, - E: Error + Display + 'static, - F: Format>, - GF: Format>, -{ - match node { - Node::Leaf(w) => { - write!(f, "{w}")?; - inline_sources(w.error.source(), f) - } - Node::Group(w) => { - write!(f, "{w} (")?; - draw_inline_many::(w.error.as_ref(), f)?; - write!(f, ")") - } - } -} - -#[cfg(test)] -mod tests { - use crate::ManyErrors; - use crate::many_errors::strategy::test_helpers::two_leaves; - use crate::tests::{Inner, Mid}; - - #[test] - fn test_inline_empty() { - let e = ManyErrors::<&str, Inner>::new(); - assert_eq!(e.one_line().to_string(), ""); - } - - #[test] - fn test_inline_single() { - let mut e = ManyErrors::<&str, Mid>::new(); - e.push("ctx", Mid::Inner(Inner::A)); - assert_eq!(e.one_line().to_string(), "ctx: mid: InnerA"); - } - - #[test] - fn test_inline_two() { - let e = two_leaves(); - let s = e.one_line().to_string(); - assert!(s.contains("2 errors:"), "got: {s}"); - assert!(s.contains("a: InnerA"), "got: {s}"); - assert!(s.contains("b: InnerB"), "got: {s}"); - assert!(s.contains("; "), "got: {s}"); - } -} diff --git a/src/many_errors/strategy/list.rs b/src/many_errors/strategy/list.rs index 5d6482c..cf094f5 100644 --- a/src/many_errors/strategy/list.rs +++ b/src/many_errors/strategy/list.rs @@ -5,19 +5,19 @@ use core::{ error::Error, - fmt::{self, Display}, + fmt::{self, Debug, Display}, iter, }; use itertools::Itertools; use crate::{ - Format, + Format, OneLine, many_errors::{ManyErrors, Node, Subgroup}, with_context::WithContext, }; -use super::{impl_aggregate_format, inline_sources}; +use super::impl_aggregate_format; /// Aggregate strategy that renders a [`ManyErrors`] as a numbered list. /// @@ -31,13 +31,17 @@ use super::{impl_aggregate_format, inline_sources}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] pub struct List; -impl_aggregate_format!(List, |errors, f| draw_list_many::( - errors, 0, f -)); +impl_aggregate_format!(List, [+ ::core::fmt::Debug], |errors, f| draw_list_many::< + C, + E, + GC, + F, + GF, +>(errors, 0, f)); /// Render `errors` as a numbered list at nesting `depth`. /// -/// - `None` writes nothing. +/// - `None` writes `"no errors"`. /// - `One` delegates straight to [`draw_list_node`] with no header or number /// (a lone error reads better inline than as `1. ...`). /// - `Many` writes the `"N errors:"` header, then one `"{indent}{i}. "` prefix @@ -52,13 +56,13 @@ fn draw_list_many( f: &mut fmt::Formatter<'_>, ) -> fmt::Result where - C: Display, - E: Error + Display + 'static, + C: Display + Debug, + E: Error + 'static, F: Format>, GF: Format>, { match errors { - ManyErrors::None => Ok(()), + ManyErrors::None => write!(f, "no errors"), ManyErrors::One(node) => draw_list_node::(node, depth, f), ManyErrors::Many(nodes) => { write!(f, "{} errors:", nodes.len())?; @@ -75,11 +79,12 @@ where /// Render a single node; the `"{i}. "` prefix has already been written by the /// caller. /// -/// - `Leaf` writes the context/error pair (`{w}`), then appends its source -/// chain inline via [`inline_sources`] (`": src1: src2"`) — lists keep each -/// entry on one logical line. +/// - `Leaf` renders the whole pair on one logical line via the [`OneLine`] +/// strategy: `{w}` (context/error through `F`) followed by its source chain +/// joined with `": "` — `WithContext`'s own `Display`/`Error::source` give +/// exactly that. /// - `Group` writes the label, then: -/// - empty group → `"{w}: (no errors)"`; +/// - empty group → `"{w}: no errors"`; /// - single child → `"{w}: "` and recurse at the *same* `depth` (the child /// is rendered inline after the colon, not as a new numbered row); /// - many children → `"{w} (N errors):"` header, then a fresh numbered list @@ -90,18 +95,15 @@ fn draw_list_node( f: &mut fmt::Formatter<'_>, ) -> fmt::Result where - C: Display, - E: Error + Display + 'static, + C: Display + Debug, + E: Error + 'static, F: Format>, GF: Format>, { match node { - Node::Leaf(w) => { - write!(f, "{w}")?; - inline_sources(w.error.source(), f) - } + Node::Leaf(w) => >::fmt(w, f), Node::Group(w) => match w.error.as_ref() { - ManyErrors::None => write!(f, "{w}: (no errors)"), + ManyErrors::None => write!(f, "{w}: no errors"), ManyErrors::One(inner) => { write!(f, "{w}: ")?; draw_list_node::(inner, depth, f) @@ -121,14 +123,58 @@ where #[cfg(test)] mod tests { - use crate::many_errors::strategy::test_helpers::two_leaves; + use crate::ManyErrors; + use crate::many_errors::strategy::test_helpers::{two_leaves, with_chain}; + use crate::tests::Inner; + + #[test] + fn test_list_empty() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.list().to_string(), "no errors"); + } + + #[test] + fn test_list_single_leaf_no_header() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("ctx", Inner::A); + assert_eq!(e.list().to_string(), "ctx: InnerA"); + } #[test] fn test_list_two_leaves() { - let e = two_leaves(); - let s = e.list().to_string(); - assert!(s.contains("2 errors:"), "got: {s}"); - assert!(s.contains("1. a: InnerA"), "got: {s}"); - assert!(s.contains("2. b: InnerB"), "got: {s}"); + assert_eq!( + two_leaves().list().to_string(), + "2 errors:\n1. a: InnerA\n2. b: InnerB" + ); + } + + /// Leaves walk their source chain via `OneLine`. + #[test] + fn test_list_walks_source_chain() { + let s = with_chain().list().to_string(); + assert!(s.contains("1. a: mid: InnerA"), "got: {s}"); + assert!(s.contains("2. b: mid: InnerB"), "got: {s}"); + } + + #[test] + fn test_list_nested_group() { + let mut inner = ManyErrors::<&str, Inner>::new(); + inner.push("x", Inner::A); + inner.push("y", Inner::B); + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push("leaf", Inner::A); + outer.push_group("region", inner); + + assert_eq!( + outer.list().to_string(), + "2 errors:\n1. leaf: InnerA\n2. region (2 errors):\n 1. x: InnerA\n 2. y: InnerB" + ); + } + + #[test] + fn test_list_empty_group() { + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push_group("g", ManyErrors::new()); + assert_eq!(outer.list().to_string(), "g: no errors"); } } diff --git a/src/many_errors/strategy/mod.rs b/src/many_errors/strategy/mod.rs index af3e092..067be34 100644 --- a/src/many_errors/strategy/mod.rs +++ b/src/many_errors/strategy/mod.rs @@ -1,34 +1,41 @@ -//! Aggregate format strategies for [`ManyErrors`]: [`Tree`], [`List`], [`Bullets`], [`Inline`]. +//! Aggregate format strategies for [`ManyErrors`]: [`Tree`], [`List`], [`Bullets`], [`Joined`]. //! //! All strategies implement [`Format>`] (and the ref trampoline //! [`Format<&ManyErrors<…>>`]) so they work with both `Display` and //! [`Formatted`](crate::Formatted) wrappers. //! +//! `Summary` is the crate-internal shallow strategy backing the default +//! [`Display`](core::fmt::Display): own text only, no source chains. +//! //! Group headers are rendered through the group's own label strategy `GF` via //! `write!(f, "{w}")` (default [`ContextField`](crate::with_context::ContextField): //! the label only); the structural ` (N errors):` / `: ` and children are added //! by the aggregate strategy itself. -use core::{error::Error, fmt}; - mod bullets; -mod inline; mod list; +mod one_line; mod tree; pub use bullets::Bullets; -pub use inline::Inline; pub use list::List; +pub use one_line::Joined; +pub(crate) use one_line::Summary; pub use tree::Tree; /// Emits the `Format>` impl and its `Format<&ManyErrors<…>>` ref -/// trampoline for an aggregate strategy with no extra generic parameters. The -/// closure-like argument names the entry-point `draw_*_many` call. +/// trampoline for an aggregate strategy with no extra generic parameters. +/// +/// `[$($cbound)*]` is appended to the leaf-context bound `C: Display`: pass +/// `[+ ::core::fmt::Debug]` for strategies whose leaves go through the +/// chain-walking [`OneLine`](crate::OneLine) (which needs `WithContext: Error`, +/// hence `C: Debug`), or `[]` for shallow strategies. The closure-like argument +/// names the entry-point `draw_*` call. macro_rules! impl_aggregate_format { - ($strategy:ident, |$errors:ident, $f:ident| $call:expr) => { + ($strategy:ident, [$($cbound:tt)*], |$errors:ident, $f:ident| $call:expr) => { impl $crate::Format<$crate::ManyErrors> for $strategy where - C: ::core::fmt::Display, + C: ::core::fmt::Display $($cbound)*, E: ::core::error::Error + ::core::fmt::Display + 'static, F: $crate::Format<$crate::with_context::WithContext>, GF: $crate::Format<$crate::many_errors::Subgroup>, @@ -43,7 +50,7 @@ macro_rules! impl_aggregate_format { impl $crate::Format<&$crate::ManyErrors> for $strategy where - C: ::core::fmt::Display, + C: ::core::fmt::Display $($cbound)*, E: ::core::error::Error + ::core::fmt::Display + 'static, F: $crate::Format<$crate::with_context::WithContext>, GF: $crate::Format<$crate::many_errors::Subgroup>, @@ -60,21 +67,6 @@ macro_rules! impl_aggregate_format { pub(crate) use impl_aggregate_format; -/// Write the source chain as `": {src1}: {src2}: ..."` on the current line. -/// -/// Shared by the [`List`], [`Bullets`], and [`Inline`] leaf renderers. -pub(super) fn inline_sources( - source: Option<&dyn Error>, - f: &mut fmt::Formatter<'_>, -) -> fmt::Result { - let mut opt_src = source; - while let Some(src) = opt_src { - write!(f, ": {src}")?; - opt_src = src.source(); - } - Ok(()) -} - #[cfg(test)] pub(super) mod test_helpers { use crate::ManyErrors; diff --git a/src/many_errors/strategy/one_line.rs b/src/many_errors/strategy/one_line.rs new file mode 100644 index 0000000..f1fd06e --- /dev/null +++ b/src/many_errors/strategy/one_line.rs @@ -0,0 +1,295 @@ +//! Single-line strategies: [`Joined`] (deep, walks source chains) and +//! [`Summary`] (shallow, own text only — the default [`Display`]). +//! +//! Both share one traversal ([`draw_one_line_many`]) parameterized by a leaf +//! renderer, so they differ in exactly one place: how a leaf is printed. +//! `Joined` routes leaves through the chain-walking [`OneLine`] (needs +//! `C: Debug`, via `WithContext: Error`); `Summary` prints the leaf's own text +//! `{w}` only (no `Debug`, keeping `ManyErrors: Display` at `C: Display`). + +use core::{ + error::Error, + fmt::{self, Display}, +}; + +use crate::{ + Format, OneLine, + many_errors::{ManyErrors, Node, Subgroup}, + with_context::WithContext, +}; + +use super::impl_aggregate_format; + +/// Aggregate strategy that renders a [`ManyErrors`] on a single line, walking +/// each leaf's source chain via the per-error [`OneLine`] strategy. +/// +/// Siblings are separated by `"; "`, nested groups wrapped in parens. +/// +/// # Output example +/// ```text +/// 3 errors: a: InnerA; b: InnerB; c: InnerC +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Joined; + +impl_aggregate_format!(Joined, [+ ::core::fmt::Debug], |errors, f| draw_joined::< + C, + E, + GC, + F, + GF, +>(errors, f)); + +/// Shallow single-line strategy backing the default [`Display`] of +/// [`ManyErrors`]: each error's own text only, **no source chains**. +/// +/// Siblings are separated by `"; "`, nested groups wrapped in parens. +/// +/// # Output example +/// ```text +/// 2 errors: leaf: InnerA; region (2 errors: x: InnerA; y: InnerB) +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct Summary; + +impl_aggregate_format!(Summary, [], |errors, f| draw_summary::( + errors, f +)); + +/// [`Joined`] entry point: leaves walk their source chain via [`OneLine`]. +fn draw_joined( + errors: &ManyErrors, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display + fmt::Debug, + E: Error + 'static, + F: Format>, + GF: Format>, +{ + draw_one_line_many(errors, >>::fmt, f) +} + +/// [`Summary`] entry point: leaves print their own text only. +fn draw_summary( + errors: &ManyErrors, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + 'static, + F: Format>, + GF: Format>, +{ + draw_one_line_many(errors, |w, f| write!(f, "{w}"), f) +} + +/// Shared single-line traversal, generic over the per-leaf renderer `leaf`. +/// +/// - `None` writes nothing. +/// - `None` writes `"no errors"`. +/// - `One` delegates to [`draw_one_line_node`] with no header. +/// - `Many` writes the `"N errors: "` header, then each child separated by +/// `"; "` (a `first` flag suppresses the leading separator). +fn draw_one_line_many( + errors: &ManyErrors, + leaf: L, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + 'static, + F: Format>, + GF: Format>, + L: Fn(&WithContext, &mut fmt::Formatter<'_>) -> fmt::Result + Copy, +{ + match errors { + ManyErrors::None => write!(f, "no errors"), + ManyErrors::One(node) => draw_one_line_node(node, leaf, f), + ManyErrors::Many(nodes) => { + write!(f, "{} errors: ", nodes.len())?; + let mut first = true; + for node in nodes { + if !first { + write!(f, "; ")?; + } + first = false; + draw_one_line_node(node, leaf, f)?; + } + Ok(()) + } + } +} + +/// Render a single node on the current line. +/// +/// - `Leaf` → `leaf(w, f)`. +/// - `Group` → `"{w} ("`, the nested aggregate via [`draw_one_line_many`], then +/// `")"`. Parens keep depth unambiguous, and an empty group falls out as +/// `"{w} (no errors)"` with no special case. +fn draw_one_line_node( + node: &Node, + leaf: L, + f: &mut fmt::Formatter<'_>, +) -> fmt::Result +where + C: Display, + E: Error + 'static, + F: Format>, + GF: Format>, + L: Fn(&WithContext, &mut fmt::Formatter<'_>) -> fmt::Result + Copy, +{ + match node { + Node::Leaf(w) => leaf(w, f), + Node::Group(w) => { + write!(f, "{w} (")?; + draw_one_line_many(w.error.as_ref(), leaf, f)?; + write!(f, ")") + } + } +} + +#[cfg(test)] +mod tests { + use crate::ManyErrors; + use crate::many_errors::strategy::test_helpers::{two_leaves, with_chain}; + use crate::tests::{Inner, Mid}; + + /// `{leaf, group{x, y}}` — the standard nested fixture, leaf first. + fn nested() -> ManyErrors<&'static str, Inner> { + let mut inner = ManyErrors::new(); + inner.push("x", Inner::A); + inner.push("y", Inner::B); + let mut outer = ManyErrors::new(); + outer.push("leaf", Inner::A); + outer.push_group("region", inner); + outer + } + + // --- Joined (deep) --- + + #[test] + fn test_joined_empty() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.joined().to_string(), "no errors"); + } + + #[test] + fn test_joined_single_leaf_no_header() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("ctx", Inner::A); + assert_eq!(e.joined().to_string(), "ctx: InnerA"); + } + + #[test] + fn test_joined_two_leaves() { + assert_eq!( + two_leaves().joined().to_string(), + "2 errors: a: InnerA; b: InnerB" + ); + } + + /// Deep: leaf source chains are walked and joined with `": "`. + #[test] + fn test_joined_walks_source_chain() { + assert_eq!( + with_chain().joined().to_string(), + "2 errors: a: mid: InnerA; b: mid: InnerB" + ); + } + + #[test] + fn test_joined_nested_group() { + assert_eq!( + nested().joined().to_string(), + "2 errors: leaf: InnerA; region (2 errors: x: InnerA; y: InnerB)" + ); + } + + #[test] + fn test_joined_single_group() { + let mut inner = ManyErrors::<&str, Inner>::new(); + inner.push("x", Inner::A); + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push_group("g", inner); + assert_eq!(outer.joined().to_string(), "g (x: InnerA)"); + } + + #[test] + fn test_joined_empty_group() { + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push_group("g", ManyErrors::new()); + assert_eq!(outer.joined().to_string(), "g (no errors)"); + } + + // --- Summary (shallow, the default Display) --- + + #[test] + fn test_summary_empty() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.to_string(), "no errors"); + } + + #[test] + fn test_summary_single_leaf_no_header() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("ctx", Inner::A); + assert_eq!(e.to_string(), "ctx: InnerA"); + } + + #[test] + fn test_summary_two_leaves() { + assert_eq!(two_leaves().to_string(), "2 errors: a: InnerA; b: InnerB"); + } + + /// Shallow: a leaf's source is NOT walked (`mid`, not `mid: InnerA`). + #[test] + fn test_summary_does_not_walk_source() { + let s = with_chain().to_string(); + assert_eq!(s, "2 errors: a: mid; b: mid"); + assert!(!s.contains("InnerA"), "source must not be walked: {s}"); + } + + #[test] + fn test_summary_nested_group() { + assert_eq!( + nested().to_string(), + "2 errors: leaf: InnerA; region (2 errors: x: InnerA; y: InnerB)" + ); + } + + #[test] + fn test_summary_single_group() { + let mut inner = ManyErrors::<&str, Inner>::new(); + inner.push("x", Inner::A); + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push_group("g", inner); + assert_eq!(outer.to_string(), "g (x: InnerA)"); + } + + #[test] + fn test_summary_empty_group() { + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push_group("g", ManyErrors::new()); + assert_eq!(outer.to_string(), "g (no errors)"); + } + + /// Heterogeneous: group labels are `usize`, leaf contexts are `&str`. + #[test] + fn test_summary_heterogeneous_group_label() { + let mut inner = ManyErrors::<&str, Inner, usize>::new(); + inner.push("x", Inner::A); + let mut outer = ManyErrors::<&str, Inner, usize>::new(); + outer.push("leaf", Inner::B); + outer.push_group(7, inner); + assert_eq!(outer.to_string(), "2 errors: leaf: InnerB; 7 (x: InnerA)"); + } + + /// A leaf whose error carries a source is still shallow under Summary. + #[test] + fn test_summary_single_leaf_with_source() { + let mut e = ManyErrors::<&str, Mid>::new(); + e.push("ctx", Mid::Inner(Inner::A)); + assert_eq!(e.to_string(), "ctx: mid"); + } +} diff --git a/src/many_errors/strategy/tree.rs b/src/many_errors/strategy/tree.rs index 6d73c79..b23d737 100644 --- a/src/many_errors/strategy/tree.rs +++ b/src/many_errors/strategy/tree.rs @@ -75,7 +75,7 @@ impl Format where C: Display, - E: Error + Display + 'static, + E: Error + 'static, F: Format>, GF: Format>, Conn: TreeConnectors, @@ -91,7 +91,7 @@ impl Format<&ManyErrors where C: Display, - E: Error + Display + 'static, + E: Error + 'static, F: Format>, GF: Format>, Conn: TreeConnectors, @@ -170,13 +170,13 @@ fn draw_many( ) -> fmt::Result where C: Display, - E: Error + Display + 'static, + E: Error + 'static, F: Format>, GF: Format>, Conn: TreeConnectors, { match errors { - ManyErrors::None => Ok(()), + ManyErrors::None => write!(f, "no errors"), ManyErrors::One(node) => draw_node::(node, levels, f), ManyErrors::Many(nodes) => { let pre_first = if show_header { @@ -199,7 +199,7 @@ fn draw_children( ) -> fmt::Result where C: Display, - E: Error + Display + 'static, + E: Error + 'static, F: Format>, GF: Format>, Conn: TreeConnectors, @@ -229,7 +229,7 @@ fn draw_node( ) -> fmt::Result where C: Display, - E: Error + Display + 'static, + E: Error + 'static, F: Format>, GF: Format>, Conn: TreeConnectors, @@ -240,7 +240,7 @@ where draw_error_chain::(w.error.source(), levels, f) } Node::Group(w) => match w.error.as_ref() { - ManyErrors::None => indented::(f, levels, 0, format_args!("{w}: (no errors)")), + ManyErrors::None => indented::(f, levels, 0, format_args!("{w}: no errors")), ManyErrors::One(inner) => { indented::(f, levels, 0, format_args!("{w}: "))?; draw_node::(inner, levels, f) @@ -290,7 +290,14 @@ mod tests { #[test] fn test_tree_empty() { let e = ManyErrors::<&str, Inner>::new(); - assert_eq!(e.tree().to_string(), ""); + assert_eq!(e.tree().to_string(), "no errors"); + } + + #[test] + fn test_tree_empty_group() { + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push_group("g", ManyErrors::new()); + assert_eq!(outer.tree().to_string(), "g: no errors"); } #[test] diff --git a/src/oneline.rs b/src/oneline.rs index a795639..8670cc8 100644 --- a/src/oneline.rs +++ b/src/oneline.rs @@ -9,10 +9,10 @@ use crate::{Format, chain}; /// For a different separator (or any per-element formatting), implement /// [`Format`] yourself using [`chain`]. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct Flat; +pub struct OneLine; /// Walks the source chain and joins each error's `Display` output with `": "`. -impl Format for Flat { +impl Format for OneLine { fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", chain(&error).format(": ")) } @@ -23,7 +23,7 @@ mod tests { use std::io; use crate::{ - Flat, FormatError, Formatted, + FormatError, Formatted, OneLine, tests::{Arrow, Error, Inner}, }; @@ -43,15 +43,15 @@ mod tests { // Debug surfaces the wrapped error and the active strategy. assert_eq!( format!("{:?}", error.one_line()), - "Formatted { error: One, format: Flat }" + "Formatted { error: One, format: OneLine }" ); - assert_eq!(Formatted::<_, Flat>::new(Error::One).to_string(), "One"); + assert_eq!(Formatted::<_, OneLine>::new(Error::One).to_string(), "One"); let error = Error::Two(Inner::A); assert_eq!(error.one_line().to_string(), "Two: InnerA"); assert_eq!( format!("{:?}", error.one_line()), - "Formatted { error: Two(A), format: Flat }" + "Formatted { error: Two(A), format: OneLine }" ); } diff --git a/src/suggestion.rs b/src/suggestion.rs index a39c1f6..c26013c 100644 --- a/src/suggestion.rs +++ b/src/suggestion.rs @@ -125,7 +125,7 @@ mod tests { ); // They can be composed via Add. assert_eq!( - e.formatted::>>() + e.formatted::>>() .to_string(), "One\nTry passing --help to see available options.", ); diff --git a/src/tests.rs b/src/tests.rs index 2f7f5f9..8bfbb85 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -131,10 +131,10 @@ fn _assert_derive_traits() { impl core::error::Error for DummyError {} fn assert_all() {} - assert_all::>(); + assert_all::>(); assert_all::>(); assert_all::>(); - assert_all::(); + assert_all::(); assert_all::(); // The phantom strategy param must NOT leak a `Trait` bound: these must @@ -200,10 +200,11 @@ fn test_many_variant() { errs.push("b", Inner::B); let e = Error::Many(errs); assert_eq!(e.to_string(), "Many"); - // ManyErrors is the source; one_line walks the chain and embeds ManyErrors::Display (Tree). + // ManyErrors is the source; one_line walks the chain and embeds + // ManyErrors::Display, now the shallow single-line Summary. assert_eq!( e.one_line().to_string(), - "Many: 2 errors:\n├─ a: InnerA\n└─ b: InnerB" + "Many: 2 errors: a: InnerA; b: InnerB" ); } diff --git a/src/with_context/format.rs b/src/with_context/format.rs index 6a0de50..7f09452 100644 --- a/src/with_context/format.rs +++ b/src/with_context/format.rs @@ -84,6 +84,7 @@ pub type PathColon = WithColonSpace; mod tests { use super::*; use crate::{WithContext, separator::WithSpace, tests::Inner}; + #[cfg(feature = "std")] use std::io; /// `PathColon` formats path contexts directly, without a wrapper newtype. diff --git a/tests/max_custom.rs b/tests/max_custom.rs index 5da3c36..837a83d 100644 --- a/tests/max_custom.rs +++ b/tests/max_custom.rs @@ -131,7 +131,7 @@ fn fully_custom_tree() { assert_eq!(rendered, expected); assert_eq!( - outer.one_line().to_string(), + outer.joined().to_string(), "2 errors: {us-east#3} (2 errors: config ▸ operation failed: fsync « write failed: disk full; network ▸ operation failed: connect « write failed: disk full); startup ▸ operation failed: load « write failed: disk full" ); } @@ -207,7 +207,7 @@ fn malformed_messages_and_strategies() { // Manual print: shows the actual garbled layout (run with `--nocapture`). println!("--- tree ---\n{rendered}"); - println!("--- one line ---\n{}", outer.one_line()); + println!("--- one line ---\n{}", outer.joined()); let expected_tree = "\ 2 errors: @@ -236,8 +236,8 @@ fn malformed_messages_and_strategies() { assert_eq!(rendered, expected_tree); - // `one_line` (the Inline strategy) is single-line by design: it keeps its own - // `; ` / `: ` separators and passes embedded control chars through untouched + // `joined` (the deep single-line strategy) keeps its own `; ` / `: ` + // separators and passes embedded control chars through untouched // (re-indentation only applies to the structural tree renderer). let expected_one_line = "\ 2 errors: [us\teast#9] @@ -254,5 +254,5 @@ up failed: load\t=> write\tfailed: disk \tfull"; - assert_eq!(outer.one_line().to_string(), expected_one_line); + assert_eq!(outer.joined().to_string(), expected_one_line); } From 376a688c8570f480d45ae203a93f144b726a5663 Mon Sep 17 00:00:00 2001 From: Max Wase Date: Sun, 31 May 2026 12:20:57 +0200 Subject: [PATCH 9/9] Clippy fixes --- Cargo.toml | 4 ++++ src/lib.rs | 4 ++-- src/tests.rs | 1 + tests/max_custom.rs | 4 ++++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ac7bc0a..43ab99b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,7 @@ alloc = [] [[example]] name = "with_context" required-features = ["std"] + +[[example]] +name = "many_errors" +required-features = ["std"] diff --git a/src/lib.rs b/src/lib.rs index b31f9e4..06cd079 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,8 +71,8 @@ pub use with_context::WithContext; /// — these impls bound the marker `…: Debug + Default`, while their /// auto-traits ([`Clone`]/[`Copy`]/[`PartialEq`]/[`Eq`]/[`Hash`]) stay free of /// any marker bound. -/// - **Payload types** ([`WithContext`], [`ManyErrors`](crate::ManyErrors), -/// [`Node`](crate::Node)) print their own name and fields, hiding the phantom +/// - **Payload types** ([`WithContext`], [`ManyErrors`], [`Node`]) print their +/// own name and fields, hiding the phantom /// strategy. Thin display adapters ([`DisplayPath`]) instead stay transparent /// to mirror their target's `Debug`. pub trait Format { diff --git a/src/tests.rs b/src/tests.rs index 8bfbb85..50e5341 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -143,6 +143,7 @@ fn _assert_derive_traits() { assert_all::>(); assert_all::>(); assert_all::>(); + #[cfg(feature = "alloc")] assert_all::>(); // `WithContext` has no `Default`, but its other auto-traits must still be diff --git a/tests/max_custom.rs b/tests/max_custom.rs index 837a83d..afc3ecb 100644 --- a/tests/max_custom.rs +++ b/tests/max_custom.rs @@ -1,5 +1,9 @@ //! Maximum-customization integration test for the `ManyErrors` rendering path. //! +//! `ManyErrors` lives behind the `alloc` feature, so the whole test is gated. +#![cfg(feature = "alloc")] +//! +//! //! Nothing here relies on a crate-provided strategy or a defaulted generic: //! //! - every `ManyErrors` / `WithContext` type parameter is spelled out;