Skip to main content

lean_ctx/core/extractors/
json.rs

1//! JSON → clean text + structure-aware chunks (EPIC 12.13).
2//!
3//! Valid JSON is rendered as stable pretty text and chunked by its top-level
4//! structure (one chunk per array element / object entry) so each chunk is a
5//! self-contained record. Invalid JSON degrades gracefully to a single chunk —
6//! the seam must never panic or drop content for arbitrary input.
7
8use serde_json::Value;
9
10/// Render `input` as normalized JSON text (pretty, stable key order via serde).
11/// Falls back to the trimmed input when it is not valid JSON.
12#[must_use]
13pub fn to_text(input: &str) -> String {
14    match serde_json::from_str::<Value>(input) {
15        Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| input.trim().to_string()),
16        Err(_) => input.trim().to_string(),
17    }
18}
19
20/// Structure-aware chunks: array ⇒ one chunk per element; object ⇒ one chunk per
21/// `"key": value` entry; scalar/invalid ⇒ a single chunk. Never empty for
22/// non-empty input.
23#[must_use]
24pub fn chunks(input: &str) -> Vec<String> {
25    let out = match serde_json::from_str::<Value>(input) {
26        Ok(Value::Array(items)) if !items.is_empty() => items
27            .iter()
28            .map(|v| serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()))
29            .collect(),
30        Ok(Value::Object(map)) if !map.is_empty() => map
31            .iter()
32            .map(|(k, v)| {
33                let val = serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string());
34                format!("{}: {}", serde_json::to_string(k).unwrap_or_default(), val)
35            })
36            .collect(),
37        Ok(other) => {
38            vec![serde_json::to_string_pretty(&other).unwrap_or_else(|_| other.to_string())]
39        }
40        Err(_) => vec![input.trim().to_string()],
41    };
42    out.into_iter().filter(|c| !c.trim().is_empty()).collect()
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[test]
50    fn array_chunks_per_element() {
51        let c = chunks(r#"[{"a":1},{"b":2}]"#);
52        assert_eq!(c.len(), 2);
53        assert!(c[0].contains("\"a\""));
54        assert!(c[1].contains("\"b\""));
55    }
56
57    #[test]
58    fn object_chunks_per_entry() {
59        let c = chunks(r#"{"name":"x","age":3}"#);
60        assert_eq!(c.len(), 2);
61        assert!(c.iter().any(|s| s.contains("\"name\"")));
62    }
63
64    #[test]
65    fn invalid_json_is_single_chunk() {
66        let c = chunks("not json at all");
67        assert_eq!(c, vec!["not json at all".to_string()]);
68    }
69
70    #[test]
71    fn to_text_normalizes() {
72        let t = to_text(r#"{"b":2,"a":1}"#);
73        assert!(t.contains("\"a\": 1"));
74    }
75}