syncable_cli/agent/ui/
helmlint_display.rs

1//! Helmlint result display for terminal output
2//!
3//! Provides colored, formatted output for Helm chart 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 helmlint results in a formatted, colored terminal output
14pub struct HelmlintDisplay;
15
16impl HelmlintDisplay {
17    /// Format and print helmlint 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 helmlint 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        // Chart path
33        let chart = result["chart"].as_str().unwrap_or("helm chart");
34
35        // Header
36        let _ = writeln!(handle);
37        let _ = writeln!(
38            handle,
39            "{}{}╭─ {} Helmlint {}{}╮{}",
40            brand::PURPLE,
41            brand::BOLD,
42            icons::HELM,
43            "─".repeat(BOX_WIDTH - 15),
44            brand::DIM,
45            brand::RESET
46        );
47
48        // Chart path line
49        let _ = writeln!(
50            handle,
51            "{}│  {}{}{}{}",
52            brand::DIM,
53            brand::CYAN,
54            chart,
55            " ".repeat((BOX_WIDTH - 4 - chart.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"].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                // Files checked
111                let files = summary["files_checked"].as_u64().unwrap_or(0);
112                let stats = format!("{} files checked", files);
113                let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1));
114                let _ = writeln!(
115                    handle,
116                    "{}│  {}{}{}{}",
117                    brand::DIM,
118                    brand::DIM,
119                    stats,
120                    " ".repeat((BOX_WIDTH - 4 - stats.len()).max(0)),
121                    brand::RESET
122                );
123            } else {
124                // Priority breakdown
125                if let Some(by_priority) = summary.get("by_priority") {
126                    let critical = by_priority["critical"].as_u64().unwrap_or(0);
127                    let high = by_priority["high"].as_u64().unwrap_or(0);
128                    let medium = by_priority["medium"].as_u64().unwrap_or(0);
129                    let low = by_priority["low"].as_u64().unwrap_or(0);
130
131                    let mut counts = String::new();
132                    if critical > 0 {
133                        counts.push_str(&format!("{} {} critical  ", icons::CRITICAL, critical));
134                    }
135                    if high > 0 {
136                        counts.push_str(&format!("{} {} high  ", icons::HIGH, high));
137                    }
138                    if medium > 0 {
139                        counts.push_str(&format!("{} {} medium  ", icons::MEDIUM, medium));
140                    }
141                    if low > 0 {
142                        counts.push_str(&format!("{} {} low", icons::LOW, low));
143                    }
144
145                    let padding = if counts.len() < BOX_WIDTH - 4 {
146                        (BOX_WIDTH - 4 - counts.chars().count()).max(0)
147                    } else {
148                        0
149                    };
150                    let _ = writeln!(
151                        handle,
152                        "{}│  {}{}{}",
153                        brand::DIM,
154                        counts,
155                        " ".repeat(padding),
156                        brand::RESET
157                    );
158                }
159            }
160        }
161
162        // Quick fixes section
163        if let Some(quick_fixes) = result.get("quick_fixes").and_then(|f| f.as_array())
164            && !quick_fixes.is_empty()
165        {
166            let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1));
167            let _ = writeln!(
168                handle,
169                "{}│  {}{} Quick Fixes:{}{}",
170                brand::DIM,
171                brand::PURPLE,
172                icons::FIX,
173                " ".repeat(BOX_WIDTH - 18),
174                brand::RESET
175            );
176
177            for fix in quick_fixes.iter().take(5) {
178                if let Some(fix_str) = fix.as_str() {
179                    // Split fix into parts if it contains " - "
180                    let (issue, remediation) = if let Some(pos) = fix_str.find(" - ") {
181                        (&fix_str[..pos], &fix_str[pos + 3..])
182                    } else {
183                        (fix_str, "")
184                    };
185
186                    let issue_display = if issue.len() > BOX_WIDTH - 10 {
187                        format!("{}...", &issue[..BOX_WIDTH - 13])
188                    } else {
189                        issue.to_string()
190                    };
191
192                    let _ = writeln!(
193                        handle,
194                        "{}│    {}→ {}{}{}{}",
195                        brand::DIM,
196                        brand::CYAN,
197                        issue_display,
198                        " ".repeat((BOX_WIDTH - 8 - issue_display.len()).max(0)),
199                        brand::RESET,
200                        brand::RESET
201                    );
202
203                    if !remediation.is_empty() {
204                        let rem_display = if remediation.len() > BOX_WIDTH - 10 {
205                            format!("{}...", &remediation[..BOX_WIDTH - 13])
206                        } else {
207                            remediation.to_string()
208                        };
209                        let _ = writeln!(
210                            handle,
211                            "{}│      {}{}{}{}",
212                            brand::DIM,
213                            brand::DIM,
214                            rem_display,
215                            " ".repeat((BOX_WIDTH - 8 - rem_display.len()).max(0)),
216                            brand::RESET
217                        );
218                    }
219                }
220            }
221        }
222
223        // Critical and High priority issues with details
224        Self::print_priority_section(
225            &mut handle,
226            result,
227            "critical",
228            "Critical Issues",
229            brand::CORAL,
230        );
231        Self::print_priority_section(&mut handle, result, "high", "High Priority", brand::PEACH);
232
233        // Medium/Low summary
234        let medium_count = result["action_plan"]["medium"]
235            .as_array()
236            .map(|a| a.len())
237            .unwrap_or(0);
238        let low_count = result["action_plan"]["low"]
239            .as_array()
240            .map(|a| a.len())
241            .unwrap_or(0);
242        let other_count = medium_count + low_count;
243
244        if other_count > 0 {
245            let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1));
246            let msg = format!(
247                "{} {} priority issue{} (use --verbose to see all)",
248                other_count,
249                if medium_count > 0 {
250                    "medium/low"
251                } else {
252                    "low"
253                },
254                if other_count == 1 { "" } else { "s" }
255            );
256            let _ = writeln!(
257                handle,
258                "{}│  {}{}{}{}",
259                brand::DIM,
260                brand::DIM,
261                msg,
262                " ".repeat((BOX_WIDTH - 4 - msg.len()).max(0)),
263                brand::RESET
264            );
265        }
266
267        // Footer
268        let _ = writeln!(
269            handle,
270            "{}╰{}╯{}",
271            brand::DIM,
272            "─".repeat(BOX_WIDTH - 2),
273            brand::RESET
274        );
275        let _ = writeln!(handle);
276
277        let _ = handle.flush();
278    }
279
280    /// Print a section for a priority level
281    fn print_priority_section(
282        handle: &mut io::StdoutLock,
283        result: &serde_json::Value,
284        priority: &str,
285        title: &str,
286        color: &str,
287    ) {
288        if let Some(issues) = result["action_plan"][priority].as_array() {
289            if issues.is_empty() {
290                return;
291            }
292
293            let _ = writeln!(handle, "{}│{}", brand::DIM, " ".repeat(BOX_WIDTH - 1));
294            let _ = writeln!(
295                handle,
296                "{}│  {}{}:{}{}",
297                brand::DIM,
298                color,
299                title,
300                " ".repeat((BOX_WIDTH - 4 - title.len() - 1).max(0)),
301                brand::RESET
302            );
303
304            for issue in issues.iter().take(5) {
305                let code = issue["code"].as_str().unwrap_or("???");
306                let file = issue["file"].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                // File and line info
315                let file_short = if file.len() > 30 {
316                    format!("...{}", &file[file.len() - 27..])
317                } else {
318                    file.to_string()
319                };
320
321                // Issue header line
322                let header = format!("{}:{} {} {}", file_short, line, code, badge);
323                let header_len = header.chars().count();
324                let _ = writeln!(
325                    handle,
326                    "{}│    {}{}{}{}",
327                    brand::DIM,
328                    brand::CYAN,
329                    header,
330                    " ".repeat((BOX_WIDTH - 6 - header_len).max(0)),
331                    brand::RESET
332                );
333
334                // Message
335                let msg_display = if message.len() > BOX_WIDTH - 8 {
336                    format!("{}...", &message[..BOX_WIDTH - 11])
337                } else {
338                    message.to_string()
339                };
340                let _ = writeln!(
341                    handle,
342                    "{}│    {}{}{}",
343                    brand::DIM,
344                    msg_display,
345                    " ".repeat((BOX_WIDTH - 6 - msg_display.len()).max(0)),
346                    brand::RESET
347                );
348
349                // Fix recommendation
350                if let Some(fix) = issue["fix"].as_str() {
351                    let fix_display = if fix.len() > BOX_WIDTH - 12 {
352                        format!("{}...", &fix[..BOX_WIDTH - 15])
353                    } else {
354                        fix.to_string()
355                    };
356                    let _ = writeln!(
357                        handle,
358                        "{}│    {}→ {}{}{}",
359                        brand::DIM,
360                        brand::CYAN,
361                        fix_display,
362                        " ".repeat((BOX_WIDTH - 8 - fix_display.len()).max(0)),
363                        brand::RESET
364                    );
365                }
366            }
367
368            if issues.len() > 5 {
369                let more_msg = format!("... and {} more", issues.len() - 5);
370                let _ = writeln!(
371                    handle,
372                    "{}│    {}{}{}{}",
373                    brand::DIM,
374                    brand::DIM,
375                    more_msg,
376                    " ".repeat((BOX_WIDTH - 6 - more_msg.len()).max(0)),
377                    brand::RESET
378                );
379            }
380        }
381    }
382
383    /// Get category badge with color
384    fn get_category_badge(category: &str) -> String {
385        match category {
386            "Security" | "security" => format!("{}[SEC]{}", brand::CORAL, brand::RESET),
387            "Structure" | "structure" => format!("{}[STRUCT]{}", brand::DIM, brand::RESET),
388            "Values" | "values" => format!("{}[VAL]{}", brand::PEACH, brand::RESET),
389            "Template" | "template" => format!("{}[TPL]{}", brand::PEACH, brand::RESET),
390            "Best Practice" | "best-practice" => format!("{}[BP]{}", brand::CYAN, brand::RESET),
391            _ => String::new(),
392        }
393    }
394
395    /// Format a compact single-line summary for tool call display
396    pub fn format_summary(json_result: &str) -> String {
397        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_result) {
398            let success = parsed["success"].as_bool().unwrap_or(false);
399            let total = parsed["summary"]["total"].as_u64().unwrap_or(0);
400
401            if success && total == 0 {
402                format!(
403                    "{}{} {} Helm chart OK - no issues{}",
404                    brand::SUCCESS,
405                    icons::SUCCESS,
406                    icons::HELM,
407                    brand::RESET
408                )
409            } else {
410                let critical = parsed["summary"]["by_priority"]["critical"]
411                    .as_u64()
412                    .unwrap_or(0);
413                let high = parsed["summary"]["by_priority"]["high"]
414                    .as_u64()
415                    .unwrap_or(0);
416
417                if critical > 0 {
418                    format!(
419                        "{}{} {} {} critical, {} high priority issues{}",
420                        brand::CORAL,
421                        icons::CRITICAL,
422                        icons::HELM,
423                        critical,
424                        high,
425                        brand::RESET
426                    )
427                } else if high > 0 {
428                    format!(
429                        "{}{} {} {} high priority issues{}",
430                        brand::PEACH,
431                        icons::HIGH,
432                        icons::HELM,
433                        high,
434                        brand::RESET
435                    )
436                } else {
437                    format!(
438                        "{}{} {} {} issues (medium/low){}",
439                        brand::PEACH,
440                        icons::MEDIUM,
441                        icons::HELM,
442                        total,
443                        brand::RESET
444                    )
445                }
446            }
447        } else {
448            format!("{} Helmlint analysis complete", icons::HELM)
449        }
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_format_summary_success() {
459        let json = r#"{"success": true, "summary": {"total": 0, "by_priority": {"critical": 0, "high": 0, "medium": 0, "low": 0}}}"#;
460        let summary = HelmlintDisplay::format_summary(json);
461        assert!(summary.contains("OK"));
462    }
463
464    #[test]
465    fn test_format_summary_high() {
466        let json = r#"{"success": false, "summary": {"total": 3, "by_priority": {"critical": 0, "high": 2, "medium": 1, "low": 0}}}"#;
467        let summary = HelmlintDisplay::format_summary(json);
468        assert!(summary.contains("high"));
469    }
470
471    #[test]
472    fn test_category_badge() {
473        let badge = HelmlintDisplay::get_category_badge("Template");
474        assert!(badge.contains("TPL"));
475    }
476
477    #[test]
478    fn test_print_result_with_issues() {
479        // Test that print doesn't panic with real data
480        let json = r#"{
481            "chart": "test-chart",
482            "success": false,
483            "decision_context": "High priority issues found. Fix template syntax.",
484            "summary": {
485                "total": 3,
486                "files_checked": 5,
487                "by_priority": {"critical": 0, "high": 2, "medium": 1, "low": 0}
488            },
489            "action_plan": {
490                "critical": [],
491                "high": [{
492                    "code": "HL3001",
493                    "file": "templates/deployment.yaml",
494                    "line": 15,
495                    "category": "Template",
496                    "message": "Unclosed template block",
497                    "fix": "Add {{- end }} to close the block"
498                }, {
499                    "code": "HL1007",
500                    "file": "Chart.yaml",
501                    "line": 1,
502                    "category": "Structure",
503                    "message": "Missing maintainers field",
504                    "fix": "Add maintainers list with name and email"
505                }],
506                "medium": [{
507                    "code": "HL2003",
508                    "file": "values.yaml",
509                    "line": 8,
510                    "category": "Values",
511                    "message": "Unused value defined",
512                    "fix": "Remove unused value or reference it in templates"
513                }],
514                "low": []
515            },
516            "quick_fixes": ["templates/deployment.yaml:15 HL3001 - Add {{- end }}", "Chart.yaml:1 HL1007 - Add maintainers list"]
517        }"#;
518
519        // Just test it doesn't panic
520        HelmlintDisplay::print_result(json);
521    }
522
523    #[test]
524    fn test_print_result_success() {
525        let json = r#"{
526            "chart": "good-chart",
527            "success": true,
528            "decision_context": "No issues found.",
529            "summary": {
530                "total": 0,
531                "files_checked": 8,
532                "by_priority": {"critical": 0, "high": 0, "medium": 0, "low": 0}
533            },
534            "action_plan": {"critical": [], "high": [], "medium": [], "low": []}
535        }"#;
536
537        // Just test it doesn't panic
538        HelmlintDisplay::print_result(json);
539    }
540}