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    None
47}
48
49fn compress_build(output: &str) -> String {
50    let mut steps = 0u32;
51    let mut last_step = String::new();
52    let mut errors = Vec::new();
53
54    for line in output.lines() {
55        if line.starts_with("Step ") || (line.starts_with('#') && line.contains('[')) {
56            steps += 1;
57            last_step = line.trim().to_string();
58        }
59        if line.contains("ERROR") || line.contains("error:") {
60            errors.push(line.trim().to_string());
61        }
62    }
63
64    if !errors.is_empty() {
65        return format!(
66            "{steps} steps, {} errors:\n{}",
67            errors.len(),
68            errors.join("\n")
69        );
70    }
71
72    if steps > 0 {
73        format!("{steps} steps, last: {last_step}")
74    } else {
75        "built".to_string()
76    }
77}
78
79fn compress_ps(output: &str) -> String {
80    let lines: Vec<&str> = output.lines().collect();
81    if lines.len() <= 1 {
82        return "no containers".to_string();
83    }
84
85    let mut containers = Vec::new();
86    for line in &lines[1..] {
87        let parts: Vec<&str> = line.split_whitespace().collect();
88        if parts.len() >= 7 {
89            let name = parts.last().unwrap_or(&"?");
90            let status = parts.get(4).unwrap_or(&"?");
91            containers.push(format!("{name}: {status}"));
92        }
93    }
94
95    if containers.is_empty() {
96        return "no containers".to_string();
97    }
98    containers.join("\n")
99}
100
101fn compress_images(output: &str) -> String {
102    let lines: Vec<&str> = output.lines().collect();
103    if lines.len() <= 1 {
104        return "no images".to_string();
105    }
106
107    let mut images = Vec::new();
108    for line in &lines[1..] {
109        let parts: Vec<&str> = line.split_whitespace().collect();
110        if parts.len() >= 5 {
111            let repo = parts[0];
112            let tag = parts[1];
113            let size = parts.last().unwrap_or(&"?");
114            if repo == "<none>" {
115                continue;
116            }
117            images.push(format!("{repo}:{tag} ({size})"));
118        }
119    }
120
121    if images.is_empty() {
122        return "no images".to_string();
123    }
124    format!("{} images:\n{}", images.len(), images.join("\n"))
125}
126
127fn compress_logs(output: &str) -> String {
128    let lines: Vec<&str> = output.lines().collect();
129    if lines.len() <= 10 {
130        return output.to_string();
131    }
132
133    let mut deduped: Vec<(String, u32)> = Vec::new();
134    for line in &lines {
135        let normalized = log_timestamp_re().replace(line, "[T]").to_string();
136        let stripped = normalized.trim().to_string();
137        if stripped.is_empty() {
138            continue;
139        }
140
141        if let Some(last) = deduped.last_mut() {
142            if last.0 == stripped {
143                last.1 += 1;
144                continue;
145            }
146        }
147        deduped.push((stripped, 1));
148    }
149
150    let result: Vec<String> = deduped
151        .iter()
152        .map(|(line, count)| {
153            if *count > 1 {
154                format!("{line} (x{count})")
155            } else {
156                line.clone()
157            }
158        })
159        .collect();
160
161    if result.len() > 30 {
162        let last_lines = &result[result.len() - 15..];
163        format!(
164            "... ({} lines total)\n{}",
165            lines.len(),
166            last_lines.join("\n")
167        )
168    } else {
169        result.join("\n")
170    }
171}
172
173fn compress_compose_ps(output: &str) -> String {
174    let lines: Vec<&str> = output.lines().collect();
175    if lines.len() <= 1 {
176        return "no services".to_string();
177    }
178
179    let mut services = Vec::new();
180    for line in &lines[1..] {
181        let parts: Vec<&str> = line.split_whitespace().collect();
182        if parts.len() >= 3 {
183            let name = parts[0];
184            let status_parts: Vec<&str> = parts[1..].to_vec();
185            let status = status_parts.join(" ");
186            services.push(format!("{name}: {status}"));
187        }
188    }
189
190    if services.is_empty() {
191        return "no services".to_string();
192    }
193    format!("{} services:\n{}", services.len(), services.join("\n"))
194}
195
196fn compress_compose_action(output: &str) -> String {
197    let trimmed = output.trim();
198    if trimmed.is_empty() {
199        return "ok".to_string();
200    }
201
202    let mut created = 0u32;
203    let mut started = 0u32;
204    let mut stopped = 0u32;
205    let mut removed = 0u32;
206
207    for line in trimmed.lines() {
208        let l = line.to_lowercase();
209        if l.contains("created") || l.contains("creating") {
210            created += 1;
211        }
212        if l.contains("started") || l.contains("starting") {
213            started += 1;
214        }
215        if l.contains("stopped") || l.contains("stopping") {
216            stopped += 1;
217        }
218        if l.contains("removed") || l.contains("removing") {
219            removed += 1;
220        }
221    }
222
223    let mut parts = Vec::new();
224    if created > 0 {
225        parts.push(format!("{created} created"));
226    }
227    if started > 0 {
228        parts.push(format!("{started} started"));
229    }
230    if stopped > 0 {
231        parts.push(format!("{stopped} stopped"));
232    }
233    if removed > 0 {
234        parts.push(format!("{removed} removed"));
235    }
236
237    if parts.is_empty() {
238        return "ok".to_string();
239    }
240    format!("ok ({})", parts.join(", "))
241}
242
243fn compress_network(output: &str) -> String {
244    let lines: Vec<&str> = output.lines().collect();
245    if lines.len() <= 1 {
246        return output.trim().to_string();
247    }
248
249    let mut networks = Vec::new();
250    for line in &lines[1..] {
251        let parts: Vec<&str> = line.split_whitespace().collect();
252        if parts.len() >= 3 {
253            let name = parts[1];
254            let driver = parts[2];
255            networks.push(format!("{name} ({driver})"));
256        }
257    }
258
259    if networks.is_empty() {
260        return "no networks".to_string();
261    }
262    networks.join(", ")
263}
264
265fn compress_volume(output: &str) -> String {
266    let lines: Vec<&str> = output.lines().collect();
267    if lines.len() <= 1 {
268        return output.trim().to_string();
269    }
270
271    let volumes: Vec<&str> = lines[1..]
272        .iter()
273        .filter_map(|l| l.split_whitespace().nth(1))
274        .collect();
275
276    if volumes.is_empty() {
277        return "no volumes".to_string();
278    }
279    format!("{} volumes: {}", volumes.len(), volumes.join(", "))
280}
281
282fn compress_inspect(output: &str) -> String {
283    let trimmed = output.trim();
284    if trimmed.starts_with('[') || trimmed.starts_with('{') {
285        if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
286            return compress_json_value(&val, 0);
287        }
288    }
289    if trimmed.lines().count() > 20 {
290        let lines: Vec<&str> = trimmed.lines().collect();
291        return format!(
292            "{}\n... ({} more lines)",
293            lines[..10].join("\n"),
294            lines.len() - 10
295        );
296    }
297    trimmed.to_string()
298}
299
300fn compress_exec(output: &str) -> String {
301    let trimmed = output.trim();
302    if trimmed.is_empty() {
303        return "ok".to_string();
304    }
305    let lines: Vec<&str> = trimmed.lines().collect();
306    if lines.len() > 30 {
307        let last = &lines[lines.len() - 10..];
308        return format!("... ({} lines)\n{}", lines.len(), last.join("\n"));
309    }
310    trimmed.to_string()
311}
312
313fn compress_json_value(val: &serde_json::Value, depth: usize) -> String {
314    if depth > 2 {
315        return "...".to_string();
316    }
317    match val {
318        serde_json::Value::Object(map) => {
319            let keys: Vec<String> = map.keys().take(15).map(|k| k.to_string()).collect();
320            let total = map.len();
321            if total > 15 {
322                format!("{{{} ... +{} keys}}", keys.join(", "), total - 15)
323            } else {
324                format!("{{{}}}", keys.join(", "))
325            }
326        }
327        serde_json::Value::Array(arr) => format!("[...{}]", arr.len()),
328        other => format!("{other}"),
329    }
330}