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}