common/
error_collector.rs1use std::collections::HashSet;
29
30const DEFAULT_MAX_UNIQUE: usize = 20;
31
32pub struct ErrorCollector {
39 inner: std::sync::Mutex<Inner>,
40}
41
42impl std::fmt::Debug for ErrorCollector {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 let inner = self.inner.lock().unwrap();
45 f.debug_struct("ErrorCollector")
46 .field("total_count", &inner.total_count)
47 .field("unique_causes", &inner.unique_causes.len())
48 .finish()
49 }
50}
51
52struct Inner {
53 first_error: Option<anyhow::Error>,
55 unique_causes: Vec<String>,
57 seen_causes: HashSet<String>,
59 total_count: usize,
61 max_unique: usize,
63 dropped_unique_count: usize,
65}
66
67impl ErrorCollector {
68 #[must_use]
70 pub fn new(max_unique: usize) -> Self {
71 Self {
72 inner: std::sync::Mutex::new(Inner {
73 first_error: None,
74 unique_causes: Vec::new(),
75 seen_causes: HashSet::new(),
76 total_count: 0,
77 max_unique,
78 dropped_unique_count: 0,
79 }),
80 }
81 }
82
83 pub fn push(&self, error: anyhow::Error) {
86 let root_cause = error.root_cause().to_string();
87 let mut inner = self.inner.lock().unwrap();
88 inner.total_count += 1;
89 if inner.first_error.is_none() {
90 inner.first_error = Some(error);
91 }
92 if inner.seen_causes.contains(&root_cause) {
93 return;
94 }
95 if inner.unique_causes.len() < inner.max_unique {
96 inner.seen_causes.insert(root_cause.clone());
97 inner.unique_causes.push(root_cause);
98 } else {
99 inner.dropped_unique_count += 1;
103 }
104 }
105
106 pub fn has_errors(&self) -> bool {
108 self.inner.lock().unwrap().total_count > 0
109 }
110
111 pub fn take_error(&self) -> Option<anyhow::Error> {
122 let mut inner = self.inner.lock().unwrap();
123 if inner.total_count == 0 {
124 return None;
125 }
126 if inner.unique_causes.len() <= 1 && inner.dropped_unique_count == 0 {
128 if let Some(err) = inner.first_error.take() {
129 return Some(err);
130 }
131 if let Some(cause) = inner.unique_causes.first() {
133 return Some(anyhow::anyhow!("{}", cause));
134 }
135 return None;
136 }
137 let mut msg = String::from("multiple errors occurred:");
139 for cause in &inner.unique_causes {
140 msg.push_str("\n- ");
141 msg.push_str(cause);
142 }
143 if inner.dropped_unique_count > 0 {
144 msg.push_str(&format!(
145 "\n({} additional errors suppressed)",
146 inner.dropped_unique_count
147 ));
148 }
149 Some(anyhow::anyhow!("{msg}"))
150 }
151 pub fn into_error(self) -> Option<anyhow::Error> {
154 self.take_error()
155 }
156}
157
158impl Default for ErrorCollector {
159 fn default() -> Self {
160 Self::new(DEFAULT_MAX_UNIQUE)
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn no_errors_returns_none() {
170 let collector = ErrorCollector::default();
171 assert!(!collector.has_errors());
172 assert!(collector.into_error().is_none());
173 }
174
175 #[test]
176 fn single_error_preserves_chain() {
177 let collector = ErrorCollector::default();
178 let original =
179 anyhow::anyhow!("Permission denied (os error 13)").context("failed to create file");
180 collector.push(original);
181 assert!(collector.has_errors());
182 let err = collector.into_error().unwrap();
183 let msg = format!("{:#}", err);
184 assert!(
185 msg.contains("failed to create file"),
186 "expected context in '{msg}'"
187 );
188 assert!(
189 msg.contains("Permission denied"),
190 "expected root cause in '{msg}'"
191 );
192 }
193
194 #[test]
195 fn duplicate_root_causes_deduped() {
196 let collector = ErrorCollector::default();
197 collector.push(anyhow::anyhow!("Permission denied (os error 13)"));
198 collector.push(anyhow::anyhow!("Permission denied (os error 13)"));
199 collector.push(anyhow::anyhow!("Permission denied (os error 13)"));
200 let err = collector.into_error().unwrap();
202 let msg = format!("{:#}", err);
203 assert!(
204 msg.contains("Permission denied"),
205 "expected root cause in '{msg}'"
206 );
207 assert!(
208 !msg.contains("multiple errors"),
209 "single unique cause should not say 'multiple errors': '{msg}'"
210 );
211 }
212
213 #[test]
214 fn multiple_unique_causes_listed() {
215 let collector = ErrorCollector::default();
216 collector.push(anyhow::anyhow!("Permission denied (os error 13)"));
217 collector.push(anyhow::anyhow!("No space left on device (os error 28)"));
218 let err = collector.into_error().unwrap();
219 let msg = format!("{}", err);
220 assert!(
221 msg.contains("multiple errors occurred:"),
222 "expected multi-error header in '{msg}'"
223 );
224 assert!(
225 msg.contains("Permission denied"),
226 "expected first cause in '{msg}'"
227 );
228 assert!(
229 msg.contains("No space left on device"),
230 "expected second cause in '{msg}'"
231 );
232 }
233
234 #[test]
235 fn respects_max_unique_cap() {
236 let collector = ErrorCollector::new(2);
237 collector.push(anyhow::anyhow!("error A"));
238 collector.push(anyhow::anyhow!("error B"));
239 collector.push(anyhow::anyhow!("error C")); collector.push(anyhow::anyhow!("error C")); let err = collector.into_error().unwrap();
242 let msg = format!("{}", err);
243 assert!(msg.contains("error A"), "expected first cause in '{msg}'");
244 assert!(msg.contains("error B"), "expected second cause in '{msg}'");
245 assert!(
246 !msg.contains("error C"),
247 "third cause should be suppressed in '{msg}'"
248 );
249 assert!(
250 msg.contains("2 additional errors suppressed"),
251 "expected suppression count in '{msg}'"
252 );
253 }
254 #[test]
255 fn max_unique_one_with_multiple_causes() {
256 let collector = ErrorCollector::new(1);
258 collector.push(anyhow::anyhow!("error A"));
259 collector.push(anyhow::anyhow!("error B"));
260 let err = collector.into_error().unwrap();
261 let msg = format!("{}", err);
262 assert!(
263 msg.contains("multiple errors occurred:"),
264 "expected multi-error header in '{msg}'"
265 );
266 assert!(msg.contains("error A"), "expected tracked cause in '{msg}'");
267 assert!(
268 msg.contains("1 additional errors suppressed"),
269 "expected suppression count in '{msg}'"
270 );
271 }
272
273 #[test]
274 fn context_wrapped_errors_dedup_by_root_cause() {
275 let collector = ErrorCollector::default();
276 let e1 = anyhow::anyhow!("Permission denied (os error 13)")
277 .context("failed to create /dst/foo/a.txt");
278 let e2 = anyhow::anyhow!("Permission denied (os error 13)")
279 .context("failed to create /dst/foo/b.txt");
280 collector.push(e1);
281 collector.push(e2);
282 let err = collector.into_error().unwrap();
284 let msg = format!("{:#}", err);
285 assert!(
286 msg.contains("failed to create /dst/foo/a.txt"),
287 "expected first error's context in '{msg}'"
288 );
289 assert!(
290 msg.contains("Permission denied"),
291 "expected root cause in '{msg}'"
292 );
293 }
294
295 #[test]
296 fn has_errors_is_threadsafe() {
297 let collector = std::sync::Arc::new(ErrorCollector::default());
298 let c = collector.clone();
299 let handle = std::thread::spawn(move || {
300 c.push(anyhow::anyhow!("error from thread"));
301 });
302 handle.join().unwrap();
303 assert!(collector.has_errors());
304 }
305
306 #[test]
307 fn take_error_idempotent() {
308 let collector = ErrorCollector::default();
309 collector.push(anyhow::anyhow!("Permission denied (os error 13)"));
310 let err1 = collector.take_error();
312 assert!(err1.is_some(), "first take_error should return Some");
313 let err2 = collector.take_error();
315 assert!(err2.is_some(), "second take_error should return Some");
316 let msg = format!("{:#}", err2.unwrap());
317 assert!(
318 msg.contains("Permission denied"),
319 "expected root cause in '{msg}'"
320 );
321 }
322}