Skip to main content

lean_ctx/core/patterns/
docker.rs

1use 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        // Fallback: if health/exit annotations are in the raw line but missing
110        // from the column-extracted status (column slicing can truncate them),
111        // recover them from the raw line.
112        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}