Skip to main content

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