face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! Shared helpers used by multiple per-strategy clusterers (§5.2,
//! §5.3): exact-value keying, score range aggregation, and numeric
//! extraction.
//!
//! Lifted out of `exact.rs` so `top.rs` (and any future categorical
//! strategy that groups by exact value) can reuse the same coercion
//! rules without duplicating them. The numeric helper is shared by the
//! §5.2 `bands` / `quantiles` / `natural` clusterers.

use serde_json::Value;

use crate::{Diagnostics, Record, SkipReason, SkipReport};

/// Lowercase JSON kind name, suitable for [`SkipReason::WrongType`]'s
/// `kind` field and stderr messages.
pub(super) fn json_kind(value: &Value) -> &'static str {
    match value {
        Value::Null => "null",
        Value::Bool(_) => "boolean",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}

/// String representation of a JSON value for exact-cluster keying.
///
/// - `String` → the string itself.
/// - `Bool` → `"true"` / `"false"`.
/// - `Number` (integer-valued, signed or unsigned) → integer string.
/// - `Number` (fractional) → not accepted (record drops).
/// - `Null` → `"null"` (kept as a label so users see the gap).
/// - `Array` / `Object` → not supported for exact (strategy auto-pick
///   would have routed these elsewhere); record drops.
pub(super) fn exact_key(value: &Value) -> Option<String> {
    match value {
        Value::String(s) => Some(s.clone()),
        Value::Bool(b) => Some(if *b { "true".into() } else { "false".into() }),
        Value::Null => Some("null".into()),
        Value::Number(n) => {
            if let Some(i) = n.as_i64() {
                Some(i.to_string())
            } else {
                // Fractional / non-finite / out-of-i64 unsigned: drop.
                n.as_u64().map(|u| u.to_string())
            }
        }
        Value::Array(_) | Value::Object(_) => None,
    }
}

/// Resolve the axis `field` on a record to an `f64` for the §5.2
/// numeric strategies (`bands`, `quantiles`, `natural`).
///
/// Returns `Some(f64)` when the path resolves to a JSON number that
/// fits an `f64`. Returns `None` when:
///
/// - the path is absent on this record,
/// - the value is not a number (string, array, object, null),
/// - the value is a boolean (consistent with score-detection's choice
///   per §5.1: booleans go through the categorical path).
///
/// Callers decide whether the `None` outcome should drop silently
/// (path absent) or drop with a [`crate::SkipReport`] (present but
/// not numeric) — the helper does not have the context to distinguish
/// the two cases on its own. See [`crate::path::resolve`] for the
/// missing-path signal: a `Result::Err` from `resolve` is the absent
/// case, while a `Some(value)` whose `as_f64` is `None` is the
/// present-but-uncoercible case.
pub(super) fn extract_numeric(value: &Value) -> Option<f64> {
    match value {
        // `as_f64` would happily turn `true`/`false` into `1.0`/`0.0`.
        // §5.1 routes booleans to categorical, so reject them here.
        Value::Bool(_) => None,
        Value::Number(n) => n.as_f64(),
        _ => None,
    }
}

/// Resolve every record's `field` to an `f64` for the §5.2 numeric
/// strategies. Missing-path records drop silently; records whose
/// resolved value is not a finite numeric (per [`extract_numeric`]
/// plus the `is_finite` check) drop and emit a [`SkipReport`].
///
/// Used by `bands`, `quantiles`, and `natural`. Each strategy gets
/// the same record-extraction policy without duplicating the loop.
pub(super) fn resolve_numeric<D: Diagnostics + ?Sized>(
    items: Vec<Record>,
    field: &str,
    diag: &mut D,
) -> Vec<(f64, Record)> {
    let mut out: Vec<(f64, Record)> = Vec::with_capacity(items.len());
    for (index, record) in items.into_iter().enumerate() {
        let Ok(resolved) = crate::path::resolve(&record.raw, field) else {
            // Path absent — silent drop.
            continue;
        };
        match extract_numeric(resolved) {
            Some(v) if v.is_finite() => out.push((v, record)),
            // Present-but-not-a-finite-number: the path resolved, the
            // value just isn't usable. Surface as `WrongType` so the
            // user sees the kind that tripped the resolver.
            _ => diag.record_skip(SkipReport {
                record_index: index,
                reason: SkipReason::WrongType {
                    field: field.to_string(),
                    kind: json_kind(resolved).to_string(),
                },
            }),
        }
    }
    out
}

/// Min/max of resolved scores across `group`. `(None, None)` when no
/// record has a score.
pub(super) fn score_range(group: &[Record]) -> (Option<f64>, Option<f64>) {
    let mut min: Option<f64> = None;
    let mut max: Option<f64> = None;
    for r in group {
        if let Some(s) = r.score {
            min = Some(match min {
                Some(m) if m < s => m,
                _ => s,
            });
            max = Some(match max {
                Some(m) if m > s => m,
                _ => s,
            });
        }
    }
    (min, max)
}