lean_ctx/core/patterns/
docker.rs1use 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}