Skip to main content

lean_ctx_patterns/
curl.rs

1// SPDX-License-Identifier: Apache-2.0
2
3fn truncate_at_char_boundary(s: &str, max: usize) -> &str {
4    &s[..s.floor_char_boundary(max)]
5}
6
7/// True when `command` targets one of the configured passthrough URLs, whose
8/// responses must reach the caller uncompressed. The engine supplies the URLs
9/// from its configuration through [`crate::PatternEnv`].
10pub fn is_passthrough(command: &str, passthrough_urls: &[String]) -> bool {
11    passthrough_urls
12        .iter()
13        .any(|url| command.contains(url.as_str()))
14}
15
16pub fn compress(output: &str) -> Option<String> {
17    let trimmed = output.trim();
18
19    if trimmed.starts_with('{') || trimmed.starts_with('[') {
20        return compress_json(trimmed);
21    }
22
23    if trimmed.starts_with("<!") || trimmed.starts_with("<html") || trimmed.starts_with("<HTML") {
24        return Some(compress_html(trimmed));
25    }
26
27    if trimmed.starts_with("HTTP/") {
28        return compress_headers(trimmed);
29    }
30
31    if trimmed.starts_with("<?xml") || trimmed.starts_with("<rss") || trimmed.starts_with("<feed") {
32        let lines = trimmed.lines().count();
33        let size = trimmed.len();
34        return Some(format!("XML ({size} bytes, {lines} lines)"));
35    }
36
37    if trimmed.len() > 2000 {
38        return Some(compress_large_text(trimmed));
39    }
40
41    None
42}
43
44fn compress_large_text(output: &str) -> String {
45    let lines: Vec<&str> = output.lines().collect();
46    let total_lines = lines.len();
47    let size = output.len();
48
49    let head_count = 20.min(total_lines);
50    let tail_count = 10.min(total_lines.saturating_sub(head_count));
51
52    let mut result = String::with_capacity(2048);
53    result.push_str(&format!(
54        "curl output ({size} bytes, {total_lines} lines):\n"
55    ));
56    for line in lines.iter().take(head_count) {
57        if line.len() > 200 {
58            result.push_str(truncate_at_char_boundary(line, 200));
59            result.push_str("…\n");
60        } else {
61            result.push_str(line);
62            result.push('\n');
63        }
64    }
65    if total_lines > head_count + tail_count {
66        result.push_str(&format!(
67            "\n[… {} lines omitted …]\n\n",
68            total_lines - head_count - tail_count
69        ));
70        for line in lines.iter().skip(total_lines - tail_count) {
71            if line.len() > 200 {
72                result.push_str(truncate_at_char_boundary(line, 200));
73                result.push_str("…\n");
74            } else {
75                result.push_str(line);
76                result.push('\n');
77            }
78        }
79    }
80    result
81}
82
83fn compress_json(output: &str) -> Option<String> {
84    let val: serde_json::Value = serde_json::from_str(output).ok()?;
85
86    // A top-level array of repeated objects (paginated list responses) is the
87    // one shape where the redacting schema below collapses to a useless
88    // `[object(NK); N]`. Defer to the shared generic core (#936): the lossless
89    // crusher keeps every datum in a reconstructible form when it at least
90    // halves the payload. Objects keep curl's redacting, value-previewing schema
91    // (secret keys, short-string previews) — the crusher is value-preserving and
92    // must never be used where redaction is the contract.
93    if matches!(val, serde_json::Value::Array(_))
94        && let Some(crushed) = crate::json_crush::crush_value_if_beneficial(&val, output.len())
95    {
96        return Some(format!("JSON ({} bytes):\n{}", output.len(), crushed));
97    }
98
99    let schema = extract_schema(&val, 0);
100    let size = output.len();
101
102    Some(format!("JSON ({size} bytes):\n{schema}"))
103}
104
105fn extract_schema(val: &serde_json::Value, depth: usize) -> String {
106    if depth > 3 {
107        return "  ".repeat(depth) + "...";
108    }
109
110    let indent = "  ".repeat(depth);
111
112    match val {
113        serde_json::Value::Object(map) => {
114            let mut lines = Vec::new();
115            for (key, value) in map.iter().take(15) {
116                let type_str = match value {
117                    serde_json::Value::Null => "null".to_string(),
118                    serde_json::Value::Bool(_) => "bool".to_string(),
119                    serde_json::Value::Number(_) => "number".to_string(),
120                    serde_json::Value::String(s) => {
121                        if is_sensitive_key(key) {
122                            format!("string({}, REDACTED)", s.len())
123                        } else if s.len() > 50 {
124                            format!("string({})", s.len())
125                        } else {
126                            format!("\"{s}\"")
127                        }
128                    }
129                    serde_json::Value::Array(arr) => {
130                        if arr.is_empty() {
131                            "[]".to_string()
132                        } else {
133                            let inner = value_type(&arr[0]);
134                            format!("[{inner}; {}]", arr.len())
135                        }
136                    }
137                    serde_json::Value::Object(inner) => {
138                        if inner.len() <= 3 {
139                            let keys: Vec<&String> = inner.keys().collect();
140                            format!(
141                                "{{{}}}",
142                                keys.iter()
143                                    .map(|k| k.as_str())
144                                    .collect::<Vec<_>>()
145                                    .join(", ")
146                            )
147                        } else {
148                            format!("{{{}K}}", inner.len())
149                        }
150                    }
151                };
152                lines.push(format!("{indent}  {key}: {type_str}"));
153            }
154            if map.len() > 15 {
155                lines.push(format!("{indent}  ... +{} more keys", map.len() - 15));
156            }
157            format!("{indent}{{\n{}\n{indent}}}", lines.join("\n"))
158        }
159        serde_json::Value::Array(arr) => {
160            if arr.is_empty() {
161                format!("{indent}[]")
162            } else {
163                let inner = value_type(&arr[0]);
164                format!("{indent}[{inner}; {}]", arr.len())
165            }
166        }
167        _ => format!("{indent}{}", value_type(val)),
168    }
169}
170
171fn value_type(val: &serde_json::Value) -> String {
172    match val {
173        serde_json::Value::Null => "null".to_string(),
174        serde_json::Value::Bool(_) => "bool".to_string(),
175        serde_json::Value::Number(_) => "number".to_string(),
176        serde_json::Value::String(_) => "string".to_string(),
177        serde_json::Value::Array(_) => "array".to_string(),
178        serde_json::Value::Object(m) => format!("object({}K)", m.len()),
179    }
180}
181
182fn is_sensitive_key(key: &str) -> bool {
183    let lower = key.to_ascii_lowercase();
184    lower.contains("token")
185        || lower.contains("key")
186        || lower.contains("secret")
187        || lower.contains("password")
188        || lower.contains("passwd")
189        || lower.contains("auth")
190        || lower.contains("credential")
191        || lower.contains("api_key")
192        || lower.contains("apikey")
193        || lower.contains("access_token")
194        || lower.contains("refresh_token")
195        || lower.contains("private")
196}
197
198fn compress_html(output: &str) -> String {
199    let lines = output.lines().count();
200    let size = output.len();
201
202    let title = output
203        .find("<title>")
204        .and_then(|start| {
205            let after = &output[start + 7..];
206            after.find("</title>").map(|end| &after[..end])
207        })
208        .unwrap_or("(no title)");
209
210    format!("HTML: \"{title}\" ({size} bytes, {lines} lines)")
211}
212
213fn compress_headers(output: &str) -> Option<String> {
214    let mut status = String::new();
215    let mut content_type = String::new();
216    let mut content_length = String::new();
217
218    for line in output.lines().take(20) {
219        if line.starts_with("HTTP/") {
220            status = line.to_string();
221        } else if line.to_lowercase().starts_with("content-type:") {
222            content_type = line.split(':').nth(1).unwrap_or("").trim().to_string();
223        } else if line.to_lowercase().starts_with("content-length:") {
224            content_length = line.split(':').nth(1).unwrap_or("").trim().to_string();
225        }
226    }
227
228    if status.is_empty() {
229        return None;
230    }
231
232    let mut result = status;
233    if !content_type.is_empty() {
234        result.push_str(&format!(" | {content_type}"));
235    }
236    if !content_length.is_empty() {
237        result.push_str(&format!(" | {content_length}B"));
238    }
239
240    Some(result)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn json_gets_compressed() {
249        let json = r#"{"name":"test","value":42,"nested":{"a":1,"b":2}}"#;
250        let result = compress(json);
251        assert!(result.is_some());
252        assert!(result.unwrap().contains("JSON"));
253    }
254
255    #[test]
256    fn json_array_of_objects_uses_lossless_crush() {
257        // Top-level array with a field constant across all rows: the shared
258        // `json_crush` core compacts it losslessly instead of curl's useless
259        // `[object(NK); N]` summary (#936).
260        let mut json = String::from("[");
261        for i in 0..12 {
262            if i > 0 {
263                json.push(',');
264            }
265            json.push_str(&format!(r#"{{"status":"active","plan":"pro","id":{i}}}"#));
266        }
267        json.push(']');
268        let result = compress(&json).expect("array json compresses");
269        assert!(
270            result.contains("JSON ("),
271            "keeps curl JSON banner: {result}"
272        );
273        assert!(
274            result.contains("_lc_crush"),
275            "expected shared crushed core output: {result}"
276        );
277        let body = result.split_once('\n').unwrap().1;
278        let restored = crate::json_crush::reconstruct(body).expect("reconstructs");
279        assert_eq!(
280            restored,
281            serde_json::from_str::<serde_json::Value>(&json).unwrap()
282        );
283    }
284
285    #[test]
286    fn html_gets_compressed() {
287        let html = "<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>";
288        let result = compress(html);
289        assert!(result.is_some());
290        assert!(result.unwrap().contains("HTML"));
291    }
292
293    #[test]
294    fn plain_text_returns_none() {
295        assert!(compress("just plain text").is_none());
296    }
297}