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