diff --git a/Cargo.lock b/Cargo.lock index 6b6a8e5..d8c26a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,7 +27,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "errortools" -version = "0.3.0" +version = "0.3.1" dependencies = [ "derive-where", "itertools", diff --git a/Cargo.toml b/Cargo.toml index 4170b15..dbb22d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "errortools" authors = ["Max Wase "] -version = "0.3.0" +version = "0.3.1" edition = "2024" description = "Quality of life utilities for error handling in Rust." repository = "https://github.com/maxwase/errortools" diff --git a/plugins/errortools/skills/structured-error-handling/SKILL.md b/plugins/errortools/skills/structured-error-handling/SKILL.md index 20a0ac8..e361278 100644 --- a/plugins/errortools/skills/structured-error-handling/SKILL.md +++ b/plugins/errortools/skills/structured-error-handling/SKILL.md @@ -36,13 +36,26 @@ impl std::fmt::Display for MyError { /* ... */ } // hand-rolled impl std::error::Error for MyError { /* ... */ } // hand-rolled (or absent) ``` -**2.** Collapse single-variant enums to structs. +**2.** Collapse single-variant enums to structs — but first check the struct +earns its existence. If its **only** job is to staple one incidental value (an +index, ID, key, attempt, path) onto a foreign error and no caller will `match` +on it, don't write a struct at all: use `WithContext` / `WithPath` (rule 7). ```rust // BAD pub enum Error { ReadFile(#[source] io::Error) } // GOOD pub struct Error(#[source] io::Error); + +// ALSO BAD -- a struct whose only purpose is to carry the offending value +#[derive(thiserror::Error)] +#[error("Index {index} exceeds u16 range")] +pub struct Error { index: u32, #[source] source: TryFromIntError } +let id = u16::try_from(value).map_err(|source| Error { index: value, source })?; + +// GOOD -- tag the value onto the source; renders ": " +pub type Error = errortools::WithContext; +let id = u16::try_from(value).map_err(|source| WithContext::new(value, source))?; ``` **3.** Use a tuple variant when wrapping a foreign error with no extra context. @@ -53,8 +66,10 @@ pub struct Error(#[source] io::Error); ConfigOpen(#[source] io::Error), ``` -**4.** Use a struct variant when extra context is needed. Put context in named -fields, never inside the message via `format!`. +**4.** Use a struct variant when extra context is needed **and a caller will +match on that context**. Put context in named fields, never inside the message +via `format!`. If the context is only ever rendered (never matched), don't add a +field — layer `WithContext` over the variant instead (rule 7). ```rust // BAD @@ -89,8 +104,12 @@ TokioJoin(&'static str), ``` **7.** For incidental context that callers will never match on (a file path, a -retry count, a record ID), prefer `WithContext` / `WithPath` over inventing a -single-variant wrapper. Basic usage and rendering live in `using-errortools` → +retry count, a record ID, the offending value being converted), prefer +`WithContext` / `WithPath` over inventing a single-variant wrapper. The trigger +is **"will anyone branch on it?"**, never *where the value came from* — a value +produced inside the function (e.g. the element being converted in a loop) is +just as incidental as one passed in as a parameter. Basic usage and rendering +live in `using-errortools` → "Attaching incidental context" and `references/with-context.md`; the design rule below covers the case those don't -- needing a named variant *and* a path. @@ -225,16 +244,33 @@ fail fast or collect per-item errors with `ManyErrors`. The canonical for item in items { let _ = process(item); } ``` +## Large source errors + +**15.** Box large source errors to keep the variant size small. + +```rust +// BAD -- variant is as large as the biggest source error +#[error("Render failed")] +Render(#[source] SomeLargeError), + +// GOOD +#[error("Render failed")] +Render(#[source] Box), +``` + +`Box` implements `std::error::Error` when `E: Error`, so `#[source]` chains +through the box transparently and `OneLine` / `Chain` still walk the full chain. + ## `anyhow` / `Box` -**15.** Avoid `anyhow` or `Box` in production code or library code. Callers cannot +**16.** Avoid `anyhow` or `Box` in production code or library code. Callers cannot branch on variants, and the chain is opaque. Both are acceptable in tests and temporary scripts. If the project currently uses `anyhow`, see `migrating-from-unstructured`. ## Tests -**16.** Assert the exact error variant, not just `.is_err()`. Once a variant is +**17.** Assert the exact error variant, not just `.is_err()`. Once a variant is worth matching, the unhappy path is worth testing: cover the error cases, not only the happy path. diff --git a/plugins/errortools/skills/using-errortools/SKILL.md b/plugins/errortools/skills/using-errortools/SKILL.md index 7ed3459..a664061 100644 --- a/plugins/errortools/skills/using-errortools/SKILL.md +++ b/plugins/errortools/skills/using-errortools/SKILL.md @@ -175,6 +175,23 @@ items.into_iter() See `references/many-errors.md` for nesting with `push_group` and render options. +Two `ManyErrors` instances can be merged with `+` (the `Add` impl). This is +useful when parallel work produces independent error sets that need to be +reported together. + +```rust +let errs = errs_a + errs_b; +errs.into_result(())?; +``` + +Merge of Results is also supported: + +```rust +let result_a: Result<(), Error> = ...; +let result_b: Result<(), Error> = ...; +let merged: ManyErrors<_, _> = result_a + result_b; +``` + ## Choosing a format strategy | Context | Strategy | diff --git a/src/many_errors/add.rs b/src/many_errors/add.rs new file mode 100644 index 0000000..fbe2c62 --- /dev/null +++ b/src/many_errors/add.rs @@ -0,0 +1,228 @@ +use crate::ManyErrors; + +/// Merges all top-level nodes from `rhs` into `self`. +impl core::ops::Add for ManyErrors { + type Output = Self; + + fn add(mut self, rhs: Self) -> Self { + self.extend(rhs); + self + } +} + +/// Appends a single leaf `(context, error)`. +impl core::ops::Add<(C, E)> for ManyErrors { + type Output = Self; + + fn add(mut self, (context, error): (C, E)) -> Self { + self.push(context, error); + self + } +} + +/// Appends `error` if `result` is `Err`; leaves `self` unchanged on `Ok`. +impl core::ops::Add<(C, Result)> for ManyErrors { + type Output = Self; + + fn add(mut self, (context, result): (C, Result)) -> Self { + if let Err(error) = result { + self.push(context, error); + } + self + } +} + +/// Appends `item` if `option` is `Some`; leaves `self` unchanged on `None`. +/// +/// `I` can be anything that `ManyErrors` already accepts via `Add`: +/// `(C, E)`, `(C, Result)`, or another `ManyErrors`. +impl core::ops::Add> for ManyErrors +where + ManyErrors: core::ops::Add>, +{ + type Output = Self; + + fn add(self, option: Option) -> Self { + match option { + Some(item) => self + item, + None => self, + } + } +} + +impl core::ops::AddAssign for ManyErrors { + fn add_assign(&mut self, rhs: Self) { + self.extend(rhs); + } +} + +impl core::ops::AddAssign<(C, E)> for ManyErrors { + fn add_assign(&mut self, (context, error): (C, E)) { + self.push(context, error); + } +} + +impl core::ops::AddAssign<(C, Result)> for ManyErrors { + fn add_assign(&mut self, (context, result): (C, Result)) { + if let Err(error) = result { + self.push(context, error); + } + } +} + +/// Appends `item` if `option` is `Some`; leaves `self` unchanged on `None`. +impl core::ops::AddAssign> for ManyErrors +where + Self: core::ops::AddAssign, +{ + fn add_assign(&mut self, option: Option) { + if let Some(item) = option { + *self += item; + } + } +} + +#[cfg(test)] +mod tests { + use crate::tests::Inner; + + use super::*; + + // --- Add --- + + #[test] + fn test_add_many_errors_merges_nodes() { + let mut a = ManyErrors::<&str, Inner>::new(); + a.push("a", Inner::A); + let mut b = ManyErrors::<&str, Inner>::new(); + b.push("b", Inner::B); + b.push("c", Inner::A); + let merged = a + b; + assert_eq!(merged.len(), 3); + } + + #[test] + fn test_add_many_errors_with_empty() { + let mut a = ManyErrors::<&str, Inner>::new(); + a.push("a", Inner::A); + let merged = a + ManyErrors::new(); + assert_eq!(merged.len(), 1); + } + + #[test] + fn test_add_tuple_pushes_leaf() { + let errs = ManyErrors::<&str, Inner>::new() + ("ctx", Inner::A); + assert_eq!(errs.len(), 1); + } + + #[test] + fn test_add_result_err_pushes() { + let errs = ManyErrors::<&str, Inner>::new() + ("ctx", Err::<(), _>(Inner::A)); + assert_eq!(errs.len(), 1); + } + + #[test] + fn test_add_result_ok_skips() { + let errs = ManyErrors::<&str, Inner>::new() + ("ctx", Ok::<(), _>(())); + assert!(errs.is_empty()); + } + + #[test] + fn test_add_result_chain() { + let results: alloc::vec::Vec> = + alloc::vec![Ok(()), Err(Inner::A), Ok(()), Err(Inner::B)]; + let errs = results + .into_iter() + .enumerate() + .fold(ManyErrors::::new(), |acc, (i, r)| { + acc + (i, r) + }); + assert_eq!(errs.len(), 2); + } + + #[test] + fn test_add_option_tuple_some_pushes() { + let errs = ManyErrors::<&str, Inner>::new() + Some(("ctx", Inner::A)); + assert_eq!(errs.len(), 1); + } + + #[test] + fn test_add_option_tuple_none_skips() { + let errs = ManyErrors::<&str, Inner>::new() + None::<(&str, Inner)>; + assert!(errs.is_empty()); + } + + #[test] + fn test_add_option_result_some_err_pushes() { + let errs = ManyErrors::<&str, Inner>::new() + Some(("ctx", Err::<(), _>(Inner::A))); + assert_eq!(errs.len(), 1); + } + + #[test] + fn test_add_option_result_some_ok_skips() { + let errs = ManyErrors::<&str, Inner>::new() + Some(("ctx", Ok::<(), Inner>(()))); + assert!(errs.is_empty()); + } + + #[test] + fn test_add_option_many_errors_some_merges() { + let mut other = ManyErrors::<&str, Inner>::new(); + other.push("b", Inner::B); + let errs = ManyErrors::<&str, Inner>::new() + Some(other); + assert_eq!(errs.len(), 1); + } + + #[test] + fn test_add_option_many_errors_none_skips() { + let errs = ManyErrors::<&str, Inner>::new() + None::>; + assert!(errs.is_empty()); + } + + // --- AddAssign --- + + #[test] + fn test_add_assign_many_errors() { + let mut a = ManyErrors::<&str, Inner>::new(); + a.push("a", Inner::A); + let mut b = ManyErrors::<&str, Inner>::new(); + b.push("b", Inner::B); + b.push("c", Inner::A); + a += b; + assert_eq!(a.len(), 3); + } + + #[test] + fn test_add_assign_tuple() { + let mut errs = ManyErrors::<&str, Inner>::new(); + errs += ("ctx", Inner::A); + assert_eq!(errs.len(), 1); + } + + #[test] + fn test_add_assign_result_err() { + let mut errs = ManyErrors::<&str, Inner>::new(); + errs += ("ctx", Err::<(), _>(Inner::A)); + assert_eq!(errs.len(), 1); + } + + #[test] + fn test_add_assign_result_ok() { + let mut errs = ManyErrors::<&str, Inner>::new(); + errs += ("ctx", Ok::<(), Inner>(())); + assert!(errs.is_empty()); + } + + #[test] + fn test_add_assign_option_some_pushes() { + let mut errs = ManyErrors::<&str, Inner>::new(); + errs += Some(("ctx", Inner::A)); + assert_eq!(errs.len(), 1); + } + + #[test] + fn test_add_assign_option_none_skips() { + let mut errs = ManyErrors::<&str, Inner>::new(); + errs += None::<(&str, Inner)>; + assert!(errs.is_empty()); + } +} diff --git a/src/many_errors/mod.rs b/src/many_errors/mod.rs index 7a7b396..682c016 100644 --- a/src/many_errors/mod.rs +++ b/src/many_errors/mod.rs @@ -14,6 +14,7 @@ use crate::{ with_context::{Colon, WithContext}, }; +mod add; mod iter; mod node; pub mod strategy; @@ -37,12 +38,66 @@ pub use strategy::{Bullets, Joined, List, Tree}; /// [`FormatError::formatted`](crate::FormatError::formatted) for full generic /// control (e.g. `Tree`). /// -/// Note that the generic per-error helpers -/// [`one_line`](crate::FormatError::one_line) / -/// [`chain`](crate::FormatError::chain) walk [`Error::source`], which is -/// always `None` here — on a `ManyErrors` they render exactly the shallow -/// `Display` text. For deep aggregate rendering use -/// [`joined`](ManyErrors::joined) / [`tree`](ManyErrors::tree) instead. +/// # ⚠ Warning: `one_line` / `chain` do NOT walk the aggregate +/// +/// **[`FormatError::one_line`](crate::FormatError::one_line) and +/// [`FormatError::chain`](crate::FormatError::chain) walk [`Error::source`], +/// which is always `None` on a `ManyErrors`.** They will silently render +/// only the shallow `Display` summary — the individual errors and their own +/// source chains are invisible to them. +/// +/// To print the full aggregate with per-error source chains, use one of the +/// dedicated aggregate renderers: +/// +/// ``` +/// # use errortools::ManyErrors; +/// # let errs: ManyErrors<&str, std::io::Error> = ManyErrors::default(); +/// // Flat: "ctx: err1\nctx: err2\n…" +/// println!("{}", errs.joined()); +/// +/// // Hierarchical tree (walks each leaf's Error::source chain too): +/// println!("{}", errs.tree()); +/// ``` +/// +/// If you need the generic [`FormatError`](crate::FormatError) API (e.g.\ to +/// pick `Tree` at call-site), use +/// [`formatted`](ManyErrors::formatted) — **not** `one_line` / `chain`. +/// +/// # Embedding in an error enum +/// +/// When an operation can fail on multiple items, the typical pattern is a +/// dedicated `Many` variant that holds the aggregate. Because +/// `Error::source` is always `None` on a `ManyErrors`, **do not add +/// `#[source]` or `#[from]`** — they are useless here. Instead, render the +/// full tree inline inside the variant message so that callers who call +/// `one_line()` / `chain()` on the *outer* error still see the whole picture: +/// +/// ```rust +/// use errortools::ManyErrors; +/// use thiserror::Error; +/// +/// #[derive(Debug, Error)] +/// enum ProcessError { +/// #[error("Invalid input: {0}")] +/// InvalidInput(String), +/// } +/// +/// #[derive(Debug, Error)] +/// enum Error { +/// #[error("Single failure: {0}")] +/// One(#[source] ProcessError), +/// +/// // No #[source] / #[from]: source() is always None on ManyErrors. +/// // The tree is rendered eagerly inside the message so chain() / one_line() +/// // on the *outer* Error still surface every failure. +/// #[error("Multiple failures:\n{}", .0.tree())] +/// Many(ManyErrors>), +/// } +/// ``` +/// +/// Calling `chain()` on an `Error::Many` value will print the `#[error]` +/// text — which already contains the rendered tree — and stop there (no +/// further `source()` walk). That is intentional: the tree is the trace. /// /// # Customizing group rendering /// Two independent levers: @@ -180,6 +235,44 @@ impl ManyErrors { self.push_node(Node::Leaf(WithContext::new(context, error))); } + /// Appends a leaf error with context if `option` is `Some`; no-op on `None`. + /// + /// # Example + /// ``` + /// use errortools::ManyErrors; + /// + /// let mut errs = ManyErrors::<&str, std::io::Error>::new(); + /// errs.push_some("step 1", None); + /// assert!(errs.is_empty()); + /// errs.push_some("step 2", Some(std::io::Error::other("fail"))); + /// assert_eq!(errs.len(), 1); + /// ``` + pub fn push_some(&mut self, context: C, option: Option) { + if let Some(error) = option { + self.push(context, error); + } + } + + /// Appends a leaf error if `result` is `Err`; no-op on `Ok`. + /// + /// Named version of `*self += (context, result)` for discoverability. + /// + /// # Example + /// ``` + /// use errortools::ManyErrors; + /// + /// let mut errs = ManyErrors::<&str, std::io::Error>::new(); + /// errs.push_result("step 1", Ok::<(), _>(())); + /// assert!(errs.is_empty()); + /// errs.push_result::<()>("step 2", Err(std::io::Error::other("fail"))); + /// assert_eq!(errs.len(), 1); + /// ``` + pub fn push_result(&mut self, context: C, result: Result) { + if let Err(error) = result { + self.push(context, error); + } + } + /// Appends a named sub-group of errors. /// /// # Example @@ -198,6 +291,28 @@ impl ManyErrors { self.push_node(Node::Group(Subgroup::new(context, errors))); } + /// Wraps `self` as a named sub-group inside a fresh `ManyErrors`. + /// + /// Builder-style complement to [`push_group`](Self::push_group): instead of + /// attaching a child to an existing parent, this creates the parent and + /// returns it. Empty `self` produces a group with zero leaves. + /// + /// # Example + /// ``` + /// use errortools::ManyErrors; + /// + /// let mut inner = ManyErrors::<&str, std::io::Error>::new(); + /// inner.push("a", std::io::Error::other("x")); + /// + /// let outer = inner.into_group("region"); + /// assert_eq!(outer.len(), 1); // one group node + /// ``` + pub fn into_group(self, context: GC) -> Self { + let mut outer = Self::None; + outer.push_group(context, self); + outer + } + /// Appends a child [`Node`] directly, promoting `None → One → Many`. /// /// Accepts anything convertible into a [`Node`]: a `(C, E)` pair, a @@ -231,6 +346,93 @@ impl ManyErrors { } } + /// Returns `Some(())` if no errors were recorded, `None` otherwise. + /// + /// Mirrors [`Result::ok`]: discards the errors and signals whether the + /// operation succeeded. For the inverse (keeping the errors) use + /// [`err`](Self::err); to turn into a `Result` with a success value use + /// [`into_result`](Self::into_result). + /// + /// # Example + /// ``` + /// use errortools::ManyErrors; + /// + /// let ok = ManyErrors::<&str, std::io::Error>::new(); + /// assert_eq!(ok.ok(), Some(())); + /// + /// let mut errs = ManyErrors::<&str, std::io::Error>::new(); + /// errs.push("step", std::io::Error::other("fail")); + /// assert_eq!(errs.ok(), None); + /// ``` + pub fn ok(self) -> Option<()> { + matches!(self, Self::None).then_some(()) + } + + /// Returns `Some(self)` if errors were recorded, `None` if empty. + /// + /// Mirrors [`Result::err`]: preserves the errors and discards the success + /// signal. Useful when you want to handle errors only when present, without + /// needing a success value (compare [`into_result`](Self::into_result)). + /// + /// # Example + /// ``` + /// use errortools::ManyErrors; + /// + /// let ok = ManyErrors::<&str, std::io::Error>::new(); + /// assert!(ok.err().is_none()); + /// + /// let mut errs = ManyErrors::<&str, std::io::Error>::new(); + /// errs.push("step", std::io::Error::other("fail")); + /// assert!(errs.err().is_some()); + /// ``` + pub fn err(self) -> Option { + match self { + Self::None => None, + other => Some(other), + } + } + + /// Transforms every leaf error by applying `f`, returning a new + /// `ManyErrors` with the same context and group structure. + /// + /// Mirrors [`Result::map_err`]. Format strategies are reset to defaults + /// for the new error type. Group contexts (`GC`) are left unchanged; to + /// map those, use [`map_context`](Self::map_context) after (or before) this. + /// + /// # Example + /// ``` + /// use errortools::ManyErrors; + /// + /// let mut errs = ManyErrors::<&str, i32>::new(); + /// errs.push("a", 1); + /// errs.push("b", 2); + /// let mapped = errs.map_err(|n| n.to_string()); + /// assert_eq!(mapped.len(), 2); + /// ``` + pub fn map_err(self, mut f: impl FnMut(E) -> E2) -> ManyErrors { + map_err_many(self, &mut f) + } + + /// Transforms every leaf context by applying `f`, returning a new + /// `ManyErrors` with the same error and group-context structure. + /// + /// Mirrors [`Result::map`] (the "non-error" half of the pair). Only leaf + /// contexts (`C`) are mapped; group labels (`GC`) are unchanged. Format + /// strategies are reset to defaults for the new context type. + /// + /// # Example + /// ``` + /// use errortools::ManyErrors; + /// + /// let mut errs = ManyErrors::<&str, std::io::Error>::new(); + /// errs.push("config", std::io::Error::other("missing")); + /// let mapped = errs.map_context(|ctx| format!("step:{ctx}")); + /// assert_eq!(mapped.len(), 1); + /// ``` + pub fn map_context(self, mut f: impl FnMut(C) -> C2) -> ManyErrors { + map_context_many(self, &mut f) + } + /// Switches the leaf strategy `F` and group-label strategy `GF` without /// touching the stored values, rebuilding the tree recursively (O(n), one /// new box per group). The aggregate counterpart of @@ -268,7 +470,25 @@ impl ManyErrors { crate::Formatted::new(self) } + /// Renders on a single line: `;`-separated siblings, parens around groups, + /// walking each leaf's source chain. + /// + /// # Shadowing [`FormatError::one_line`](crate::FormatError::one_line) + /// + /// The trait method uses [`OneLine`](crate::OneLine), which walks + /// [`Error::source`] — but `ManyErrors::source` is always `None`, so it + /// would only emit the shallow [`Display`] summary. This inherent method + /// wins at the call site and produces a meaningful aggregate rendering + /// instead. The trait method remains reachable as + /// `FormatError::one_line(&errs)` if the `OneLine` behavior is needed. + pub fn one_line(&self) -> crate::Formatted<&Self, Joined> { + crate::Formatted::new(self) + } + /// Renders on a single line: `;`-separated siblings, parens around groups. + /// + /// Alias for [`one_line`](Self::one_line); prefer that name for + /// consistency with [`FormatError`](crate::FormatError). pub fn joined(&self) -> crate::Formatted<&Self, Joined> { crate::Formatted::new(self) } @@ -341,6 +561,79 @@ where } } +// --- map_err / map_context helpers --- +// Free functions so Subgroup (in node.rs) does not need access to a private +// method on ManyErrors; WithContext and Subgroup fields are all `pub`. + +fn map_err_node( + node: Node, + f: &mut impl FnMut(E) -> E2, +) -> Node { + match node { + Node::Leaf(w) => Node::Leaf(WithContext::new(w.context, f(w.error))), + Node::Group(g) => Node::Group(Subgroup::new(g.context, map_err_many(*g.errors, f))), + } +} + +fn map_err_many( + me: ManyErrors, + f: &mut impl FnMut(E) -> E2, +) -> ManyErrors { + match me { + ManyErrors::None => ManyErrors::None, + ManyErrors::One(n) => ManyErrors::One(map_err_node(n, f)), + ManyErrors::Many(v) => { + let mut out = Vec::with_capacity(v.len()); + for n in v { + out.push(map_err_node(n, f)); + } + ManyErrors::Many(out) + } + } +} + +fn map_context_node( + node: Node, + f: &mut impl FnMut(C) -> C2, +) -> Node { + match node { + Node::Leaf(w) => Node::Leaf(WithContext::new(f(w.context), w.error)), + Node::Group(g) => Node::Group(Subgroup::new(g.context, map_context_many(*g.errors, f))), + } +} + +fn map_context_many( + me: ManyErrors, + f: &mut impl FnMut(C) -> C2, +) -> ManyErrors { + match me { + ManyErrors::None => ManyErrors::None, + ManyErrors::One(n) => ManyErrors::One(map_context_node(n, f)), + ManyErrors::Many(v) => { + let mut out = Vec::with_capacity(v.len()); + for n in v { + out.push(map_context_node(n, f)); + } + ManyErrors::Many(out) + } + } +} + +/// Converts `(context, Ok(_))` into an empty `ManyErrors` and +/// `(context, Err(e))` into a single-leaf `ManyErrors`. +impl From<(C, Result)> for ManyErrors { + fn from((context, result): (C, Result)) -> Self { + match result { + Ok(_) => Self::None, + Err(error) => { + let mut me = Self::None; + me.push(context, error); + me + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -554,6 +847,141 @@ mod tests { 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"); + assert_eq!(e.one_line().to_string(), "ctx: mid: InnerA"); + assert_eq!(e.joined().to_string(), e.one_line().to_string()); + } + + /// `one_line()` walks leaf chains; `FormatError::one_line` stops at Display. + #[test] + fn test_one_line_shadows_format_error_trait() { + use crate::FormatError; + let mut e = ManyErrors::<&str, Mid>::new(); + e.push("a", Mid::Inner(Inner::A)); + e.push("b", Mid::Inner(Inner::B)); + // inherent: walks chains + assert_eq!( + e.one_line().to_string(), + "2 errors: a: mid: InnerA; b: mid: InnerB" + ); + // trait (shallow): stops at ManyErrors::source() = None + assert_eq!( + FormatError::one_line(&e).to_string(), + "2 errors: a: mid; b: mid" + ); + } + + // --- ok / err --- + + #[test] + fn test_ok_empty_is_some() { + let e = ManyErrors::<&str, Inner>::new(); + assert_eq!(e.ok(), Some(())); + } + + #[test] + fn test_ok_with_errors_is_none() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + assert_eq!(e.ok(), None); + } + + #[test] + fn test_err_empty_is_none() { + let e = ManyErrors::<&str, Inner>::new(); + assert!(e.err().is_none()); + } + + #[test] + fn test_err_with_errors_is_some() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + let errs = e.err().expect("should be Some"); + assert_eq!(errs.len(), 1); + } + + // --- map_err --- + + #[test] + fn test_map_err_none_stays_none() { + let e = ManyErrors::<&str, Inner>::new(); + let mapped: ManyErrors<&str, String> = e.map_err(|err| err.to_string()); + assert!(mapped.is_empty()); + } + + #[test] + fn test_map_err_transforms_leaves() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::B); + // Map Inner → Mid to stay within Error-implementing types. + let mapped = e.map_err(Mid::Inner); + assert_eq!(mapped.len(), 2); + assert_eq!(mapped.to_string(), "2 errors: a: mid; b: mid"); + } + + #[test] + fn test_map_err_recurses_into_groups() { + let mut inner = ManyErrors::<&str, Inner>::new(); + inner.push("x", Inner::A); + + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push("leaf", Inner::B); + outer.push_group("region", inner); + + let mapped = outer.map_err(Mid::Inner); + assert_eq!(mapped.len(), 2); + // Group structure preserved, errors replaced. + assert_eq!(mapped.to_string(), "2 errors: leaf: mid; region (x: mid)"); + } + + // --- map_context --- + + #[test] + fn test_map_context_none_stays_none() { + let e = ManyErrors::<&str, Inner>::new(); + // GC stays &str (unchanged); only leaf C changes. + let mapped: ManyErrors = e.map_context(|ctx| ctx.to_uppercase()); + assert!(mapped.is_empty()); + } + + #[test] + fn test_map_context_transforms_leaves() { + let mut e = ManyErrors::<&str, Inner>::new(); + e.push("a", Inner::A); + e.push("b", Inner::B); + let mapped = e.map_context(|ctx| ctx.to_uppercase()); + assert_eq!(mapped.len(), 2); + assert_eq!(mapped.to_string(), "2 errors: A: InnerA; B: InnerB"); + } + + #[test] + fn test_map_context_recurses_into_groups_leaves_only() { + let mut inner = ManyErrors::<&str, Inner>::new(); + inner.push("x", Inner::A); + + let mut outer = ManyErrors::<&str, Inner>::new(); + outer.push("leaf", Inner::B); + outer.push_group("region", inner); + + let mapped = outer.map_context(|ctx| ctx.to_uppercase()); + // group label "region" (GC) unchanged; leaf contexts uppercased + assert_eq!( + mapped.to_string(), + "2 errors: LEAF: InnerB; region (X: InnerA)" + ); + } + + // --- From<(C, Result)> --- + + #[test] + fn test_from_result_err_produces_one_leaf() { + let e: ManyErrors<&str, Inner> = ("ctx", Err::<(), _>(Inner::A)).into(); + assert_eq!(e.len(), 1); + } + + #[test] + fn test_from_result_ok_produces_empty() { + let e: ManyErrors<&str, Inner> = ("ctx", Ok::<(), Inner>(())).into(); + assert!(e.is_empty()); } }