Skip to main content

lean_ctx_patterns/
kubectl.rs

1// SPDX-License-Identifier: Apache-2.0
2
3macro_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    // Find STATUS column index for aggregation
67    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 STATUS column exists and we have many rows, produce aggregation summary
82    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    // For small result sets, show compact table
101    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            // Events section detected
184        } 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 = &current_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}