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