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.lock b/Cargo.lock index 77053d3..ffd4be1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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" @@ -12,7 +29,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" name = "errortools" version = "0.1.0" dependencies = [ + "derive-where", "itertools", + "pretty_assertions", "thiserror", ] @@ -25,6 +44,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 +108,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 bd5e78d..09af4f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,15 +14,23 @@ categories = ["rust-patterns", "command-line-interface", "no-std"] include = ["src/**/*.rs", "examples/**/*.rs", "README.md", "CHANGELOG.md", "LICENSE*"] [dependencies] +derive-where = "1.6.1" itertools = { version = "0.14", default-features = false } [dev-dependencies] +pretty_assertions = "1.4.1" thiserror = "2" [features] default = ["std"] -std = ["itertools/use_std"] + +std = ["alloc", "itertools/use_std"] +alloc = [] [[example]] name = "with_context" required-features = ["std"] + +[[example]] +name = "many_errors" +required-features = ["std"] diff --git a/README.md b/README.md index 8aba8eb..a601e61 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 } ``` @@ -262,6 +262,51 @@ 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 you can render as a tree, list, or single line. + +```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 shapes nest. `tree()` gives the Unicode tree, walking each error's source chain: + +```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` (`{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.joined()); // ;-separated one line, 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: @@ -281,11 +326,12 @@ 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 | +| [`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..58e82bc --- /dev/null +++ b/examples/many_errors.rs @@ -0,0 +1,66 @@ +//! 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 = 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()); + + println!(); + println!("=== Bullets ==="); + println!("{}", all.bullets()); + + println!(); + println!("=== Joined (deep one-line) ==="); + println!("{}", all.joined()); + + 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.rs b/src/add.rs deleted file mode 100644 index 944d68c..0000000 --- a/src/add.rs +++ /dev/null @@ -1,290 +0,0 @@ -use core::{fmt, marker::PhantomData}; - -use crate::Format; - -/// Combines two [`Format`] strategies, rendering `L` then `R` against the same value. -/// -/// `Add` is a type-level combinator: both strategies are tag types, never -/// instantiated. The combined strategy implements [`Format`] when both -/// `L` and `R` do. Bounds compose automatically, so `Add` -/// requires `E: Suggest` because [`Suggestion`](crate::Suggestion) does. -/// -/// There is no built-in separator. Use [`NewLine`](separator::NewLine) or -/// [`Space`](separator::Space) (or any custom [`Format`] tag) as the middle term: -/// -/// ```text -/// Add, Suggestion> -/// ``` -/// -/// renders the one-line chain, a newline, then the top-level suggestion hint. -/// -/// `Add` writes both sides unconditionally — if `R` produces no output (e.g. -/// a [`Suggestion`](crate::Suggestion) variant without a hint), the separator -/// is still written. -#[derive(Default, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Add(PhantomData (L, R)>); - -/// Prints the inner strategy values (instantiated via [`Default`]) instead of -/// `Add(PhantomData)`. -impl fmt::Debug for Add { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("Add") - .field(&L::default()) - .field(&R::default()) - .finish() - } -} - -impl Format for Add -where - E: ?Sized, - L: Format, - R: Format, -{ - fn fmt(error: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { - L::fmt(error, f)?; - R::fmt(error, f) - } -} - -pub mod separator { - //! Separator strategies for [`Add`]. - //! - //! Each is a [`Format`] tag that ignores its input and writes a fixed - //! string. Because [`Format`] no longer requires `E: Error`, these - //! separators also compose with non-error formatters (e.g. the field - //! extractors used by [`WithContext`](crate::WithContext)). - //! - //! This may be worth extending and separating into its own crate in - //! future, but for now it just has a few simple built-in separators. - - use crate::{Add, Format}; - - use core::fmt; - - /// [`Format`] strategy that writes a single line feed and ignores the input. - /// - /// Designed as a separator term inside [`Add`], e.g. - /// `Add>`. - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub struct NewLine; - - impl Format for NewLine { - fn fmt(_: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("\n") - } - } - - /// [`Format`] strategy that writes a single space and ignores the input. - /// - /// Designed as a separator term inside [`Add`]. - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub struct Space; - - impl Format for Space { - fn fmt(_: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(" ") - } - } - - /// [`Format`] strategy that writes nothing. - /// - /// Useful as a no-op identity element when composing strategies with [`Add`]. - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub struct Empty; - - impl Format for Empty { - fn fmt(_: &E, _: &mut fmt::Formatter<'_>) -> fmt::Result { - Ok(()) - } - } - - /// [`Format`] strategy that writes a colon (`":"`) and ignores the input. - /// - /// Pair with [`Space`] via [`ColonSpace`] for the common `": "` separator. - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub struct Colon; - - impl Format for Colon { - fn fmt(_: &E, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(":") - } - } - - /// Convenience alias for `Add` — writes `": "`. - pub type ColonSpace = Add; - - /// [`Add`] with an explicit separator slot: writes `L`, then `Sep`, then `R`. - /// - /// Equivalent to `Add, R>` — a thin convenience over manual - /// nesting. Pair with the separators in this module: - /// [`WithSpace`](WithSpace) is `WithSep`, - /// [`WithNewLine`](WithNewLine) is `WithSep`, - /// [`WithColonSpace`](WithColonSpace) is `WithSep`. - pub type WithSep = Add, R>; - - /// `Add` of `L` and `R` with a [`Space`] between — equivalent to - /// [`WithSep`](WithSep). - pub type WithSpace = WithSep; - - /// `Add` of `L` and `R` with a [`NewLine`] between — equivalent to - /// [`WithSep`](WithSep). - pub type WithNewLine = WithSep; - - /// `Add` of `L` and `R` with [`ColonSpace`] between — equivalent to - /// [`WithSep`](WithSep). - pub type WithColonSpace = WithSep; -} - -#[cfg(test)] -mod tests { - use core::error::Error; - - use thiserror::Error; - - use super::*; - use crate::{Formatted, OneLine, Suggest, Suggestion, Tree, tests::ErrorInner}; - use separator::*; - - #[derive(Error, Debug)] - enum SugError { - #[error("env file missing")] - NoEnv, - #[error("something else")] - Other, - } - - impl Suggest for SugError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NoEnv => f.write_str("Did you mean rename the .env.example file to .env?"), - Self::Other => Ok(()), - } - } - } - - fn _assert_traits() { - fn assert_all< - T: Clone + Copy + Default + PartialEq + Eq + core::hash::Hash + Send + Sync, - >() { - } - assert_all::>(); - assert_all::, Suggestion>>(); - assert_all::(); - assert_all::(); - - fn assert_format>() {} - assert_format::>(); - assert_format::>(); - assert_format::, Suggestion>>(); - - // Confirm Error bound still gates the leaf strategy, just not the trait. - fn assert_oneline() - where - OneLine: Format, - { - } - assert_oneline::(); - } - - #[test] - fn test_one_line_plus_newline() { - let error = crate::tests::Error::Two(ErrorInner::One); - assert_eq!( - Formatted::<_, Add>::new(error).to_string(), - "Two: One\n" - ); - } - - #[test] - fn test_nested_oneline_newline_suggestion() { - let error = SugError::NoEnv; - assert_eq!( - Formatted::<_, Add, Suggestion>>::new(error).to_string(), - "env file missing\nDid you mean rename the .env.example file to .env?" - ); - } - - #[test] - fn test_empty_rhs_keeps_separator() { - let error = SugError::Other; - assert_eq!( - Formatted::<_, Add, Suggestion>>::new(error).to_string(), - "something else\n" - ); - } - - #[test] - fn test_right_associated_nesting() { - let error = crate::tests::Error::Two(ErrorInner::One); - assert_eq!( - Formatted::<_, Add>>::new(error).to_string(), - "Two: One\nTwo: One" - ); - } - - #[test] - fn test_space_between_repeats() { - let error = crate::tests::Error::One; - assert_eq!( - Formatted::<_, Add>>::new(error).to_string(), - "One One" - ); - } - - #[test] - fn test_debug_prints_inner() { - let add = Add::::default(); - assert_eq!(format!("{add:?}"), "Add(OneLine, NewLine)"); - } - - #[test] - fn test_colon_space_alias() { - // ColonSpace ignores the error and writes ": ". - let error = crate::tests::Error::One; - assert_eq!( - Formatted::<_, Add>>::new(error).to_string(), - "One: One" - ); - } - - #[test] - fn test_with_space_alias() { - use separator::WithSpace; - let error = crate::tests::Error::One; - assert_eq!( - Formatted::<_, WithSpace>::new(error).to_string(), - "One One" - ); - } - - #[test] - fn test_with_newline_alias() { - use separator::WithNewLine; - let error = crate::tests::Error::Two(ErrorInner::One); - assert_eq!( - Formatted::<_, WithNewLine>::new(error).to_string(), - "Two: One\nTwo: One" - ); - } - - #[test] - fn test_with_colon_space_alias() { - use separator::WithColonSpace; - let error = crate::tests::Error::One; - assert_eq!( - Formatted::<_, WithColonSpace>::new(error).to_string(), - "One: One" - ); - } - - #[test] - fn test_add_sep_generic_alias() { - use separator::Colon; - let error = crate::tests::Error::One; - assert_eq!( - Formatted::<_, WithSep>::new(error).to_string(), - "One:One" - ); - } -} diff --git a/src/add/mod.rs b/src/add/mod.rs new file mode 100644 index 0000000..0b3982e --- /dev/null +++ b/src/add/mod.rs @@ -0,0 +1,127 @@ +use core::{fmt, marker::PhantomData}; + +use derive_where::derive_where; + +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_where(Clone, Copy, PartialEq, Eq, Hash, Default)] +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::{Chain, Formatted, OneLine, Suggestion, 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/chain.rs b/src/chain.rs new file mode 100644 index 0000000..2b23597 --- /dev/null +++ b/src/chain.rs @@ -0,0 +1,158 @@ +//! 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 derive_where::derive_where; +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_where(Clone, Copy, PartialEq, Eq, Hash, Default)] +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 e8fa269..fcc56d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,24 +6,35 @@ #![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}; +use derive_where::derive_where; + mod add; +mod chain; +mod connectors; mod main_result; +#[cfg(feature = "alloc")] +pub mod many_errors; 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::{Bullets, Joined, List, ManyErrors, Node, Subgroup, Tree}; pub use oneline::OneLine; #[cfg(feature = "std")] pub use path_display::DisplayPath; pub use suggestion::{Suggest, Suggestion}; -pub use tree::{Tree, TreeIndent, TreeMarker}; pub use with_context::WithContext; /// A static strategy for formatting a value to a [`fmt::Formatter`]. @@ -31,13 +42,14 @@ 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. /// /// `E` is the value being formatted; each strategy declares its own bounds: -/// [`OneLine`] and [`Tree`] 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 @@ -46,11 +58,39 @@ 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`], [`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; } +/// Sentinel [`Format`] strategy that delegates to the value's own [`fmt::Display`] +/// impl. +/// +/// 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; + +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 @@ -66,9 +106,12 @@ pub trait FormatError { 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 @@ -93,7 +136,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`. -#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] +#[derive_where(Clone, Copy, Default, PartialEq, Eq, Hash; E)] pub struct Formatted(E, PhantomData F>); impl Formatted { @@ -104,120 +147,27 @@ 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) } } -/// 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() } } #[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..0893bd6 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,15 @@ 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"); - // Display of DisplaySwapDebug = Debug of inner = forwarded to error's Debug. - assert_eq!(wrapped.to_string(), "Two(One)"); + 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: OneLine }" + ); } #[test] @@ -153,33 +140,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 +176,22 @@ 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 = Debug of inner Formatted = error + strategy. + assert_eq!( + wrapped.to_string(), + "Formatted { error: One, format: Add(Add(OneLine, NewLine), Suggestion) }" ); - // Display of DisplaySwapDebug forwards to inner Debug = forwarded to the - // wrapped error's Debug (i.e. the original `SugError` Debug derive). - assert_eq!(wrapped.to_string(), "NoEnv"); } #[test] fn test_main_result_with_suggestion_exit_code() { use std::process::ExitCode; - fn main_with_error(err: bool) -> MainResultWithSuggestion { + fn main_with_error(err: bool) -> MainResultWithSuggestion { if err { - Err(SugError::NoEnv)?; + Err(Error::One)?; } Ok(ExitCode::SUCCESS) } @@ -211,7 +200,7 @@ mod tests { let wrapped = main_with_error(true).unwrap_err(); assert_eq!( wrapped.0.to_string(), - "env file missing\nDid you mean rename the .env.example file to .env?" + "One\nTry passing --help to see available options." ); } diff --git a/src/many_errors/iter.rs b/src/many_errors/iter.rs new file mode 100644 index 0000000..1f8c8e8 --- /dev/null +++ b/src/many_errors/iter.rs @@ -0,0 +1,442 @@ +// --- Iter --- + +use core::ops::ControlFlow; + +use crate::{ + AsDisplay, ManyErrors, + with_context::{Colon, 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) + } +} + +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 = AsDisplay>(IterInner<'a, C, E, GC, F, GF>); + +enum IterInner<'a, C, E, GC, F, GF> { + Empty, + One(Option<&'a Node>), + Many(core::slice::Iter<'a, Node>), +} + +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(n) => IterInner::One(Some(n)), + ManyErrors::Many(v) => IterInner::Many(v.iter()), + }) + } +} + +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 { + 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(), + } + } +} + +// --- IterMut --- + +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<'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 = AsDisplay>(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(), + } + } + + 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(), + } + } +} + +// --- 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(), + } + } + + 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 + } +} + +impl FromIterator<(C, E)> for ManyErrors { + fn from_iter>(iter: I) -> Self { + let mut me = Self::None; + me.extend(iter); + me + } +} + +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_node(Node::Leaf(item)); + } + } +} + +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, 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_node(Node::Leaf(w)); + if stop { + break; + } + } + } +} + +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, Node, WithContext, tests::Inner}; + use itertools::Itertools as _; + use std::{io, ops::ControlFlow}; + + #[test] + fn test_collect_from_with_context() { + 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); + } + + #[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::<&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: 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() { + 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); + } + + #[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() { + let items: alloc::vec::Vec = alloc::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::<&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].as_leaf().unwrap().context, "a"); + } + + #[test] + fn test_iter_many() { + 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"]); + } + + #[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); + } + + #[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/mod.rs b/src/many_errors/mod.rs new file mode 100644 index 0000000..9659ef2 --- /dev/null +++ b/src/many_errors/mod.rs @@ -0,0 +1,416 @@ +//! Aggregated, context-tagged errors from iterator/fold-style operations. + +use core::{ + error::Error, + fmt::{self, Debug, Display, Formatter}, +}; + +use derive_where::derive_where; + +use alloc::{vec, vec::Vec}; + +use crate::{ + AsDisplay, Format, + with_context::{Colon, WithContext}, +}; + +mod iter; +mod node; +mod strategy; + +pub use crate::connectors::{Ascii, Connectors, TreeConnectors, Unicode}; +pub use node::{Node, Subgroup}; +pub use strategy::{Bullets, Joined, List, Tree}; + +/// Zero or more context-tagged errors, arranged as a rose tree. +/// +/// 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 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`). +/// +/// # Customizing group rendering +/// Two independent levers: +/// - **Label decoration** — the group-label strategy `GF` is a label-only +/// [`Format`](crate::Format) (default [`AsDisplay`]). +/// Set it to wrap or restyle just the label; it composes with every built-in +/// shape, including [`tree`](ManyErrors::tree) (the label is re-indented under +/// the tree prefix). `GF` never sees the nested errors — laying those out is +/// the aggregate strategy's job, so a `GF` that rendered them would +/// double-render and break the layout. +/// - **Whole layout** — for full control over label, separators, and nesting, +/// implement [`Format>`](crate::Format) for your own marker +/// (exactly like [`Tree`]/[`List`]) and render via +/// [`formatted`](crate::FormatError::formatted). +/// +/// 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`](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; +/// use std::io; +/// +/// 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_where(Clone, PartialEq, Eq, Hash; C, E, GC)] +#[derive_where(Default)] +pub enum ManyErrors { + /// No errors recorded. + #[derive_where(default)] + None, + /// Exactly one child. + One(Node), + /// Two or more children. + Many(Vec>), +} + +// `Debug` stays manual: `Many` renders as a bare list (not a `Many(..)` tuple) +// and `None` prints as the bare string — custom output, not a std-shaped derive. +impl 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(), + } + } +} + +// --- Core API --- + +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 direct children (leaves + sub-groups). + pub const fn len(&self) -> usize { + match self { + Self::None => 0, + Self::One(_) => 1, + Self::Many(v) => v.len(), + } + } + + /// Appends a leaf error with context, promoting `None → One → Many`. + /// + /// # Example + /// ``` + /// use errortools::ManyErrors; + /// + /// let mut errs = ManyErrors::<&str, std::io::Error>::new(); + /// errs.push("step 1", std::io::Error::other("fail")); + /// assert_eq!(errs.len(), 1); + /// ``` + 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(Subgroup::new(context, errors))); + } + + pub(crate) fn push_node(&mut self, node: Node) { + let prev = core::mem::take(self); + *self = match prev { + Self::None => Self::One(node), + Self::One(first) => Self::Many(vec![first, node]), + Self::Many(mut v) => { + v.push(node); + 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), + } + } +} + +// --- 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 joined(&self) -> crate::Formatted<&Self, Joined> { + crate::Formatted::new(self) + } +} + +/// 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 + Debug, + E: Error + 'static, + F: Format>, + GF: Format, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + >::fmt(self, f) + } +} + +impl Error for ManyErrors +where + C: Display + Debug, + GC: Debug, + E: Error + 'static, + F: Format>, + GF: Format, +{ + /// 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)> { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::{Inner, Mid}; + + // --- push / push_group / variants --- + + #[test] + fn test_new_is_none() { + let e = ManyErrors::<&str, Inner>::new(); + assert!(matches!(e, ManyErrors::None)); + assert!(e.is_empty()); + assert_eq!(e.len(), 0); + } + + #[test] + fn test_push_none_to_one() { + let mut e = ManyErrors::<&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::<&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::::new(); + for i in 0..5u32 { + 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] + fn test_into_result_none_ok() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.into_result(42), Ok(42)); + } + + #[test] + fn test_into_result_one_err() { + let mut e = ManyErrors::<&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::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::A); + assert!(e.into_result(()).is_err()); + } + + // --- Error::source --- + + #[test] + fn test_source_none() { + let e = ManyErrors::<&str, Inner>::new(); + assert!(e.source().is_none()); + } + + #[test] + 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)); + assert!(e.source().is_none()); + } + + #[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::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::A); + assert!(e.source().is_none()); + } + + // --- Display (Summary: shallow, single-line, no source chains) --- + + #[test] + fn test_display_empty() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.to_string(), "no errors"); + } + + #[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() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::B); + 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] + 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("leaf", Inner::A); + outer.push_group("region", inner); + + 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.joined().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..258735d --- /dev/null +++ b/src/many_errors/node.rs @@ -0,0 +1,188 @@ +//! A single child of a [`ManyErrors`]: a leaf error-with-context, or a named sub-group. + +use core::{ + fmt::{self, Display, Formatter}, + marker::PhantomData, +}; + +use alloc::boxed::Box; + +use derive_where::derive_where; + +use crate::with_context::{Colon, WithContext}; +use crate::{AsDisplay, Format}; + +use super::ManyErrors; + +/// The payload of a [`Node::Group`]: a label `GroupContext` paired with the boxed nested +/// [`ManyErrors`]. +/// +/// [`Display`] renders the **label only**, through the label strategy `GroupFormat` +/// (default [`AsDisplay`]: the label's own `Display`). The nested `errors` are +/// *not* rendered here — the active aggregate strategy ([`Tree`](crate::Tree) / +/// [`List`](crate::List) / …) owns their structural layout. That is why `GroupFormat` is +/// bound [`Format`](Format) and never sees `errors`: a label formatter that +/// also rendered the children would double-render (and shatter) tree/list output. +#[derive_where(Clone, PartialEq, Eq, Hash, Debug; C, E, GroupContext)] +pub struct Subgroup { + /// The group label. + pub context: GroupContext, + /// The boxed nested errors (boxed to break the recursion with [`ManyErrors`]). + pub errors: Box>, + + /// Grounds the `GroupFormat` label strategy: it otherwise appears only inside the + /// recursive `errors`, leaving its variance undeterminable. Mirrors + /// [`WithContext`]'s `PhantomData F>`. + #[derive_where(skip(Debug))] + _label: PhantomData GroupFormat>, +} + +impl Subgroup { + /// Creates a sub-group pairing `context` (the label) with nested `errors`. + pub fn new( + context: GroupContext, + errors: ManyErrors, + ) -> Self { + Self { + context, + errors: Box::new(errors), + _label: PhantomData, + } + } +} + +/// Renders the **label only**, via the label strategy `GroupFormat`. The nested errors +/// are laid out by the active aggregate strategy, not here. +impl Display for Subgroup +where + GroupFormat: Format, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + GroupFormat::fmt(&self.context, f) + } +} + +/// A child of a [`ManyErrors`]: either a leaf error paired with context, or a +/// named sub-group of further errors. +/// +/// Each variant 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 [`Subgroup`] — a label `GroupContext` paired with the boxed +/// nested [`ManyErrors`]. The label is formatted by `GroupFormat` (default +/// [`AsDisplay`](crate::AsDisplay): the label's own `Display`); the nested +/// errors' layout is owned by the aggregate strategy, so `GroupFormat` is a label-only +/// [`Format`](Format) and never touches them. +/// +/// The standard-trait impls bound only `C`/`E`/`GroupContext` — never the `F`/`GroupFormat` marker +/// params (mirroring [`WithContext`]'s `PhantomData F>`). +#[derive_where(Clone, PartialEq, Eq, Hash, Debug; C, E, GroupContext)] +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), +} + +// --- 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, AsDisplay>; + + #[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(Subgroup::new("region", 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(Subgroup::new("grp", 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(Subgroup::new("grp", 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..54a968c --- /dev/null +++ b/src/many_errors/strategy/bullets.rs @@ -0,0 +1,171 @@ +//! [`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, Debug, Display}, + iter, +}; + +use itertools::Itertools; + +use crate::{ + Format, OneLine, + many_errors::{ManyErrors, Node}, + with_context::WithContext, +}; + +use super::impl_aggregate_format; + +/// 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 `"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 +/// `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 + Debug, + E: Error + 'static, + F: Format>, + GF: Format, +{ + match errors { + ManyErrors::None => write!(f, "no errors"), + 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` → 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 +/// 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 + Debug, + E: Error + 'static, + F: Format>, + GF: Format, +{ + if with_bullet { + let indent = iter::repeat_n(" ", depth).format(""); + write!(f, "\n{indent}• ")?; + } + match node { + Node::Leaf(w) => >::fmt(w, f), + Node::Group(w) => match w.errors.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::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() { + assert_eq!( + 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/list.rs b/src/many_errors/strategy/list.rs new file mode 100644 index 0000000..d8ea9c3 --- /dev/null +++ b/src/many_errors/strategy/list.rs @@ -0,0 +1,176 @@ +//! [`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, Debug, Display}, + iter, +}; + +use itertools::Itertools; + +use crate::{ + Format, OneLine, + many_errors::{ManyErrors, Node}, + with_context::WithContext, +}; + +use super::impl_aggregate_format; + +/// 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 `"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 +/// 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 + Debug, + E: Error + 'static, + F: Format>, + GF: Format, +{ + match errors { + ManyErrors::None => write!(f, "no errors"), + 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` 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"`; +/// - 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 + Debug, + E: Error + 'static, + F: Format>, + GF: Format, +{ + match node { + Node::Leaf(w) => >::fmt(w, f), + Node::Group(w) => match w.errors.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::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() { + 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 new file mode 100644 index 0000000..973023e --- /dev/null +++ b/src/many_errors/strategy/mod.rs @@ -0,0 +1,86 @@ +//! 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 [`AsDisplay`](crate::AsDisplay): the label's own +//! `Display`). `GF` is a label-only [`Format`](crate::Format); the structural +//! ` (N errors):` / `: ` and the children are added by the aggregate strategy +//! itself, which owns all nested layout. + +mod bullets; +mod list; +mod one_line; +mod tree; + +pub use bullets::Bullets; +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_*` call. +macro_rules! impl_aggregate_format { + ($strategy:ident, |$errors:ident, $f:ident| $call:expr) => { + impl $crate::Format<$crate::ManyErrors> for $strategy + where + // The debug bound for display is needed to satisfy the `Error` impl that is required for the top-level source-waling formatter + C: ::core::fmt::Display + ::core::fmt::Debug, + E: ::core::error::Error + ::core::fmt::Display + 'static, + F: $crate::Format<$crate::with_context::WithContext>, + GF: $crate::Format, + { + 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 + ::core::fmt::Debug, + E: ::core::error::Error + ::core::fmt::Display + 'static, + F: $crate::Format<$crate::with_context::WithContext>, + GF: $crate::Format, + { + fn fmt( + errors: &&$crate::ManyErrors, + f: &mut ::core::fmt::Formatter<'_>, + ) -> ::core::fmt::Result { + >>::fmt(errors, f) + } + } + }; +} + +pub(crate) use impl_aggregate_format; + +#[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/one_line.rs b/src/many_errors/strategy/one_line.rs new file mode 100644 index 0000000..f11a2ec --- /dev/null +++ b/src/many_errors/strategy/one_line.rs @@ -0,0 +1,291 @@ +//! 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}, + 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, |errors, f| draw_joined::( + 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.errors.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 new file mode 100644 index 0000000..67a176a --- /dev/null +++ b/src/many_errors/strategy/tree.rs @@ -0,0 +1,379 @@ +//! [`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 derive_where::derive_where; + +use alloc::vec::Vec; + +use itertools::Itertools; + +use crate::{ + Format, + connectors::{TreeConnectors, Unicode}, + many_errors::{ManyErrors, Node}, + 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_where(Clone, Copy, PartialEq, Eq, Hash, Default)] +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 + '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 + '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 + 'static, + F: Format>, + GF: Format, + Conn: TreeConnectors, +{ + match errors { + ManyErrors::None => write!(f, "no errors"), + 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 + '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 + '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.errors.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(), "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] + 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. `GF` is a label-only + /// [`Format`] — it receives the bare label and cannot reach the nested errors. + #[test] + fn test_tree_custom_group_format() { + // Brackets the group label. + struct Bracket; + impl Format for Bracket { + fn fmt(label: &GC, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{label}]") + } + } + + 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 97a3f99..8670cc8 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] @@ -43,38 +40,34 @@ 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: OneLine }" + ); 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()), + "Formatted { error: Two(A), format: OneLine }" + ); } #[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..c26013c 100644 --- a/src/suggestion.rs +++ b/src/suggestion.rs @@ -48,64 +48,175 @@ 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_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 }" + ); + } - 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!( - error.suggestion().to_string(), - "Did you mean rename the .env.example file to .env?" + e.suggestion().to_string(), + "Try passing --help to see available options.", + ); + // They can be composed via Add. + assert_eq!( + 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 debug_forwards_to_inner() { - let error = SugError::NoEnv; - assert_eq!(format!("{:?}", error.suggestion()), "NoEnv"); + 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 one_line_still_works_on_suggestion_types() { - let error = SugError::NoEnv; - assert_eq!(error.one_line().to_string(), "env file missing"); + 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 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..50e5341 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,318 @@ +//! 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::(); + + // 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::>(); + #[cfg(feature = "alloc")] + assert_all::>(); + + // `WithContext` has no `Default`, but its other auto-traits must still be + // `F`-free. + fn assert_no_default() {} + assert_no_default::>(); +} + +#[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("a", Inner::A); + 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, now the shallow single-line Summary. + assert_eq!( + e.one_line().to_string(), + "Many: 2 errors: a: InnerA; b: 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 chain walk the chain. "Transparent" must not appear. + let e = Error::Transparent(Mid::Inner(Inner::A)); + let one = e.one_line().to_string(); + let chain_out = e.chain().to_string(); + assert!( + !one.contains("Transparent"), + "one_line should not contain 'Transparent': {one}" + ); + assert!( + !chain_out.contains("Transparent"), + "chain should not contain 'Transparent': {chain_out}" + ); + assert_eq!(one, "mid: InnerA"); + assert_eq!(chain_out, "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 deleted file mode 100644 index 3bd7b8b..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, ErrorInner}, - }; - - #[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(ErrorInner::One); - assert_eq!(error.tree().to_string(), "Two\n└── One"); - } - - #[test] - fn test_tree_nested() { - let error = Error::Two(ErrorInner::Two); - assert_eq!(error.tree().to_string(), "Two\n└── Two"); - } - - #[test] - fn test_tree_custom_marker_and_indent() { - #[derive(Default)] - struct Arrow; - impl fmt::Display for Arrow { - 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(ErrorInner::One); - assert_eq!( - Formatted::<_, Tree>::new(error).to_string(), - "Two\n|-> One" - ); - } - - #[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(ErrorInner::One); - assert_eq!( - Formatted::<_, AsciiTree>::new(error).to_string(), - "Two\n|-- One" - ); - } -} diff --git a/src/with_context.rs b/src/with_context.rs deleted file mode 100644 index 816655e..0000000 --- a/src/with_context.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! Context-tagged error pair. - -use core::{ - error::Error, - fmt::{self, Debug, Display, Formatter}, - marker::PhantomData, -}; - -use crate::Format; - -pub use crate::with_context::format::{Colon, ContextField, ErrorField, WithContextColon}; -#[cfg(feature = "std")] -pub use crate::with_context::format::{ContextPath, PathColon, WithContextPathColon}; - -/// Convenience alias for [`WithContext`] with the default [`PathColon`] strategy. -#[cfg(feature = "std")] -pub type WithPath = WithContext; - -/// A context value paired with an error, rendered through a static -/// [`Format`] strategy. -/// -/// `Display` delegates to `F::fmt(self, f)`, so any `F: Format>` -/// can format the pair. Strategies are usually built by composing the field -/// extractors [`ContextField`] / [`ErrorField`] (or [`ContextPath`] when -/// `C: AsRef`) with separator strategies via -/// [`Add`](crate::Add) / [`WithSep`](crate::separator::WithSep), e.g. the default -/// [`Colon`] is [`WithColonSpace`](crate::separator::WithColonSpace). -/// -/// [`Error::source`] returns the inner error's source (skipping `error` itself, -/// since the strategy already prints it), so chain-walking strategies don't -/// duplicate it. -/// -/// # Example -/// ``` -/// use errortools::{FormatError, with_context::WithContextColon}; -/// use std::io; -/// -/// let err = io::Error::new(io::ErrorKind::NotFound, "file missing"); -/// let ctx = WithContextColon::new("path/to/config", err); -/// assert_eq!(ctx.one_line().to_string(), "path/to/config: file missing"); -/// ``` -/// # Custom formatting -/// There are 2 ways to customize the formatting strategy: -/// -/// ## Custom strategy via composition of field extractors and separators -/// ``` -/// use errortools::{WithContext, separator::WithSpace, with_context::{ContextField, ErrorField}}; -/// -/// // Same as `Colon` but uses a single space instead of ": ". -/// type SpacePair = WithSpace; -/// let w = WithContext::<_, _, SpacePair>::new("step", "boom"); -/// assert_eq!(w.to_string(), "step boom"); -/// ``` -/// -/// ## Custom strategy via an impl of `Format> for YourStrategy` -/// ``` -/// use core::fmt::{self, Display, Formatter}; -/// use errortools::{Format, WithContext}; -/// -/// struct Arrow; -/// impl Format> for Arrow { -/// fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { -/// write!(f, "{} -> {}", w.context, w.error) -/// } -/// } -/// -/// let w = WithContext::<_, _, Arrow>::new(1, "boom"); -/// assert_eq!(w.to_string(), "1 -> boom"); -/// ``` -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub struct WithContext { - /// The context value tagging this error (e.g. a file path or step number). - pub context: C, - /// The underlying error. - pub error: E, - - _format: PhantomData F>, -} - -impl WithContext { - /// Creates a new [`WithContext`] pairing `context` with `error`. - /// - /// Use [`WithContextColon`] for the default `Colon` strategy and type inference on `new` without a turbofish. - pub const fn new(context: C, error: E) -> Self { - Self { - context, - error, - _format: PhantomData, - } - } - - /// Switches the formatting strategy without touching the stored values. - pub fn with_format(self) -> WithContext - where - G: Format>, - { - WithContext { - context: self.context, - error: self.error, - _format: PhantomData, - } - } -} - -impl From<(C, E)> for WithContext { - fn from((context, error): (C, E)) -> Self { - Self::new(context, error) - } -} - -/// Renders the pair via the strategy `F`. `C` and `E` have no `Display` bound -/// here — the strategy decides what each must implement. -impl Display for WithContext -where - F: Format, -{ - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - F::fmt(self, f) - } -} - -/// Forwards to the fields' `Debug` rather than printing the `PhantomData` tag. -impl Debug for WithContext { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - f.debug_struct("WithContext") - .field("context", &self.context) - .field("error", &self.error) - .finish() - } -} - -impl Error for WithContext -where - C: Debug, - E: Error + 'static, - F: Format, -{ - /// Returns the inner error's source, skipping the inner error itself - /// (already shown via [`Display`]) so chain-walking strategies don't - /// duplicate it. - fn source(&self) -> Option<&(dyn Error + 'static)> { - self.error.source() - } -} - -mod format { - //! Field extractors and pre-composed strategies for [`WithContext`]. - - use core::fmt::{self, Display, Formatter}; - #[cfg(feature = "std")] - use std::path::Path; - - #[allow(unused_imports)] // referenced from doc links - use crate::add::separator::{ColonSpace, WithSep}; - use crate::{Format, add::separator::WithColonSpace}; - - use super::WithContext; - - /// Convenience alias for [`WithContext`] with the default [`Colon`] strategy. - /// - /// Use this when you don't need a custom format and want type inference on - /// [`WithContext::new`] to work with a default strategy without a turbofish. - pub type WithContextColon = WithContext; - - /// Convenience alias for [`WithContext`] with the [`PathColon`] strategy. - /// Use this when your context is a path and you want it rendered via `Path::display` - /// without needing to wrap it in [`DisplayPath`](crate::DisplayPath) or another newtype. - #[cfg(feature = "std")] - pub type WithContextPathColon = WithContext; - - /// [`Format`] extractor that prints the `context` field via `Display`. - /// - /// Compose with [`ErrorField`] and a separator to build pair strategies: - /// [`WithSep`](WithSep) is exactly [`Colon`]. - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub struct ContextField; - - impl Format> for ContextField { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { - Display::fmt(&w.context, f) - } - } - - /// [`Format`] extractor that prints the `error` field via `Display`. - /// - /// Counterpart to [`ContextField`]. See [`Colon`] for the canonical use. - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub struct ErrorField; - - impl Format> for ErrorField { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { - Display::fmt(&w.error, f) - } - } - - /// [`Format`] extractor that prints the `context` field via [`Path::display`]. - /// - /// `Path` and `PathBuf` don't implement [`Display`] (paths may not be valid - /// UTF-8), so [`ContextField`] won't accept them. `ContextPath` plugs that - /// gap without needing a wrapper newtype around the context value. - #[cfg(feature = "std")] - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub struct ContextPath; - - #[cfg(feature = "std")] - impl, E, F> Format> for ContextPath { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { - w.context.as_ref().display().fmt(f) - } - } - - /// Default pair strategy: writes `"{context}: {error}"` for any pair of - /// `Display` values. - /// - /// Equivalent to [`WithColonSpace`](WithColonSpace). - pub type Colon = WithColonSpace; - - /// Path-aware pair strategy: writes `"{path}: {error}"` where `path` is - /// rendered via [`Path::display`]. - /// - /// Equivalent to [`WithColonSpace`](WithColonSpace). - #[cfg(feature = "std")] - pub type PathColon = WithColonSpace; -} - -#[cfg(test)] -mod tests { - use std::{error::Error as _, io}; - - use thiserror::Error; - - use super::*; - use crate::FormatError; - - #[derive(Error, Debug)] - #[error("leaf error")] - struct Leaf; - - #[derive(Error, Debug)] - #[error("middle")] - struct Middle(#[source] Leaf); - - /// Custom one-shot strategy: `[ctx] err`. - struct Bracketed; - impl Format> for Bracketed { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "[{}] {}", w.context, w.error) - } - } - - /// Caller-facing error in this test module. The `#[from]` impl is what - /// drives `F = Bracketed` inference at the `?` site in [`returning_error`]. - #[derive(Error, Debug)] - #[error("an error happened")] - pub struct Error(#[from] WithContext<&'static str, Middle, Bracketed>); - - fn returning_middle() -> Result<(), Middle> { - Err(Middle(Leaf)) - } - - /// Realistic use: a function tags an inner error with context via - /// `map_err`, then `?` routes it through `#[from]` into the caller's - /// error type. - /// Most importantly, `F` is inferred from the `From` impl on `Error`. - fn returning_error() -> Result<(), Error> { - returning_middle().map_err(|e| WithContext::new("context", e))?; - Ok(()) - } - - #[test] - fn test_new_and_fields() { - let w = WithContextColon::new("ctx", Leaf); - assert_eq!(w.context, "ctx"); - } - - #[test] - fn test_from_tuple() { - let w: WithContextColon<&str, Leaf> = ("ctx", Leaf).into(); - assert_eq!(w.context, "ctx"); - } - - #[test] - fn test_display_default_format() { - let w = WithContextColon::new("step 3", Leaf); - assert_eq!(w.to_string(), "step 3: leaf error"); - } - - #[test] - fn test_source_skips_inner_error() { - // Leaf has no source, so skipping it yields None. - let w = WithContextColon::new("ctx", Leaf); - assert!(w.source().is_none()); - - // For Middle(Leaf), source must be Leaf — not Middle (which Display already shows). - let w = WithContextColon::new("ctx", Middle(Leaf)); - let src = w.source().expect("source must be Some"); - assert_eq!(src.to_string(), "leaf error"); - } - - #[test] - fn test_one_line_walks_full_chain() { - let w = WithContextColon::new("ctx", Middle(Leaf)); - assert_eq!(w.one_line().to_string(), "ctx: middle: leaf error"); - } - - #[test] - fn test_io_error_chain() { - let io = io::Error::new(io::ErrorKind::NotFound, "file missing"); - let w = WithContextColon::new("config", io); - assert_eq!(w.one_line().to_string(), "config: file missing"); - } - - #[test] - fn test_custom_format_strategy() { - struct Arrow; - impl Format> for Arrow { - fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{} -> {}", w.context, w.error) - } - } - - let w = WithContext::<_, _, Arrow>::new("step", Leaf); - assert_eq!(w.to_string(), "step -> leaf error"); - } - - #[test] - fn test_custom_format_affects_one_line() { - let w = WithContext::<_, _, Bracketed>::new("ctx", Middle(Leaf)); - // Display: "[ctx] middle" — then chain appends ": leaf error" via source. - assert_eq!(w.one_line().to_string(), "[ctx] middle: leaf error"); - } - - /// End-to-end: `map_err` wraps an inner error with [`WithContext`], `?` - /// fires `From> for Error` (and pins `F`), - /// and the full chain comes out via [`FormatError::one_line`] without any - /// duplication thanks to `source` skipping the inner error. - #[test] - fn test_propagation_via_question_mark() { - let err = returning_error().expect_err("returning_error must error"); - assert_eq!(err.to_string(), "an error happened"); - assert_eq!( - err.one_line().to_string(), - "an error happened: [context] middle: leaf error", - ); - } - - /// `PathColon` formats path contexts directly, without a wrapper newtype. - #[cfg(feature = "std")] - #[test] - fn test_path_colon_strategy() { - use std::path::{Path, PathBuf}; - - let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing"); - let w = WithContext::<_, _, PathColon>::new(PathBuf::from("a/b/c.txt"), io_err); - assert_eq!(w.to_string(), "a/b/c.txt: file missing"); - - // Works for borrowed paths too. - let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing"); - let path: &Path = Path::new("a/b/c.txt"); - let w = WithContext::<_, _, PathColon>::new(path, io_err); - assert_eq!(w.to_string(), "a/b/c.txt: file missing"); - } - - /// Compose a custom delimiter without writing a new Format impl. - #[test] - fn test_composed_separator() { - use crate::separator::WithSpace; - type SpacePair = WithSpace; - - let w = WithContext::<_, _, SpacePair>::new("ctx", Leaf); - assert_eq!(w.to_string(), "ctx leaf error"); - } -} diff --git a/src/with_context/format.rs b/src/with_context/format.rs new file mode 100644 index 0000000..7f09452 --- /dev/null +++ b/src/with_context/format.rs @@ -0,0 +1,115 @@ +//! 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}; + #[cfg(feature = "std")] + use std::io; + + /// `PathColon` formats path contexts directly, without a wrapper newtype. + #[cfg(feature = "std")] + #[test] + fn test_path_colon_strategy() { + use std::path::{Path, PathBuf}; + + let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing"); + let w = WithContext::<_, _, PathColon>::new(PathBuf::from("a/b/c.txt"), io_err); + assert_eq!(w.to_string(), "a/b/c.txt: file missing"); + + // Works for borrowed paths too. + let io_err = io::Error::new(io::ErrorKind::NotFound, "file missing"); + let path: &Path = Path::new("a/b/c.txt"); + let w = WithContext::<_, _, PathColon>::new(path, io_err); + assert_eq!(w.to_string(), "a/b/c.txt: file missing"); + } + + /// Compose a custom delimiter without writing a new Format impl. + #[test] + fn test_composed_separator() { + type SpacePair = WithSpace; + + let w = WithContext::<_, _, SpacePair>::new("ctx", Inner::A); + assert_eq!(w.to_string(), "ctx InnerA"); + } +} diff --git a/src/with_context/mod.rs b/src/with_context/mod.rs new file mode 100644 index 0000000..29bda6a --- /dev/null +++ b/src/with_context/mod.rs @@ -0,0 +1,246 @@ +//! Context-tagged error pair. + +use core::{ + error::Error, + fmt::{self, Debug, Display, Formatter}, + marker::PhantomData, +}; + +use derive_where::derive_where; + +use crate::Format; + +mod format; + +pub use crate::with_context::format::{Colon, ContextField, ErrorField, WithContextColon}; +#[cfg(feature = "std")] +pub use crate::with_context::format::{ContextPath, PathColon, WithContextPathColon}; + +/// Convenience alias for [`WithContext`] with the default [`PathColon`] strategy. +#[cfg(feature = "std")] +pub type WithPath = WithContext; + +/// A context value paired with an error, rendered through a static +/// [`Format`] strategy. +/// +/// `Display` delegates to `WithContextFormat::fmt(self, f)`, so any +/// `WithContextFormat: Format>` +/// can format the pair. Strategies are usually built by composing the field +/// extractors [`ContextField`] / [`ErrorField`] (or `ContextPath` when +/// `C: AsRef`, requires `std`) with separator strategies via +/// [`Add`](crate::Add) / [`WithSep`](crate::separator::WithSep), e.g. the default +/// [`Colon`] is [`WithColonSpace`](crate::separator::WithColonSpace). +/// +/// [`Error::source`] returns the inner error's source (skipping `error` itself, +/// since the strategy already prints it), so chain-walking strategies don't +/// duplicate it. +/// +/// The standard-trait impls (`Clone`, `Copy`, `PartialEq`, `Eq`, `Hash`) bound +/// only `C`/`E`, so they do **not** impose `WithContextFormat: Trait` bounds. +/// +/// # Example +/// ``` +/// use errortools::{FormatError, with_context::WithContextColon}; +/// use std::io; +/// +/// let err = io::Error::new(io::ErrorKind::NotFound, "file missing"); +/// let ctx = WithContextColon::new("path/to/config", err); +/// assert_eq!(ctx.one_line().to_string(), "path/to/config: file missing"); +/// ``` +/// # Custom formatting +/// There are 2 ways to customize the formatting strategy: +/// +/// ## Custom strategy via composition of field extractors and separators +/// ``` +/// use errortools::{WithContext, separator::WithSpace, with_context::{ContextField, ErrorField}}; +/// +/// // Same as `Colon` but uses a single space instead of ": ". +/// type SpacePair = WithSpace; +/// let w = WithContext::<_, _, SpacePair>::new("step", "boom"); +/// assert_eq!(w.to_string(), "step boom"); +/// ``` +/// +/// ## Custom strategy via an impl of `Format> for YourStrategy` +/// ``` +/// use core::fmt::{self, Display, Formatter}; +/// use errortools::{Format, WithContext}; +/// +/// struct Arrow; +/// impl Format> for Arrow { +/// fn fmt(w: &WithContext, f: &mut Formatter<'_>) -> fmt::Result { +/// write!(f, "{} -> {}", w.context, w.error) +/// } +/// } +/// +/// let w = WithContext::<_, _, Arrow>::new(1, "boom"); +/// assert_eq!(w.to_string(), "1 -> boom"); +/// ``` +#[derive_where(Clone, Copy, PartialEq, Eq, Hash, Debug; C, E)] +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, + + #[derive_where(skip(Debug))] + _format: PhantomData WithContextFormat>, +} + +impl WithContext { + /// Creates a new [`WithContext`] pairing `context` with `error`. + /// + /// Use [`WithContextColon`] for the default `Colon` strategy and type inference on `new` without a turbofish. + pub const fn new(context: C, error: E) -> Self { + Self { + context, + error, + _format: PhantomData, + } + } + + /// Switches the formatting strategy without touching the stored values. + pub fn with_format(self) -> WithContext + where + NewSelfFormat: Format>, + { + WithContext { + context: self.context, + error: self.error, + _format: PhantomData, + } + } +} + +impl From<(C, E)> for WithContext { + fn from((context, error): (C, E)) -> Self { + Self::new(context, error) + } +} + +/// Renders the pair via the strategy `WithContextFormat`. `C` and `E` have +/// no `Display` bound here — the strategy decides what each must implement. +impl Display for WithContext +where + WithContextFormat: Format, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + WithContextFormat::fmt(self, f) + } +} + +impl Error for WithContext +where + C: Debug, + E: Error + 'static, + WithContextFormat: Format, +{ + /// Returns the inner error's source, skipping the inner error itself + /// (already shown via [`Display`]) so chain-walking strategies don't + /// duplicate it. + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.error.source() + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use thiserror::Error; + + use super::*; + use crate::{ + FormatError, + tests::{Bracketed, Inner, Mid, WcArrow}, + }; + + /// Caller-facing error in this test module. The `#[from]` impl is what + /// drives `WithContextFormat = Bracketed` inference at the `?` site in [`returning_error`]. + #[derive(Error, Debug)] + #[error("an error happened")] + pub struct PropError(#[from] WithContext<&'static str, Mid, Bracketed>); + + fn returning_middle() -> Result<(), Mid> { + Err(Mid::Inner(Inner::A)) + } + + /// Realistic use: a function tags an inner error with context via + /// `map_err`, then `?` routes it through `#[from]` into the caller's + /// error type. + /// Most importantly, `WithContextFormat` is inferred from the `From` impl on `Error`. + fn returning_error() -> Result<(), PropError> { + returning_middle().map_err(|e| WithContext::new("context", e))?; + Ok(()) + } + + #[test] + fn test_new_and_fields() { + let w = WithContextColon::new("ctx", Inner::A); + assert_eq!(w.context, "ctx"); + } + + #[test] + fn test_from_tuple() { + let w: WithContextColon<&str, Inner> = ("ctx", Inner::A).into(); + assert_eq!(w.context, "ctx"); + } + + #[test] + fn test_display_default_format() { + let w = WithContextColon::new("step 3", Inner::A); + assert_eq!(w.to_string(), "step 3: InnerA"); + } + + #[test] + fn test_source_skips_inner_error() { + // Inner::A has no source, so skipping it yields None. + let w = WithContextColon::new("ctx", Inner::A); + assert!(w.source().is_none()); + + // For Mid::Inner(Inner::A), source must be Inner — not Mid (which Display already shows). + let w = WithContextColon::new("ctx", Mid::Inner(Inner::A)); + let src = w.source().expect("source must be Some"); + assert_eq!(src.to_string(), "InnerA"); + } + + #[test] + fn test_one_line_walks_full_chain() { + let w = WithContextColon::new("ctx", Mid::Inner(Inner::A)); + assert_eq!(w.one_line().to_string(), "ctx: mid: InnerA"); + } + + #[test] + fn test_io_error_chain() { + let io = io::Error::new(io::ErrorKind::NotFound, "file missing"); + let w = WithContextColon::new("config", io); + assert_eq!(w.one_line().to_string(), "config: file missing"); + } + + #[test] + fn test_custom_format_strategy() { + let w = WithContext::<_, _, WcArrow>::new("step", Inner::A); + assert_eq!(w.to_string(), "step -> InnerA"); + } + + #[test] + fn test_custom_format_affects_one_line() { + let w = WithContext::<_, _, Bracketed>::new("ctx", Mid::Inner(Inner::A)); + // Display: "[ctx] mid" — then chain appends ": InnerA" via source. + assert_eq!(w.one_line().to_string(), "[ctx] mid: InnerA"); + } + + /// End-to-end: `map_err` wraps an inner error with [`WithContext`], `?` + /// fires `From> for PropError` (and pins + /// `WithContextFormat`), and the full chain comes out via + /// [`FormatError::one_line`] without any duplication thanks to `source` + /// skipping the inner error. + #[test] + fn test_propagation_via_question_mark() { + let err = returning_error().expect_err("returning_error must error"); + assert_eq!(err.to_string(), "an error happened"); + assert_eq!( + err.one_line().to_string(), + "an error happened: [context] mid: InnerA", + ); + } +} diff --git a/tests/max_custom.rs b/tests/max_custom.rs new file mode 100644 index 0000000..49f5c99 --- /dev/null +++ b/tests/max_custom.rs @@ -0,0 +1,262 @@ +//! 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; +//! - 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: a label-only `Format` that wraps the label in braces. +struct GroupFmt; +impl Format for GroupFmt { + fn fmt(label: &GC, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{{{label}}}") + } +} + +// ── 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.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" + ); +} + +// ── 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 (label-only `Format`) that leaves a trailing newline after the label. +struct BadGroupFmt; +impl Format for BadGroupFmt { + fn fmt(label: &GC, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "[{label}]\n ") + } +} + +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.joined()); + + 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); + + // `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] + (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.joined().to_string(), expected_one_line); +}