face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! §4.2 items-array detection.
//!
//! Given a fully-parsed JSON [`Value`], decide which array carries the
//! records to cluster and surface any leftover top-level scalars as
//! sidecar `meta` per §4.2's preservation rule.

use serde_json::{Map, Value};

use crate::error::{DetectionCandidate, FaceError};

/// Names preferred when a JSON object has multiple array-valued fields.
///
/// Order matches the §4.2 spec: items, results, data, hits, matches, scopes.
/// Earlier names win if more than one matches.
pub const ITEMS_CANDIDATES: &[&str] = &["items", "results", "data", "hits", "matches", "scopes"];

/// Tunable inputs for §4.2 items-array detection.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ItemsOptions {
    /// Field names preferred when a root JSON object has multiple
    /// array-valued fields.
    pub candidates: Vec<String>,
}

impl Default for ItemsOptions {
    fn default() -> Self {
        Self {
            candidates: ITEMS_CANDIDATES
                .iter()
                .map(|candidate| (*candidate).to_string())
                .collect(),
        }
    }
}

/// Outcome of items-array detection.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ItemsDetection {
    /// Records to cluster.
    pub items: Vec<Value>,
    /// Path used to reach the items array (`.` for a bare array,
    /// `.field` for an object field).
    pub items_path: String,
    /// Sidecar metadata: every non-array, non-object scalar plus
    /// nested objects on the root, when the root was an object. `None`
    /// when the root was a bare array.
    pub meta: Option<Map<String, Value>>,
}

/// Run §4.2 items detection over a parsed JSON [`Value`].
///
/// # Errors
///
/// - [`FaceError::AmbiguousDetection`] when the root object has
///   multiple array-valued fields and none match the named candidates.
/// - [`FaceError::InputParse`] when the root is a scalar (the input is
///   not an item-bearing shape).
///
/// # Examples
///
/// ```
/// use face_core::input::items::detect_items;
/// use serde_json::json;
///
/// let detection = detect_items(json!({"items": [1, 2, 3]})).unwrap();
/// assert_eq!(detection.items_path, ".items");
/// assert_eq!(detection.items.len(), 3);
/// ```
pub fn detect_items(value: Value) -> Result<ItemsDetection, FaceError> {
    detect_items_with_options(value, &ItemsOptions::default())
}

/// Run §4.2 items detection with caller-supplied candidate names.
pub fn detect_items_with_options(
    value: Value,
    options: &ItemsOptions,
) -> Result<ItemsDetection, FaceError> {
    match value {
        Value::Array(items) => Ok(ItemsDetection {
            items,
            items_path: ".".to_string(),
            meta: None,
        }),
        Value::Object(mut map) => detect_from_object(&mut map, options),
        other => Err(FaceError::InputParse {
            format: crate::InputFormat::Json,
            message: format!(
                "input is a {} scalar; clustering needs a JSON array or an object containing one",
                value_kind(&other),
            ),
        }),
    }
}

/// Inner helper: pick the items field from a JSON object root.
fn detect_from_object(
    map: &mut Map<String, Value>,
    options: &ItemsOptions,
) -> Result<ItemsDetection, FaceError> {
    // All array-valued fields, in insertion order.
    let array_fields: Vec<String> = map
        .iter()
        .filter_map(|(k, v)| v.as_array().map(|_| k.clone()))
        .collect();

    let chosen = match array_fields.len() {
        0 => {
            return Err(FaceError::InputParse {
                format: crate::InputFormat::Json,
                message: "input object has no array-valued field; \
                          clustering needs an array of items"
                    .to_string(),
            });
        }
        1 => array_fields.into_iter().next().expect("len == 1"),
        _ => pick_named_or_ambiguous(&array_fields, &options.candidates)?,
    };

    // Take the items array out, leaving the rest as sidecar meta.
    let items = map
        .remove(&chosen)
        .and_then(|v| match v {
            Value::Array(a) => Some(a),
            _ => None,
        })
        .expect("array_fields was filtered by `as_array().is_some()`");

    let mut meta = Map::new();
    for (k, v) in map.iter() {
        if !v.is_array() {
            meta.insert(k.clone(), v.clone());
        }
    }
    // Collapse an empty meta map to `None` so callers don't have to
    // distinguish "object root with no scalar siblings" from
    // "non-object root" themselves.
    let meta = if meta.is_empty() { None } else { Some(meta) };

    Ok(ItemsDetection {
        items,
        items_path: format!(".{chosen}"),
        meta,
    })
}

