lean_ctx_patterns/
curl.rs1fn truncate_at_char_boundary(s: &str, max: usize) -> &str {
4 &s[..s.floor_char_boundary(max)]
5}
6
7pub 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 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 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}