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