Skip to main content

face_core/input/
items.rs

1//! §4.2 items-array detection.
2//!
3//! Given a fully-parsed JSON [`Value`], decide which array carries the
4//! records to cluster and surface any leftover top-level scalars as
5//! sidecar `meta` per §4.2's preservation rule.
6
7use serde_json::{Map, Value};
8
9use crate::error::{DetectionCandidate, FaceError};
10
11/// Names preferred when a JSON object has multiple array-valued fields.
12///
13/// Order matches the §4.2 spec: items, results, data, hits, matches, scopes.
14/// Earlier names win if more than one matches.
15pub const ITEMS_CANDIDATES: &[&str] = &["items", "results", "data", "hits", "matches", "scopes"];
16
17/// Tunable inputs for §4.2 items-array detection.
18#[derive(Debug, Clone, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct ItemsOptions {
21    /// Field names preferred when a root JSON object has multiple
22    /// array-valued fields.
23    pub candidates: Vec<String>,
24}
25
26impl Default for ItemsOptions {
27    fn default() -> Self {
28        Self {
29            candidates: ITEMS_CANDIDATES
30                .iter()
31                .map(|candidate| (*candidate).to_string())
32                .collect(),
33        }
34    }
35}
36
37/// Outcome of items-array detection.
38#[derive(Debug, Clone, PartialEq)]
39#[non_exhaustive]
40pub struct ItemsDetection {
41    /// Records to cluster.
42    pub items: Vec<Value>,
43    /// Path used to reach the items array (`.` for a bare array,
44    /// `.field` for an object field).
45    pub items_path: String,
46    /// Sidecar metadata: every non-array, non-object scalar plus
47    /// nested objects on the root, when the root was an object. `None`
48    /// when the root was a bare array.
49    pub meta: Option<Map<String, Value>>,
50}
51
52/// Run §4.2 items detection over a parsed JSON [`Value`].
53///
54/// # Errors
55///
56/// - [`FaceError::AmbiguousDetection`] when the root object has
57///   multiple array-valued fields and none match the named candidates.
58/// - [`FaceError::InputParse`] when the root is a scalar (the input is
59///   not an item-bearing shape).
60///
61/// # Examples
62///
63/// ```
64/// use face_core::input::items::detect_items;
65/// use serde_json::json;
66///
67/// let detection = detect_items(json!({"items": [1, 2, 3]})).unwrap();
68/// assert_eq!(detection.items_path, ".items");
69/// assert_eq!(detection.items.len(), 3);
70/// ```
71pub fn detect_items(value: Value) -> Result<ItemsDetection, FaceError> {
72    detect_items_with_options(value, &ItemsOptions::default())
73}
74
75/// Run §4.2 items detection with caller-supplied candidate names.
76pub fn detect_items_with_options(
77    value: Value,
78    options: &ItemsOptions,
79) -> Result<ItemsDetection, FaceError> {
80    match value {
81        Value::Array(items) => Ok(ItemsDetection {
82            items,
83            items_path: ".".to_string(),
84            meta: None,
85        }),
86        Value::Object(mut map) => detect_from_object(&mut map, options),
87        other => Err(FaceError::InputParse {
88            format: crate::InputFormat::Json,
89            message: format!(
90                "input is a {} scalar; clustering needs a JSON array or an object containing one",
91                value_kind(&other),
92            ),
93        }),
94    }
95}
96
97/// Inner helper: pick the items field from a JSON object root.
98fn detect_from_object(
99    map: &mut Map<String, Value>,
100    options: &ItemsOptions,
101) -> Result<ItemsDetection, FaceError> {
102    // All array-valued fields, in insertion order.
103    let array_fields: Vec<String> = map
104        .iter()
105        .filter_map(|(k, v)| v.as_array().map(|_| k.clone()))
106        .collect();
107
108    let chosen = match array_fields.len() {
109        0 => {
110            return Err(FaceError::InputParse {
111                format: crate::InputFormat::Json,
112                message: "input object has no array-valued field; \
113                          clustering needs an array of items"
114                    .to_string(),
115            });
116        }
117        1 => array_fields.into_iter().next().expect("len == 1"),
118        _ => pick_named_or_ambiguous(&array_fields, &options.candidates)?,
119    };
120
121    // Take the items array out, leaving the rest as sidecar meta.
122    let items = map
123        .remove(&chosen)
124        .and_then(|v| match v {
125            Value::Array(a) => Some(a),
126            _ => None,
127        })
128        .expect("array_fields was filtered by `as_array().is_some()`");
129
130    let mut meta = Map::new();
131    for (k, v) in map.iter() {
132        if !v.is_array() {
133            meta.insert(k.clone(), v.clone());
134        }
135    }
136    // Collapse an empty meta map to `None` so callers don't have to
137    // distinguish "object root with no scalar siblings" from
138    // "non-object root" themselves.
139    let meta = if meta.is_empty() { None } else { Some(meta) };
140
141    Ok(ItemsDetection {
142        items,
143        items_path: format!(".{chosen}"),
144        meta,
145    })
146}
147
148/// With multiple array-valued fields, prefer one of the §4.2 candidates;
149/// otherwise raise [`FaceError::AmbiguousDetection`].
150fn pick_named_or_ambiguous(
151    array_fields: &[String],
152    candidates: &[String],
153) -> Result<String, FaceError> {
154    for candidate in candidates {
155        if array_fields.iter().any(|f| f == candidate) {
156            return Ok(candidate.clone());
157        }
158    }
159
160    let candidates = array_fields
161        .iter()
162        .map(|f| DetectionCandidate::new(format!(".{f}"), 0))
163        .collect();
164    Err(FaceError::AmbiguousDetection {
165        candidates,
166        numeric_fields: Vec::new(),
167    })
168}
169
170/// One-word kind for the InputParse message.
171fn value_kind(v: &Value) -> &'static str {
172    match v {
173        Value::Null => "null",
174        Value::Bool(_) => "boolean",
175        Value::Number(_) => "number",
176        Value::String(_) => "string",
177        Value::Array(_) => "array",
178        Value::Object(_) => "object",
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use serde_json::json;
186
187    #[test]
188    fn detects_bare_array_as_identity() {
189        let det = detect_items(json!([1, 2, 3])).unwrap();
190        assert_eq!(det.items_path, ".");
191        assert_eq!(det.items.len(), 3);
192        assert!(det.meta.is_none());
193    }
194
195    #[test]
196    fn detects_single_array_field() {
197        let det = detect_items(json!({
198            "matches": [{"a": 1}, {"a": 2}],
199            "took_ms": 12,
200        }))
201        .unwrap();
202        assert_eq!(det.items_path, ".matches");
203        assert_eq!(det.items.len(), 2);
204        let meta = det.meta.unwrap();
205        assert_eq!(meta.get("took_ms"), Some(&json!(12)));
206    }
207
208    #[test]
209    fn detects_named_items_array() {
210        let det = detect_items(json!({"items": [1, 2, 3]})).unwrap();
211        assert_eq!(det.items_path, ".items");
212        assert_eq!(det.items.len(), 3);
213    }
214
215    #[test]
216    fn prefers_named_candidate_over_other_arrays() {
217        let det = detect_items(json!({
218            "extras": [9, 9],
219            "results": [1, 2],
220        }))
221        .unwrap();
222        assert_eq!(det.items_path, ".results");
223        // No scalar siblings → meta collapses to None. The non-chosen
224        // array is dropped (not preserved) per §4.2.
225        assert!(det.meta.is_none());
226    }
227
228    #[test]
229    fn ambiguous_when_multiple_unnamed() {
230        let err = detect_items(json!({
231            "alpha": [1, 2],
232            "beta":  [3, 4],
233        }))
234        .unwrap_err();
235        match err {
236            FaceError::AmbiguousDetection { candidates, .. } => {
237                assert_eq!(candidates.len(), 2);
238                assert!(candidates.iter().any(|c| c.field == ".alpha"));
239                assert!(candidates.iter().any(|c| c.field == ".beta"));
240            }
241            other => panic!("unexpected error: {other:?}"),
242        }
243    }
244
245    #[test]
246    fn errors_when_object_has_no_array_field() {
247        let err = detect_items(json!({"a": 1, "b": "x"})).unwrap_err();
248        match err {
249            FaceError::InputParse { .. } => {}
250            other => panic!("unexpected error: {other:?}"),
251        }
252    }
253
254    #[test]
255    fn errors_on_scalar_input() {
256        let err = detect_items(json!(42)).unwrap_err();
257        match err {
258            FaceError::InputParse { .. } => {}
259            other => panic!("unexpected error: {other:?}"),
260        }
261    }
262
263    #[test]
264    fn meta_preserves_nested_objects() {
265        let det = detect_items(json!({
266            "items": [1],
267            "query": {"q": "hello", "limit": 10},
268            "took_ms": 5,
269        }))
270        .unwrap();
271        let meta = det.meta.unwrap();
272        assert_eq!(meta.get("query"), Some(&json!({"q": "hello", "limit": 10})));
273        assert_eq!(meta.get("took_ms"), Some(&json!(5)));
274    }
275}