Skip to main content

batuta/comply/
report.rs

1//! Stack Compliance Report Generation
2//!
3//! Generates reports in multiple formats: Text, JSON, HTML, Markdown.
4
5use crate::comply::rule::{FixResult, RuleResult, RuleViolation, ViolationLevel};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt::Write as FmtWrite;
9
10/// Report output format
11#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
12pub enum ComplyReportFormat {
13    #[default]
14    Text,
15    Json,
16    Markdown,
17    Html,
18}
19
20/// Stack compliance report
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ComplyReport {
23    /// Results per project per rule
24    pub results: HashMap<String, HashMap<String, ProjectRuleResult>>,
25    /// Exemptions applied
26    pub exemptions: Vec<Exemption>,
27    /// Global errors
28    pub errors: Vec<String>,
29    /// Summary statistics
30    pub summary: ComplianceSummary,
31    /// Whether report has been finalized
32    #[serde(skip)]
33    finalized: bool,
34}
35
36/// Result for a specific project-rule pair
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub enum ProjectRuleResult {
39    /// Rule check result
40    Checked(RuleResult),
41    /// Rule was exempt
42    Exempt(String),
43    /// Error occurred during check
44    Error(String),
45    /// Fix was applied
46    Fixed(FixResult),
47    /// Dry-run fix preview
48    DryRunFix(Vec<RuleViolation>),
49}
50
51/// Record of an exemption
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Exemption {
54    pub project: String,
55    pub rule: String,
56    pub reason: Option<String>,
57}
58
59/// Summary statistics for the report
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61pub struct ComplianceSummary {
62    /// Total projects checked
63    pub total_projects: usize,
64    /// Projects that passed all rules
65    pub passing_projects: usize,
66    /// Projects with violations
67    pub failing_projects: usize,
68    /// Total rules checked
69    pub total_checks: usize,
70    /// Checks that passed
71    pub passed_checks: usize,
72    /// Checks that failed
73    pub failed_checks: usize,
74    /// Total violations found
75    pub total_violations: usize,
76    /// Violations by severity
77    pub violations_by_severity: HashMap<String, usize>,
78    /// Fixable violations
79    pub fixable_violations: usize,
80    /// Pass rate as percentage
81    pub pass_rate: f64,
82}
83
84/// A single violation for display
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Violation {
87    pub project: String,
88    pub rule: String,
89    pub code: String,
90    pub message: String,
91    pub severity: ViolationSeverity,
92    pub location: Option<String>,
93    pub fixable: bool,
94}
95
96/// Violation severity for display
97#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
98pub enum ViolationSeverity {
99    Info,
100    Warning,
101    Error,
102    Critical,
103}
104
105impl From<ViolationLevel> for ViolationSeverity {
106    fn from(level: ViolationLevel) -> Self {
107        match level {
108            ViolationLevel::Info => ViolationSeverity::Info,
109            ViolationLevel::Warning => ViolationSeverity::Warning,
110            ViolationLevel::Error => ViolationSeverity::Error,
111            ViolationLevel::Critical => ViolationSeverity::Critical,
112        }
113    }
114}
115
116impl ComplyReport {
117    /// Create a new empty report
118    pub fn new() -> Self {
119        Self {
120            results: HashMap::new(),
121            exemptions: Vec::new(),
122            errors: Vec::new(),
123            summary: ComplianceSummary::default(),
124            finalized: false,
125        }
126    }
127
128    /// Add a rule result for a project
129    pub fn add_result(&mut self, project: &str, rule: &str, result: RuleResult) {
130        self.results
131            .entry(project.to_string())
132            .or_default()
133            .insert(rule.to_string(), ProjectRuleResult::Checked(result));
134    }
135
136    /// Add an exemption
137    pub fn add_exemption(&mut self, project: &str, rule: &str) {
138        self.results
139            .entry(project.to_string())
140            .or_default()
141            .insert(rule.to_string(), ProjectRuleResult::Exempt(rule.to_string()));
142        self.exemptions.push(Exemption {
143            project: project.to_string(),
144            rule: rule.to_string(),
145            reason: None,
146        });
147    }
148
149    /// Add an error
150    pub fn add_error(&mut self, project: &str, rule: &str, error: String) {
151        self.results
152            .entry(project.to_string())
153            .or_default()
154            .insert(rule.to_string(), ProjectRuleResult::Error(error));
155    }
156
157    /// Add a global error
158    pub fn add_global_error(&mut self, error: String) {
159        self.errors.push(error);
160    }
161
162    /// Add a fix result
163    pub fn add_fix_result(&mut self, project: &str, rule: &str, result: FixResult) {
164        self.results
165            .entry(project.to_string())
166            .or_default()
167            .insert(rule.to_string(), ProjectRuleResult::Fixed(result));
168    }
169
170    /// Add a dry-run fix preview
171    pub fn add_dry_run_fix(&mut self, project: &str, rule: &str, violations: &[RuleViolation]) {
172        self.results
173            .entry(project.to_string())
174            .or_default()
175            .insert(rule.to_string(), ProjectRuleResult::DryRunFix(violations.to_vec()));
176    }
177
178    /// Finalize the report and compute summary
179    pub fn finalize(&mut self) {
180        if self.finalized {
181            return;
182        }
183
184        let mut total_projects = 0;
185        let mut passing_projects = 0;
186        let mut total_checks = 0;
187        let mut passed_checks = 0;
188        let mut failed_checks = 0;
189        let mut total_violations = 0;
190        let mut fixable_violations = 0;
191        let mut violations_by_severity: HashMap<String, usize> = HashMap::new();
192
193        for rules in self.results.values() {
194            total_projects += 1;
195            let mut project_passed = true;
196
197            for result in rules.values() {
198                total_checks += 1;
199
200                match result {
201                    ProjectRuleResult::Checked(r) => {
202                        if r.passed {
203                            passed_checks += 1;
204                        } else {
205                            failed_checks += 1;
206                            project_passed = false;
207
208                            total_violations += r.violations.len();
209                            fixable_violations += r.violations.iter().filter(|v| v.fixable).count();
210                            for v in &r.violations {
211                                *violations_by_severity
212                                    .entry(format!("{}", v.severity))
213                                    .or_default() += 1;
214                            }
215                        }
216                    }
217                    ProjectRuleResult::Exempt(_) => {
218                        passed_checks += 1;
219                    }
220                    ProjectRuleResult::Error(_) => {
221                        failed_checks += 1;
222                        project_passed = false;
223                    }
224                    ProjectRuleResult::Fixed(r) => {
225                        if r.success {
226                            passed_checks += 1;
227                        } else {
228                            failed_checks += 1;
229                            project_passed = false;
230                        }
231                    }
232                    ProjectRuleResult::DryRunFix(violations) => {
233                        failed_checks += 1;
234                        project_passed = false;
235                        total_violations += violations.len();
236                        for v in violations {
237                            if v.fixable {
238                                fixable_violations += 1;
239                            }
240                        }
241                    }
242                }
243            }
244
245            if project_passed {
246                passing_projects += 1;
247            }
248        }
249
250        let pass_rate = if total_checks > 0 {
251            (passed_checks as f64 / total_checks as f64) * 100.0
252        } else {
253            100.0
254        };
255
256        self.summary = ComplianceSummary {
257            total_projects,
258            passing_projects,
259            failing_projects: total_projects - passing_projects,
260            total_checks,
261            passed_checks,
262            failed_checks,
263            total_violations,
264            violations_by_severity,
265            fixable_violations,
266            pass_rate,
267        };
268
269        self.finalized = true;
270    }
271
272    /// Get all violations as a flat list
273    pub fn violations(&self) -> Vec<Violation> {
274        let mut violations = Vec::new();
275
276        for (project, rules) in &self.results {
277            for (rule, result) in rules {
278                if let ProjectRuleResult::Checked(r) = result {
279                    for v in &r.violations {
280                        violations.push(Violation {
281                            project: project.clone(),
282                            rule: rule.clone(),
283                            code: v.code.clone(),
284                            message: v.message.clone(),
285                            severity: v.severity.into(),
286                            location: v.location.clone(),
287                            fixable: v.fixable,
288                        });
289                    }
290                }
291            }
292        }
293
294        violations
295    }
296
297    /// Check if the report indicates overall compliance
298    pub fn is_compliant(&self) -> bool {
299        self.summary.failing_projects == 0 && self.errors.is_empty()
300    }
301
302    /// Format as text
303    ///
304    /// Note: writeln! to String is infallible (fmt::Write impl for String
305    /// always returns Ok).
306    pub fn format_text(&self) -> String {
307        let mut out = String::new();
308
309        writeln!(out, "STACK COMPLIANCE REPORT").ok();
310        writeln!(out, "=======================\n").ok();
311
312        // Summary
313        writeln!(
314            out,
315            "Projects: {}/{} passing ({:.1}%)",
316            self.summary.passing_projects, self.summary.total_projects, self.summary.pass_rate
317        )
318        .ok();
319        writeln!(out, "Violations: {}", self.summary.total_violations).ok();
320        if self.summary.fixable_violations > 0 {
321            writeln!(
322                out,
323                "Fixable: {} ({:.1}%)",
324                self.summary.fixable_violations,
325                (self.summary.fixable_violations as f64 / self.summary.total_violations as f64)
326                    * 100.0
327            )
328            .ok();
329        }
330        writeln!(out).ok();
331
332        // Per-project results
333        for (project, rules) in &self.results {
334            let passed = rules.values().all(|r| {
335                matches!(r, ProjectRuleResult::Checked(r) if r.passed)
336                    || matches!(r, ProjectRuleResult::Exempt(_))
337            });
338
339            let status = if passed { "PASS" } else { "FAIL" };
340            writeln!(out, "{} {} {}", project, ".".repeat(40 - project.len().min(39)), status).ok();
341
342            for (rule, result) in rules {
343                match result {
344                    ProjectRuleResult::Checked(r) if !r.passed => {
345                        for v in &r.violations {
346                            let _ = writeln!(out, "  [{:?}] {}: {}", v.severity, v.code, v.message);
347                            if let Some(loc) = &v.location {
348                                let _ = writeln!(out, "         at {}", loc);
349                            }
350                        }
351                    }
352                    ProjectRuleResult::Checked(_) => {}
353                    ProjectRuleResult::Exempt(reason) => {
354                        writeln!(out, "  [EXEMPT] {} - {}", rule, reason).ok();
355                    }
356                    ProjectRuleResult::Error(e) => {
357                        writeln!(out, "  [ERROR] {} - {}", rule, e).ok();
358                    }
359                    ProjectRuleResult::Fixed(r) => {
360                        writeln!(out, "  [FIXED] {} fixes applied", r.fixed_count).ok();
361                    }
362                    ProjectRuleResult::DryRunFix(violations) => {
363                        writeln!(out, "  [DRY-RUN] {} violations would be fixed", violations.len())
364                            .ok();
365                    }
366                }
367            }
368        }
369
370        // Global errors
371        if !self.errors.is_empty() {
372            writeln!(out, "\nGlobal Errors:").ok();
373            for e in &self.errors {
374                writeln!(out, "  - {}", e).ok();
375            }
376        }
377
378        out
379    }
380
381    /// Format as JSON
382    pub fn format_json(&self) -> String {
383        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
384    }
385
386    /// Format as Markdown
387    pub fn format_markdown(&self) -> String {
388        let mut out = String::new();
389
390        writeln!(out, "# Stack Compliance Report\n").ok();
391
392        writeln!(out, "## Summary\n").ok();
393        writeln!(out, "| Metric | Value |").ok();
394        writeln!(out, "|--------|-------|").ok();
395        writeln!(
396            out,
397            "| Projects Passing | {}/{} ({:.1}%) |",
398            self.summary.passing_projects, self.summary.total_projects, self.summary.pass_rate
399        )
400        .ok();
401        writeln!(out, "| Total Violations | {} |", self.summary.total_violations).ok();
402        writeln!(out, "| Fixable Violations | {} |", self.summary.fixable_violations).ok();
403        writeln!(out).ok();
404
405        writeln!(out, "## Results by Project\n").ok();
406
407        for (project, rules) in &self.results {
408            let passed =
409                rules.values().all(|r| matches!(r, ProjectRuleResult::Checked(r) if r.passed));
410            let emoji = if passed { "✅" } else { "❌" };
411
412            writeln!(out, "### {} {}\n", emoji, project).ok();
413
414            for (rule, result) in rules {
415                match result {
416                    ProjectRuleResult::Checked(r) => {
417                        if r.passed {
418                            writeln!(out, "- ✅ **{}**: Passed", rule).ok();
419                        } else {
420                            writeln!(out, "- ❌ **{}**: {} violations", rule, r.violations.len())
421                                .ok();
422                            for v in &r.violations {
423                                writeln!(out, "  - `{}`: {}", v.code, v.message).ok();
424                            }
425                        }
426                    }
427                    ProjectRuleResult::Exempt(reason) => {
428                        writeln!(out, "- ⏭️ **{}**: Exempt - {}", rule, reason).ok();
429                    }
430                    ProjectRuleResult::Error(e) => {
431                        writeln!(out, "- ⚠️ **{}**: Error - {}", rule, e).ok();
432                    }
433                    _ => {}
434                }
435            }
436            writeln!(out).ok();
437        }
438
439        out
440    }
441
442    /// Format report based on format type
443    pub fn format(&self, format: ComplyReportFormat) -> String {
444        match format {
445            ComplyReportFormat::Text => self.format_text(),
446            ComplyReportFormat::Json => self.format_json(),
447            ComplyReportFormat::Markdown => self.format_markdown(),
448            ComplyReportFormat::Html => self.format_html(),
449        }
450    }
451
452    /// Format as HTML
453    pub fn format_html(&self) -> String {
454        let mut out = String::new();
455
456        writeln!(
457            out,
458            r"<!DOCTYPE html>
459<html>
460<head>
461    <title>Stack Compliance Report</title>
462    <style>
463        body {{ font-family: Roboto, sans-serif; margin: 40px; }}
464        .pass {{ color: #34A853; }}
465        .fail {{ color: #EA4335; }}
466        .warn {{ color: #FBBC04; }}
467        table {{ border-collapse: collapse; width: 100%; }}
468        th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
469        th {{ background-color: #6750A4; color: white; }}
470        tr:nth-child(even) {{ background-color: #f2f2f2; }}
471    </style>
472</head>
473<body>
474    <h1>Stack Compliance Report</h1>
475
476    <h2>Summary</h2>
477    <table>
478        <tr><th>Metric</th><th>Value</th></tr>
479        <tr><td>Projects</td><td>{}/{} ({:.1}%)</td></tr>
480        <tr><td>Total Violations</td><td>{}</td></tr>
481        <tr><td>Fixable</td><td>{}</td></tr>
482    </table>
483",
484            self.summary.passing_projects,
485            self.summary.total_projects,
486            self.summary.pass_rate,
487            self.summary.total_violations,
488            self.summary.fixable_violations
489        )
490        .ok();
491
492        writeln!(out, "    <h2>Results</h2>").ok();
493        writeln!(out, "    <table>").ok();
494        writeln!(out, "        <tr><th>Project</th><th>Status</th><th>Violations</th></tr>").ok();
495
496        for (project, rules) in &self.results {
497            let passed =
498                rules.values().all(|r| matches!(r, ProjectRuleResult::Checked(r) if r.passed));
499            let status_class = if passed { "pass" } else { "fail" };
500            let status = if passed { "PASS" } else { "FAIL" };
501
502            let violation_count: usize = rules
503                .values()
504                .filter_map(|r| match r {
505                    ProjectRuleResult::Checked(r) => Some(r.violations.len()),
506                    _ => None,
507                })
508                .sum();
509
510            writeln!(
511                out,
512                "        <tr><td>{}</td><td class=\"{}\">{}</td><td>{}</td></tr>",
513                project, status_class, status, violation_count
514            )
515            .ok();
516        }
517
518        writeln!(out, "    </table>").ok();
519        writeln!(out, "</body></html>").ok();
520
521        out
522    }
523}
524
525impl Default for ComplyReport {
526    fn default() -> Self {
527        Self::new()
528    }
529}
530
531#[cfg(test)]
532#[path = "report_tests.rs"]
533mod tests;