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