rust_guardian/report/
mod.rs

1//! Report generation with multiple output formats
2//!
3//! Architecture: Anti-Corruption Layer - Formatters translate domain objects to external formats
4//! - ValidationReport (domain) is converted to various external representations
5//! - Each formatter encapsulates the rules for its specific output format
6//! - Domain logic remains pure while supporting multiple presentation needs
7
8use crate::domain::violations::{GuardianResult, Severity, ValidationReport, Violation};
9use serde_json::Value as JsonValue;
10use std::io::Write;
11
12/// Supported output formats for validation reports
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OutputFormat {
15    /// Human-readable format with colors and context
16    Human,
17    /// JSON format for programmatic consumption
18    Json,
19    /// JUnit XML format for CI/CD integration
20    Junit,
21    /// SARIF format for security tools
22    Sarif,
23    /// GitHub Actions format for workflow integration
24    GitHub,
25    /// Agent-friendly format for easy parsing: [line:path] <violation>
26    Agent,
27}
28
29use std::str::FromStr;
30
31impl FromStr for OutputFormat {
32    type Err = String;
33
34    /// Parse format from string
35    fn from_str(s: &str) -> Result<Self, Self::Err> {
36        match s.to_lowercase().as_str() {
37            "human" => Ok(Self::Human),
38            "json" => Ok(Self::Json),
39            "junit" => Ok(Self::Junit),
40            "sarif" => Ok(Self::Sarif),
41            "github" => Ok(Self::GitHub),
42            "agent" => Ok(Self::Agent),
43            _ => Err(format!("Unknown output format: {s}")),
44        }
45    }
46}
47
48impl OutputFormat {
49    /// Get all available format names
50    pub fn all_formats() -> &'static [&'static str] {
51        &["human", "json", "junit", "sarif", "github", "agent"]
52    }
53
54    /// Validate that this format is appropriate for the given context
55    ///
56    /// Architecture Principle: Value objects should validate their domain rules
57    pub fn validate_for_context(&self, is_ci_environment: bool) -> GuardianResult<()> {
58        match (self, is_ci_environment) {
59            (Self::Human, true) => {
60                // Human format in CI might not render colors properly
61                // This is a warning, not an error
62                Ok(())
63            }
64            (Self::Junit | Self::GitHub | Self::Sarif, false) => {
65                // CI formats in interactive use are less optimal but valid
66                Ok(())
67            }
68            _ => Ok(()),
69        }
70    }
71
72    /// Check if this format supports colored output
73    pub fn supports_colors(&self) -> bool {
74        matches!(self, Self::Human)
75    }
76
77    /// Check if this format produces structured data
78    pub fn is_structured(&self) -> bool {
79        matches!(self, Self::Json | Self::Sarif | Self::Junit)
80    }
81}
82
83/// Options for customizing report output
84#[derive(Debug, Clone)]
85pub struct ReportOptions {
86    /// Whether to use colored output (for human format)
87    pub use_colors: bool,
88    /// Whether to show context lines around violations
89    pub show_context: bool,
90    /// Whether to show violation suggestions
91    pub show_suggestions: bool,
92    /// Maximum number of violations to include
93    pub max_violations: Option<usize>,
94    /// Minimum severity level to include
95    pub min_severity: Option<Severity>,
96}
97
98impl Default for ReportOptions {
99    fn default() -> Self {
100        Self {
101            use_colors: true,
102            show_context: true,
103            show_suggestions: true,
104            max_violations: None,
105            min_severity: None,
106        }
107    }
108}
109
110impl ReportOptions {
111    /// Validate options consistency
112    ///
113    /// Architecture Principle: Domain objects validate their own invariants
114    pub fn validate(&self) -> GuardianResult<()> {
115        // Validate maximum violations setting
116        if let Some(max) = self.max_violations {
117            if max == 0 {
118                return Err(crate::domain::violations::GuardianError::config(
119                    "max_violations cannot be zero - this would produce empty reports",
120                ));
121            }
122            if max > 10000 {
123                return Err(crate::domain::violations::GuardianError::config(
124                    "max_violations too high - consider using severity filtering instead",
125                ));
126            }
127        }
128
129        // Validate severity consistency
130        if let Some(min_severity) = self.min_severity {
131            if min_severity > Severity::Error {
132                return Err(crate::domain::violations::GuardianError::config(
133                    "min_severity cannot be higher than Error",
134                ));
135            }
136        }
137
138        Ok(())
139    }
140
141    /// Check if these options are optimized for the given format
142    pub fn is_optimized_for(&self, format: OutputFormat) -> bool {
143        match format {
144            OutputFormat::Human => true, // Human format supports all options
145            OutputFormat::Json | OutputFormat::Sarif => {
146                // Structured formats don't use colors or context display
147                !self.use_colors && !self.show_context
148            }
149            OutputFormat::Junit => {
150                // JUnit mainly cares about violations, not display options
151                !self.use_colors
152            }
153            OutputFormat::GitHub => {
154                // GitHub format doesn't use colors or suggestions
155                !self.use_colors && !self.show_suggestions
156            }
157            OutputFormat::Agent => {
158                // Agent format is minimal
159                !self.use_colors && !self.show_context && !self.show_suggestions
160            }
161        }
162    }
163
164    /// Create optimized options for a specific format
165    pub fn optimized_for(format: OutputFormat) -> Self {
166        match format {
167            OutputFormat::Human => Self::default(),
168            OutputFormat::Json | OutputFormat::Sarif => Self {
169                use_colors: false,
170                show_context: false,
171                show_suggestions: false,
172                ..Self::default()
173            },
174            OutputFormat::Junit => {
175                Self { use_colors: false, show_suggestions: false, ..Self::default() }
176            }
177            OutputFormat::GitHub => {
178                Self { use_colors: false, show_suggestions: false, ..Self::default() }
179            }
180            OutputFormat::Agent => Self {
181                use_colors: false,
182                show_context: false,
183                show_suggestions: false,
184                ..Self::default()
185            },
186        }
187    }
188}
189
190/// Main report formatter that dispatches to specific formatters
191pub struct ReportFormatter {
192    options: ReportOptions,
193}
194
195impl ReportFormatter {
196    /// Create a new report formatter with options
197    ///
198    /// Architecture Principle: Constructor validates domain invariants
199    pub fn new(options: ReportOptions) -> GuardianResult<Self> {
200        options.validate()?;
201        Ok(Self { options })
202    }
203
204    /// Create a formatter with validated options, panicking on invalid configuration
205    ///
206    /// For use in contexts where configuration errors are programming errors
207    pub fn with_options(options: ReportOptions) -> Self {
208        options
209            .validate()
210            .expect("ReportOptions validation failed - this indicates a programming error");
211        Self { options }
212    }
213
214    /// Validate formatter configuration and capabilities
215    ///
216    /// Architecture Principle: Domain models should validate their own consistency
217    /// This method ensures the formatter can fulfill its interface contract
218    pub fn validate_capabilities(&self) -> GuardianResult<()> {
219        // Validate color support when colors are enabled
220        if self.options.use_colors && !Self::supports_ansi_colors() {
221            return Err(crate::domain::violations::GuardianError::config(
222                "Color output requested but terminal does not support ANSI colors",
223            ));
224        }
225
226        // Validate severity filtering configuration
227        if let Some(min_severity) = self.options.min_severity {
228            if min_severity > Severity::Error {
229                return Err(crate::domain::violations::GuardianError::config(
230                    "Minimum severity cannot be higher than Error",
231                ));
232            }
233        }
234
235        // Validate violation limits
236        if let Some(max) = self.options.max_violations {
237            if max == 0 {
238                return Err(crate::domain::violations::GuardianError::config(
239                    "Maximum violations cannot be zero - use filtering instead",
240                ));
241            }
242        }
243
244        Ok(())
245    }
246
247    /// Check if the current environment supports ANSI color codes
248    fn supports_ansi_colors() -> bool {
249        // Basic heuristic - in production this would check terminal capabilities
250        std::env::var("NO_COLOR").is_err()
251            && (std::env::var("TERM").is_ok_and(|term| term != "dumb"))
252    }
253
254    /// Validate that a format operation produces expected structure
255    ///
256    /// Architecture Principle: Anti-corruption layer should validate its transformations
257    pub fn validate_format_integrity(
258        &self,
259        report: &ValidationReport,
260        format: OutputFormat,
261        output: &str,
262    ) -> GuardianResult<()> {
263        match format {
264            OutputFormat::Json => self.validate_json_structure(output),
265            OutputFormat::Junit => self.validate_junit_structure(output),
266            OutputFormat::Sarif => self.validate_sarif_structure(output),
267            OutputFormat::Human | OutputFormat::GitHub | OutputFormat::Agent => {
268                // Text formats have basic structure validation
269                if output.is_empty() && !report.violations.is_empty() {
270                    return Err(crate::domain::violations::GuardianError::config(
271                        "Non-empty report produced empty output",
272                    ));
273                }
274                Ok(())
275            }
276        }
277    }
278
279    /// Validate JSON output structure
280    fn validate_json_structure(&self, output: &str) -> GuardianResult<()> {
281        let json: JsonValue = serde_json::from_str(output).map_err(|e| {
282            crate::domain::violations::GuardianError::config(format!("Invalid JSON structure: {e}"))
283        })?;
284
285        // Ensure required fields exist
286        if json.get("violations").is_none() {
287            return Err(crate::domain::violations::GuardianError::config(
288                "JSON output missing required 'violations' field",
289            ));
290        }
291
292        if json.get("summary").is_none() {
293            return Err(crate::domain::violations::GuardianError::config(
294                "JSON output missing required 'summary' field",
295            ));
296        }
297
298        Ok(())
299    }
300
301    /// Validate JUnit XML structure
302    fn validate_junit_structure(&self, output: &str) -> GuardianResult<()> {
303        if !output.starts_with("<?xml version=\"1.0\"") {
304            return Err(crate::domain::violations::GuardianError::config(
305                "JUnit output must start with XML declaration",
306            ));
307        }
308
309        if !output.contains("<testsuite") {
310            return Err(crate::domain::violations::GuardianError::config(
311                "JUnit output must contain testsuite element",
312            ));
313        }
314
315        Ok(())
316    }
317
318    /// Validate SARIF structure
319    fn validate_sarif_structure(&self, output: &str) -> GuardianResult<()> {
320        let json: JsonValue = serde_json::from_str(output).map_err(|e| {
321            crate::domain::violations::GuardianError::config(format!("Invalid SARIF JSON: {e}"))
322        })?;
323
324        if json.get("version").and_then(|v| v.as_str()) != Some("2.1.0") {
325            return Err(crate::domain::violations::GuardianError::config(
326                "SARIF output must specify version 2.1.0",
327            ));
328        }
329
330        if json.get("runs").and_then(|r| r.as_array()).is_none_or(|arr| arr.is_empty()) {
331            return Err(crate::domain::violations::GuardianError::config(
332                "SARIF output must contain at least one run",
333            ));
334        }
335
336        Ok(())
337    }
338
339    /// Format a validation report in the specified format
340    ///
341    /// Architecture Principle: Domain services orchestrate self-validating behavior
342    /// This method ensures each transformation maintains domain integrity
343    pub fn format_report(
344        &self,
345        report: &ValidationReport,
346        format: OutputFormat,
347    ) -> GuardianResult<String> {
348        // Validate capabilities before processing
349        self.validate_capabilities()?;
350
351        // Filter violations based on options
352        let filtered_violations = self.filter_violations(&report.violations);
353
354        let output = match format {
355            OutputFormat::Human => self.format_human(report, &filtered_violations),
356            OutputFormat::Json => self.format_json(report, &filtered_violations),
357            OutputFormat::Junit => self.format_junit(report, &filtered_violations),
358            OutputFormat::Sarif => self.format_sarif(report, &filtered_violations),
359            OutputFormat::GitHub => self.format_github(report, &filtered_violations),
360            OutputFormat::Agent => self.format_agent(report, &filtered_violations),
361        }?;
362
363        // Validate output integrity before returning
364        self.validate_format_integrity(report, format, &output)?;
365
366        Ok(output)
367    }
368
369    /// Write a formatted report to a writer
370    pub fn write_report<W: Write>(
371        &self,
372        report: &ValidationReport,
373        format: OutputFormat,
374        mut writer: W,
375    ) -> GuardianResult<()> {
376        let formatted = self.format_report(report, format)?;
377        writer
378            .write_all(formatted.as_bytes())
379            .map_err(|e| crate::domain::violations::GuardianError::Io { source: e })?;
380        Ok(())
381    }
382
383    /// Filter violations based on report options
384    fn filter_violations<'a>(&self, violations: &'a [Violation]) -> Vec<&'a Violation> {
385        let mut filtered: Vec<&Violation> = violations
386            .iter()
387            .filter(|v| {
388                // Filter by minimum severity
389                if let Some(min_severity) = self.options.min_severity {
390                    if v.severity < min_severity {
391                        return false;
392                    }
393                }
394                true
395            })
396            .collect();
397
398        // Limit number of violations if requested
399        if let Some(max) = self.options.max_violations {
400            filtered.truncate(max);
401        }
402
403        filtered
404    }
405
406    /// Format report in human-readable format
407    fn format_human(
408        &self,
409        report: &ValidationReport,
410        violations: &[&Violation],
411    ) -> GuardianResult<String> {
412        let mut output = String::new();
413
414        if violations.is_empty() {
415            if self.options.use_colors {
416                output.push_str("✅ \x1b[32mNo code quality violations found\x1b[0m\n");
417            } else {
418                output.push_str("✅ No code quality violations found\n");
419            }
420        } else {
421            // Header
422            let icon = if report.has_errors() { "❌" } else { "⚠️" };
423            if self.options.use_colors {
424                let color = if report.has_errors() { "31" } else { "33" };
425                output.push_str(&format!(
426                    "{icon} \x1b[{color}mCode Quality Violations Found\x1b[0m\n\n"
427                ));
428            } else {
429                output.push_str(&format!("{icon} Code Quality Violations Found\n\n"));
430            }
431
432            // Group violations by file
433            let mut by_file: std::collections::BTreeMap<&std::path::Path, Vec<&Violation>> =
434                std::collections::BTreeMap::new();
435
436            for violation in violations {
437                by_file.entry(&violation.file_path).or_default().push(violation);
438            }
439
440            // Display each file's violations
441            for (file_path, file_violations) in by_file {
442                output.push_str(&format!("📁 {}\n", file_path.display()));
443
444                for violation in file_violations {
445                    // Format violation with colors
446                    let severity_color = match violation.severity {
447                        Severity::Error => "31",   // Red
448                        Severity::Warning => "33", // Yellow
449                        Severity::Info => "36",    // Cyan
450                    };
451
452                    let position = match (violation.line_number, violation.column_number) {
453                        (Some(line), Some(col)) => format!("{line}:{col}"),
454                        (Some(line), None) => line.to_string(),
455                        _ => "?".to_string(),
456                    };
457
458                    if self.options.use_colors {
459                        output.push_str(&format!(
460                            "  \x1b[{}m{}:{}\x1b[0m [\x1b[{}m{}\x1b[0m] {}\n",
461                            "2", // Dim
462                            position,
463                            violation.rule_id,
464                            severity_color,
465                            violation.severity.as_str(),
466                            violation.message
467                        ));
468                    } else {
469                        output.push_str(&format!(
470                            "  {}:{} [{}] {}\n",
471                            position,
472                            violation.rule_id,
473                            violation.severity.as_str(),
474                            violation.message
475                        ));
476                    }
477
478                    // Show context if available and requested
479                    if self.options.show_context {
480                        if let Some(context) = &violation.context {
481                            if self.options.use_colors {
482                                output.push_str(&format!("    \x1b[2m│ {context}\x1b[0m\n"));
483                            } else {
484                                output.push_str(&format!("    │ {context}\n"));
485                            }
486                        }
487                    }
488
489                    // Show suggestions if available and requested
490                    if self.options.show_suggestions {
491                        if let Some(suggestion) = &violation.suggested_fix {
492                            if self.options.use_colors {
493                                output.push_str(&format!("    \x1b[32m💡 {suggestion}\x1b[0m\n"));
494                            } else {
495                                output.push_str(&format!("    💡 {suggestion}\n"));
496                            }
497                        }
498                    }
499
500                    output.push('\n');
501                }
502            }
503        }
504
505        // Summary
506        output.push_str(&self.format_summary(report));
507
508        Ok(output)
509    }
510
511    /// Format report in JSON format
512    fn format_json(
513        &self,
514        report: &ValidationReport,
515        violations: &[&Violation],
516    ) -> GuardianResult<String> {
517        let json_violations: Vec<JsonValue> = violations
518            .iter()
519            .map(|v| {
520                serde_json::json!({
521                    "rule_id": v.rule_id,
522                    "severity": v.severity.as_str(),
523                    "file_path": v.file_path.display().to_string(),
524                    "line_number": v.line_number,
525                    "column_number": v.column_number,
526                    "message": v.message,
527                    "context": v.context,
528                    "suggested_fix": v.suggested_fix,
529                    "detected_at": v.detected_at.to_rfc3339()
530                })
531            })
532            .collect();
533
534        let json_report = serde_json::json!({
535            "violations": json_violations,
536            "summary": {
537                "total_files": report.summary.total_files,
538                "violations_by_severity": {
539                    "error": report.summary.violations_by_severity.error,
540                    "warning": report.summary.violations_by_severity.warning,
541                    "info": report.summary.violations_by_severity.info
542                },
543                "execution_time_ms": report.summary.execution_time_ms,
544                "validated_at": report.summary.validated_at.to_rfc3339()
545            },
546            "config_fingerprint": report.config_fingerprint
547        });
548
549        serde_json::to_string_pretty(&json_report).map_err(|e| {
550            crate::domain::violations::GuardianError::config(format!(
551                "JSON serialization failed: {e}"
552            ))
553        })
554    }
555
556    /// Format report in JUnit XML format
557    fn format_junit(
558        &self,
559        report: &ValidationReport,
560        violations: &[&Violation],
561    ) -> GuardianResult<String> {
562        let mut xml = String::new();
563        xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
564
565        let total_tests = violations.len();
566        let failures = violations.iter().filter(|v| v.severity == Severity::Error).count();
567        let errors = 0; // Currently using failures count for both failures and errors
568        let execution_time = (report.summary.execution_time_ms as f64) / 1000.0;
569
570        xml.push_str(&format!(
571            "<testsuite name=\"rust-guardian\" tests=\"{total_tests}\" failures=\"{failures}\" errors=\"{errors}\" time=\"{execution_time:.3}\">\n"
572        ));
573
574        for violation in violations {
575            xml.push_str(&format!(
576                "  <testcase classname=\"{}\" name=\"{}\">\n",
577                violation.rule_id,
578                escape_xml(&violation.file_path.display().to_string())
579            ));
580
581            if violation.severity == Severity::Error {
582                xml.push_str(&format!(
583                    "    <failure message=\"{}\">\n",
584                    escape_xml(&violation.message)
585                ));
586                xml.push_str(&format!(
587                    "      File: {}:{}:{}\n",
588                    violation.file_path.display(),
589                    violation.line_number.unwrap_or(0),
590                    violation.column_number.unwrap_or(0)
591                ));
592                if let Some(context) = &violation.context {
593                    xml.push_str(&format!("      Context: {}\n", escape_xml(context)));
594                }
595                xml.push_str("    </failure>\n");
596            }
597
598            xml.push_str("  </testcase>\n");
599        }
600
601        xml.push_str("</testsuite>\n");
602        Ok(xml)
603    }
604
605    /// Format report in SARIF format
606    fn format_sarif(
607        &self,
608        _report: &ValidationReport,
609        violations: &[&Violation],
610    ) -> GuardianResult<String> {
611        let sarif_results: Vec<JsonValue> = violations
612            .iter()
613            .map(|v| {
614                let level = match v.severity {
615                    Severity::Error => "error",
616                    Severity::Warning => "warning",
617                    Severity::Info => "note",
618                };
619
620                serde_json::json!({
621                    "ruleId": v.rule_id,
622                    "level": level,
623                    "message": {
624                        "text": v.message
625                    },
626                    "locations": [{
627                        "physicalLocation": {
628                            "artifactLocation": {
629                                "uri": v.file_path.display().to_string()
630                            },
631                            "region": {
632                                "startLine": v.line_number.unwrap_or(1),
633                                "startColumn": v.column_number.unwrap_or(1)
634                            },
635                            "contextRegion": v.context.as_ref().map(|c| serde_json::json!({
636                                "snippet": {
637                                    "text": c
638                                }
639                            }))
640                        }
641                    }]
642                })
643            })
644            .collect();
645
646        let sarif_report = serde_json::json!({
647            "version": "2.1.0",
648            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
649            "runs": [{
650                "tool": {
651                    "driver": {
652                        "name": "rust-guardian",
653                        "version": "0.1.0",
654                        "informationUri": "https://github.com/cloudfunnels/rust-guardian"
655                    }
656                },
657                "results": sarif_results
658            }]
659        });
660
661        serde_json::to_string_pretty(&sarif_report).map_err(|e| {
662            crate::domain::violations::GuardianError::config(format!(
663                "SARIF serialization failed: {e}"
664            ))
665        })
666    }
667
668    /// Format report for GitHub Actions
669    fn format_github(
670        &self,
671        _report: &ValidationReport,
672        violations: &[&Violation],
673    ) -> GuardianResult<String> {
674        let mut output = String::new();
675
676        for violation in violations {
677            let level = match violation.severity {
678                Severity::Error => "error",
679                Severity::Warning => "warning",
680                Severity::Info => "notice",
681            };
682
683            let position = match (violation.line_number, violation.column_number) {
684                (Some(line), Some(col)) => format!("line={line},col={col}"),
685                (Some(line), None) => format!("line={line}"),
686                _ => String::new(),
687            };
688
689            let position_part =
690                if position.is_empty() { String::new() } else { format!(" {position}") };
691
692            output.push_str(&format!(
693                "::{} file={},title={}{}::{}\n",
694                level,
695                violation.file_path.display(),
696                violation.rule_id,
697                position_part,
698                violation.message
699            ));
700        }
701
702        Ok(output)
703    }
704
705    /// Format report for agent consumption: [line:path] <violation>
706    fn format_agent(
707        &self,
708        _report: &ValidationReport,
709        violations: &[&Violation],
710    ) -> GuardianResult<String> {
711        let mut output = String::new();
712
713        for violation in violations {
714            let line_number = violation.line_number.unwrap_or(1);
715            let path = violation.file_path.display();
716
717            output.push_str(&format!("[{}:{}]\n{}\n\n", line_number, path, violation.message));
718        }
719
720        Ok(output)
721    }
722
723    /// Format the summary section
724    fn format_summary(&self, report: &ValidationReport) -> String {
725        let mut summary = String::new();
726
727        let total_violations = report.summary.violations_by_severity.total();
728        let execution_time = (report.summary.execution_time_ms as f64) / 1000.0;
729
730        if self.options.use_colors {
731            summary.push_str("📊 \x1b[1mSummary:\x1b[0m ");
732        } else {
733            summary.push_str("📊 Summary: ");
734        }
735
736        if total_violations == 0 {
737            if self.options.use_colors {
738                summary.push_str(&format!(
739                    "\x1b[32m0 violations\x1b[0m in {} files ({:.1}s)\n",
740                    report.summary.total_files, execution_time
741                ));
742            } else {
743                summary.push_str(&format!(
744                    "0 violations in {} files ({:.1}s)\n",
745                    report.summary.total_files, execution_time
746                ));
747            }
748        } else {
749            let mut parts = Vec::new();
750
751            if report.summary.violations_by_severity.error > 0 {
752                let text = format!(
753                    "{} error{}",
754                    report.summary.violations_by_severity.error,
755                    if report.summary.violations_by_severity.error == 1 { "" } else { "s" }
756                );
757                if self.options.use_colors {
758                    parts.push(format!("\x1b[31m{text}\x1b[0m"));
759                } else {
760                    parts.push(text);
761                }
762            }
763
764            if report.summary.violations_by_severity.warning > 0 {
765                let text = format!(
766                    "{} warning{}",
767                    report.summary.violations_by_severity.warning,
768                    if report.summary.violations_by_severity.warning == 1 { "" } else { "s" }
769                );
770                if self.options.use_colors {
771                    parts.push(format!("\x1b[33m{text}\x1b[0m"));
772                } else {
773                    parts.push(text);
774                }
775            }
776
777            if report.summary.violations_by_severity.info > 0 {
778                let text = format!("{} info", report.summary.violations_by_severity.info);
779                if self.options.use_colors {
780                    parts.push(format!("\x1b[36m{text}\x1b[0m"));
781                } else {
782                    parts.push(text);
783                }
784            }
785
786            summary.push_str(&format!(
787                "{} in {} files ({:.1}s)\n",
788                parts.join(", "),
789                report.summary.total_files,
790                execution_time
791            ));
792        }
793
794        summary
795    }
796}
797
798impl Default for ReportFormatter {
799    /// Create a formatter with default options
800    fn default() -> Self {
801        Self::with_options(ReportOptions::default())
802    }
803}
804
805/// Escape XML special characters
806fn escape_xml(s: &str) -> String {
807    s.replace('&', "&amp;")
808        .replace('<', "&lt;")
809        .replace('>', "&gt;")
810        .replace('"', "&quot;")
811        .replace('\'', "&#39;")
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use std::path::PathBuf;
818    // Test imports - unused
819
820    fn create_test_report() -> ValidationReport {
821        let mut report = ValidationReport::new();
822
823        report.add_violation(
824            crate::domain::violations::Violation::new(
825                "test_rule",
826                Severity::Error,
827                PathBuf::from("src/main.rs"),
828                "Test violation",
829            )
830            .with_position(42, 15)
831            .with_context("let x = unimplemented!();"),
832        );
833
834        report.set_files_analyzed(10);
835        report.set_execution_time(1200);
836
837        report
838    }
839
840    #[test]
841    fn test_human_format() {
842        let options = ReportOptions { use_colors: false, ..Default::default() };
843
844        // Demonstrate self-validation
845        options.validate().expect("Test options should be valid");
846
847        let formatter = ReportFormatter::with_options(options);
848
849        let report = create_test_report();
850        let output = formatter
851            .format_report(&report, OutputFormat::Human)
852            .expect("Human format should always succeed for valid reports");
853
854        // Verify the formatter self-validated the output
855        formatter
856            .validate_format_integrity(&report, OutputFormat::Human, &output)
857            .expect("Human output should pass integrity validation");
858
859        assert!(output.contains("Code Quality Violations Found"));
860        assert!(output.contains("src/main.rs"));
861        assert!(output.contains("Test violation"));
862        assert!(output.contains("Summary:"));
863    }
864
865    #[test]
866    fn test_json_format() {
867        let formatter = ReportFormatter::default();
868        let report = create_test_report();
869        let output = formatter
870            .format_report(&report, OutputFormat::Json)
871            .expect("JSON format should always succeed for valid reports");
872
873        // Verify the formatter self-validated the JSON structure
874        formatter
875            .validate_format_integrity(&report, OutputFormat::Json, &output)
876            .expect("JSON output should pass integrity validation");
877
878        let json: JsonValue =
879            serde_json::from_str(&output).expect("JSON output should be valid JSON");
880        assert!(json["violations"].is_array());
881        assert_eq!(json["violations"].as_array().expect("violations should be an array").len(), 1);
882        assert_eq!(json["violations"][0]["rule_id"], "test_rule");
883        assert_eq!(json["summary"]["total_files"], 10);
884    }
885
886    #[test]
887    fn test_junit_format() {
888        let formatter = ReportFormatter::default();
889        let report = create_test_report();
890        let output = formatter
891            .format_report(&report, OutputFormat::Junit)
892            .expect("JUnit format should always succeed for valid reports");
893
894        // Verify the formatter self-validated the JUnit structure
895        formatter
896            .validate_format_integrity(&report, OutputFormat::Junit, &output)
897            .expect("JUnit output should pass integrity validation");
898
899        assert!(output.contains("<?xml version=\"1.0\""));
900        assert!(output.contains("<testsuite"));
901        assert!(output.contains("test_rule"));
902        assert!(output.contains("<failure"));
903    }
904
905    #[test]
906    fn test_github_format() {
907        let formatter = ReportFormatter::default();
908        let report = create_test_report();
909        let output = formatter
910            .format_report(&report, OutputFormat::GitHub)
911            .expect("GitHub format should always succeed for valid reports");
912
913        // Verify the formatter self-validated the GitHub output
914        formatter
915            .validate_format_integrity(&report, OutputFormat::GitHub, &output)
916            .expect("GitHub output should pass integrity validation");
917
918        assert!(output.contains("::error"));
919        assert!(output.contains("file=src/main.rs"));
920        assert!(output.contains("line=42,col=15"));
921        assert!(output.contains("Test violation"));
922    }
923
924    #[test]
925    fn test_empty_report() {
926        let options = ReportOptions { use_colors: false, ..Default::default() };
927
928        // Demonstrate self-validation
929        options.validate().expect("Test options should be valid");
930
931        let formatter = ReportFormatter::with_options(options);
932
933        let report = ValidationReport::new();
934        let output = formatter
935            .format_report(&report, OutputFormat::Human)
936            .expect("Human format should always succeed for empty reports");
937
938        // Verify the formatter self-validated the output
939        formatter
940            .validate_format_integrity(&report, OutputFormat::Human, &output)
941            .expect("Empty report output should pass integrity validation");
942
943        assert!(output.contains("No code quality violations found"));
944    }
945
946    #[test]
947    fn test_severity_filtering() {
948        let options = ReportOptions { min_severity: Some(Severity::Error), ..Default::default() };
949
950        // Demonstrate self-validation of filtering options
951        options.validate().expect("Severity filtering options should be valid");
952
953        let formatter = ReportFormatter::with_options(options);
954
955        let mut report = ValidationReport::new();
956        report.add_violation(crate::domain::violations::Violation::new(
957            "warning_rule",
958            Severity::Warning,
959            PathBuf::from("src/lib.rs"),
960            "Warning message",
961        ));
962        report.add_violation(crate::domain::violations::Violation::new(
963            "error_rule",
964            Severity::Error,
965            PathBuf::from("src/main.rs"),
966            "Error message",
967        ));
968
969        let output = formatter
970            .format_report(&report, OutputFormat::Json)
971            .expect("JSON format should succeed for severity filtering test");
972
973        // Verify the formatter self-validated the filtered output
974        formatter
975            .validate_format_integrity(&report, OutputFormat::Json, &output)
976            .expect("Filtered JSON output should pass integrity validation");
977
978        let json: JsonValue =
979            serde_json::from_str(&output).expect("Severity filtered JSON should be valid");
980
981        // Should only include the error, not the warning
982        assert_eq!(
983            json["violations"].as_array().expect("filtered violations should be an array").len(),
984            1
985        );
986        assert_eq!(json["violations"][0]["rule_id"], "error_rule");
987    }
988
989    #[test]
990    fn test_domain_validation_behavior() {
991        // Test that invalid options are rejected
992        let invalid_options = ReportOptions {
993            max_violations: Some(0), // Invalid: zero violations
994            ..Default::default()
995        };
996
997        assert!(invalid_options.validate().is_err());
998
999        // Test that the formatter constructor validates options
1000        assert!(ReportFormatter::new(invalid_options).is_err());
1001
1002        // Test format validation for structured outputs
1003        let formatter = ReportFormatter::default();
1004
1005        // Valid JSON should pass validation
1006        let valid_json = r#"{"violations": [], "summary": {"total_files": 0}}"#;
1007        assert!(formatter.validate_json_structure(valid_json).is_ok());
1008
1009        // Invalid JSON should fail validation
1010        let invalid_json = r#"{"missing_required_fields": true}"#;
1011        assert!(formatter.validate_json_structure(invalid_json).is_err());
1012
1013        // Test OutputFormat domain behavior
1014        assert!(OutputFormat::Human.supports_colors());
1015        assert!(!OutputFormat::Json.supports_colors());
1016        assert!(OutputFormat::Json.is_structured());
1017        assert!(!OutputFormat::Human.is_structured());
1018
1019        // Test ReportOptions optimization behavior
1020        let human_options = ReportOptions::optimized_for(OutputFormat::Human);
1021        assert!(human_options.is_optimized_for(OutputFormat::Human));
1022
1023        let json_options = ReportOptions::optimized_for(OutputFormat::Json);
1024        assert!(json_options.is_optimized_for(OutputFormat::Json));
1025        assert!(!json_options.use_colors); // JSON shouldn't use colors
1026    }
1027}