Skip to main content

lean_ctx/core/patterns/
kubectl.rs

1macro_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    // Find STATUS column index for aggregation
65    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 STATUS column exists and we have many rows, produce aggregation summary
80    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    // For small result sets, show compact table
99    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            // Events section detected
182        } 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 = &current_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}