lean_ctx/core/patterns/
docker.rs1use regex::Regex;
2use std::sync::OnceLock;
3
4static LOG_TIMESTAMP_RE: OnceLock<Regex> = OnceLock::new();
5
6fn log_timestamp_re() -> &'static Regex {
7 LOG_TIMESTAMP_RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}").unwrap())
8}
9
10pub fn compress(command: &str, output: &str) -> Option<String> {
11 if command.contains("build") {
12 return Some(compress_build(output));
13 }
14 if command.contains("compose") && command.contains("ps") {
15 return Some(compress_compose_ps(output));
16 }
17 if command.contains("compose")
18 && (command.contains("up")
19 || command.contains("down")
20 || command.contains("start")
21 || command.contains("stop"))
22 {
23 return Some(compress_compose_action(output));
24 }
25 if command.contains("ps") {
26 return Some(compress_ps(output));
27 }
28 if command.contains("images") {
29 return Some(compress_images(output));
30 }
31 if command.contains("logs") {
32 return Some(compress_logs(output));
33 }
34 if command.contains("network") {
35 return Some(compress_network(output));
36 }
37 if command.contains("volume") {
38 return Some(compress_volume(output));
39 }
40 if command.contains("inspect") {
41 return Some(compress_inspect(output));
42 }
43 if command.contains("exec") || command.contains("run") {
44 return Some(compress_exec(output));
45 }
46 if command.contains("system") && command.contains("df") {
47 return Some(compress_system_df(output));
48 }
49 if command.contains("info") {
50 return Some(compress_info(output));
51 }
52 if command.contains("version") {
53 return Some(compress_version(output));
54 }
55 None
56}
57
58fn compress_build(output: &str) -> String {
59 let mut steps = 0u32;
60 let mut last_step = String::new();
61 let mut errors = Vec::new();
62
63 for line in output.lines() {
64 if line.starts_with("Step ") || (line.starts_with('#') && line.contains('[')) {
65 steps += 1;
66 last_step = line.trim().to_string();
67 }
68 if line.contains("ERROR") || line.contains("error:") {
69 errors.push(line.trim().to_string());
70 }
71 }
72
73 if !errors.is_empty() {
74 return format!(
75 "{steps} steps, {} errors:\n{}",
76 errors.len(),
77 errors.join("\n")
78 );
79 }
80
81 if steps > 0 {
82 format!("{steps} steps, last: {last_step}")
83 } else {
84 "built".to_string()
85 }
86}
87
88fn compress_ps(output: &str) -> String {
89 let lines: Vec<&str> = output.lines().collect();
90 if lines.len() <= 1 {
91 return "no containers".to_string();
92 }
93
94 let mut containers = Vec::new();
95 for line in &lines[1..] {
96 let parts: Vec<&str> = line.split_whitespace().collect();
97 if parts.len() >= 7 {
98 let name = parts.last().unwrap_or(&"?");
99 let status = parts.get(4).unwrap_or(&"?");
100 containers.push(format!("{name}: {status}"));
101 }
102 }
103
104 if containers.is_empty() {
105 return "no containers".to_string();
106 }
107 containers.join("\n")
108}
109
110fn compress_images(output: &str) -> String {
111 let lines: Vec<&str> = output.lines().collect();
112 if lines.len() <= 1 {
113 return "no images".to_string();
114 }
115
116 let mut images = Vec::new();
117 for line in &lines[1..] {
118 let parts: Vec<&str> = line.split_whitespace().collect();
119 if parts.len() >= 5 {
120 let repo = parts[0];
121 let tag = parts[1];
122 let size = parts.last().unwrap_or(&"?");
123 if repo == "<none>" {
124 continue;
125 }
126 images.push(format!("{repo}:{tag} ({size})"));
127 }
128 }
129
130 if images.is_empty() {
131 return "no images".to_string();
132 }
133 format!("{} images:\n{}", images.len(), images.join("\n"))
134}
135
136fn compress_logs(output: &str) -> String {
137 let lines: Vec<&str> = output.lines().collect();
138 if lines.len() <= 10 {
139 return output.to_string();
140 }
141
142 let mut deduped: Vec<(String, u32)> = Vec::new();
143 for line in &lines {
144 let normalized = log_timestamp_re().replace(line, "[T]").to_string();
145 let stripped = normalized.trim().to_string();
146 if stripped.is_empty() {
147 continue;
148 }
149
150 if let Some(last) = deduped.last_mut() {
151 if last.0 == stripped {
152 last.1 += 1;
153 continue;
154 }
155 }
156 deduped.push((stripped, 1));
157 }
158
159 let result: Vec<String> = deduped
160 .iter()
161 .map(|(line, count)| {
162 if *count > 1 {
163 format!("{line} (x{count})")
164 } else {
165 line.clone()
166 }
167 })
168 .collect();
169
170 if result.len() > 30 {
171 let last_lines = &result[result.len() - 15..];
172 format!(
173 "... ({} lines total)\n{}",
174 lines.len(),
175 last_lines.join("\n")
176 )
177 } else {
178 result.join("\n")
179 }
180}
181
182fn compress_compose_ps(output: &str) -> String {
183 let lines: Vec<&str> = output.lines().collect();
184 if lines.len() <= 1 {
185 return "no services".to_string();
186 }
187
188 let mut services = Vec::new();
189 for line in &lines[1..] {
190 let parts: Vec<&str> = line.split_whitespace().collect();
191 if parts.len() >= 3 {
192 let name = parts[0];
193 let status_parts: Vec<&str> = parts[1..].to_vec();
194 let status = status_parts.join(" ");
195 services.push(format!("{name}: {status}"));
196 }
197 }
198
199 if services.is_empty() {
200 return "no services".to_string();
201 }
202 format!("{} services:\n{}", services.len(), services.join("\n"))
203}
204
205fn compress_compose_action(output: &str) -> String {
206 let trimmed = output.trim();
207 if trimmed.is_empty() {
208 return "ok".to_string();
209 }
210
211 let mut created = 0u32;
212 let mut started = 0u32;
213 let mut stopped = 0u32;
214 let mut removed = 0u32;
215
216 for line in trimmed.lines() {
217 let l = line.to_lowercase();
218 if l.contains("created") || l.contains("creating") {
219 created += 1;
220 }
221 if l.contains("started") || l.contains("starting") {
222 started += 1;
223 }
224 if l.contains("stopped") || l.contains("stopping") {
225 stopped += 1;
226 }
227 if l.contains("removed") || l.contains("removing") {
228 removed += 1;
229 }
230 }
231
232 let mut parts = Vec::new();
233 if created > 0 {
234 parts.push(format!("{created} created"));
235 }
236 if started > 0 {
237 parts.push(format!("{started} started"));
238 }
239 if stopped > 0 {
240 parts.push(format!("{stopped} stopped"));
241 }
242 if removed > 0 {
243 parts.push(format!("{removed} removed"));
244 }
245
246 if parts.is_empty() {
247 return "ok".to_string();
248 }
249 format!("ok ({})", parts.join(", "))
250}
251
252fn compress_network(output: &str) -> String {
253 let lines: Vec<&str> = output.lines().collect();
254 if lines.len() <= 1 {
255 return output.trim().to_string();
256 }
257
258 let mut networks = Vec::new();
259 for line in &lines[1..] {
260 let parts: Vec<&str> = line.split_whitespace().collect();
261 if parts.len() >= 3 {
262 let name = parts[1];
263 let driver = parts[2];
264 networks.push(format!("{name} ({driver})"));
265 }
266 }
267
268 if networks.is_empty() {
269 return "no networks".to_string();
270 }
271 networks.join(", ")
272}
273
274fn compress_volume(output: &str) -> String {
275 let lines: Vec<&str> = output.lines().collect();
276 if lines.len() <= 1 {
277 return output.trim().to_string();
278 }
279
280 let volumes: Vec<&str> = lines[1..]
281 .iter()
282 .filter_map(|l| l.split_whitespace().nth(1))
283 .collect();
284
285 if volumes.is_empty() {
286 return "no volumes".to_string();
287 }
288 format!("{} volumes: {}", volumes.len(), volumes.join(", "))
289}
290
291fn compress_inspect(output: &str) -> String {
292 let trimmed = output.trim();
293 if trimmed.starts_with('[') || trimmed.starts_with('{') {
294 if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
295 return compress_json_value(&val, 0);
296 }
297 }
298 if trimmed.lines().count() > 20 {
299 let lines: Vec<&str> = trimmed.lines().collect();
300 return format!(
301 "{}\n... ({} more lines)",
302 lines[..10].join("\n"),
303 lines.len() - 10
304 );
305 }
306 trimmed.to_string()
307}
308
309fn compress_exec(output: &str) -> String {
310 let trimmed = output.trim();
311 if trimmed.is_empty() {
312 return "ok".to_string();
313 }
314 let lines: Vec<&str> = trimmed.lines().collect();
315 if lines.len() > 30 {
316 let last = &lines[lines.len() - 10..];
317 return format!("... ({} lines)\n{}", lines.len(), last.join("\n"));
318 }
319 trimmed.to_string()
320}
321
322fn compress_system_df(output: &str) -> String {
323 let mut parts = Vec::new();
324 let mut current_type = String::new();
325
326 for line in output.lines() {
327 let trimmed = line.trim();
328 if trimmed.starts_with("TYPE") {
329 continue;
330 }
331 if trimmed.starts_with("Images")
332 || trimmed.starts_with("Containers")
333 || trimmed.starts_with("Local Volumes")
334 || trimmed.starts_with("Build Cache")
335 {
336 current_type = trimmed.to_string();
337 continue;
338 }
339 if !current_type.is_empty() && trimmed.contains("RECLAIMABLE") {
340 current_type.clear();
341 continue;
342 }
343 }
344
345 let lines: Vec<&str> = output
346 .lines()
347 .filter(|l| {
348 let t = l.trim();
349 !t.is_empty()
350 && (t.contains("RECLAIMABLE")
351 || t.contains("SIZE")
352 || t.starts_with("Images")
353 || t.starts_with("Containers")
354 || t.starts_with("Local Volumes")
355 || t.starts_with("Build Cache")
356 || t.chars().next().is_some_and(|c| c.is_ascii_digit()))
357 })
358 .collect();
359
360 if lines.is_empty() {
361 return compact_output(output, 10);
362 }
363
364 for line in &lines {
365 let trimmed = line.trim();
366 if !trimmed.starts_with("TYPE") && !trimmed.is_empty() {
367 parts.push(trimmed.to_string());
368 }
369 }
370
371 if parts.is_empty() {
372 compact_output(output, 10)
373 } else {
374 parts.join("\n")
375 }
376}
377
378fn compress_info(output: &str) -> String {
379 let mut key_info = Vec::new();
380 let important_keys = [
381 "Server Version",
382 "Operating System",
383 "Architecture",
384 "CPUs",
385 "Total Memory",
386 "Docker Root Dir",
387 "Storage Driver",
388 "Containers:",
389 "Images:",
390 ];
391
392 for line in output.lines() {
393 let trimmed = line.trim();
394 for key in &important_keys {
395 if trimmed.starts_with(key) {
396 key_info.push(trimmed.to_string());
397 break;
398 }
399 }
400 }
401
402 if key_info.is_empty() {
403 return compact_output(output, 10);
404 }
405 key_info.join("\n")
406}
407
408fn compress_version(output: &str) -> String {
409 let mut parts = Vec::new();
410 let important = ["Version:", "API version:", "Go version:", "OS/Arch:"];
411
412 for line in output.lines() {
413 let trimmed = line.trim();
414 for key in &important {
415 if trimmed.starts_with(key) {
416 parts.push(trimmed.to_string());
417 break;
418 }
419 }
420 }
421
422 if parts.is_empty() {
423 return compact_output(output, 5);
424 }
425 parts.join("\n")
426}
427
428fn compact_output(text: &str, max: usize) -> String {
429 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
430 if lines.len() <= max {
431 return lines.join("\n");
432 }
433 format!(
434 "{}\n... ({} more lines)",
435 lines[..max].join("\n"),
436 lines.len() - max
437 )
438}
439
440fn compress_json_value(val: &serde_json::Value, depth: usize) -> String {
441 if depth > 2 {
442 return "...".to_string();
443 }
444 match val {
445 serde_json::Value::Object(map) => {
446 let keys: Vec<String> = map.keys().take(15).map(|k| k.to_string()).collect();
447 let total = map.len();
448 if total > 15 {
449 format!("{{{} ... +{} keys}}", keys.join(", "), total - 15)
450 } else {
451 format!("{{{}}}", keys.join(", "))
452 }
453 }
454 serde_json::Value::Array(arr) => format!("[...{}]", arr.len()),
455 other => format!("{other}"),
456 }
457}