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 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}