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 header = lines[0];
95 let col_positions = parse_docker_columns(header);
96
97 let mut containers = Vec::new();
98 for line in &lines[1..] {
99 if line.trim().is_empty() {
100 continue;
101 }
102
103 let name = extract_column(line, &col_positions, "NAMES")
104 .unwrap_or_else(|| extract_last_word(line));
105 let status =
106 extract_column(line, &col_positions, "STATUS").unwrap_or_else(|| "?".to_string());
107 let image = extract_column(line, &col_positions, "IMAGE");
108
109 let mut entry = name.clone();
110 if let Some(img) = image {
111 entry = format!("{name} ({img})");
112 }
113 entry = format!("{entry}: {status}");
114 containers.push(entry);
115 }
116
117 if containers.is_empty() {
118 return "no containers".to_string();
119 }
120 containers.join("\n")
121}
122
123fn parse_docker_columns(header: &str) -> Vec<(String, usize)> {
124 let cols = [
125 "CONTAINER ID",
126 "IMAGE",
127 "COMMAND",
128 "CREATED",
129 "STATUS",
130 "PORTS",
131 "NAMES",
132 ];
133 let mut positions: Vec<(String, usize)> = Vec::new();
134 for col in &cols {
135 if let Some(pos) = header.find(col) {
136 positions.push((col.to_string(), pos));
137 }
138 }
139 positions.sort_by_key(|(_, pos)| *pos);
140 positions
141}
142
143fn extract_column(line: &str, cols: &[(String, usize)], name: &str) -> Option<String> {
144 let idx = cols.iter().position(|(n, _)| n == name)?;
145 let start = cols[idx].1;
146 let end = cols.get(idx + 1).map(|(_, p)| *p).unwrap_or(line.len());
147 if start >= line.len() {
148 return None;
149 }
150 let end = end.min(line.len());
151 let val = line[start..end].trim().to_string();
152 if val.is_empty() {
153 None
154 } else {
155 Some(val)
156 }
157}
158
159fn extract_last_word(line: &str) -> String {
160 line.split_whitespace().last().unwrap_or("?").to_string()
161}
162
163fn compress_images(output: &str) -> String {
164 let lines: Vec<&str> = output.lines().collect();
165 if lines.len() <= 1 {
166 return "no images".to_string();
167 }
168
169 let mut images = Vec::new();
170 for line in &lines[1..] {
171 let parts: Vec<&str> = line.split_whitespace().collect();
172 if parts.len() >= 5 {
173 let repo = parts[0];
174 let tag = parts[1];
175 let size = parts.last().unwrap_or(&"?");
176 if repo == "<none>" {
177 continue;
178 }
179 images.push(format!("{repo}:{tag} ({size})"));
180 }
181 }
182
183 if images.is_empty() {
184 return "no images".to_string();
185 }
186 format!("{} images:\n{}", images.len(), images.join("\n"))
187}
188
189fn compress_logs(output: &str) -> String {
190 let lines: Vec<&str> = output.lines().collect();
191 if lines.len() <= 10 {
192 return output.to_string();
193 }
194
195 let mut deduped: Vec<(String, u32)> = Vec::new();
196 for line in &lines {
197 let normalized = log_timestamp_re().replace(line, "[T]").to_string();
198 let stripped = normalized.trim().to_string();
199 if stripped.is_empty() {
200 continue;
201 }
202
203 if let Some(last) = deduped.last_mut() {
204 if last.0 == stripped {
205 last.1 += 1;
206 continue;
207 }
208 }
209 deduped.push((stripped, 1));
210 }
211
212 let result: Vec<String> = deduped
213 .iter()
214 .map(|(line, count)| {
215 if *count > 1 {
216 format!("{line} (x{count})")
217 } else {
218 line.clone()
219 }
220 })
221 .collect();
222
223 if result.len() > 30 {
224 let result_strs: Vec<&str> = result.iter().map(|s| s.as_str()).collect();
225 let middle = &result_strs[..result_strs.len() - 15];
226 let safety = crate::core::safety_needles::extract_safety_lines(middle, 20);
227 let last_lines = &result[result.len() - 15..];
228
229 let mut out = format!("... ({} lines total", lines.len());
230 if !safety.is_empty() {
231 out.push_str(&format!(", {} safety-relevant preserved", safety.len()));
232 }
233 out.push_str(")\n");
234 for s in &safety {
235 out.push_str(s);
236 out.push('\n');
237 }
238 out.push_str(&last_lines.join("\n"));
239 out
240 } else {
241 result.join("\n")
242 }
243}
244
245fn compress_compose_ps(output: &str) -> String {
246 let lines: Vec<&str> = output.lines().collect();
247 if lines.len() <= 1 {
248 return "no services".to_string();
249 }
250
251 let mut services = Vec::new();
252 for line in &lines[1..] {
253 let parts: Vec<&str> = line.split_whitespace().collect();
254 if parts.len() >= 3 {
255 let name = parts[0];
256 let status_parts: Vec<&str> = parts[1..].to_vec();
257 let status = status_parts.join(" ");
258 services.push(format!("{name}: {status}"));
259 }
260 }
261
262 if services.is_empty() {
263 return "no services".to_string();
264 }
265 format!("{} services:\n{}", services.len(), services.join("\n"))
266}
267
268fn compress_compose_action(output: &str) -> String {
269 let trimmed = output.trim();
270 if trimmed.is_empty() {
271 return "ok".to_string();
272 }
273
274 let mut created = 0u32;
275 let mut started = 0u32;
276 let mut stopped = 0u32;
277 let mut removed = 0u32;
278
279 for line in trimmed.lines() {
280 let l = line.to_lowercase();
281 if l.contains("created") || l.contains("creating") {
282 created += 1;
283 }
284 if l.contains("started") || l.contains("starting") {
285 started += 1;
286 }
287 if l.contains("stopped") || l.contains("stopping") {
288 stopped += 1;
289 }
290 if l.contains("removed") || l.contains("removing") {
291 removed += 1;
292 }
293 }
294
295 let mut parts = Vec::new();
296 if created > 0 {
297 parts.push(format!("{created} created"));
298 }
299 if started > 0 {
300 parts.push(format!("{started} started"));
301 }
302 if stopped > 0 {
303 parts.push(format!("{stopped} stopped"));
304 }
305 if removed > 0 {
306 parts.push(format!("{removed} removed"));
307 }
308
309 if parts.is_empty() {
310 return "ok".to_string();
311 }
312 format!("ok ({})", parts.join(", "))
313}
314
315fn compress_network(output: &str) -> String {
316 let lines: Vec<&str> = output.lines().collect();
317 if lines.len() <= 1 {
318 return output.trim().to_string();
319 }
320
321 let mut networks = Vec::new();
322 for line in &lines[1..] {
323 let parts: Vec<&str> = line.split_whitespace().collect();
324 if parts.len() >= 3 {
325 let name = parts[1];
326 let driver = parts[2];
327 networks.push(format!("{name} ({driver})"));
328 }
329 }
330
331 if networks.is_empty() {
332 return "no networks".to_string();
333 }
334 networks.join(", ")
335}
336
337fn compress_volume(output: &str) -> String {
338 let lines: Vec<&str> = output.lines().collect();
339 if lines.len() <= 1 {
340 return output.trim().to_string();
341 }
342
343 let volumes: Vec<&str> = lines[1..]
344 .iter()
345 .filter_map(|l| l.split_whitespace().nth(1))
346 .collect();
347
348 if volumes.is_empty() {
349 return "no volumes".to_string();
350 }
351 format!("{} volumes: {}", volumes.len(), volumes.join(", "))
352}
353
354fn compress_inspect(output: &str) -> String {
355 let trimmed = output.trim();
356 if trimmed.starts_with('[') || trimmed.starts_with('{') {
357 if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
358 return compress_json_value(&val, 0);
359 }
360 }
361 if trimmed.lines().count() > 20 {
362 let lines: Vec<&str> = trimmed.lines().collect();
363 return format!(
364 "{}\n... ({} more lines)",
365 lines[..10].join("\n"),
366 lines.len() - 10
367 );
368 }
369 trimmed.to_string()
370}
371
372fn compress_exec(output: &str) -> String {
373 let trimmed = output.trim();
374 if trimmed.is_empty() {
375 return "ok".to_string();
376 }
377 let lines: Vec<&str> = trimmed.lines().collect();
378 if lines.len() > 30 {
379 let last = &lines[lines.len() - 10..];
380 return format!("... ({} lines)\n{}", lines.len(), last.join("\n"));
381 }
382 trimmed.to_string()
383}
384
385fn compress_system_df(output: &str) -> String {
386 let mut parts = Vec::new();
387 let mut current_type = String::new();
388
389 for line in output.lines() {
390 let trimmed = line.trim();
391 if trimmed.starts_with("TYPE") {
392 continue;
393 }
394 if trimmed.starts_with("Images")
395 || trimmed.starts_with("Containers")
396 || trimmed.starts_with("Local Volumes")
397 || trimmed.starts_with("Build Cache")
398 {
399 current_type = trimmed.to_string();
400 continue;
401 }
402 if !current_type.is_empty() && trimmed.contains("RECLAIMABLE") {
403 current_type.clear();
404 continue;
405 }
406 }
407
408 let lines: Vec<&str> = output
409 .lines()
410 .filter(|l| {
411 let t = l.trim();
412 !t.is_empty()
413 && (t.contains("RECLAIMABLE")
414 || t.contains("SIZE")
415 || t.starts_with("Images")
416 || t.starts_with("Containers")
417 || t.starts_with("Local Volumes")
418 || t.starts_with("Build Cache")
419 || t.chars().next().is_some_and(|c| c.is_ascii_digit()))
420 })
421 .collect();
422
423 if lines.is_empty() {
424 return compact_output(output, 10);
425 }
426
427 for line in &lines {
428 let trimmed = line.trim();
429 if !trimmed.starts_with("TYPE") && !trimmed.is_empty() {
430 parts.push(trimmed.to_string());
431 }
432 }
433
434 if parts.is_empty() {
435 compact_output(output, 10)
436 } else {
437 parts.join("\n")
438 }
439}
440
441fn compress_info(output: &str) -> String {
442 let mut key_info = Vec::new();
443 let important_keys = [
444 "Server Version",
445 "Operating System",
446 "Architecture",
447 "CPUs",
448 "Total Memory",
449 "Docker Root Dir",
450 "Storage Driver",
451 "Containers:",
452 "Images:",
453 ];
454
455 for line in output.lines() {
456 let trimmed = line.trim();
457 for key in &important_keys {
458 if trimmed.starts_with(key) {
459 key_info.push(trimmed.to_string());
460 break;
461 }
462 }
463 }
464
465 if key_info.is_empty() {
466 return compact_output(output, 10);
467 }
468 key_info.join("\n")
469}
470
471fn compress_version(output: &str) -> String {
472 let mut parts = Vec::new();
473 let important = ["Version:", "API version:", "Go version:", "OS/Arch:"];
474
475 for line in output.lines() {
476 let trimmed = line.trim();
477 for key in &important {
478 if trimmed.starts_with(key) {
479 parts.push(trimmed.to_string());
480 break;
481 }
482 }
483 }
484
485 if parts.is_empty() {
486 return compact_output(output, 5);
487 }
488 parts.join("\n")
489}
490
491fn compact_output(text: &str, max: usize) -> String {
492 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
493 if lines.len() <= max {
494 return lines.join("\n");
495 }
496 format!(
497 "{}\n... ({} more lines)",
498 lines[..max].join("\n"),
499 lines.len() - max
500 )
501}
502
503fn compress_json_value(val: &serde_json::Value, depth: usize) -> String {
504 if depth > 2 {
505 return "...".to_string();
506 }
507 match val {
508 serde_json::Value::Object(map) => {
509 let keys: Vec<String> = map.keys().take(15).map(|k| k.to_string()).collect();
510 let total = map.len();
511 if total > 15 {
512 format!("{{{} ... +{} keys}}", keys.join(", "), total - 15)
513 } else {
514 format!("{{{}}}", keys.join(", "))
515 }
516 }
517 serde_json::Value::Array(arr) => format!("[...{}]", arr.len()),
518 other => format!("{other}"),
519 }
520}