use crate::domain::violations::{GuardianResult, Severity, ValidationReport, Violation};
use serde_json::Value as JsonValue;
use std::io::Write;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Human,
Json,
Junit,
Sarif,
GitHub,
Agent,
}
use std::str::FromStr;
impl FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"human" => Ok(Self::Human),
"json" => Ok(Self::Json),
"junit" => Ok(Self::Junit),
"sarif" => Ok(Self::Sarif),
"github" => Ok(Self::GitHub),
"agent" => Ok(Self::Agent),
_ => Err(format!("Unknown output format: {s}")),
}
}
}
impl OutputFormat {
pub fn all_formats() -> &'static [&'static str] {
&["human", "json", "junit", "sarif", "github", "agent"]
}
pub fn validate_for_context(&self, is_ci_environment: bool) -> GuardianResult<()> {
match (self, is_ci_environment) {
(Self::Human, true) => {
Ok(())
}
(Self::Junit | Self::GitHub | Self::Sarif, false) => {
Ok(())
}
_ => Ok(()),
}
}
pub fn supports_colors(&self) -> bool {
matches!(self, Self::Human)
}
pub fn is_structured(&self) -> bool {
matches!(self, Self::Json | Self::Sarif | Self::Junit)
}
}
#[derive(Debug, Clone)]
pub struct ReportOptions {
pub use_colors: bool,
pub show_context: bool,
pub show_suggestions: bool,
pub max_violations: Option<usize>,
pub min_severity: Option<Severity>,
}
impl Default for ReportOptions {
fn default() -> Self {
Self {
use_colors: true,
show_context: true,
show_suggestions: true,
max_violations: None,
min_severity: None,
}
}
}
impl ReportOptions {
pub fn validate(&self) -> GuardianResult<()> {
if let Some(max) = self.max_violations {
if max == 0 {
return Err(crate::domain::violations::GuardianError::config(
"max_violations cannot be zero - this would produce empty reports",
));
}
if max > 10000 {
return Err(crate::domain::violations::GuardianError::config(
"max_violations too high - consider using severity filtering instead",
));
}
}
if let Some(min_severity) = self.min_severity {
if min_severity > Severity::Error {
return Err(crate::domain::violations::GuardianError::config(
"min_severity cannot be higher than Error",
));
}
}
Ok(())
}
pub fn is_optimized_for(&self, format: OutputFormat) -> bool {
match format {
OutputFormat::Human => true, OutputFormat::Json | OutputFormat::Sarif => {
!self.use_colors && !self.show_context
}
OutputFormat::Junit => {
!self.use_colors
}
OutputFormat::GitHub => {
!self.use_colors && !self.show_suggestions
}
OutputFormat::Agent => {
!self.use_colors && !self.show_context && !self.show_suggestions
}
}
}
pub fn optimized_for(format: OutputFormat) -> Self {
match format {
OutputFormat::Human => Self::default(),
OutputFormat::Json | OutputFormat::Sarif => Self {
use_colors: false,
show_context: false,
show_suggestions: false,
..Self::default()
},
OutputFormat::Junit => Self {
use_colors: false,
show_suggestions: false,
..Self::default()
},
OutputFormat::GitHub => Self {
use_colors: false,
show_suggestions: false,
..Self::default()
},
OutputFormat::Agent => Self {
use_colors: false,
show_context: false,
show_suggestions: false,
..Self::default()
},
}
}
}
pub struct ReportFormatter {
options: ReportOptions,
}
impl ReportFormatter {
pub fn new(options: ReportOptions) -> GuardianResult<Self> {
options.validate()?;
Ok(Self { options })
}
pub fn with_options(options: ReportOptions) -> Self {
options
.validate()
.expect("ReportOptions validation failed - this indicates a programming error");
Self { options }
}
pub fn validate_capabilities(&self) -> GuardianResult<()> {
if self.options.use_colors && !Self::supports_ansi_colors() {
return Err(crate::domain::violations::GuardianError::config(
"Color output requested but terminal does not support ANSI colors",
));
}
if let Some(min_severity) = self.options.min_severity {
if min_severity > Severity::Error {
return Err(crate::domain::violations::GuardianError::config(
"Minimum severity cannot be higher than Error",
));
}
}
if let Some(max) = self.options.max_violations {
if max == 0 {
return Err(crate::domain::violations::GuardianError::config(
"Maximum violations cannot be zero - use filtering instead",
));
}
}
Ok(())
}
fn supports_ansi_colors() -> bool {
if std::env::var("NO_COLOR").is_ok() {
return false;
}
if std::env::var("GITHUB_ACTIONS").is_ok() || std::env::var("CI").is_ok() {
return true;
}
std::env::var("TERM").is_ok_and(|term| term != "dumb")
}
pub fn validate_format_integrity(
&self,
report: &ValidationReport,
format: OutputFormat,
output: &str,
) -> GuardianResult<()> {
match format {
OutputFormat::Json => self.validate_json_structure(output),
OutputFormat::Junit => self.validate_junit_structure(output),
OutputFormat::Sarif => self.validate_sarif_structure(output),
OutputFormat::Human | OutputFormat::GitHub | OutputFormat::Agent => {
if output.is_empty() && !report.violations.is_empty() {
return Err(crate::domain::violations::GuardianError::config(
"Non-empty report produced empty output",
));
}
Ok(())
}
}
}
fn validate_json_structure(&self, output: &str) -> GuardianResult<()> {
let json: JsonValue = serde_json::from_str(output).map_err(|e| {
crate::domain::violations::GuardianError::config(format!("Invalid JSON structure: {e}"))
})?;
if json.get("violations").is_none() {
return Err(crate::domain::violations::GuardianError::config(
"JSON output missing required 'violations' field",
));
}
if json.get("summary").is_none() {
return Err(crate::domain::violations::GuardianError::config(
"JSON output missing required 'summary' field",
));
}
Ok(())
}
fn validate_junit_structure(&self, output: &str) -> GuardianResult<()> {
if !output.starts_with("<?xml version=\"1.0\"") {
return Err(crate::domain::violations::GuardianError::config(
"JUnit output must start with XML declaration",
));
}
if !output.contains("<testsuite") {
return Err(crate::domain::violations::GuardianError::config(
"JUnit output must contain testsuite element",
));
}
Ok(())
}
fn validate_sarif_structure(&self, output: &str) -> GuardianResult<()> {
let json: JsonValue = serde_json::from_str(output).map_err(|e| {
crate::domain::violations::GuardianError::config(format!("Invalid SARIF JSON: {e}"))
})?;
if json.get("version").and_then(|v| v.as_str()) != Some("2.1.0") {
return Err(crate::domain::violations::GuardianError::config(
"SARIF output must specify version 2.1.0",
));
}
if json
.get("runs")
.and_then(|r| r.as_array())
.is_none_or(|arr| arr.is_empty())
{
return Err(crate::domain::violations::GuardianError::config(
"SARIF output must contain at least one run",
));
}
Ok(())
}
pub fn format_report(
&self,
report: &ValidationReport,
format: OutputFormat,
) -> GuardianResult<String> {
self.validate_capabilities()?;
let filtered_violations = self.filter_violations(&report.violations);
let output = match format {
OutputFormat::Human => self.format_human(report, &filtered_violations),
OutputFormat::Json => self.format_json(report, &filtered_violations),
OutputFormat::Junit => self.format_junit(report, &filtered_violations),
OutputFormat::Sarif => self.format_sarif(report, &filtered_violations),
OutputFormat::GitHub => self.format_github(report, &filtered_violations),
OutputFormat::Agent => self.format_agent(report, &filtered_violations),
}?;
self.validate_format_integrity(report, format, &output)?;
Ok(output)
}
pub fn write_report<W: Write>(
&self,
report: &ValidationReport,
format: OutputFormat,
mut writer: W,
) -> GuardianResult<()> {
let formatted = self.format_report(report, format)?;
writer
.write_all(formatted.as_bytes())
.map_err(|e| crate::domain::violations::GuardianError::Io { source: e })?;
Ok(())
}
fn filter_violations<'a>(&self, violations: &'a [Violation]) -> Vec<&'a Violation> {
let mut filtered: Vec<&Violation> = violations
.iter()
.filter(|v| {
if let Some(min_severity) = self.options.min_severity {
if v.severity < min_severity {
return false;
}
}
true
})
.collect();
if let Some(max) = self.options.max_violations {
filtered.truncate(max);
}
filtered
}
fn format_human(
&self,
report: &ValidationReport,
violations: &[&Violation],
) -> GuardianResult<String> {
let mut output = String::new();
if violations.is_empty() {
if self.options.use_colors {
output.push_str("✅ \x1b[32mNo code quality violations found\x1b[0m\n");
} else {
output.push_str("✅ No code quality violations found\n");
}
} else {
let icon = if report.has_errors() { "❌" } else { "⚠️" };
if self.options.use_colors {
let color = if report.has_errors() { "31" } else { "33" };
output.push_str(&format!(
"{icon} \x1b[{color}mCode Quality Violations Found\x1b[0m\n\n"
));
} else {
output.push_str(&format!("{icon} Code Quality Violations Found\n\n"));
}
let mut by_file: std::collections::BTreeMap<&std::path::Path, Vec<&Violation>> =
std::collections::BTreeMap::new();
for violation in violations {
by_file
.entry(&violation.file_path)
.or_default()
.push(violation);
}
for (file_path, file_violations) in by_file {
output.push_str(&format!("📁 {}\n", file_path.display()));
for violation in file_violations {
let severity_color = match violation.severity {
Severity::Error => "31", Severity::Warning => "33", Severity::Info => "36", };
let position = match (violation.line_number, violation.column_number) {
(Some(line), Some(col)) => format!("{line}:{col}"),
(Some(line), None) => line.to_string(),
_ => "?".to_string(),
};
if self.options.use_colors {
output.push_str(&format!(
" \x1b[{}m{}:{}\x1b[0m [\x1b[{}m{}\x1b[0m] {}\n",
"2", position,
violation.rule_id,
severity_color,
violation.severity.as_str(),
violation.message
));
} else {
output.push_str(&format!(
" {}:{} [{}] {}\n",
position,
violation.rule_id,
violation.severity.as_str(),
violation.message
));
}
if self.options.show_context {
if let Some(context) = &violation.context {
if self.options.use_colors {
output.push_str(&format!(" \x1b[2m│ {context}\x1b[0m\n"));
} else {
output.push_str(&format!(" │ {context}\n"));
}
}
}
if self.options.show_suggestions {
if let Some(suggestion) = &violation.suggested_fix {
if self.options.use_colors {
output.push_str(&format!(" \x1b[32m💡 {suggestion}\x1b[0m\n"));
} else {
output.push_str(&format!(" 💡 {suggestion}\n"));
}
}
}
output.push('\n');
}
}
}
output.push_str(&self.format_summary(report));
Ok(output)
}
fn format_json(
&self,
report: &ValidationReport,
violations: &[&Violation],
) -> GuardianResult<String> {
let json_violations: Vec<JsonValue> = violations
.iter()
.map(|v| {
serde_json::json!({
"rule_id": v.rule_id,
"severity": v.severity.as_str(),
"file_path": v.file_path.display().to_string(),
"line_number": v.line_number,
"column_number": v.column_number,
"message": v.message,
"context": v.context,
"suggested_fix": v.suggested_fix,
"detected_at": v.detected_at.to_rfc3339()
})
})
.collect();
let json_report = serde_json::json!({
"violations": json_violations,
"summary": {
"total_files": report.summary.total_files,
"violations_by_severity": {
"error": report.summary.violations_by_severity.error,
"warning": report.summary.violations_by_severity.warning,
"info": report.summary.violations_by_severity.info
},
"execution_time_ms": report.summary.execution_time_ms,
"validated_at": report.summary.validated_at.to_rfc3339()
},
"config_fingerprint": report.config_fingerprint
});
serde_json::to_string_pretty(&json_report).map_err(|e| {
crate::domain::violations::GuardianError::config(format!(
"JSON serialization failed: {e}"
))
})
}
fn format_junit(
&self,
report: &ValidationReport,
violations: &[&Violation],
) -> GuardianResult<String> {
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let total_tests = violations.len();
let failures = violations
.iter()
.filter(|v| v.severity == Severity::Error)
.count();
let errors = 0; let execution_time = (report.summary.execution_time_ms as f64) / 1000.0;
xml.push_str(&format!(
"<testsuite name=\"rust-guardian\" tests=\"{total_tests}\" failures=\"{failures}\" errors=\"{errors}\" time=\"{execution_time:.3}\">\n"
));
for violation in violations {
xml.push_str(&format!(
" <testcase classname=\"{}\" name=\"{}\">\n",
violation.rule_id,
escape_xml(&violation.file_path.display().to_string())
));
if violation.severity == Severity::Error {
xml.push_str(&format!(
" <failure message=\"{}\">\n",
escape_xml(&violation.message)
));
xml.push_str(&format!(
" File: {}:{}:{}\n",
violation.file_path.display(),
violation.line_number.unwrap_or(0),
violation.column_number.unwrap_or(0)
));
if let Some(context) = &violation.context {
xml.push_str(&format!(" Context: {}\n", escape_xml(context)));
}
xml.push_str(" </failure>\n");
}
xml.push_str(" </testcase>\n");
}
xml.push_str("</testsuite>\n");
Ok(xml)
}
fn format_sarif(
&self,
_report: &ValidationReport,
violations: &[&Violation],
) -> GuardianResult<String> {
let sarif_results: Vec<JsonValue> = violations
.iter()
.map(|v| {
let level = match v.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "note",
};
serde_json::json!({
"ruleId": v.rule_id,
"level": level,
"message": {
"text": v.message
},
"locations": [{
"physicalLocation": {
"artifactLocation": {
"uri": v.file_path.display().to_string()
},
"region": {
"startLine": v.line_number.unwrap_or(1),
"startColumn": v.column_number.unwrap_or(1)
},
"contextRegion": v.context.as_ref().map(|c| serde_json::json!({
"snippet": {
"text": c
}
}))
}
}]
})
})
.collect();
let sarif_report = serde_json::json!({
"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [{
"tool": {
"driver": {
"name": "rust-guardian",
"version": "0.1.1",
"informationUri": "https://github.com/cloudfunnels/rust-guardian"
}
},
"results": sarif_results
}]
});
serde_json::to_string_pretty(&sarif_report).map_err(|e| {
crate::domain::violations::GuardianError::config(format!(
"SARIF serialization failed: {e}"
))
})
}
fn format_github(
&self,
_report: &ValidationReport,
violations: &[&Violation],
) -> GuardianResult<String> {
let mut output = String::new();
for violation in violations {
let level = match violation.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "notice",
};
let position = match (violation.line_number, violation.column_number) {
(Some(line), Some(col)) => format!("line={line},col={col}"),
(Some(line), None) => format!("line={line}"),
_ => String::new(),
};
let position_part = if position.is_empty() {
String::new()
} else {
format!(" {position}")
};
output.push_str(&format!(
"::{} file={},title={}{}::{}\n",
level,
violation.file_path.display(),
violation.rule_id,
position_part,
violation.message
));
}
Ok(output)
}
fn format_agent(
&self,
_report: &ValidationReport,
violations: &[&Violation],
) -> GuardianResult<String> {
let mut output = String::new();
for violation in violations {
let line_number = violation.line_number.unwrap_or(1);
let path = violation.file_path.display();
output.push_str(&format!(
"[{}:{}]\n{}\n\n",
line_number, path, violation.message
));
}
Ok(output)
}
fn format_summary(&self, report: &ValidationReport) -> String {
let mut summary = String::new();
let total_violations = report.summary.violations_by_severity.total();
let execution_time = (report.summary.execution_time_ms as f64) / 1000.0;
if self.options.use_colors {
summary.push_str("📊 \x1b[1mSummary:\x1b[0m ");
} else {
summary.push_str("📊 Summary: ");
}
if total_violations == 0 {
if self.options.use_colors {
summary.push_str(&format!(
"\x1b[32m0 violations\x1b[0m in {} files ({:.1}s)\n",
report.summary.total_files, execution_time
));
} else {
summary.push_str(&format!(
"0 violations in {} files ({:.1}s)\n",
report.summary.total_files, execution_time
));
}
} else {
let mut parts = Vec::new();
if report.summary.violations_by_severity.error > 0 {
let text = format!(
"{} error{}",
report.summary.violations_by_severity.error,
if report.summary.violations_by_severity.error == 1 {
""
} else {
"s"
}
);
if self.options.use_colors {
parts.push(format!("\x1b[31m{text}\x1b[0m"));
} else {
parts.push(text);
}
}
if report.summary.violations_by_severity.warning > 0 {
let text = format!(
"{} warning{}",
report.summary.violations_by_severity.warning,
if report.summary.violations_by_severity.warning == 1 {
""
} else {
"s"
}
);
if self.options.use_colors {
parts.push(format!("\x1b[33m{text}\x1b[0m"));
} else {
parts.push(text);
}
}
if report.summary.violations_by_severity.info > 0 {
let text = format!("{} info", report.summary.violations_by_severity.info);
if self.options.use_colors {
parts.push(format!("\x1b[36m{text}\x1b[0m"));
} else {
parts.push(text);
}
}
summary.push_str(&format!(
"{} in {} files ({:.1}s)\n",
parts.join(", "),
report.summary.total_files,
execution_time
));
}
summary
}
}
impl Default for ReportFormatter {
fn default() -> Self {
Self::with_options(ReportOptions::default())
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn create_test_report() -> ValidationReport {
let mut report = ValidationReport::new();
report.add_violation(
crate::domain::violations::Violation::new(
"test_rule",
Severity::Error,
PathBuf::from("src/main.rs"),
"Test violation",
)
.with_position(42, 15)
.with_context("let x = unimplemented!();"),
);
report.set_files_analyzed(10);
report.set_execution_time(1200);
report
}
#[test]
fn test_human_format() {
let options = ReportOptions {
use_colors: false,
..Default::default()
};
options.validate().expect("Test options should be valid");
let formatter = ReportFormatter::with_options(options);
let report = create_test_report();
let output = formatter
.format_report(&report, OutputFormat::Human)
.expect("Human format should always succeed for valid reports");
formatter
.validate_format_integrity(&report, OutputFormat::Human, &output)
.expect("Human output should pass integrity validation");
assert!(output.contains("Code Quality Violations Found"));
assert!(output.contains("src/main.rs"));
assert!(output.contains("Test violation"));
assert!(output.contains("Summary:"));
}
#[test]
fn test_json_format() {
let formatter = ReportFormatter::default();
let report = create_test_report();
let output = formatter
.format_report(&report, OutputFormat::Json)
.expect("JSON format should always succeed for valid reports");
formatter
.validate_format_integrity(&report, OutputFormat::Json, &output)
.expect("JSON output should pass integrity validation");
let json: JsonValue =
serde_json::from_str(&output).expect("JSON output should be valid JSON");
assert!(json["violations"].is_array());
assert_eq!(
json["violations"]
.as_array()
.expect("violations should be an array")
.len(),
1
);
assert_eq!(json["violations"][0]["rule_id"], "test_rule");
assert_eq!(json["summary"]["total_files"], 10);
}
#[test]
fn test_junit_format() {
let formatter = ReportFormatter::default();
let report = create_test_report();
let output = formatter
.format_report(&report, OutputFormat::Junit)
.expect("JUnit format should always succeed for valid reports");
formatter
.validate_format_integrity(&report, OutputFormat::Junit, &output)
.expect("JUnit output should pass integrity validation");
assert!(output.contains("<?xml version=\"1.0\""));
assert!(output.contains("<testsuite"));
assert!(output.contains("test_rule"));
assert!(output.contains("<failure"));
}
#[test]
fn test_github_format() {
let formatter = ReportFormatter::default();
let report = create_test_report();
let output = formatter
.format_report(&report, OutputFormat::GitHub)
.expect("GitHub format should always succeed for valid reports");
formatter
.validate_format_integrity(&report, OutputFormat::GitHub, &output)
.expect("GitHub output should pass integrity validation");
assert!(output.contains("::error"));
assert!(output.contains("file=src/main.rs"));
assert!(output.contains("line=42,col=15"));
assert!(output.contains("Test violation"));
}
#[test]
fn test_empty_report() {
let options = ReportOptions {
use_colors: false,
..Default::default()
};
options.validate().expect("Test options should be valid");
let formatter = ReportFormatter::with_options(options);
let report = ValidationReport::new();
let output = formatter
.format_report(&report, OutputFormat::Human)
.expect("Human format should always succeed for empty reports");
formatter
.validate_format_integrity(&report, OutputFormat::Human, &output)
.expect("Empty report output should pass integrity validation");
assert!(output.contains("No code quality violations found"));
}
#[test]
fn test_severity_filtering() {
let options = ReportOptions {
min_severity: Some(Severity::Error),
..Default::default()
};
options
.validate()
.expect("Severity filtering options should be valid");
let formatter = ReportFormatter::with_options(options);
let mut report = ValidationReport::new();
report.add_violation(crate::domain::violations::Violation::new(
"warning_rule",
Severity::Warning,
PathBuf::from("src/lib.rs"),
"Warning message",
));
report.add_violation(crate::domain::violations::Violation::new(
"error_rule",
Severity::Error,
PathBuf::from("src/main.rs"),
"Error message",
));
let output = formatter
.format_report(&report, OutputFormat::Json)
.expect("JSON format should succeed for severity filtering test");
formatter
.validate_format_integrity(&report, OutputFormat::Json, &output)
.expect("Filtered JSON output should pass integrity validation");
let json: JsonValue =
serde_json::from_str(&output).expect("Severity filtered JSON should be valid");
assert_eq!(
json["violations"]
.as_array()
.expect("filtered violations should be an array")
.len(),
1
);
assert_eq!(json["violations"][0]["rule_id"], "error_rule");
}
#[test]
fn test_domain_validation_behavior() {
let invalid_options = ReportOptions {
max_violations: Some(0), ..Default::default()
};
assert!(invalid_options.validate().is_err());
assert!(ReportFormatter::new(invalid_options).is_err());
let formatter = ReportFormatter::default();
let valid_json = r#"{"violations": [], "summary": {"total_files": 0}}"#;
assert!(formatter.validate_json_structure(valid_json).is_ok());
let invalid_json = r#"{"missing_required_fields": true}"#;
assert!(formatter.validate_json_structure(invalid_json).is_err());
assert!(OutputFormat::Human.supports_colors());
assert!(!OutputFormat::Json.supports_colors());
assert!(OutputFormat::Json.is_structured());
assert!(!OutputFormat::Human.is_structured());
let human_options = ReportOptions::optimized_for(OutputFormat::Human);
assert!(human_options.is_optimized_for(OutputFormat::Human));
let json_options = ReportOptions::optimized_for(OutputFormat::Json);
assert!(json_options.is_optimized_for(OutputFormat::Json));
assert!(!json_options.use_colors); }
}