lean_ctx/core/patterns/
kubectl.rs1macro_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_ts_re() -> &'static regex::Regex {
11 static_regex!(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\S*\s+")
12}
13fn resource_action_re() -> &'static regex::Regex {
14 static_regex!(r"(\S+/\S+)\s+(configured|created|unchanged|deleted)")
15}
16
17pub fn compress(command: &str, output: &str) -> Option<String> {
18 if command.contains("logs") || command.contains("log ") {
19 return Some(compress_logs(output));
20 }
21 if command.contains("describe") {
22 return Some(compress_describe(output));
23 }
24 if command.contains("apply") {
25 return Some(compress_apply(output));
26 }
27 if command.contains("delete") {
28 return Some(compress_delete(output));
29 }
30 if command.contains("get") {
31 return Some(compress_get(output));
32 }
33 if command.contains("exec") {
34 return Some(compress_exec(output));
35 }
36 if command.contains("top") {
37 return Some(compress_top(output));
38 }
39 if command.contains("rollout") {
40 return Some(compress_rollout(output));
41 }
42 if command.contains("scale") {
43 return Some(compress_simple(output));
44 }
45 Some(compact_table(output))
46}
47
48fn compress_get(output: &str) -> String {
49 let lines: Vec<&str> = output.lines().collect();
50 if lines.is_empty() {
51 return "no resources".to_string();
52 }
53 if lines.len() == 1 && lines[0].starts_with("No resources") {
54 return "no resources".to_string();
55 }
56
57 if lines.len() <= 1 {
58 return output.trim().to_string();
59 }
60
61 let header = lines[0];
62 let cols: Vec<&str> = header.split_whitespace().collect();
63
64 let status_col = cols.iter().position(|c| c.eq_ignore_ascii_case("STATUS"));
66
67 let data_lines: Vec<Vec<&str>> = lines[1..]
68 .iter()
69 .map(|l| l.split_whitespace().collect::<Vec<&str>>())
70 .filter(|p| !p.is_empty())
71 .collect();
72
73 if data_lines.is_empty() {
74 return "no resources".to_string();
75 }
76
77 let total = data_lines.len();
78
79 if let Some(si) = status_col {
81 if total > 5 {
82 let mut status_counts: std::collections::HashMap<&str, usize> =
83 std::collections::HashMap::new();
84 for row in &data_lines {
85 if let Some(status) = row.get(si) {
86 *status_counts.entry(status).or_default() += 1;
87 }
88 }
89 let mut summary_parts: Vec<String> = status_counts
90 .iter()
91 .map(|(k, v)| format!("{v} {k}"))
92 .collect();
93 summary_parts.sort_by(|a, b| b.cmp(a));
94 return format!("{total} resources ({})", summary_parts.join(", "));
95 }
96 }
97
98 let mut rows = Vec::new();
100 for parts in &data_lines {
101 let name = parts[0];
102 let relevant: Vec<&str> = parts.iter().skip(1).take(4).copied().collect();
103 rows.push(format!("{name} {}", relevant.join(" ")));
104 }
105
106 let col_hint = cols
107 .iter()
108 .skip(1)
109 .take(4)
110 .copied()
111 .collect::<Vec<&str>>()
112 .join(" ");
113 format!("[{col_hint}]\n{}", rows.join("\n"))
114}
115
116fn compress_logs(output: &str) -> String {
117 let lines: Vec<&str> = output.lines().collect();
118 if lines.len() <= 10 {
119 return output.to_string();
120 }
121
122 let mut deduped: Vec<(String, u32)> = Vec::new();
123 for line in &lines {
124 let stripped = log_ts_re().replace(line, "").trim().to_string();
125 if stripped.is_empty() {
126 continue;
127 }
128
129 if let Some(last) = deduped.last_mut() {
130 if last.0 == stripped {
131 last.1 += 1;
132 continue;
133 }
134 }
135 deduped.push((stripped, 1));
136 }
137
138 let result: Vec<String> = deduped
139 .iter()
140 .map(|(line, count)| {
141 if *count > 1 {
142 format!("{line} (x{count})")
143 } else {
144 line.clone()
145 }
146 })
147 .collect();
148
149 if result.len() > 30 {
150 let tail = &result[result.len() - 20..];
151 return format!("... ({} lines total)\n{}", lines.len(), tail.join("\n"));
152 }
153 result.join("\n")
154}
155
156fn compress_describe(output: &str) -> String {
157 let lines: Vec<&str> = output.lines().collect();
158 if lines.len() <= 20 {
159 return output.to_string();
160 }
161
162 let mut sections = Vec::new();
163 let mut current_section = String::new();
164 let mut current_lines: Vec<&str> = Vec::new();
165 for line in &lines {
166 if !line.starts_with(' ')
167 && !line.starts_with('\t')
168 && line.ends_with(':')
169 && !line.contains(" ")
170 {
171 if !current_section.is_empty() {
172 let count = current_lines.len();
173 if count <= 3 {
174 sections.push(format!("{current_section}\n{}", current_lines.join("\n")));
175 } else {
176 sections.push(format!("{current_section} ({count} lines)"));
177 }
178 }
179 current_section = line.trim_end_matches(':').to_string();
180 current_lines.clear();
181 } else {
183 current_lines.push(line);
184 }
185 }
186
187 if !current_section.is_empty() {
188 let count = current_lines.len();
189 if current_section == "Events" && count > 5 {
190 let last_events = ¤t_lines[count.saturating_sub(5)..];
191 sections.push(format!(
192 "Events (last 5 of {count}):\n{}",
193 last_events.join("\n")
194 ));
195 } else if count <= 5 {
196 sections.push(format!("{current_section}\n{}", current_lines.join("\n")));
197 } else {
198 sections.push(format!("{current_section} ({count} lines)"));
199 }
200 }
201
202 sections.join("\n")
203}
204
205fn compress_apply(output: &str) -> String {
206 let trimmed = output.trim();
207 if trimmed.is_empty() {
208 return "ok".to_string();
209 }
210
211 let mut configured = 0u32;
212 let mut created = 0u32;
213 let mut unchanged = 0u32;
214 let mut deleted = 0u32;
215 let mut resources = Vec::new();
216
217 for line in trimmed.lines() {
218 if let Some(caps) = resource_action_re().captures(line) {
219 let resource = &caps[1];
220 let action = &caps[2];
221 match action {
222 "configured" => configured += 1,
223 "created" => created += 1,
224 "unchanged" => unchanged += 1,
225 "deleted" => deleted += 1,
226 _ => {}
227 }
228 resources.push(format!("{resource} {action}"));
229 }
230 }
231
232 let total = configured + created + unchanged + deleted;
233 if total == 0 {
234 return compact_output(trimmed, 5);
235 }
236
237 let mut summary = Vec::new();
238 if created > 0 {
239 summary.push(format!("{created} created"));
240 }
241 if configured > 0 {
242 summary.push(format!("{configured} configured"));
243 }
244 if unchanged > 0 {
245 summary.push(format!("{unchanged} unchanged"));
246 }
247 if deleted > 0 {
248 summary.push(format!("{deleted} deleted"));
249 }
250
251 format!("ok ({total} resources: {})", summary.join(", "))
252}
253
254fn compress_delete(output: &str) -> String {
255 let trimmed = output.trim();
256 if trimmed.is_empty() {
257 return "ok".to_string();
258 }
259
260 let deleted: Vec<&str> = trimmed.lines().filter(|l| l.contains("deleted")).collect();
261
262 if deleted.is_empty() {
263 return compact_output(trimmed, 3);
264 }
265 format!("deleted {} resources", deleted.len())
266}
267
268fn compress_exec(output: &str) -> String {
269 let trimmed = output.trim();
270 if trimmed.is_empty() {
271 return "ok".to_string();
272 }
273 let lines: Vec<&str> = trimmed.lines().collect();
274 if lines.len() > 20 {
275 let tail = &lines[lines.len() - 10..];
276 return format!("... ({} lines)\n{}", lines.len(), tail.join("\n"));
277 }
278 trimmed.to_string()
279}
280
281fn compress_top(output: &str) -> String {
282 compact_table(output)
283}
284
285fn compress_rollout(output: &str) -> String {
286 compact_output(output, 5)
287}
288
289fn compress_simple(output: &str) -> String {
290 let trimmed = output.trim();
291 if trimmed.is_empty() {
292 return "ok".to_string();
293 }
294 compact_output(trimmed, 3)
295}
296
297fn compact_table(text: &str) -> String {
298 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
299 if lines.len() <= 15 {
300 return lines.join("\n");
301 }
302 format!(
303 "{}\n... ({} more rows)",
304 lines[..15].join("\n"),
305 lines.len() - 15
306 )
307}
308
309fn compact_output(text: &str, max: usize) -> String {
310 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
311 if lines.len() <= max {
312 return lines.join("\n");
313 }
314 format!(
315 "{}\n... ({} more lines)",
316 lines[..max].join("\n"),
317 lines.len() - max
318 )
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn get_pods_many_aggregates_by_status() {
327 let output = "\
328NAME READY STATUS RESTARTS AGE
329api-server-abc123 1/1 Running 0 5d
330api-server-def456 1/1 Running 0 5d
331worker-1-ghi789 1/1 Running 2 3d
332worker-2-jkl012 1/1 Running 0 3d
333scheduler-mno345 0/1 Pending 0 1h
334cache-pqr678 0/1 CrashLoopBackOff 5 2d
335db-stu901 1/1 Running 0 10d
336";
337 let result = compress("kubectl get pods -A", output);
338 let r = result.unwrap();
339 assert!(r.contains("7 resources"));
340 assert!(r.contains("Running"));
341 assert!(r.contains("Pending"));
342 assert!(r.contains("CrashLoopBackOff"));
343 }
344
345 #[test]
346 fn get_pods_few_shows_compact_table() {
347 let output = "\
348NAME READY STATUS RESTARTS AGE
349my-pod-123 1/1 Running 0 5d
350my-pod-456 1/1 Running 0 3d
351";
352 let result = compress("kubectl get pods", output);
353 let r = result.unwrap();
354 assert!(r.contains("my-pod-123"));
355 assert!(r.contains("[READY STATUS RESTARTS AGE]"));
356 }
357
358 #[test]
359 fn get_no_resources() {
360 let result = compress(
361 "kubectl get pods",
362 "No resources found in default namespace.\n",
363 );
364 assert_eq!(result.unwrap(), "no resources");
365 }
366
367 #[test]
368 fn apply_aggregates_actions() {
369 let output = "\
370service/api-gateway configured
371deployment.apps/api-server configured
372configmap/settings created
373secret/db-credentials unchanged
374";
375 let result = compress("kubectl apply -f deploy/", output);
376 let r = result.unwrap();
377 assert!(r.contains("4 resources"));
378 assert!(r.contains("2 configured"));
379 assert!(r.contains("1 created"));
380 assert!(r.contains("1 unchanged"));
381 }
382
383 #[test]
384 fn logs_deduplicates_repeated_lines() {
385 let mut lines = Vec::new();
386 for i in 0..20 {
387 lines.push(format!("2026-05-25T10:{i:02}:00Z INFO: Processing request"));
388 }
389 lines.push("2026-05-25T10:20:00Z ERROR: Connection refused".to_string());
390 let output = lines.join("\n");
391 let result = compress("kubectl logs my-pod", &output);
392 let r = result.unwrap();
393 assert!(r.contains("(x"));
394 assert!(r.contains("Connection refused"));
395 }
396
397 #[test]
398 fn describe_extracts_events() {
399 let mut output = String::new();
400 output.push_str("Name: my-pod\n");
401 output.push_str("Namespace: default\n");
402 output.push_str("Labels:\n");
403 for i in 0..10 {
404 output.push_str(&format!(" app=myapp-{i}\n"));
405 }
406 output.push_str("Conditions:\n");
407 output.push_str(" Type Status\n");
408 output.push_str(" Ready True\n");
409 output.push_str("Events:\n");
410 for i in 0..8 {
411 output.push_str(&format!(" Normal Pulled {i}m kubelet pulled image\n"));
412 }
413 let result = compress("kubectl describe pod my-pod", &output);
414 let r = result.unwrap();
415 assert!(r.contains("Events (last 5 of 8)"));
416 }
417
418 #[test]
419 fn delete_counts_resources() {
420 let output = "\
421pod \"worker-1\" deleted
422pod \"worker-2\" deleted
423pod \"worker-3\" deleted
424";
425 let result = compress("kubectl delete pods -l app=worker", output);
426 assert_eq!(result.unwrap(), "deleted 3 resources");
427 }
428}