/// With multiple array-valued fields, prefer one of the §4.2 candidates;
/// otherwise raise [`FaceError::AmbiguousDetection`].
fn pick_named_or_ambiguous(
    array_fields: &[String],
    candidates: &[String],
) -> Result<String, FaceError> {
    for candidate in candidates {
        if array_fields.iter().any(|f| f == candidate) {
            return Ok(candidate.clone());
        }
    }

    let candidates = array_fields
        .iter()
        .map(|f| DetectionCandidate::new(format!(".{f}"), 0))
        .collect();
    Err(FaceError::AmbiguousDetection {
        candidates,
        numeric_fields: Vec::new(),
    })
}

/// One-word kind for the InputParse message.
fn value_kind(v: &Value) -> &'static str {
    match v {
        Value::Null => "null",
        Value::Bool(_) => "boolean",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}

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

    #[test]
    fn detects_bare_array_as_identity() {
        let det = detect_items(json!([1, 2, 3])).unwrap();
        assert_eq!(det.items_path, ".");
        assert_eq!(det.items.len(), 3);
        assert!(det.meta.is_none());
    }

    #[test]
    fn detects_single_array_field() {
        let det = detect_items(json!({
            "matches": [{"a": 1}, {"a": 2}],
            "took_ms": 12,
        }))
        .unwrap();
        assert_eq!(det.items_path, ".matches");
        assert_eq!(det.items.len(), 2);
        let meta = det.meta.unwrap();
        assert_eq!(meta.get("took_ms"), Some(&json!(12)));
    }

    #[test]
    fn detects_named_items_array() {
        let det = detect_items(json!({"items": [1, 2, 3]})).unwrap();
        assert_eq!(det.items_path, ".items");
        assert_eq!(det.items.len(), 3);
    }

    #[test]
    fn prefers_named_candidate_over_other_arrays() {
        let det = detect_items(json!({
            "extras": [9, 9],
            "results": [1, 2],
        }))
        .unwrap();
        assert_eq!(det.items_path, ".results");
        // No scalar siblings → meta collapses to None. The non-chosen
        // array is dropped (not preserved) per §4.2.
        assert!(det.meta.is_none());
    }

    #[test]
    fn ambiguous_when_multiple_unnamed() {
        let err = detect_items(json!({
            "alpha": [1, 2],
            "beta":  [3, 4],
        }))
        .unwrap_err();
        match err {
            FaceError::AmbiguousDetection { candidates, .. } => {
                assert_eq!(candidates.len(), 2);
                assert!(candidates.iter().any(|c| c.field == ".alpha"));
                assert!(candidates.iter().any(|c| c.field == ".beta"));
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn errors_when_object_has_no_array_field() {
        let err = detect_items(json!({"a": 1, "b": "x"})).unwrap_err();
        match err {
            FaceError::InputParse { .. } => {}
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn errors_on_scalar_input() {
        let err = detect_items(json!(42)).unwrap_err();
        match err {
            FaceError::InputParse { .. } => {}
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn meta_preserves_nested_objects() {
        let det = detect_items(json!({
            "items": [1],
            "query": {"q": "hello", "limit": 10},
            "took_ms": 5,
        }))
        .unwrap();
        let meta = det.meta.unwrap();
        assert_eq!(meta.get("query"), Some(&json!({"q": "hello", "limit": 10})));
        assert_eq!(meta.get("took_ms"), Some(&json!(5)));
    }
}