Skip to main content

lean_ctx_patterns/
json_schema.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use crate::json_crush;
4
5pub fn compress(output: &str) -> Option<String> {
6    let trimmed = output.trim();
7
8    if !trimmed.starts_with('{') && !trimmed.starts_with('[') {
9        return None;
10    }
11
12    let val: serde_json::Value = match serde_json::from_str(trimmed) {
13        Ok(v) => v,
14        Err(_) => return None,
15    };
16
17    // Prefer the lossless crusher when the payload is redundant: it keeps all
18    // data (reconstructible via `json_crush::reconstruct`) instead of the
19    // schema outline that drops every value (#934 / #936).
20    if let Some(text) = json_crush::crush_value_if_beneficial(&val, trimmed.len()) {
21        return Some(text);
22    }
23
24    let schema = extract_schema(&val, 0);
25    Some(schema)
26}
27
28/// Lossless crush of a verbatim data-command's JSON output (`gh api`, `jq`,
29/// `kubectl get -o json`, `curl` …). Returns `Some` only when it at least
30/// halves the payload, so an opt-in caller reshapes solely when it clearly
31/// pays — and never loses a datum. Returns `None` for non-JSON or low-redundancy
32/// output (the caller then keeps it verbatim).
33pub fn crush_verbatim(output: &str) -> Option<String> {
34    json_crush::crush_text_if_beneficial(output)
35}
36
37fn extract_schema(val: &serde_json::Value, depth: usize) -> String {
38    let indent = "  ".repeat(depth);
39    match val {
40        serde_json::Value::Object(map) => {
41            if map.is_empty() {
42                return format!("{indent}{{}}");
43            }
44            if depth > 3 {
45                return format!("{indent}{{...{} keys}}", map.len());
46            }
47
48            let mut entries = Vec::new();
49            for (key, value) in map.iter().take(20) {
50                let type_str = type_of(value);
51                match value {
52                    serde_json::Value::Object(inner) if !inner.is_empty() && depth < 3 => {
53                        let nested = extract_schema(value, depth + 1);
54                        entries.push(format!("{indent}  {key}: {{\n{nested}\n{indent}  }}"));
55                    }
56                    serde_json::Value::Array(arr) if !arr.is_empty() => {
57                        let item_type = if let Some(first) = arr.first() {
58                            type_of(first)
59                        } else {
60                            "any".to_string()
61                        };
62                        entries.push(format!("{indent}  {key}: [{item_type}...{}]", arr.len()));
63                    }
64                    _ => {
65                        entries.push(format!("{indent}  {key}: {type_str}"));
66                    }
67                }
68            }
69            if map.len() > 20 {
70                entries.push(format!("{indent}  ...+{} more keys", map.len() - 20));
71            }
72            entries.join("\n")
73        }
74        serde_json::Value::Array(arr) => {
75            if arr.is_empty() {
76                return format!("{indent}[]");
77            }
78            let first_schema = extract_schema(&arr[0], depth + 1);
79            format!(
80                "{indent}[{} items, each:\n{first_schema}\n{indent}]",
81                arr.len()
82            )
83        }
84        other => format!("{indent}{}", type_of(other)),
85    }
86}
87
88fn type_of(val: &serde_json::Value) -> String {
89    match val {
90        serde_json::Value::Null => "null".to_string(),
91        serde_json::Value::Bool(_) => "bool".to_string(),
92        serde_json::Value::Number(n) => {
93            if n.is_f64() {
94                "float".to_string()
95            } else {
96                "int".to_string()
97            }
98        }
99        serde_json::Value::String(s) => {
100            if s.len() > 50 {
101                format!("str({})", s.len())
102            } else {
103                "str".to_string()
104            }
105        }
106        serde_json::Value::Array(arr) => format!("[...{}]", arr.len()),
107        serde_json::Value::Object(map) => format!("{{...{} keys}}", map.len()),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    /// Array of objects sharing constant `status`/`region`, only `id` varying —
116    /// high redundancy the lossless crusher should factor into `_defaults`.
117    fn redundant_array(n: usize) -> String {
118        let items: Vec<String> = (0..n)
119            .map(|i| {
120                format!(
121                    "{{\"status\":\"ok\",\"region\":\"us-east-1\",\"tier\":\"standard\",\"id\":{i}}}"
122                )
123            })
124            .collect();
125        format!("[{}]", items.join(","))
126    }
127
128    #[test]
129    fn compress_prefers_lossless_crush_for_redundant_array() {
130        let raw = redundant_array(12);
131        let out = compress(&raw).expect("array-of-objects compresses");
132
133        // Lossless crush is chosen (marker present) — not the value-dropping
134        // schema outline.
135        assert!(out.contains("_lc_crush"), "expected crushed form: {out}");
136        assert!(!out.contains("items, each"), "schema outline leaked: {out}");
137
138        // It actually pays: at least halves the payload.
139        assert!(
140            out.len() * 2 <= raw.len(),
141            "crush must at least halve payload"
142        );
143
144        // And it is fully reversible.
145        let restored = json_crush::reconstruct(&out).expect("crushed form reconstructs");
146        let expected: serde_json::Value = serde_json::from_str(&raw).unwrap();
147        assert_eq!(restored, expected, "roundtrip must be lossless");
148    }
149
150    #[test]
151    fn compress_falls_back_to_schema_for_heterogeneous_array() {
152        // Every field varies → nothing to factor → crush not beneficial → the
153        // (lossy) schema outline is used instead.
154        let raw = r#"[{"id":1,"name":"alice","email":"a@x.io"},{"id":2,"name":"bob","email":"b@y.io"},{"id":3,"name":"cara","email":"c@z.io"}]"#;
155        let out = compress(raw).expect("array compresses to schema");
156        assert!(
157            !out.contains("_lc_crush"),
158            "should not crush low-redundancy"
159        );
160        assert!(
161            out.contains("items, each"),
162            "expected schema outline: {out}"
163        );
164    }
165
166    #[test]
167    fn crush_verbatim_some_only_when_it_pays() {
168        // Redundant → reshaped (and reversible).
169        let raw = redundant_array(20);
170        let crushed = crush_verbatim(&raw).expect("redundant verbatim json is crushed");
171        assert!(crushed.len() * 2 <= raw.len());
172        let restored = json_crush::reconstruct(&crushed).unwrap();
173        assert_eq!(
174            restored,
175            serde_json::from_str::<serde_json::Value>(&raw).unwrap()
176        );
177
178        // Low redundancy → None (caller keeps it verbatim).
179        let hetero = r#"[{"id":1,"k":"aaa"},{"id":2,"k":"bbb"}]"#;
180        assert!(crush_verbatim(hetero).is_none());
181
182        // Non-JSON → None.
183        assert!(crush_verbatim("not json at all").is_none());
184        assert!(crush_verbatim("").is_none());
185    }
186
187    #[test]
188    fn compress_is_deterministic() {
189        let raw = redundant_array(15);
190        let a = compress(&raw).unwrap();
191        let b = compress(&raw).unwrap();
192        assert_eq!(a, b, "output must be a pure function of input");
193    }
194}