Skip to main content

lean_ctx/core/patterns/
curl.rs

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