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            && !quick_fixes.is_empty()
131        {
132            let _ = writeln!(
133                handle,
134                "\n{}{}  Quick Fixes:{}",
135                ansi::DOCKER_BLUE,
136                icons::FIX,
137                ansi::RESET
138            );
139            for fix in quick_fixes.iter().take(5) {
140                if let Some(fix_str) = fix.as_str() {
141                    let _ = writeln!(
142                        handle,
143                        "{}    {} {}{}",
144                        ansi::INFO_BLUE,
145                        icons::ARROW,
146                        fix_str,
147                        ansi::RESET
148                    );
149                }
150            }
151        }
152
153        // Critical and High priority issues with details
154        Self::print_priority_section(
155            &mut handle,
156            result,
157            "critical",
158            "Critical Issues",
159            ansi::CRITICAL,
160        );
161        Self::print_priority_section(&mut handle, result, "high", "High Priority", ansi::HIGH);
162
163        // Optionally show medium (collapsed)
164        if let Some(medium_issues) = result["action_plan"]["medium"].as_array()
165            && !medium_issues.is_empty()
166        {
167            let _ = writeln!(
168                handle,
169                "\n{}  {} {} medium priority issue{} (run with --verbose to see all){}",
170                ansi::MEDIUM,
171                icons::MEDIUM,
172                medium_issues.len(),
173                if medium_issues.len() == 1 { "" } else { "s" },
174                ansi::RESET
175            );
176        }
177
178        // Footer separator
179        let _ = writeln!(
180            handle,
181            "{}{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}",
182            ansi::DOCKER_BLUE,
183            ansi::DIM,
184            ansi::RESET
185        );
186
187        let _ = handle.flush();
188    }
189
190    /// Print a section for a priority level
191    fn print_priority_section(
192        handle: &mut io::StdoutLock,
193        result: &serde_json::Value,
194        priority: &str,
195        title: &str,
196        color: &str,
197    ) {
198        if let Some(issues) = result["action_plan"][priority].as_array() {
199            if issues.is_empty() {
200                return;
201            }
202
203            let _ = writeln!(handle, "\n{}  {}:{}", color, title, ansi::RESET);
204
205            for issue in issues.iter().take(10) {
206                let code = issue["code"].as_str().unwrap_or("???");
207                let line = issue["line"].as_u64().unwrap_or(0);
208                let message = issue["message"].as_str().unwrap_or("");
209                let category = issue["category"].as_str().unwrap_or("");
210
211                // Category badge
212                let category_badge = match category {
213                    "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
214                    "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
215                    "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
216                    "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
217                    "maintainability" => format!("{}[MAINT]{}", ansi::GRAY, ansi::RESET),
218                    _ => String::new(),
219                };
220
221                let _ = writeln!(
222                    handle,
223                    "    {}{}:{}{} {}{}{} {} {}",
224                    ansi::DIM,
225                    line,
226                    ansi::RESET,
227                    ansi::DOCKER_BLUE,
228                    code,
229                    ansi::RESET,
230                    category_badge,
231                    ansi::GRAY,
232                    message,
233                );
234
235                // Show fix recommendation
236                if let Some(fix) = issue["fix"].as_str() {
237                    let _ = writeln!(handle, "       {}→ {}{}", ansi::INFO_BLUE, fix, ansi::RESET);
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"]
269                    .as_u64()
270                    .unwrap_or(0);
271                let high = parsed["summary"]["by_priority"]["high"]
272                    .as_u64()
273                    .unwrap_or(0);
274
275                if critical > 0 {
276                    format!(
277                        "{}{} {} {} critical, {} high priority issues{}",
278                        ansi::CRITICAL,
279                        icons::ERROR,
280                        icons::DOCKER,
281                        critical,
282                        high,
283                        ansi::RESET
284                    )
285                } else if high > 0 {
286                    format!(
287                        "{}{} {} {} high priority issues{}",
288                        ansi::HIGH,
289                        icons::WARNING,
290                        icons::DOCKER,
291                        high,
292                        ansi::RESET
293                    )
294                } else {
295                    format!(
296                        "{}{} {} {} issues (medium/low){}",
297                        ansi::MEDIUM,
298                        icons::WARNING,
299                        icons::DOCKER,
300                        total,
301                        ansi::RESET
302                    )
303                }
304            }
305        } else {
306            format!("{} Hadolint analysis complete", icons::DOCKER)
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn test_format_summary_success() {
317        let json = r#"{"success": true, "summary": {"total": 0, "by_priority": {"critical": 0, "high": 0, "medium": 0, "low": 0}}}"#;
318        let summary = HadolintDisplay::format_summary(json);
319        assert!(summary.contains("OK"));
320    }
321
322    #[test]
323    fn test_format_summary_critical() {
324        let json = r#"{"success": false, "summary": {"total": 3, "by_priority": {"critical": 1, "high": 2, "medium": 0, "low": 0}}}"#;
325        let summary = HadolintDisplay::format_summary(json);
326        assert!(summary.contains("critical"));
327    }
328}