Skip to main content

lean_ctx/core/patterns/
json_schema.rs

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