Skip to main content

face_core/
diagnostics.rs

1//! Side channel for non-fatal events emitted during clustering (§11.1).
2//!
3//! Strategies and the tree builder report per-record skip events through
4//! a [`Diagnostics`] sink rather than aborting. The `face-cli` binary
5//! wires its sink to stderr printing and to the envelope's
6//! `result.skipped` counter; tests typically use [`VecDiagnostics`] to
7//! assert on the recorded events, and call sites that don't care use
8//! [`NullDiagnostics`].
9
10use crate::SkipReport;
11
12/// Side channel for non-fatal events emitted during clustering.
13///
14/// `face-cli` wires this to stderr printing plus the envelope's
15/// `result.skipped` counter; libraries embed their own sink.
16pub trait Diagnostics {
17    /// Record one [`SkipReport`]. Implementations decide whether to
18    /// print, count, accumulate, or drop it.
19    fn record_skip(&mut self, report: SkipReport);
20}
21
22/// A no-op sink, useful in tests and as a default when callers don't
23/// care about per-record warnings.
24///
25/// # Examples
26///
27/// ```
28/// use face_core::NullDiagnostics;
29///
30/// // Hand to any function expecting `&mut dyn Diagnostics` (or a
31/// // generic `D: Diagnostics`); the sink discards every event.
32/// let mut diag = NullDiagnostics;
33/// let _: &mut dyn face_core::Diagnostics = &mut diag;
34/// ```
35#[derive(Debug, Default)]
36pub struct NullDiagnostics;
37
38impl Diagnostics for NullDiagnostics {
39    fn record_skip(&mut self, _: SkipReport) {}
40}
41
42/// A `Vec`-backed sink for tests and integration code that wants to
43/// assert on the recorded skip events.
44///
45/// `VecDiagnostics` is `#[non_exhaustive]` — construct via
46/// [`VecDiagnostics::default`]. The `skips` field stays `pub` so
47/// integration tests can read recorded events directly; downstream
48/// crates that prefer not to depend on a `pub` field can use
49/// [`VecDiagnostics::skips`] for read access.
50///
51/// # Examples
52///
53/// ```
54/// use face_core::VecDiagnostics;
55///
56/// let diag = VecDiagnostics::default();
57/// assert!(diag.skips().is_empty());
58/// ```
59#[derive(Debug, Default)]
60#[non_exhaustive]
61pub struct VecDiagnostics {
62    /// Skips recorded since construction, in order.
63    pub skips: Vec<SkipReport>,
64}
65
66impl VecDiagnostics {
67    /// Borrow the recorded skip reports in insertion order.
68    ///
69    /// Equivalent to reading `self.skips` directly but doesn't depend
70    /// on the `pub` field, which keeps the accessor stable if the
71    /// internal representation evolves.
72    pub fn skips(&self) -> &[SkipReport] {
73        &self.skips
74    }
75}
76
77impl Diagnostics for VecDiagnostics {
78    fn record_skip(&mut self, report: SkipReport) {
79        self.skips.push(report);
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use crate::SkipReason;
87
88    #[test]
89    fn null_sink_drops_events() {
90        let mut diag = NullDiagnostics;
91        diag.record_skip(SkipReport {
92            record_index: 1,
93            reason: SkipReason::MissingField { field: "x".into() },
94        });
95        // Nothing to assert — no observable state.
96    }
97
98    #[test]
99    fn vec_sink_collects_in_order() {
100        let mut diag = VecDiagnostics::default();
101        diag.record_skip(SkipReport {
102            record_index: 0,
103            reason: SkipReason::MissingField { field: "a".into() },
104        });
105        diag.record_skip(SkipReport {
106            record_index: 1,
107            reason: SkipReason::MissingField { field: "b".into() },
108        });
109        assert_eq!(diag.skips.len(), 2);
110        assert_eq!(diag.skips[0].record_index, 0);
111        assert_eq!(diag.skips[1].record_index, 1);
112    }
113}