lean_ctx/core/patterns/
curl.rs1fn 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
87 if matches!(val, serde_json::Value::Array(_))
95 && let Some(crushed) =
96 crate::core::json_crush::crush_value_if_beneficial(&val, output.len())
97 {
98 return Some(format!("JSON ({} bytes):\n{}", output.len(), crushed));
99 }
100
101 let schema = extract_schema(&val, 0);
102 let size = output.len();
103
104 Some(format!("JSON ({size} bytes):\n{schema}"))
105}
106
107fn extract_schema(val: &serde_json::Value, depth: usize) -> String {
108 if depth > 3 {
109 return " ".repeat(depth) + "...";
110 }
111
112 let indent = " ".repeat(depth);
113
114 match val {
115 serde_json::Value::Object(map) => {
116 let mut lines = Vec::new();
117 for (key, value) in map.iter().take(15) {
118 let type_str = match value {
119 serde_json::Value::Null => "null".to_string(),
120 serde_json::Value::Bool(_) => "bool".to_string(),
121 serde_json::Value::Number(_) => "number".to_string(),
122 serde_json::Value::String(s) => {
123 if is_sensitive_key(key) {
124 format!("string({}, REDACTED)", s.len())
125 } else if s.len() > 50 {
126 format!("string({})", s.len())
127 } else {
128 format!("\"{s}\"")
129 }
130 }
131 serde_json::Value::Array(arr) => {
132 if arr.is_empty() {
133 "[]".to_string()
134 } else {
135 let inner = value_type(&arr[0]);
136 format!("[{inner}; {}]", arr.len())
137 }
138 }
139 serde_json::Value::Object(inner) => {
140 if inner.len() <= 3 {
141 let keys: Vec<&String> = inner.keys().collect();
142 format!(
143 "{{{}}}",
144 keys.iter()
145 .map(|k| k.as_str())
146 .collect::<Vec<_>>()
147 .join(", ")
148 )
149 } else {
150 format!("{{{}K}}", inner.len())
151 }
152 }
153 };
154 lines.push(format!("{indent} {key}: {type_str}"));
155 }
156 if map.len() > 15 {
157 lines.push(format!("{indent} ... +{} more keys", map.len() - 15));
158 }
159 format!("{indent}{{\n{}\n{indent}}}", lines.join("\n"))
160 }
161 serde_json::Value::Array(arr) => {
162 if arr.is_empty() {
163 format!("{indent}[]")
164 } else {
165 let inner = value_type(&arr[0]);
166 format!("{indent}[{inner}; {}]", arr.len())
167 }
168 }
169 _ => format!("{indent}{}", value_type(val)),
170 }
171}
172
173fn value_type(val: &serde_json::Value) -> String {
174 match val {
175 serde_json::Value::Null => "null".to_string(),
176 serde_json::Value::Bool(_) => "bool".to_string(),
177 serde_json::Value::Number(_) => "number".to_string(),
178 serde_json::Value::String(_) => "string".to_string(),
179 serde_json::Value::Array(_) => "array".to_string(),
180 serde_json::Value::Object(m) => format!("object({}K)", m.len()),
181 }
182}
183
184fn is_sensitive_key(key: &str) -> bool {
185 let lower = key.to_ascii_lowercase();
186 lower.contains("token")
187 || lower.contains("key")
188 || lower.contains("secret")
189 || lower.contains("password")
190 || lower.contains("passwd")
191 || lower.contains("auth")
192 || lower.contains("credential")
193 || lower.contains("api_key")
194 || lower.contains("apikey")
195 || lower.contains("access_token")
196 || lower.contains("refresh_token")
197 || lower.contains("private")
198}
199
200fn compress_html(output: &str) -> String {
201 let lines = output.lines().count();
202 let size = output.len();
203
204 let title = output
205 .find("<title>")
206 .and_then(|start| {
207 let after = &output[start + 7..];
208 after.find("</title>").map(|end| &after[..end])
209 })
210 .unwrap_or("(no title)");
211
212 format!("HTML: \"{title}\" ({size} bytes, {lines} lines)")
213}
214
215fn compress_headers(output: &str) -> Option<String> {
216 let mut status = String::new();
217 let mut content_type = String::new();
218 let mut content_length = String::new();
219
220 for line in output.lines().take(20) {
221 if line.starts_with("HTTP/") {
222 status = line.to_string();
223 } else if line.to_lowercase().starts_with("content-type:") {
224 content_type = line.split(':').nth(1).unwrap_or("").trim().to_string();
225 } else if line.to_lowercase().starts_with("content-length:") {
226 content_length = line.split(':').nth(1).unwrap_or("").trim().to_string();
227 }
228 }
229
230 if status.is_empty() {
231 return None;
232 }
233
234 let mut result = status;
235 if !content_type.is_empty() {
236 result.push_str(&format!(" | {content_type}"));
237 }
238 if !content_length.is_empty() {
239 result.push_str(&format!(" | {content_length}B"));
240 }
241
242 Some(result)
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn json_gets_compressed() {
251 let json = r#"{"name":"test","value":42,"nested":{"a":1,"b":2}}"#;
252 let result = compress(json);
253 assert!(result.is_some());
254 assert!(result.unwrap().contains("JSON"));
255 }
256
257 #[test]
258 fn json_array_of_objects_uses_lossless_crush() {
259 let mut json = String::from("[");
263 for i in 0..12 {
264 if i > 0 {
265 json.push(',');
266 }
267 json.push_str(&format!(r#"{{"status":"active","plan":"pro","id":{i}}}"#));
268 }
269 json.push(']');
270 let result = compress(&json).expect("array json compresses");
271 assert!(
272 result.contains("JSON ("),
273 "keeps curl JSON banner: {result}"
274 );
275 assert!(
276 result.contains("_lc_crush"),
277 "expected shared crushed core output: {result}"
278 );
279 let body = result.split_once('\n').unwrap().1;
280 let restored = crate::core::json_crush::reconstruct(body).expect("reconstructs");
281 assert_eq!(
282 restored,
283 serde_json::from_str::<serde_json::Value>(&json).unwrap()
284 );
285 }
286
287 #[test]
288 fn html_gets_compressed() {
289 let html = "<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>";
290 let result = compress(html);
291 assert!(result.is_some());
292 assert!(result.unwrap().contains("HTML"));
293 }
294
295 #[test]
296 fn plain_text_returns_none() {
297 assert!(compress("just plain text").is_none());
298 }
299}