Skip to main content

common/
error_collector.rs

1//! Collects and deduplicates errors for non-fail-early operation modes.
2//!
3//! All RCP tools (rcp, rrm, rlink, rcmp) can encounter multiple errors during a single run.
4//! When `--fail-early` is not set, tools continue past errors and report them at the end.
5//! This module provides [`ErrorCollector`] to accumulate those errors, deduplicate cascading
6//! failures (e.g., "Permission denied" from many files in the same unwritable directory),
7//! and produce a final error with the root cause chain intact.
8//!
9//! # Thread Safety
10//!
11//! [`ErrorCollector`] uses [`std::sync::Mutex`] internally, making it safe to share across
12//! async tasks via `Arc<ErrorCollector>`. The lock is held only for brief push/check
13//! operations, never across `.await` points.
14//!
15//! # Example
16//!
17//! ```
18//! use common::error_collector::ErrorCollector;
19//!
20//! let collector = ErrorCollector::new(20);
21//! collector.push(anyhow::anyhow!("Permission denied (os error 13)"));
22//! collector.push(anyhow::anyhow!("Permission denied (os error 13)"));
23//! assert!(collector.has_errors());
24//! // returns the first error with its chain intact since there's only one unique cause
25//! let error = collector.into_error().unwrap();
26//! assert!(format!("{:#}", error).contains("Permission denied"));
27//! ```
28use std::collections::HashSet;
29
30const DEFAULT_MAX_UNIQUE: usize = 20;
31
32/// Thread-safe error collector that deduplicates errors by root cause.
33///
34/// Designed to replace the `error_occurred: bool` + generic-error-message pattern
35/// used throughout the codebase. Stores the first error with its full [`anyhow::Error`]
36/// chain intact, deduplicates subsequent errors by root cause string, and caps the
37/// number of unique causes tracked.
38pub 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 preserved with full anyhow chain
54    first_error: Option<anyhow::Error>,
55    /// deduplicated root cause strings, insertion-ordered
56    unique_causes: Vec<String>,
57    /// set for O(1) dedup lookups
58    seen_causes: HashSet<String>,
59    /// total errors pushed, including duplicates
60    total_count: usize,
61    /// maximum number of unique causes to store
62    max_unique: usize,
63    /// number of unique causes dropped because the cap was reached
64    dropped_unique_count: usize,
65}
66
67impl ErrorCollector {
68    /// Creates a new collector that tracks up to `max_unique` distinct root causes.
69    #[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    /// Records an error. The first error's full chain is preserved; subsequent errors
84    /// are deduplicated by their root cause string.
85    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            // past cap: don't grow seen_causes (keeps memory bounded).
100            // repeated beyond-cap causes may increment this counter more than once,
101            // so it's a count of dropped errors, not necessarily unique.
102            inner.dropped_unique_count += 1;
103        }
104    }
105
106    /// Returns `true` if any errors have been recorded.
107    pub fn has_errors(&self) -> bool {
108        self.inner.lock().unwrap().total_count > 0
109    }
110
111    /// Returns the final error, or `None` if no errors occurred.
112    ///
113    /// - If there is exactly one unique root cause, returns the original first error
114    ///   with its full anyhow chain intact (so `{:#}` works correctly downstream).
115    /// - If there are multiple unique root causes, returns a new error listing all of them.
116    /// - If more errors were seen than tracked, appends a count of suppressed causes.
117    ///
118    /// Can be called on a shared reference (e.g., through `Arc`). Takes the first error
119    /// out of the collector, so subsequent calls will return a synthesized error if any
120    /// errors were recorded.
121    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        // single unique cause with nothing dropped: return the original error with full chain
127        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            // first_error already taken by a previous call - synthesize from stored cause
132            if let Some(cause) = inner.unique_causes.first() {
133                return Some(anyhow::anyhow!("{}", cause));
134            }
135            return None;
136        }
137        // multiple unique causes: build a summary listing each
138        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    /// Consumes the collector and returns the final error. Equivalent to [`Self::take_error`]
152    /// but takes ownership, guaranteeing no other references exist.
153    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        // single unique cause -> returns original first error
201        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")); // exceeds cap
240        collector.push(anyhow::anyhow!("error C")); // not in seen_causes (bounded), counted again
241        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        // with max_unique=1, a second distinct cause should still produce a multi-error summary
257        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        // same root cause, different context -> single unique cause -> returns first error with chain
283        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        // first call takes the original error
311        let err1 = collector.take_error();
312        assert!(err1.is_some(), "first take_error should return Some");
313        // second call should still return an error synthesized from the stored cause
314        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}