lean_ctx_patterns/
docker.rs1macro_rules! static_regex {
4 ($pattern:expr_2021) => {{
5 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6 RE.get_or_init(|| {
7 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
8 })
9 }};
10}
11
12fn log_timestamp_re() -> &'static regex::Regex {
13 static_regex!(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
14}
15
16pub fn compress(command: &str, output: &str) -> Option<String> {
17 if command.contains("build") {
18 return Some(compress_build(output));
19 }
20 if command.contains("compose") && command.contains("ps") {
21 return Some(compress_compose_ps(output));
22 }
23 if command.contains("compose")
24 && (command.contains("up")
25 || command.contains("down")
26 || command.contains("start")
27 || command.contains("stop"))
28 {
29 return Some(compress_compose_action(output));
30 }
31 if command.contains("ps") {
32 return Some(compress_ps(output));
33 }
34 if command.contains("images") {
35 return Some(compress_images(output));
36 }
37 if command.contains("logs") {
38 return Some(compress_logs(output));
39 }
40 if command.contains("network") {
41 return Some(compress_network(output));
42 }
43 if command.contains("volume") {
44 return Some(compress_volume(output));
45 }
46 if command.contains("inspect") {
47 return Some(compress_inspect(output));
48 }
49 if command.contains("exec") || command.contains("run") {
50 return Some(compress_exec(output));
51 }
52 if command.contains("system") && command.contains("df") {
53 return Some(compress_system_df(output));
54 }
55 if command.contains("info") {
56 return Some(compress_info(output));
57 }
58 if command.contains("version") {
59 return Some(compress_version(output));
60 }
61 None
62}
63
64fn compress_build(output: &str) -> String {
65 let mut steps = 0u32;
66 let mut last_step = String::new();
67 let mut errors = Vec::new();
68
69 for line in output.lines() {
70 if line.starts_with("Step ") || (line.starts_with('#') && line.contains('[')) {
71 steps += 1;
72 last_step = line.trim().to_string();
73 }
74 if line.contains("ERROR") || line.contains("error:") {
75 errors.push(line.trim().to_string());
76 }
77 }
78
79 if !errors.is_empty() {
80 return format!(
81 "{steps} steps, {} errors:\n{}",
82 errors.len(),
83 errors.join("\n")
84 );
85 }
86
87 if steps > 0 {
88 format!("{steps} steps, last: {last_step}")
89 } else {
90 "built".to_string()
91 }
92}
93
94fn compress_ps(output: &str) -> String {
95 let lines: Vec<&str> = output.lines().collect();
96 if lines.len() <= 1 {
97 return "no containers".to_string();
98 }
99
100 let header = lines[0];
101 let col_positions = parse_docker_columns(header);
102
103 let mut containers = Vec::new();
104 for line in &lines[1..] {
105 if line.trim().is_empty() {
106 continue;
107 }
108
109 let name = extract_column(line, &col_positions, "NAMES")
110 .unwrap_or_else(|| extract_last_word(line));
111 let mut status =
112 extract_column(line, &col_positions, "STATUS").unwrap_or_else(|| "?".to_string());
113 let image = extract_column(line, &col_positions, "IMAGE");
114
115 for annotation in &["(unhealthy)", "(healthy)", "(health: starting)"] {
119 if line.contains(annotation) && !status.contains(annotation) {
120 status = format!("{status} {annotation}");
121 }
122 }
123 if line.contains("Exited")
124 && !status.contains("Exited")
125 && let Some(pos) = line.find("Exited")
126 {
127 let end = line[pos..].find(')').map_or(pos + 6, |p| pos + p + 1);
128 let exited_str = &line[pos..end.min(line.len())];
129 status = exited_str.to_string();
130 }
131
132 let mut entry = name.clone();
133 if let Some(img) = image {
134 entry = format!("{name} ({img})");
135 }
136 entry = format!("{entry}: {status}");
137 containers.push(entry);
138 }
139
140 if containers.is_empty() {
141 return "no containers".to_string();
142 }
143 containers.join("\n")
144}
145
146fn parse_docker_columns(header: &str) -> Vec<(String, usize)> {
147 let cols = [
148 "CONTAINER ID",
149 "IMAGE",
150 "COMMAND",
151 "CREATED",
152 "STATUS",
153 "PORTS",
154 "NAMES",
155 ];
156 let mut positions: Vec<(String, usize)> = Vec::new();
157 for col in &cols {
158 if let Some(pos) = header.find(col) {
159 positions.push((col.to_string(), pos));
160 }
161 }
162 positions.sort_by_key(|(_, pos)| *pos);
163 positions
164}
165
166fn extract_column(line: &str, cols: &[(String, usize)], name: &str) -> Option<String> {
167 let idx = cols.iter().position(|(n, _)| n == name)?;
168 let start = cols[idx].1;
169 let end = cols.get(idx + 1).map_or(line.len(), |(_, p)| *p);
170 if start >= line.len() {
171 return None;
172 }
173 let end = end.min(line.len());
174 let val = line[start..end].trim().to_string();
175 if val.is_empty() { None } else { Some(val) }
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 && last.0 == stripped
224 {
225 last.1 += 1;
226 continue;
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(std::string::String::as_str).collect();
244 let middle = &result_strs[..result_strs.len() - 15];
245 let safety = crate::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 && let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed)
377 {
378 return compress_json_value(&val, 0);
379 }
380 if trimmed.lines().count() > 20 {
381 let lines: Vec<&str> = trimmed.lines().collect();
382 return format!(
383 "{}\n{}",
384 lines[..10].join("\n"),
385 super::elision_marker(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 }
424 }
425
426 let lines: Vec<&str> = output
427 .lines()
428 .filter(|l| {
429 let t = l.trim();
430 !t.is_empty()
431 && (t.contains("RECLAIMABLE")
432 || t.contains("SIZE")
433 || t.starts_with("Images")
434 || t.starts_with("Containers")
435 || t.starts_with("Local Volumes")
436 || t.starts_with("Build Cache")
437 || t.chars().next().is_some_and(|c| c.is_ascii_digit()))
438 })
439 .collect();
440
441 if lines.is_empty() {
442 return compact_output(output, 10);
443 }
444
445 for line in &lines {
446 let trimmed = line.trim();
447 if !trimmed.starts_with("TYPE") && !trimmed.is_empty() {
448 parts.push(trimmed.to_string());
449 }
450 }
451
452 if parts.is_empty() {
453 compact_output(output, 10)
454 } else {
455 parts.join("\n")
456 }
457}
458
459fn compress_info(output: &str) -> String {
460 let mut key_info = Vec::new();
461 let important_keys = [
462 "Server Version",
463 "Operating System",
464 "Architecture",
465 "CPUs",
466 "Total Memory",
467 "Docker Root Dir",
468 "Storage Driver",
469 "Containers:",
470 "Images:",
471 ];
472
473 for line in output.lines() {
474 let trimmed = line.trim();
475 for key in &important_keys {
476 if trimmed.starts_with(key) {
477 key_info.push(trimmed.to_string());
478 break;
479 }
480 }
481 }
482
483 if key_info.is_empty() {
484 return compact_output(output, 10);
485 }
486 key_info.join("\n")
487}
488
489fn compress_version(output: &str) -> String {
490 let mut parts = Vec::new();
491 let important = ["Version:", "API version:", "Go version:", "OS/Arch:"];
492
493 for line in output.lines() {
494 let trimmed = line.trim();
495 for key in &important {
496 if trimmed.starts_with(key) {
497 parts.push(trimmed.to_string());
498 break;
499 }
500 }
501 }
502
503 if parts.is_empty() {
504 return compact_output(output, 5);
505 }
506 parts.join("\n")
507}
508
509fn compact_output(text: &str, max: usize) -> String {
510 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
511 if lines.len() <= max {
512 return lines.join("\n");
513 }
514 format!(
515 "{}\n{}",
516 lines[..max].join("\n"),
517 super::elision_marker(lines.len() - max)
518 )
519}
520
521fn compress_json_value(val: &serde_json::Value, depth: usize) -> String {
522 if depth > 2 {
523 return "...".to_string();
524 }
525 match val {
526 serde_json::Value::Object(map) => {
527 let keys: Vec<String> = map.keys().take(15).cloned().collect();
528 let total = map.len();
529 if total > 15 {
530 format!("{{{} ... +{} keys}}", keys.join(", "), total - 15)
531 } else {
532 format!("{{{}}}", keys.join(", "))
533 }
534 }
535 serde_json::Value::Array(arr) => format!("[...{}]", arr.len()),
536 other => format!("{other}"),
537 }
538}