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