Skip to main content

lean_ctx/core/patterns/
docker.rs

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