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}