use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::audit::normalized::NormalizedReport;
use crate::audit::{AuditReport, BatchReport};
use crate::error::Result;
use crate::output::builder::build_batch_presentation;
use crate::output::builder::build_view_model;
use crate::output::explanations::get_explanation;
use crate::output::module::ReportModule as _;
use crate::output::report_model::{ReportConfig, UrlMatrixRow};
pub fn format_json_normalized(
normalized: &NormalizedReport,
report: &AuditReport,
pretty: bool,
) -> Result<String> {
let json_report = JsonReport::from_normalized(normalized, report);
let output = if pretty {
serde_json::to_string_pretty(&json_report)
} else {
serde_json::to_string(&json_report)
};
output.map_err(|e| crate::error::AuditError::OutputError {
reason: format!("JSON serialization failed: {}", e),
})
}
pub fn format_json_cached(normalized: &NormalizedReport, pretty: bool) -> Result<String> {
#[derive(Debug, Serialize)]
struct CachedJsonReport<'a> {
metadata: ReportMetadata,
report: &'a NormalizedReport,
}
let payload = CachedJsonReport {
metadata: ReportMetadata {
tool: format!("auditmysite v{}", env!("CARGO_PKG_VERSION")),
timestamp: normalized.timestamp,
wcag_level: normalized.wcag_level.to_string(),
execution_time_ms: normalized.duration_ms,
},
report: normalized,
};
let output = if pretty {
serde_json::to_string_pretty(&payload)
} else {
serde_json::to_string(&payload)
};
output.map_err(|e| crate::error::AuditError::OutputError {
reason: format!("JSON serialization failed: {}", e),
})
}
pub fn format_json_batch(batch_report: &BatchReport, pretty: bool) -> Result<String> {
let normalized_reports: Vec<NormalizedReport> = batch_report
.reports
.iter()
.map(crate::audit::normalize)
.collect();
let presentation = build_batch_presentation(batch_report);
let payload = BatchJsonReport {
metadata: ReportMetadata {
tool: format!("auditmysite v{}", env!("CARGO_PKG_VERSION")),
timestamp: batch_report_timestamp(batch_report),
wcag_level: normalized_reports
.first()
.map(|report| report.wcag_level.to_string())
.unwrap_or_else(|| "mixed".to_string()),
execution_time_ms: batch_report.total_duration_ms,
},
summary: &batch_report.summary,
crawl_diagnostics: batch_report.crawl_diagnostics.as_ref(),
errors: &batch_report.errors,
url_matrix: presentation.url_matrix,
reports: normalized_reports,
};
let output = if pretty {
serde_json::to_string_pretty(&payload)
} else {
serde_json::to_string(&payload)
};
output.map_err(|e| crate::error::AuditError::OutputError {
reason: format!("JSON serialization failed: {}", e),
})
}
#[derive(Debug, Serialize)]
pub struct JsonReport {
pub metadata: ReportMetadata,
pub report: NormalizedReport,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub fix_guidance: Vec<FixGuidance>,
#[serde(skip_serializing_if = "Option::is_none")]
pub performance: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seo: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub security: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mobile: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ux: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub journey: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub confidence_summary: Vec<OutputConfidenceSignal>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<OutputCapabilitySignal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dark_mode: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_quality: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_visibility: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub history: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
pub struct FixGuidance {
pub rule_id: String,
pub title: String,
pub wcag_criterion: String,
pub severity: String,
pub occurrence_count: usize,
pub problem: String,
pub user_impact: String,
pub typical_cause: String,
pub recommendation: String,
pub technical_note: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_example: Option<CodeExample>,
pub affected_selectors: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct CodeExample {
pub bad: String,
pub good: String,
}
#[derive(Debug, Serialize)]
pub struct OutputConfidenceSignal {
pub signal: String,
pub assessment: String,
}
#[derive(Debug, Serialize)]
pub struct OutputCapabilitySignal {
pub signal: String,
pub source: String,
pub confidence: String,
pub surfaces: Vec<String>,
pub note: String,
}
#[derive(Debug, Serialize)]
pub struct ReportMetadata {
pub tool: String,
pub timestamp: DateTime<Utc>,
pub wcag_level: String,
pub execution_time_ms: u64,
}
#[derive(Debug, Serialize)]
struct BatchJsonReport<'a> {
pub metadata: ReportMetadata,
pub summary: &'a crate::audit::BatchSummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub crawl_diagnostics: Option<&'a crate::audit::CrawlDiagnostics>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: &'a Vec<crate::audit::BatchError>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub url_matrix: Vec<UrlMatrixRow>,
pub reports: Vec<NormalizedReport>,
}
impl JsonReport {
pub fn from_normalized(normalized: &NormalizedReport, raw: &AuditReport) -> Self {
let fix_guidance = Self::build_fix_guidance(normalized);
let vm = build_view_model(normalized, &ReportConfig::default());
Self {
metadata: ReportMetadata {
tool: format!("auditmysite v{}", env!("CARGO_PKG_VERSION")),
timestamp: raw.timestamp,
wcag_level: normalized.wcag_level.to_string(),
execution_time_ms: normalized.duration_ms,
},
report: normalized.clone(),
fix_guidance,
performance: raw.performance.as_ref().map(|m| m.to_json()),
seo: raw.seo.as_ref().map(|m| m.to_json()),
security: raw.security.as_ref().map(|m| m.to_json()),
mobile: raw.mobile.as_ref().map(|m| m.to_json()),
ux: raw.ux.as_ref().map(|m| m.to_json()),
journey: raw.journey.as_ref().map(|m| m.to_json()),
dark_mode: raw.dark_mode.as_ref().map(|m| m.to_json()),
source_quality: raw.source_quality.as_ref().map(|m| m.to_json()),
ai_visibility: raw.ai_visibility.as_ref().map(|m| m.to_json()),
confidence_summary: vm
.methodology
.confidence_summary
.iter()
.map(|(signal, assessment)| OutputConfidenceSignal {
signal: signal.clone(),
assessment: assessment.clone(),
})
.collect(),
capabilities: vm
.methodology
.capabilities
.iter()
.map(|cap| OutputCapabilitySignal {
signal: cap.signal.clone(),
source: cap.source.clone(),
confidence: cap.confidence.clone(),
surfaces: cap.surfaces.clone(),
note: cap.note.clone(),
})
.collect(),
history: None,
}
}
fn build_fix_guidance(normalized: &NormalizedReport) -> Vec<FixGuidance> {
normalized
.findings
.iter()
.map(|finding| {
let expl = get_explanation(&finding.rule_id);
let mut seen = std::collections::HashSet::new();
let affected_selectors: Vec<String> = finding
.occurrences
.iter()
.filter_map(|o| o.selector.clone())
.filter(|s| {
(s.contains('.')
|| s.contains('#')
|| s.contains('[')
|| s.contains('>')
|| s.contains(' '))
&& seen.insert(s.clone())
})
.take(10)
.collect();
let code_example = expl.and_then(|e| match (e.example_bad, e.example_good) {
(Some(bad), Some(good)) => Some(CodeExample {
bad: bad.to_string(),
good: good.to_string(),
}),
_ => None,
});
FixGuidance {
rule_id: finding.rule_id.clone(),
title: expl
.map(|e| e.customer_title.to_string())
.unwrap_or_else(|| finding.title.clone()),
wcag_criterion: finding.wcag_criterion.clone(),
severity: format!("{:?}", finding.severity).to_lowercase(),
occurrence_count: finding.occurrence_count,
problem: expl
.map(|e| e.customer_description.to_string())
.unwrap_or_else(|| finding.description.clone()),
user_impact: expl
.map(|e| e.user_impact.to_string())
.unwrap_or_else(|| finding.user_impact.clone()),
typical_cause: expl
.map(|e| e.typical_cause.to_string())
.unwrap_or_default(),
recommendation: expl
.map(|e| e.recommendation.to_string())
.unwrap_or_default(),
technical_note: expl
.map(|e| e.technical_note.to_string())
.unwrap_or_default(),
code_example,
affected_selectors,
}
})
.collect()
}
pub fn to_json(&self, pretty: bool) -> Result<String> {
let output = if pretty {
serde_json::to_string_pretty(self)
} else {
serde_json::to_string(self)
};
output.map_err(|e| crate::error::AuditError::OutputError {
reason: format!("JSON serialization failed: {}", e),
})
}
}
fn batch_report_timestamp(batch_report: &BatchReport) -> DateTime<Utc> {
batch_report
.reports
.iter()
.map(|report| report.timestamp)
.max()
.unwrap_or(DateTime::<Utc>::UNIX_EPOCH)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::normalized::normalize;
use crate::cli::WcagLevel;
use crate::wcag::WcagResults;
#[test]
fn test_json_report_normalized() {
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
500,
);
let normalized = normalize(&report);
let output = format_json_normalized(&normalized, &report, true).unwrap();
assert!(output.contains("example.com"));
assert!(output.contains("\"score\": 100"));
assert!(output.contains("\"grade\": \"A\""));
assert!(output.contains("\"certificate\": \"SEHR GUT\""));
assert!(output.contains("\"severity_counts\""));
}
#[test]
fn test_json_has_taxonomy_fields() {
use crate::taxonomy::Severity;
use crate::wcag::Violation;
let mut results = WcagResults::new();
results.add_violation(Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::High,
"Missing alt",
"n1",
));
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
results,
500,
);
let normalized = normalize(&report);
let output = format_json_normalized(&normalized, &report, true).unwrap();
assert!(output.contains("\"dimension\""));
assert!(output.contains("\"subcategory\""));
assert!(output.contains("\"issue_class\""));
assert!(output.contains("\"aggregation_key\""));
assert!(output.contains("\"user_impact\""));
}
#[test]
fn test_json_score_matches_normalized() {
use crate::taxonomy::Severity;
use crate::wcag::Violation;
let mut results = WcagResults::new();
results.add_violation(Violation::new(
"1.1.1",
"Alt",
WcagLevel::A,
Severity::High,
"Missing",
"n1",
));
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
results,
500,
);
let normalized = normalize(&report);
let json_report = JsonReport::from_normalized(&normalized, &report);
assert_eq!(json_report.report.score, normalized.score);
assert_eq!(json_report.report.grade, normalized.grade);
assert_eq!(json_report.report.certificate, normalized.certificate);
}
#[test]
fn test_json_violations_match_severity_counts() {
use crate::taxonomy::Severity;
use crate::wcag::Violation;
let mut results = WcagResults::new();
for node in ["n1", "n2", "n3"] {
results.add_violation(Violation::new(
"1.1.1",
"Alt",
WcagLevel::A,
Severity::High,
"Missing alt",
node,
));
}
results.add_violation(Violation::new(
"1.4.3",
"Contrast",
WcagLevel::AA,
Severity::High,
"Low contrast",
"n4",
));
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
results,
500,
);
let normalized = normalize(&report);
let json_report = JsonReport::from_normalized(&normalized, &report);
let occurrence_total: usize = json_report
.report
.findings
.iter()
.map(|f| f.occurrences.len())
.sum();
assert_eq!(
json_report.report.severity_counts.total, occurrence_total,
"severity_counts.total ({}) must equal sum of finding occurrences ({})",
json_report.report.severity_counts.total, occurrence_total,
);
for finding in &json_report.report.findings {
assert!(
!finding.occurrences.is_empty(),
"Finding {} has no occurrences",
finding.rule_id
);
}
let high_from_findings: usize = json_report
.report
.findings
.iter()
.filter(|f| f.severity == crate::wcag::Severity::High)
.map(|f| f.occurrences.len())
.sum();
assert_eq!(json_report.report.severity_counts.high, high_from_findings);
}
#[test]
fn test_all_active_modules_present_in_json() {
use crate::output::module::active_modules;
use crate::performance::{PerformanceGrade, PerformanceScore, WebVitals};
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
500,
)
.with_performance(crate::audit::PerformanceResults {
vitals: WebVitals::default(),
score: PerformanceScore {
overall: 80,
grade: PerformanceGrade::Gold,
lcp_score: Some(20),
fcp_score: Some(20),
cls_score: Some(20),
interactivity_score: Some(20),
metrics_available: 4,
},
render_blocking: None,
content_weight: None,
})
.with_seo(crate::seo::SeoAnalysis::default())
.with_security(crate::security::SecurityAnalysis {
score: 90,
grade: "A".to_string(),
headers: Default::default(),
ssl: Default::default(),
issues: vec![],
recommendations: vec![],
})
.with_ux(crate::ux::analyze_ux(&crate::AXTree::new()))
.with_journey(crate::journey::analyze_journey(&crate::AXTree::new()));
let active_keys: Vec<&'static str> = active_modules(&report)
.into_iter()
.map(|(key, _)| key)
.collect();
let normalized = normalize(&report);
let json_report = JsonReport::from_normalized(&normalized, &report);
let json_str = json_report.to_json(true).unwrap();
let json_value: serde_json::Value = serde_json::from_str(&json_str).unwrap();
for key in &active_keys {
assert!(
json_value.get(key).is_some(),
"Module '{}' is active but missing from JSON output — add it to JsonReport",
key
);
}
}
}