Skip to main content

lean_ctx_patterns/
docker.rs

1// SPDX-License-Identifier: Apache-2.0
2
3macro_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        // Fallback: if health/exit annotations are in the raw line but missing
116        // from the column-extracted status (column slicing can truncate them),
117        // recover them from the raw line.
118        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}