syncable_cli/agent/ui/
kubelint_display.rs

1//! Kubelint result display for terminal output
2//!
3//! Provides colored, formatted output for Kubernetes manifest lint results
4//! using Syncable brand styling with box-drawing characters.
5
6use crate::agent::ui::colors::icons;
7use crate::agent::ui::response::brand;
8use std::io::{self, Write};
9
10/// Box width for consistent display
11const BOX_WIDTH: usize = 72;
12
13/// Display kubelint results in a formatted, colored terminal output
14pub struct KubelintDisplay;
15
16impl KubelintDisplay {
17    /// Format and print kubelint results from the JSON output
18    pub fn print_result(json_result: &str) {
19        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_result) {
20            Self::print_formatted(&parsed);
21        } else {
22            // Fallback: just print the raw result
23            println!("{}", json_result);
24        }
25    }
26
27    /// Print formatted kubelint output with Syncable brand styling
28    fn print_formatted(result: &serde_json::Value) {
29        let stdout = io::stdout();
30        let mut handle = stdout.lock();
31
32        // Source path
33        let source = result["source"].as_str().unwrap_or("kubernetes manifests");
34
35        // Header
36        let _ = writeln!(handle);
37        let _ = writeln!(
38            handle,
39            "{}{}╭─ {} Kubelint {}{}╮{}",
40            brand::PURPLE,
41            brand::BOLD,
42            icons::KUBERNETES,
43            "─".repeat(BOX_WIDTH - 16),
44            brand::DIM,
45            brand::RESET
46        );
47
48        // Source path line
49        let _ = writeln!(
50            handle,
51            "{}│  {}{}{}{}",
52            brand::DIM,
53            brand::CYAN,
54            source,
55            " ".repeat((BOX_WIDTH - 4 - source.len()).max(0)),
56            brand::RESET
57        );
58
59        // Empty line
60        let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1));
61
62        // Decision context
63        if let Some(context) = result["decision_context"].as_str() {
64            let context_color = if context.contains("CRITICAL") {
65                brand::CORAL
66            } else if context.contains("High") || context.contains("high") {
67                brand::PEACH
68            } else if context.contains("Good") || context.contains("No issues") {
69                brand::SUCCESS
70            } else {
71                brand::PEACH
72            };
73
74            // Truncate context if too long
75            let display_context = if context.len() > BOX_WIDTH - 6 {
76                &context[..BOX_WIDTH - 9]
77            } else {
78                context
79            };
80
81            let _ = writeln!(
82                handle,
83                "{}│  {}{}{}{}",
84                brand::DIM,
85                context_color,
86                display_context,
87                " ".repeat((BOX_WIDTH - 4 - display_context.len()).max(0)),
88                brand::RESET
89            );
90        }
91
92        // Empty line
93        let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1));
94
95        // Summary counts
96        if let Some(summary) = result.get("summary") {
97            let total = summary["total_issues"].as_u64().unwrap_or(0);
98
99            if total == 0 {
100                let _ = writeln!(
101                    handle,
102                    "{}│  {}{} All checks passed! No issues found.{}{}",
103                    brand::DIM,
104                    brand::SUCCESS,
105                    icons::SUCCESS,
106                    " ".repeat(BOX_WIDTH - 42),
107                    brand::RESET
108                );
109
110                // Objects analyzed
111                let objects = summary["objects_analyzed"].as_u64().unwrap_or(0);
112                let checks = summary["checks_run"].as_u64().unwrap_or(0);
113                let stats = format!("{} objects analyzed • {} checks run", objects, checks);
114                let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1));
115                let _ = writeln!(
116                    handle,
117                    "{}│  {}{}{}{}",
118                    brand::DIM,
119                    brand::DIM,
120                    stats,
121                    " ".repeat((BOX_WIDTH - 4 - stats.len()).max(0)),
122                    brand::RESET
123                );
124            } else {
125                // Priority breakdown
126                if let Some(by_priority) = summary.get("by_priority") {
127                    let critical = by_priority["critical"].as_u64().unwrap_or(0);
128                    let high = by_priority["high"].as_u64().unwrap_or(0);
129                    let medium = by_priority["medium"].as_u64().unwrap_or(0);
130                    let low = by_priority["low"].as_u64().unwrap_or(0);
131
132                    let mut counts = String::new();
133                    if critical > 0 {
134                        counts.push_str(&format!("{} {} critical  ", icons::CRITICAL, critical));
135                    }
136                    if high > 0 {
137                        counts.push_str(&format!("{} {} high  ", icons::HIGH, high));
138                    }
139                    if medium > 0 {
140                        counts.push_str(&format!("{} {} medium  ", icons::MEDIUM, medium));
141                    }
142                    if low > 0 {
143                        counts.push_str(&format!("{} {} low", icons::LOW, low));
144                    }
145
146                    let padding = if counts.len() < BOX_WIDTH - 4 {
147                        (BOX_WIDTH - 4 - counts.chars().count()).max(0)
148                    } else {
149                        0
150                    };
151                    let _ = writeln!(
152                        handle,
153                        "{}│  {}{}{}",
154                        brand::DIM,
155                        counts,
156                        " ".repeat(padding),
157                        brand::RESET
158                    );
159                }
160            }
161        }
162
163        // Quick fixes section
164        if let Some(quick_fixes) = result.get("quick_fixes").and_then(|f| f.as_array())
165            && !quick_fixes.is_empty()
166        {
167            let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1));
168            let _ = writeln!(
169                handle,
170                "{}│  {}{} Quick Fixes:{}{}",
171                brand::DIM,
172                brand::PURPLE,
173                icons::FIX,
174                " ".repeat(BOX_WIDTH - 18),
175                brand::RESET
176            );
177
178            for fix in quick_fixes.iter().take(5) {
179                if let Some(fix_str) = fix.as_str() {
180                    // Split fix into parts if it contains " - "
181                    let (issue, remediation) = if let Some(pos) = fix_str.find(" - ") {
182                        (&fix_str[..pos], &fix_str[pos + 3..])
183                    } else {
184                        (fix_str, "")
185                    };
186
187                    let issue_display = if issue.len() > BOX_WIDTH - 10 {
188                        format!("{}...", &issue[..BOX_WIDTH - 13])
189                    } else {
190                        issue.to_string()
191                    };
192
193                    let _ = writeln!(
194                        handle,
195                        "{}│    {}→ {}{}{}{}",
196                        brand::DIM,
197                        brand::CYAN,
198                        issue_display,
199                        " ".repeat((BOX_WIDTH - 8 - issue_display.len()).max(0)),
200                        brand::RESET,
201                        brand::RESET
202                    );
203
204                    if !remediation.is_empty() {
205                        let rem_display = if remediation.len() > BOX_WIDTH - 10 {
206                            format!("{}...", &remediation[..BOX_WIDTH - 13])
207                        } else {
208                            remediation.to_string()
209                        };
210                        let _ = writeln!(
211                            handle,
212                            "{}│      {}{}{}{}",
213                            brand::DIM,
214                            brand::DIM,
215                            rem_display,
216                            " ".repeat((BOX_WIDTH - 8 - rem_display.len()).max(0)),
217                            brand::RESET
218                        );
219                    }
220                }
221            }
222        }
223
224        // Critical and High priority issues with details
225        Self::print_priority_section(
226            &mut handle,
227            result,
228            "critical",
229            "Critical Issues",
230            brand::CORAL,
231        );
232        Self::print_priority_section(&mut handle, result, "high", "High Priority", brand::PEACH);
233
234        // Medium/Low summary
235        let medium_count = result["action_plan"]["medium"]
236            .as_array()
237            .map(|a| a.len())
238            .unwrap_or(0);
239        let low_count = result["action_plan"]["low"]
240            .as_array()
241            .map(|a| a.len())
242            .unwrap_or(0);
243        let other_count = medium_count + low_count;
244
245        if other_count > 0 {
246            let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1));
247            let msg = format!(
248                "{} {} priority issue{} (use --verbose to see all)",
249                other_count,
250                if medium_count > 0 {
251                    "medium/low"
252                } else {
253                    "low"
254                },
255                if other_count == 1 { "" } else { "s" }
256            );
257            let _ = writeln!(
258                handle,
259                "{}│  {}{}{}{}",
260                brand::DIM,
261                brand::DIM,
262                msg,
263                " ".repeat((BOX_WIDTH - 4 - msg.len()).max(0)),
264                brand::RESET
265            );
266        }
267
268        // Footer
269        let _ = writeln!(
270            handle,
271            "{}╰{}╯{}",
272            brand::DIM,
273            "─".repeat(BOX_WIDTH - 2),
274            brand::RESET
275        );
276        let _ = writeln!(handle);
277
278        let _ = handle.flush();
279    }
280
281    /// Print a section for a priority level
282    fn print_priority_section(
283        handle: &mut io::StdoutLock,
284        result: &serde_json::Value,
285        priority: &str,
286        title: &str,
287        color: &str,
288    ) {
289        if let Some(issues) = result["action_plan"][priority].as_array() {
290            if issues.is_empty() {
291                return;
292            }
293
294            let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1));
295            let _ = writeln!(
296                handle,
297                "{}│  {}{}:{}{}",
298                brand::DIM,
299                color,
300                title,
301                " ".repeat((BOX_WIDTH - 4 - title.len() - 1).max(0)),
302                brand::RESET
303            );
304
305            for issue in issues.iter().take(5) {
306                let code = issue["check"].as_str().unwrap_or("???");
307                let line = issue["line"].as_u64().unwrap_or(0);
308                let message = issue["message"].as_str().unwrap_or("");
309                let category = issue["category"].as_str().unwrap_or("");
310
311                // Category badge
312                let badge = Self::get_category_badge(category);
313
314                // Issue header line
315                let header = format!("Line {} • {} {}", line, code, badge);
316                let _ = writeln!(
317                    handle,
318                    "{}│    {}{}{}{}",
319                    brand::DIM,
320                    brand::CYAN,
321                    header,
322                    " ".repeat((BOX_WIDTH - 6 - header.chars().count()).max(0)),
323                    brand::RESET
324                );
325
326                // Message
327                let msg_display = if message.len() > BOX_WIDTH - 8 {
328                    format!("{}...", &message[..BOX_WIDTH - 11])
329                } else {
330                    message.to_string()
331                };
332                let _ = writeln!(
333                    handle,
334                    "{}│    {}{}{}",
335                    brand::DIM,
336                    msg_display,
337                    " ".repeat((BOX_WIDTH - 6 - msg_display.len()).max(0)),
338                    brand::RESET
339                );
340
341                // Remediation
342                if let Some(remediation) = issue["remediation"].as_str() {
343                    let rem_display = if remediation.len() > BOX_WIDTH - 12 {
344                        format!("{}...", &remediation[..BOX_WIDTH - 15])
345                    } else {
346                        remediation.to_string()
347                    };
348                    let _ = writeln!(
349                        handle,
350                        "{}│    {}→ {}{}{}",
351                        brand::DIM,
352                        brand::CYAN,
353                        rem_display,
354                        " ".repeat((BOX_WIDTH - 8 - rem_display.len()).max(0)),
355                        brand::RESET
356                    );
357                }
358            }
359
360            if issues.len() > 5 {
361                let more_msg = format!("... and {} more", issues.len() - 5);
362                let _ = writeln!(
363                    handle,
364                    "{}│    {}{}{}{}",
365                    brand::DIM,
366                    brand::DIM,
367                    more_msg,
368                    " ".repeat((BOX_WIDTH - 6 - more_msg.len()).max(0)),
369                    brand::RESET
370                );
371            }
372        }
373    }
374
375    /// Get category badge with color
376    fn get_category_badge(category: &str) -> String {
377        match category {
378            "security" => format!("{}[SEC]{}", brand::CORAL, brand::RESET),
379            "rbac" => format!("{}[RBAC]{}", brand::CORAL, brand::RESET),
380            "best-practice" => format!("{}[BP]{}", brand::CYAN, brand::RESET),
381            "validation" => format!("{}[VAL]{}", brand::PEACH, brand::RESET),
382            "ports" => format!("{}[PORT]{}", brand::PEACH, brand::RESET),
383            "disruption-budget" => format!("{}[PDB]{}", brand::DIM, brand::RESET),
384            "autoscaling" => format!("{}[HPA]{}", brand::DIM, brand::RESET),
385            "deprecated-api" => format!("{}[DEP]{}", brand::PEACH, brand::RESET),
386            "service" => format!("{}[SVC]{}", brand::DIM, brand::RESET),
387            _ => String::new(),
388        }
389    }
390
391    /// Format a compact single-line summary for tool call display
392    pub fn format_summary(json_result: &str) -> String {
393        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_result) {
394            let success = parsed["success"].as_bool().unwrap_or(false);
395            let total = parsed["summary"]["total_issues"].as_u64().unwrap_or(0);
396
397            if success && total == 0 {
398                format!(
399                    "{}{} {} K8s manifests OK - no issues{}",
400                    brand::SUCCESS,
401                    icons::SUCCESS,
402                    icons::KUBERNETES,
403                    brand::RESET
404                )
405            } else {
406                let critical = parsed["summary"]["by_priority"]["critical"]
407                    .as_u64()
408                    .unwrap_or(0);
409                let high = parsed["summary"]["by_priority"]["high"]
410                    .as_u64()
411                    .unwrap_or(0);
412
413                if critical > 0 {
414                    format!(
415                        "{}{} {} {} critical, {} high priority issues{}",
416                        brand::CORAL,
417                        icons::CRITICAL,
418                        icons::KUBERNETES,
419                        critical,
420                        high,
421                        brand::RESET
422                    )
423                } else if high > 0 {
424                    format!(
425                        "{}{} {} {} high priority issues{}",
426                        brand::PEACH,
427                        icons::HIGH,
428                        icons::KUBERNETES,
429                        high,
430                        brand::RESET
431                    )
432                } else {
433                    format!(
434                        "{}{} {} {} issues (medium/low){}",
435                        brand::PEACH,
436                        icons::MEDIUM,
437                        icons::KUBERNETES,
438                        total,
439                        brand::RESET
440                    )
441                }
442            }
443        } else {
444            format!("{} Kubelint analysis complete", icons::KUBERNETES)
445        }
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_format_summary_success() {
455        let json = r#"{"success": true, "summary": {"total_issues": 0, "by_priority": {"critical": 0, "high": 0, "medium": 0, "low": 0}}}"#;
456        let summary = KubelintDisplay::format_summary(json);
457        assert!(summary.contains("OK"));
458    }
459
460    #[test]
461    fn test_format_summary_critical() {
462        let json = r#"{"success": false, "summary": {"total_issues": 3, "by_priority": {"critical": 1, "high": 2, "medium": 0, "low": 0}}}"#;
463        let summary = KubelintDisplay::format_summary(json);
464        assert!(summary.contains("critical"));
465    }
466
467    #[test]
468    fn test_category_badge() {
469        let badge = KubelintDisplay::get_category_badge("security");
470        assert!(badge.contains("SEC"));
471    }
472
473    #[test]
474    fn test_print_result_with_issues() {
475        // Test that print doesn't panic with real data
476        let json = r#"{
477            "source": "test.yaml",
478            "success": false,
479            "decision_context": "CRITICAL security issues found.",
480            "summary": {
481                "total_issues": 2,
482                "objects_analyzed": 1,
483                "checks_run": 63,
484                "by_priority": {"critical": 1, "high": 1, "medium": 0, "low": 0}
485            },
486            "action_plan": {
487                "critical": [{
488                    "check": "privileged-container",
489                    "severity": "error",
490                    "priority": "critical",
491                    "category": "security",
492                    "message": "Container running in privileged mode",
493                    "line": 20,
494                    "remediation": "Set privileged: false"
495                }],
496                "high": [{
497                    "check": "latest-tag",
498                    "severity": "warning",
499                    "priority": "high",
500                    "category": "best-practice",
501                    "message": "Image uses :latest tag",
502                    "line": 18,
503                    "remediation": "Use specific tag"
504                }],
505                "medium": [],
506                "low": []
507            },
508            "quick_fixes": ["Deployment/nginx: privileged-container - Set privileged: false"]
509        }"#;
510
511        // Just test it doesn't panic
512        KubelintDisplay::print_result(json);
513    }
514
515    #[test]
516    fn test_print_result_success() {
517        let json = r#"{
518            "source": "secure.yaml",
519            "success": true,
520            "decision_context": "No issues found.",
521            "summary": {
522                "total_issues": 0,
523                "objects_analyzed": 3,
524                "checks_run": 63,
525                "by_priority": {"critical": 0, "high": 0, "medium": 0, "low": 0}
526            },
527            "action_plan": {"critical": [], "high": [], "medium": [], "low": []}
528        }"#;
529
530        // Just test it doesn't panic
531        KubelintDisplay::print_result(json);
532    }
533}