1use crate::domain::violations::{GuardianResult, Severity, ValidationReport, Violation};
9use serde_json::Value as JsonValue;
10use std::io::Write;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OutputFormat {
15 Human,
17 Json,
19 Junit,
21 Sarif,
23 GitHub,
25 Agent,
27}
28
29use std::str::FromStr;
30
31impl FromStr for OutputFormat {
32 type Err = String;
33
34 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 pub fn all_formats() -> &'static [&'static str] {
51 &["human", "json", "junit", "sarif", "github", "agent"]
52 }
53
54 pub fn validate_for_context(&self, is_ci_environment: bool) -> GuardianResult<()> {
58 match (self, is_ci_environment) {
59 (Self::Human, true) => {
60 Ok(())
63 }
64 (Self::Junit | Self::GitHub | Self::Sarif, false) => {
65 Ok(())
67 }
68 _ => Ok(()),
69 }
70 }
71
72 pub fn supports_colors(&self) -> bool {
74 matches!(self, Self::Human)
75 }
76
77 pub fn is_structured(&self) -> bool {
79 matches!(self, Self::Json | Self::Sarif | Self::Junit)
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct ReportOptions {
86 pub use_colors: bool,
88 pub show_context: bool,
90 pub show_suggestions: bool,
92 pub max_violations: Option<usize>,
94 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 pub fn validate(&self) -> GuardianResult<()> {
115 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 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 pub fn is_optimized_for(&self, format: OutputFormat) -> bool {
143 match format {
144 OutputFormat::Human => true, OutputFormat::Json | OutputFormat::Sarif => {
146 !self.use_colors && !self.show_context
148 }
149 OutputFormat::Junit => {
150 !self.use_colors
152 }
153 OutputFormat::GitHub => {
154 !self.use_colors && !self.show_suggestions
156 }
157 OutputFormat::Agent => {
158 !self.use_colors && !self.show_context && !self.show_suggestions
160 }
161 }
162 }
163
164 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
194pub struct ReportFormatter {
196 options: ReportOptions,
197}
198
199impl ReportFormatter {
200 pub fn new(options: ReportOptions) -> GuardianResult<Self> {
204 options.validate()?;
205 Ok(Self { options })
206 }
207
208 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 pub fn validate_capabilities(&self) -> GuardianResult<()> {
223 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 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 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 fn supports_ansi_colors() -> bool {
253 if std::env::var("NO_COLOR").is_ok() {
255 return false;
256 }
257
258 if std::env::var("GITHUB_ACTIONS").is_ok() || std::env::var("CI").is_ok() {
260 return true;
261 }
262
263 std::env::var("TERM").is_ok_and(|term| term != "dumb")
265 }
266
267 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 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 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 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 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 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 pub fn format_report(
361 &self,
362 report: &ValidationReport,
363 format: OutputFormat,
364 ) -> GuardianResult<String> {
365 self.validate_capabilities()?;
367
368 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 self.validate_format_integrity(report, format, &output)?;
382
383 Ok(output)
384 }
385
386 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 fn filter_violations<'a>(&self, violations: &'a [Violation]) -> Vec<&'a Violation> {
402 let mut filtered: Vec<&Violation> = violations
403 .iter()
404 .filter(|v| {
405 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 if let Some(max) = self.options.max_violations {
417 filtered.truncate(max);
418 }
419
420 filtered
421 }
422
423 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 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 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 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 let severity_color = match violation.severity {
467 Severity::Error => "31", Severity::Warning => "33", Severity::Info => "36", };
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", 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 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 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 output.push_str(&self.format_summary(report));
527
528 Ok(output)
529 }
530
531 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 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; 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 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 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 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 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 fn default() -> Self {
838 Self::with_options(ReportOptions::default())
839 }
840}
841
842fn escape_xml(s: &str) -> String {
844 s.replace('&', "&")
845 .replace('<', "<")
846 .replace('>', ">")
847 .replace('"', """)
848 .replace('\'', "'")
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use std::path::PathBuf;
855 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 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 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 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 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 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 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 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 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 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 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 let invalid_options = ReportOptions {
1050 max_violations: Some(0), ..Default::default()
1052 };
1053
1054 assert!(invalid_options.validate().is_err());
1055
1056 assert!(ReportFormatter::new(invalid_options).is_err());
1058
1059 let formatter = ReportFormatter::default();
1061
1062 let valid_json = r#"{"violations": [], "summary": {"total_files": 0}}"#;
1064 assert!(formatter.validate_json_structure(valid_json).is_ok());
1065
1066 let invalid_json = r#"{"missing_required_fields": true}"#;
1068 assert!(formatter.validate_json_structure(invalid_json).is_err());
1069
1070 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 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); }
1084}