lean_ctx/core/patterns/
curl.rs1pub 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}