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") {
21 return compress_html(trimmed);
22 }
23
24 if trimmed.starts_with("HTTP/") {
25 return compress_headers(trimmed);
26 }
27
28 None
29}
30
31fn compress_json(output: &str) -> Option<String> {
32 let val: serde_json::Value = serde_json::from_str(output).ok()?;
33 let schema = extract_schema(&val, 0);
34 let size = output.len();
35
36 Some(format!("JSON ({} bytes):\n{schema}", size))
37}
38
39fn extract_schema(val: &serde_json::Value, depth: usize) -> String {
40 if depth > 3 {
41 return " ".repeat(depth) + "...";
42 }
43
44 let indent = " ".repeat(depth);
45
46 match val {
47 serde_json::Value::Object(map) => {
48 let mut lines = Vec::new();
49 for (key, value) in map.iter().take(15) {
50 let type_str = match value {
51 serde_json::Value::Null => "null".to_string(),
52 serde_json::Value::Bool(_) => "bool".to_string(),
53 serde_json::Value::Number(_) => "number".to_string(),
54 serde_json::Value::String(s) => {
55 if is_sensitive_key(key) {
56 format!("string({}, REDACTED)", s.len())
57 } else if s.len() > 50 {
58 format!("string({})", s.len())
59 } else {
60 format!("\"{}\"", s)
61 }
62 }
63 serde_json::Value::Array(arr) => {
64 if arr.is_empty() {
65 "[]".to_string()
66 } else {
67 let inner = value_type(&arr[0]);
68 format!("[{inner}; {}]", arr.len())
69 }
70 }
71 serde_json::Value::Object(inner) => {
72 if inner.len() <= 3 {
73 let keys: Vec<&String> = inner.keys().collect();
74 format!(
75 "{{{}}}",
76 keys.iter()
77 .map(|k| k.as_str())
78 .collect::<Vec<_>>()
79 .join(", ")
80 )
81 } else {
82 format!("{{{}K}}", inner.len())
83 }
84 }
85 };
86 lines.push(format!("{indent} {key}: {type_str}"));
87 }
88 if map.len() > 15 {
89 lines.push(format!("{indent} ... +{} more keys", map.len() - 15));
90 }
91 format!("{indent}{{\n{}\n{indent}}}", lines.join("\n"))
92 }
93 serde_json::Value::Array(arr) => {
94 if arr.is_empty() {
95 format!("{indent}[]")
96 } else {
97 let inner = value_type(&arr[0]);
98 format!("{indent}[{inner}; {}]", arr.len())
99 }
100 }
101 _ => format!("{indent}{}", value_type(val)),
102 }
103}
104
105fn value_type(val: &serde_json::Value) -> String {
106 match val {
107 serde_json::Value::Null => "null".to_string(),
108 serde_json::Value::Bool(_) => "bool".to_string(),
109 serde_json::Value::Number(_) => "number".to_string(),
110 serde_json::Value::String(_) => "string".to_string(),
111 serde_json::Value::Array(_) => "array".to_string(),
112 serde_json::Value::Object(m) => format!("object({}K)", m.len()),
113 }
114}
115
116fn is_sensitive_key(key: &str) -> bool {
117 let lower = key.to_ascii_lowercase();
118 lower.contains("token")
119 || lower.contains("key")
120 || lower.contains("secret")
121 || lower.contains("password")
122 || lower.contains("passwd")
123 || lower.contains("auth")
124 || lower.contains("credential")
125 || lower.contains("api_key")
126 || lower.contains("apikey")
127 || lower.contains("access_token")
128 || lower.contains("refresh_token")
129 || lower.contains("private")
130}
131
132fn compress_html(output: &str) -> Option<String> {
133 let lines = output.lines().count();
134 let size = output.len();
135
136 let title = output
137 .find("<title>")
138 .and_then(|start| {
139 let after = &output[start + 7..];
140 after.find("</title>").map(|end| &after[..end])
141 })
142 .unwrap_or("(no title)");
143
144 Some(format!("HTML: \"{title}\" ({size} bytes, {lines} lines)"))
145}
146
147fn compress_headers(output: &str) -> Option<String> {
148 let mut status = String::new();
149 let mut content_type = String::new();
150 let mut content_length = String::new();
151
152 for line in output.lines().take(20) {
153 if line.starts_with("HTTP/") {
154 status = line.to_string();
155 } else if line.to_lowercase().starts_with("content-type:") {
156 content_type = line.split(':').nth(1).unwrap_or("").trim().to_string();
157 } else if line.to_lowercase().starts_with("content-length:") {
158 content_length = line.split(':').nth(1).unwrap_or("").trim().to_string();
159 }
160 }
161
162 if status.is_empty() {
163 return None;
164 }
165
166 let mut result = status;
167 if !content_type.is_empty() {
168 result.push_str(&format!(" | {content_type}"));
169 }
170 if !content_length.is_empty() {
171 result.push_str(&format!(" | {content_length}B"));
172 }
173
174 Some(result)
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn json_gets_compressed() {
183 let json = r#"{"name":"test","value":42,"nested":{"a":1,"b":2}}"#;
184 let result = compress(json);
185 assert!(result.is_some());
186 assert!(result.unwrap().contains("JSON"));
187 }
188
189 #[test]
190 fn html_gets_compressed() {
191 let html = "<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>";
192 let result = compress(html);
193 assert!(result.is_some());
194 assert!(result.unwrap().contains("HTML"));
195 }
196
197 #[test]
198 fn plain_text_returns_none() {
199 assert!(compress("just plain text").is_none());
200 }
201}