Skip to main content

scoped_error/
report.rs

1// Copyright (C) 2026 Kan-Ru Chen <kanru@kanru.info>
2//
3// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
4
5//! Error report formatting with tree structure support.
6//!
7//! This module provides [`ErrorReport`], which formats error chains
8//! for human-readable output. It supports both linear chains (single
9//! cause via [`Error::source`]) and tree structures (multiple causes
10//! from [`Many`](crate::Many)).
11//!
12//! The formatting uses ASCII characters for broad terminal compatibility:
13//! - `|--` for branches that continue (have siblings after)
14//! - '`--` for final branches
15//! - `|` for vertical continuation lines
16
17use std::error::Error;
18use std::fmt;
19use std::slice::Iter;
20
21use crate::Many;
22
23/// A formatted error report.
24///
25/// `ErrorReport` wraps a reference to a `'static` error and formats it
26/// with its full causal chain. It is created via
27/// [`ErrorExt::report`](crate::ErrorExt::report).
28///
29/// The report automatically detects [`Many`](crate::Many)
30/// and renders them as a tree structure. Linear error chains are rendered
31/// with the same tree characters for visual consistency.
32///
33/// # Output Format
34///
35/// **Linear chain:**
36/// ```text
37/// Failed to start service, at src/main.rs:42:10
38/// |-- Failed to initialize database, at src/db.rs:15:5
39/// `-- connection refused
40/// ```
41///
42/// **Many causes (tree):**
43/// ```text
44/// Batch operation failed, at src/main.rs:20:5 (3 causes)
45/// |-- Task A failed, at src/worker.rs:8:9
46/// |   `-- I/O error: file not found
47/// |-- Task B failed, at src/worker.rs:12:9
48/// |   `-- network timeout
49/// `-- Task C failed, at src/worker.rs:16:9
50///     `-- invalid input
51/// ```
52///
53/// **Nested Many:**
54/// ```text
55/// Distributed operation failed, at src/cluster.rs:50:5 (2 causes)
56/// |-- Node A batch failed, at src/node.rs:25:10 (2 causes)
57/// |   |-- Task 1 failed
58/// |   `-- Task 2 failed
59/// `-- Node B batch failed, at src/node.rs:30:10 (1 cause)
60///     `-- Task 3 failed
61/// ```
62///
63/// # Example
64///
65/// ```
66/// use scoped_error::{Error, expect_error, ErrorExt};
67///
68/// let err: Error = expect_error("Database connection failed", || {
69///     std::fs::read_to_string("nonexistent")?;
70///     Ok(())
71/// }).unwrap_err();
72///
73/// println!("{}", err.report());
74/// // Output:
75/// // Database connection failed, at src/main.rs:3:5
76/// // `-- No such file or directory (os error 2)
77/// ```
78pub struct ErrorReport<'a>(pub &'a (dyn Error + 'static));
79
80impl<'a> ErrorReport<'a> {
81    /// Create a new error report from an error reference.
82    ///
83    /// The error must be `'static` to allow downcasting for
84    /// [`Many`] detection.
85    pub fn new(error: &'a (dyn Error + 'static)) -> Self {
86        Self(error)
87    }
88}
89
90impl fmt::Display for ErrorReport<'_> {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        write_error(f, self.0, 0, "")
93    }
94}
95
96enum UnifiedChildren<'a> {
97    Slice(&'a [Box<dyn Error + Send + Sync + 'static>]),
98    Single(Option<&'a (dyn Error + 'static)>),
99}
100
101enum ErrorIter<'a> {
102    Slice(Iter<'a, Box<dyn Error + Send + Sync + 'static>>),
103    Single(Option<&'a (dyn Error + 'static)>),
104}
105
106impl<'a> Iterator for ErrorIter<'a> {
107    type Item = &'a (dyn Error + 'static);
108
109    fn next(&mut self) -> Option<Self::Item> {
110        match self {
111            ErrorIter::Slice(iter) => iter.next().map(|b| b.as_ref() as _),
112            ErrorIter::Single(opt) => opt.take(),
113        }
114    }
115}
116
117impl<'a> UnifiedChildren<'a> {
118    fn from(error: &'a (dyn Error + 'static)) -> Self {
119        if let Some(multi) = error.downcast_ref::<Many>() {
120            UnifiedChildren::Slice(multi.causes())
121        } else {
122            // Linear chain
123            UnifiedChildren::Single(error.source())
124        }
125    }
126    fn len(&self) -> usize {
127        match self {
128            UnifiedChildren::Slice(s) => s.len(),
129            UnifiedChildren::Single(opt) => opt.iter().len(),
130        }
131    }
132    fn iter(&self) -> ErrorIter<'a> {
133        match *self {
134            UnifiedChildren::Slice(slice) => ErrorIter::Slice(slice.iter()),
135            UnifiedChildren::Single(opt) => ErrorIter::Single(opt),
136        }
137    }
138}
139
140/// Write an error with its causal chain in tree format.
141pub fn write_error(
142    f: &mut fmt::Formatter<'_>,
143    error: &(dyn Error + 'static),
144    level: usize,
145    prefix: &str,
146) -> fmt::Result {
147    write!(f, "{}", error)?;
148
149    // Check for Many to render as tree
150    let children = UnifiedChildren::from(error);
151    let children_len = children.len();
152
153    for (i, child) in children.iter().enumerate() {
154        let child_child_len = UnifiedChildren::from(child).len();
155        let is_linear = level == 0 && children_len == 1 && child_child_len == 1;
156
157        if i != children_len - 1 || is_linear {
158            write!(f, "\n{}|-- ", prefix)?;
159        } else {
160            write!(f, "\n{}`-- ", prefix)?;
161        }
162
163        if is_linear {
164            write_error(f, child, 0, prefix)?;
165        } else if i < children_len - 1 {
166            write_error(f, child, level + 1, &format!("{}|   ", prefix))?;
167        } else {
168            write_error(f, child, level + 1, &format!("{}    ", prefix))?;
169        }
170    }
171
172    Ok(())
173}