face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! One concrete record passed into clustering.
//!
//! [`Record`] is the unit the tree builder operates on: the parsed
//! input value plus an optional resolved score. Score resolution is
//! per-record permissive — strategies decide whether a missing score
//! is a problem (§5.2 numeric strategies require scores; categorical
//! strategies don't). `--invert` polarity flipping is the caller's
//! responsibility; this slice does not auto-invert.

use serde_json::Value;

/// One concrete record passed into clustering.
///
/// `raw` is the original parsed item (never mutated). `score` is the
/// result of resolving the optional score path against `raw` and
/// converting to `f64`. `None` when no score was configured for this
/// run, or when the score path didn't resolve to a number for this
/// particular record.
///
/// `Record` is `#[non_exhaustive]` — construct values via
/// [`Record::new`] or [`Record::from_items`].
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Record {
    /// Original parsed item, never mutated.
    pub raw: Value,
    /// Resolved score, when a score path was configured and the value
    /// at that path was a JSON number.
    pub score: Option<f64>,
}

impl Record {
    /// Construct a record with no score resolved.
    ///
    /// # Examples
    ///
    /// ```
    /// use face_core::Record;
    /// use serde_json::json;
    ///
    /// let r = Record::new(json!({"score": 0.9}));
    /// assert!(r.score.is_none());
    /// ```
    pub fn new(raw: Value) -> Self {
        Self { raw, score: None }
    }

    /// Build a `Vec<Record>` from a parsed items list.
    ///
    /// When `score_path` is `None`, all records have `score: None`.
    /// When `score_path` is `Some(path)`, the path is resolved against
    /// each record. If the resolved value is a JSON number, it
    /// becomes the record's score. Otherwise the score remains `None`
    /// (this is per-record permissiveness — strategies decide whether
    /// missing scores are a problem).
    ///
    /// `--invert` polarity flipping is the caller's responsibility:
    /// pass already-inverted scores via a follow-up `apply_invert`,
    /// or build records via `from_items` and let the caller iterate.
    /// Slice 5 does not auto-invert.
    ///
    /// # Examples
    ///
    /// ```
    /// use face_core::Record;
    /// use serde_json::json;
    ///
    /// let items = vec![
    ///     json!({"score": 0.9}),
    ///     json!({"score": "n/a"}),
    /// ];
    /// let records = Record::from_items(items, Some("score"));
    /// assert_eq!(records[0].score, Some(0.9));
    /// assert_eq!(records[1].score, None);
    /// ```
    pub fn from_items(items: Vec<Value>, score_path: Option<&str>) -> Vec<Record> {
        items
            .into_iter()
            .map(|raw| {
                let score = score_path
                    .and_then(|p| crate::path::resolve(&raw, p).ok())
                    .and_then(Value::as_f64);
                Record { raw, score }
            })
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn from_items_resolves_score() {
        let items = vec![json!({"score": 0.9}), json!({"score": 0.42})];
        let records = Record::from_items(items, Some("score"));
        assert_eq!(records.len(), 2);
        assert_eq!(records[0].score, Some(0.9));
        assert_eq!(records[1].score, Some(0.42));
    }

    #[test]
    fn from_items_handles_missing_path() {
        // Path not found, value is non-numeric, and no path requested
        // all collapse to `score: None` without erroring.
        let items = vec![
            json!({"score": "high"}),
            json!({"other": 1.0}),
            json!({"score": 0.5}),
        ];
        let records = Record::from_items(items, Some("score"));
        assert_eq!(records[0].score, None);
        assert_eq!(records[1].score, None);
        assert_eq!(records[2].score, Some(0.5));

        // No score path at all → all None.
        let items = vec![json!({"score": 0.9})];
        let records = Record::from_items(items, None);
        assert_eq!(records[0].score, None);
    }

    #[test]
    fn new_constructs_score_none() {
        let r = Record::new(json!({"foo": 1}));
        assert!(r.score.is_none());
    }
}