face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Side channel for non-fatal events emitted during clustering (§11.1).
//!
//! Strategies and the tree builder report per-record skip events through
//! a [`Diagnostics`] sink rather than aborting. The `face-cli` binary
//! wires its sink to stderr printing and to the envelope's
//! `result.skipped` counter; tests typically use [`VecDiagnostics`] to
//! assert on the recorded events, and call sites that don't care use
//! [`NullDiagnostics`].

use crate::SkipReport;

/// Side channel for non-fatal events emitted during clustering.
///
/// `face-cli` wires this to stderr printing plus the envelope's
/// `result.skipped` counter; libraries embed their own sink.
pub trait Diagnostics {
    /// Record one [`SkipReport`]. Implementations decide whether to
    /// print, count, accumulate, or drop it.
    fn record_skip(&mut self, report: SkipReport);
}

/// A no-op sink, useful in tests and as a default when callers don't
/// care about per-record warnings.
///
/// # Examples
///
/// ```
/// use face_core::NullDiagnostics;
///
/// // Hand to any function expecting `&mut dyn Diagnostics` (or a
/// // generic `D: Diagnostics`); the sink discards every event.
/// let mut diag = NullDiagnostics;
/// let _: &mut dyn face_core::Diagnostics = &mut diag;
/// ```
#[derive(Debug, Default)]
pub struct NullDiagnostics;

impl Diagnostics for NullDiagnostics {
    fn record_skip(&mut self, _: SkipReport) {}
}

/// A `Vec`-backed sink for tests and integration code that wants to
/// assert on the recorded skip events.
///
/// `VecDiagnostics` is `#[non_exhaustive]` — construct via
/// [`VecDiagnostics::default`]. The `skips` field stays `pub` so
/// integration tests can read recorded events directly; downstream
/// crates that prefer not to depend on a `pub` field can use
/// [`VecDiagnostics::skips`] for read access.
///
/// # Examples
///
/// ```
/// use face_core::VecDiagnostics;
///
/// let diag = VecDiagnostics::default();
/// assert!(diag.skips().is_empty());
/// ```
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct VecDiagnostics {
    /// Skips recorded since construction, in order.
    pub skips: Vec<SkipReport>,
}

impl VecDiagnostics {
    /// Borrow the recorded skip reports in insertion order.
    ///
    /// Equivalent to reading `self.skips` directly but doesn't depend
    /// on the `pub` field, which keeps the accessor stable if the
    /// internal representation evolves.
    pub fn skips(&self) -> &[SkipReport] {
        &self.skips
    }
}

impl Diagnostics for VecDiagnostics {
    fn record_skip(&mut self, report: SkipReport) {
        self.skips.push(report);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::SkipReason;

    #[test]
    fn null_sink_drops_events() {
        let mut diag = NullDiagnostics;
        diag.record_skip(SkipReport {
            record_index: 1,
            reason: SkipReason::MissingField { field: "x".into() },
        });
        // Nothing to assert — no observable state.
    }

    #[test]
    fn vec_sink_collects_in_order() {
        let mut diag = VecDiagnostics::default();
        diag.record_skip(SkipReport {
            record_index: 0,
            reason: SkipReason::MissingField { field: "a".into() },
        });
        diag.record_skip(SkipReport {
            record_index: 1,
            reason: SkipReason::MissingField { field: "b".into() },
        });
        assert_eq!(diag.skips.len(), 2);
        assert_eq!(diag.skips[0].record_index, 0);
        assert_eq!(diag.skips[1].record_index, 1);
    }
}