syncable_cli/agent/ui/
hadolint_display.rs

1//! Hadolint result display for terminal output
2//!
3//! Provides colored, formatted output for Dockerfile lint results
4//! that's visually distinct and easy to recognize.
5
6use crate::agent::ui::colors::{ansi, icons};
7use std::io::{self, Write};
8
9/// Display hadolint results in a formatted, colored terminal output
10pub struct HadolintDisplay;
11
12impl HadolintDisplay {
13    /// Format and print hadolint results from the JSON output
14    pub fn print_result(json_result: &str) {
15        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_result) {
16            Self::print_formatted(&parsed);
17        } else {
18            // Fallback: just print the raw result
19            println!("{}", json_result);
20        }
21    }
22
23    /// Print formatted hadolint output
24    fn print_formatted(result: &serde_json::Value) {
25        let stdout = io::stdout();
26        let mut handle = stdout.lock();
27
28        // Header with Docker icon and file name
29        let file = result["file"].as_str().unwrap_or("Dockerfile");
30        let _ = writeln!(
31            handle,
32            "\n{}{}━━━ {} Hadolint: {} ━━━{}",
33            ansi::DOCKER_BLUE,
34            ansi::BOLD,
35            icons::DOCKER,
36            file,
37            ansi::RESET
38        );
39
40        // Decision context
41        if let Some(context) = result["decision_context"].as_str() {
42            let context_color = if context.contains("Critical") {
43                ansi::CRITICAL
44            } else if context.contains("High") {
45                ansi::HIGH
46            } else if context.contains("Medium") || context.contains("improvements") {
47                ansi::MEDIUM
48            } else {
49                ansi::LOW
50            };
51            let _ = writeln!(
52                handle,
53                "{}  {} {}{}",
54                context_color,
55                icons::ARROW,
56                context,
57                ansi::RESET
58            );
59        }
60
61        // Summary counts
62        if let Some(summary) = result.get("summary") {
63            let total = summary["total"].as_u64().unwrap_or(0);
64            if total == 0 {
65                let _ = writeln!(
66                    handle,
67                    "\n{}  {} No issues found!{}",
68                    ansi::SUCCESS,
69                    icons::SUCCESS,
70                    ansi::RESET
71                );
72            } else {
73                let _ = writeln!(handle);
74
75                // Priority breakdown
76                if let Some(by_priority) = summary.get("by_priority") {
77                    let critical = by_priority["critical"].as_u64().unwrap_or(0);
78                    let high = by_priority["high"].as_u64().unwrap_or(0);
79                    let medium = by_priority["medium"].as_u64().unwrap_or(0);
80                    let low = by_priority["low"].as_u64().unwrap_or(0);
81
82                    let _ = write!(handle, "  ");
83                    if critical > 0 {
84                        let _ = write!(
85                            handle,
86                            "{}{} {} critical{}  ",
87                            ansi::CRITICAL,
88                            icons::CRITICAL,
89                            critical,
90                            ansi::RESET
91                        );
92                    }
93                    if high > 0 {
94                        let _ = write!(
95                            handle,
96                            "{}{} {} high{}  ",
97                            ansi::HIGH,
98                            icons::HIGH,
99                            high,
100                            ansi::RESET
101                        );
102                    }
103                    if medium > 0 {
104                        let _ = write!(
105                            handle,
106                            "{}{} {} medium{}  ",
107                            ansi::MEDIUM,
108                            icons::MEDIUM,
109                            medium,
110                            ansi::RESET
111                        );
112                    }
113                    if low > 0 {
114                        let _ = write!(
115                            handle,
116                            "{}{} {} low{}",
117                            ansi::LOW,
118                            icons::LOW,
119                            low,
120                            ansi::RESET
121                        );
122                    }
123                    let _ = writeln!(handle);
124                }
125            }
126        }
127
128        // Quick fixes (most important)
129        if let Some(quick_fixes) = result.get("quick_fixes").and_then(|f| f.as_array()) {
130            if !quick_fixes.is_empty() {
131                let _ = writeln!(
132                    handle,
133                    "\n{}{}  Quick Fixes:{}",
134                    ansi::DOCKER_BLUE,
135                    icons::FIX,
136                    ansi::RESET
137                );
138                for fix in quick_fixes.iter().take(5) {
139                    if let Some(fix_str) = fix.as_str() {
140                        let _ = writeln!(
141                            handle,
142                            "{}    {} {}{}",
143                            ansi::INFO_BLUE,
144                            icons::ARROW,
145                            fix_str,
146                            ansi::RESET
147                        );
148                    }
149                }
150            }
151        }
152
153        // Critical and High priority issues with details
154        Self::print_priority_section(&mut handle, result, "critical", "Critical Issues", ansi::CRITICAL);
155        Self::print_priority_section(&mut handle, result, "high", "High Priority", ansi::HIGH);
156
157        // Optionally show medium (collapsed)
158        if let Some(medium_issues) = result["action_plan"]["medium"].as_array() {
159            if !medium_issues.is_empty() {
160                let _ = writeln!(
161                    handle,
162                    "\n{}  {} {} medium priority issue{} (run with --verbose to see all){}",
163                    ansi::MEDIUM,
164                    icons::MEDIUM,
165                    medium_issues.len(),
166                    if medium_issues.len() == 1 { "" } else { "s" },
167                    ansi::RESET
168                );
169            }
170        }
171
172        // Footer separator
173        let _ = writeln!(
174            handle,
175            "{}{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}",
176            ansi::DOCKER_BLUE,
177            ansi::DIM,
178            ansi::RESET
179        );
180
181        let _ = handle.flush();
182    }
183
184    /// Print a section for a priority level
185    fn print_priority_section(
186        handle: &mut io::StdoutLock,
187        result: &serde_json::Value,
188        priority: &str,
189        title: &str,
190        color: &str,
191    ) {
192        if let Some(issues) = result["action_plan"][priority].as_array() {
193            if issues.is_empty() {
194                return;
195            }
196
197            let _ = writeln!(handle, "\n{}  {}:{}", color, title, ansi::RESET);
198
199            for issue in issues.iter().take(10) {
200                let code = issue["code"].as_str().unwrap_or("???");
201                let line = issue["line"].as_u64().unwrap_or(0);
202                let message = issue["message"].as_str().unwrap_or("");
203                let category = issue["category"].as_str().unwrap_or("");
204
205                // Category badge
206                let category_badge = match category {
207                    "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
208                    "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
209                    "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
210                    "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
211                    "maintainability" => format!("{}[MAINT]{}", ansi::GRAY, ansi::RESET),
212                    _ => String::new(),
213                };
214
215                let _ = writeln!(
216                    handle,
217                    "    {}{}:{}{} {}{}{} {} {}",
218                    ansi::DIM,
219                    line,
220                    ansi::RESET,
221                    ansi::DOCKER_BLUE,
222                    code,
223                    ansi::RESET,
224                    category_badge,
225                    ansi::GRAY,
226                    message,
227                );
228
229                // Show fix recommendation
230                if let Some(fix) = issue["fix"].as_str() {
231                    let _ = writeln!(
232                        handle,
233                        "       {}→ {}{}",
234                        ansi::INFO_BLUE,
235                        fix,
236                        ansi::RESET
237                    );
238                }
239            }
240
241            if issues.len() > 10 {
242                let _ = writeln!(
243                    handle,
244                    "    {}... and {} more{}",
245                    ansi::DIM,
246                    issues.len() - 10,
247                    ansi::RESET
248                );
249            }
250        }
251    }
252
253    /// Format a compact single-line summary for tool call display
254    pub fn format_summary(json_result: &str) -> String {
255        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json_result) {
256            let success = parsed["success"].as_bool().unwrap_or(false);
257            let total = parsed["summary"]["total"].as_u64().unwrap_or(0);
258
259            if success && total == 0 {
260                format!(
261                    "{}{} {} Dockerfile OK - no issues{}",
262                    ansi::SUCCESS,
263                    icons::SUCCESS,
264                    icons::DOCKER,
265                    ansi::RESET
266                )
267            } else {
268                let critical = parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(0);
269                let high = parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(0);
270
271                if critical > 0 {
272                    format!(
273                        "{}{} {} {} critical, {} high priority issues{}",
274                        ansi::CRITICAL,
275                        icons::ERROR,
276                        icons::DOCKER,
277                        critical,
278                        high,
279                        ansi::RESET
280                    )
281                } else if high > 0 {
282                    format!(
283                        "{}{} {} {} high priority issues{}",
284                        ansi::HIGH,
285                        icons::WARNING,
286                        icons::DOCKER,
287                        high,
288                        ansi::RESET
289                    )
290                } else {
291                    format!(
292                        "{}{} {} {} issues (medium/low){}",
293                        ansi::MEDIUM,
294                        icons::WARNING,
295                        icons::DOCKER,
296                        total,
297                        ansi::RESET
298                    )
299                }
300            }
301        } else {
302            format!("{} Hadolint analysis complete", icons::DOCKER)
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_format_summary_success() {
313        let json = r#"{"success": true, "summary": {"total": 0, "by_priority": {"critical": 0, "high": 0, "medium": 0, "low": 0}}}"#;
314        let summary = HadolintDisplay::format_summary(json);
315        assert!(summary.contains("OK"));
316    }
317
318    #[test]
319    fn test_format_summary_critical() {
320        let json = r#"{"success": false, "summary": {"total": 3, "by_priority": {"critical": 1, "high": 2, "medium": 0, "low": 0}}}"#;
321        let summary = HadolintDisplay::format_summary(json);
322        assert!(summary.contains("critical"));
323    }
324}