Skip to main content

lean_ctx/core/patterns/
curl.rs

1fn truncate_at_char_boundary(s: &str, max: usize) -> &str {
2    &s[..s.floor_char_boundary(max)]
3}
4
5pub fn compress_with_cmd(command: &str, output: &str) -> Option<String> {
6    let cfg = crate::core::config::Config::load();
7    if !cfg.passthrough_urls.is_empty() {
8        for url in &cfg.passthrough_urls {
9            if command.contains(url.as_str()) {
10                return None;
11            }
12        }
13    }
14    compress(output)
15}
16
17pub fn compress(output: &str) -> Option<String> {
18    let trimmed = output.trim();
19
20    if trimmed.starts_with('{') || trimmed.starts_with('[') {
21        return compress_json(trimmed);
22    }
23
24    if trimmed.starts_with("<!") || trimmed.starts_with("<html") || trimmed.starts_with("<HTML") {
25        return Some(compress_html(trimmed));
26    }
27
28    if trimmed.starts_with("HTTP/") {
29        return compress_headers(trimmed);
30    }
31
32    if trimmed.starts_with("<?xml") || trimmed.starts_with("<rss") || trimmed.starts_with("<feed") {
33        let lines = trimmed.lines().count();
34        let size = trimmed.len();
35        return Some(format!("XML ({size} bytes, {lines} lines)"));
36    }
37
38    if trimmed.len() > 2000 {
39        return Some(compress_large_text(trimmed));
40    }
41
42    None
43}
44
45fn compress_large_text(output: &str) -> String {
46    let lines: Vec<&str> = output.lines().collect();
47    let total_lines = lines.len();
48    let size = output.len();
49
50    let head_count = 20.min(total_lines);
51    let tail_count = 10.min(total_lines.saturating_sub(head_count));
52
53    let mut result = String::with_capacity(2048);
54    result.push_str(&format!(
55        "curl output ({size} bytes, {total_lines} lines):\n"
56    ));
57    for line in lines.iter().take(head_count) {
58        if line.len() > 200 {
59            result.push_str(truncate_at_char_boundary(line, 200));
60            result.push_str("…\n");
61        } else {
62            result.push_str(line);
63            result.push('\n');
64        }
65    }
66    if total_lines > head_count + tail_count {
67        result.push_str(&format!(
68            "\n[… {} lines omitted …]\n\n",
69            total_lines - head_count - tail_count
70        ));
71        for line in lines.iter().skip(total_lines - tail_count) {
72            if line.len() > 200 {
73                result.push_str(truncate_at_char_boundary(line, 200));
74                result.push_str("…\n");
75            } else {
76                result.push_str(line);
77                result.push('\n');
78            }
79        }
80    }
81    result
82}
83
84fn compress_json(output: &str) -> Option<String> {
85    let val: serde_json::Value = serde_json::from_str(output).ok()?;
86    let schema = extract_schema(&val, 0);
87    let size = output.len();
88
89    Some(format!("JSON ({size} bytes):\n{schema}"))
90}
91
92fn extract_schema(val: &serde_json::Value, depth: usize) -> String {
93    if depth > 3 {
94        return "  ".repeat(depth) + "...";
95    }
96
97    let indent = "  ".repeat(depth);
98
99    match val {
100        serde_json::Value::Object(map) => {
101            let mut lines = Vec::new();
102            for (key, value) in map.iter().take(15) {
103                let type_str = match value {
104                    serde_json::Value::Null => "null".to_string(),
105                    serde_json::Value::Bool(_) => "bool".to_string(),
106                    serde_json::Value::Number(_) => "number".to_string(),
107                    serde_json::Value::String(s) => {
108                        if is_sensitive_key(key) {
109                            format!("string({}, REDACTED)", s.len())
110                        } else if s.len() > 50 {
111                            format!("string({})", s.len())
112                        } else {
113                            format!("\"{s}\"")
114                        }
115                    }
116                    serde_json::Value::Array(arr) => {
117                        if arr.is_empty() {
118                            "[]".to_string()
119                        } else {
120                            let inner = value_type(&arr[0]);
121                            format!("[{inner}; {}]", arr.len())
122                        }
123                    }
124                    serde_json::Value::Object(inner) => {
125                        if inner.len() <= 3 {
126                            let keys: Vec<&String> = inner.keys().collect();
127                            format!(
128                                "{{{}}}",
129                                keys.iter()
130                                    .map(|k| k.as_str())
131                                    .collect::<Vec<_>>()
132                                    .join(", ")
133                            )
134                        } else {
135                            format!("{{{}K}}", inner.len())
136                        }
137                    }
138                };
139                lines.push(format!("{indent}  {key}: {type_str}"));
140            }
141            if map.len() > 15 {
142                lines.push(format!("{indent}  ... +{} more keys", map.len() - 15));
143            }
144            format!("{indent}{{\n{}\n{indent}}}", lines.join("\n"))
145        }
146        serde_json::Value::Array(arr) => {
147            if arr.is_empty() {
148                format!("{indent}[]")
149            } else {
150                let inner = value_type(&arr[0]);
151                format!("{indent}[{inner}; {}]", arr.len())
152            }
153        }
154        _ => format!("{indent}{}", value_type(val)),
155    }
156}
157
158fn value_type(val: &serde_json::Value) -> String {
159    match val {
160        serde_json::Value::Null => "null".to_string(),
161        serde_json::Value::Bool(_) => "bool".to_string(),
162        serde_json::Value::Number(_) => "number".to_string(),
163        serde_json::Value::String(_) => "string".to_string(),
164        serde_json::Value::Array(_) => "array".to_string(),
165        serde_json::Value::Object(m) => format!("object({}K)", m.len()),
166    }
167}
168
169fn is_sensitive_key(key: &str) -> bool {
170    let lower = key.to_ascii_lowercase();
171    lower.contains("token")
172        || lower.contains("key")
173        || lower.contains("secret")
174        || lower.contains("password")
175        || lower.contains("passwd")
176        || lower.contains("auth")
177        || lower.contains("credential")
178        || lower.contains("api_key")
179        || lower.contains("apikey")
180        || lower.contains("access_token")
181        || lower.contains("refresh_token")
182        || lower.contains("private")
183}
184
185fn compress_html(output: &str) -> String {
186    let lines = output.lines().count();
187    let size = output.len();
188
189    let title = output
190        .find("<title>")
191        .and_then(|start| {
192            let after = &output[start + 7..];
193            after.find("</title>").map(|end| &after[..end])
194        })
195        .unwrap_or("(no title)");
196
197    format!("HTML: \"{title}\" ({size} bytes, {lines} lines)")
198}
199
200fn compress_headers(output: &str) -> Option<String> {
201    let mut status = String::new();
202    let mut content_type = String::new();
203    let mut content_length = String::new();
204
205    for line in output.lines().take(20) {
206        if line.starts_with("HTTP/") {
207            status = line.to_string();
208        } else if line.to_lowercase().starts_with("content-type:") {
209            content_type = line.split(':').nth(1).unwrap_or("").trim().to_string();
210        } else if line.to_lowercase().starts_with("content-length:") {
211            content_length = line.split(':').nth(1).unwrap_or("").trim().to_string();
212        }
213    }
214
215    if status.is_empty() {
216        return None;
217    }
218
219    let mut result = status;
220    if !content_type.is_empty() {
221        result.push_str(&format!(" | {content_type}"));
222    }
223    if !content_length.is_empty() {
224        result.push_str(&format!(" | {content_length}B"));
225    }
226
227    Some(result)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn json_gets_compressed() {
236        let json = r#"{"name":"test","value":42,"nested":{"a":1,"b":2}}"#;
237        let result = compress(json);
238        assert!(result.is_some());
239        assert!(result.unwrap().contains("JSON"));
240    }
241
242    #[test]
243    fn html_gets_compressed() {
244        let html = "<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>";
245        let result = compress(html);
246        assert!(result.is_some());
247        assert!(result.unwrap().contains("HTML"));
248    }
249
250    #[test]
251    fn plain_text_returns_none() {
252        assert!(compress("just plain text").is_none());
253    }
254}