use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::audit::scoring::{AccessibilityScorer, ViolationStatistics};
use crate::browser::ThrottleProfile;
use crate::cli::WcagLevel;
use crate::dark_mode::DarkModeAnalysis;
use crate::mobile::MobileFriendliness;
use crate::performance::{ContentWeight, PerformanceScore, RenderBlockingAnalysis, WebVitals};
use crate::security::SecurityAnalysis;
use crate::seo::SeoAnalysis;
use crate::ux::UxAnalysis;
use crate::wcag::WcagResults;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThrottledPerfResult {
pub profile: ThrottleProfile,
pub lcp_ms: Option<f64>,
pub tbt_ms: Option<f64>,
pub cls: Option<f64>,
pub score: u32,
}
#[derive(Debug, Clone)]
pub struct PageScreenshots {
pub desktop: Vec<u8>,
pub mobile: Vec<u8>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "state", content = "reason")]
pub enum ScreenshotStatus {
Captured,
Failed(String),
#[default]
NotRequested,
}
#[derive(Debug, Clone)]
pub struct ViewportAuditData {
pub wcag_results: WcagResults,
pub accessibility_score: f32,
pub performance: Option<PerformanceResults>,
pub seo: Option<SeoAnalysis>,
pub mobile: Option<MobileFriendliness>,
pub ux: Option<crate::ux::UxAnalysis>,
pub journey: Option<crate::journey::JourneyAnalysis>,
}
#[derive(Debug, Clone)]
pub struct DualViewportResults {
pub desktop: ViewportAuditData,
pub mobile: ViewportAuditData,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewportScoreSet {
pub accessibility: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub performance: Option<u32>,
pub overall: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewportScores {
pub desktop: ViewportScoreSet,
pub mobile: ViewportScoreSet,
pub weighted_overall: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditReport {
pub url: String,
pub wcag_level: WcagLevel,
pub timestamp: DateTime<Utc>,
pub wcag_results: WcagResults,
pub score: f32,
pub grade: String,
pub certificate: String,
pub statistics: ViolationStatistics,
pub nodes_analyzed: usize,
pub duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub performance: Option<PerformanceResults>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seo: Option<SeoAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub security: Option<SecurityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mobile: Option<MobileFriendliness>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub budget_violations: Vec<crate::audit::budget::BudgetViolation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ux: Option<UxAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub journey: Option<crate::journey::JourneyAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dark_mode: Option<DarkModeAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_quality: Option<crate::source_quality::SourceQualityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_visibility: Option<crate::ai_visibility::AiVisibilityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_visibility: Option<crate::content_visibility::ContentVisibilityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_stack: Option<crate::tech_stack::TechStackAnalysis>,
#[serde(skip)]
pub page_screenshots: Option<PageScreenshots>,
#[serde(skip)]
pub dual_viewport: Option<DualViewportResults>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport_scores: Option<ViewportScores>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub throttled_performance: Vec<ThrottledPerfResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub patterns: Option<crate::patterns::PatternAnalysis>,
#[serde(default)]
pub screenshot_status: ScreenshotStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceResults {
pub vitals: WebVitals,
pub score: PerformanceScore,
#[serde(skip_serializing_if = "Option::is_none")]
pub render_blocking: Option<RenderBlockingAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_weight: Option<ContentWeight>,
}
impl AuditReport {
pub fn new(
url: String,
wcag_level: WcagLevel,
wcag_results: WcagResults,
duration_ms: u64,
) -> Self {
let score = AccessibilityScorer::calculate_score(&wcag_results.violations);
let grade = AccessibilityScorer::calculate_grade(score).to_string();
let certificate = AccessibilityScorer::calculate_certificate(score).to_string();
let statistics = AccessibilityScorer::calculate_statistics(&wcag_results.violations);
let nodes_analyzed = wcag_results.nodes_checked;
Self {
url,
wcag_level,
timestamp: Utc::now(),
wcag_results,
score,
grade,
certificate,
statistics,
nodes_analyzed,
duration_ms,
performance: None,
seo: None,
security: None,
mobile: None,
ux: None,
journey: None,
budget_violations: Vec::new(),
dark_mode: None,
source_quality: None,
ai_visibility: None,
content_visibility: None,
tech_stack: None,
page_screenshots: None,
dual_viewport: None,
viewport_scores: None,
throttled_performance: Vec::new(),
patterns: None,
screenshot_status: ScreenshotStatus::NotRequested,
}
}
pub fn with_performance(mut self, performance: PerformanceResults) -> Self {
self.performance = Some(performance);
self
}
pub fn with_patterns(mut self, patterns: crate::patterns::PatternAnalysis) -> Self {
self.patterns = Some(patterns);
self
}
pub fn with_seo(mut self, seo: SeoAnalysis) -> Self {
self.seo = Some(seo);
self
}
pub fn with_security(mut self, security: SecurityAnalysis) -> Self {
self.security = Some(security);
self
}
pub fn with_mobile(mut self, mobile: MobileFriendliness) -> Self {
self.mobile = Some(mobile);
self
}
pub fn with_ux(mut self, ux: UxAnalysis) -> Self {
self.ux = Some(ux);
self
}
pub fn with_journey(mut self, journey: crate::journey::JourneyAnalysis) -> Self {
self.journey = Some(journey);
self
}
pub fn with_dark_mode(mut self, dark_mode: DarkModeAnalysis) -> Self {
self.dark_mode = Some(dark_mode);
self
}
pub fn with_tech_stack(mut self, tech_stack: crate::tech_stack::TechStackAnalysis) -> Self {
self.tech_stack = Some(tech_stack);
self
}
pub fn violation_count(&self) -> usize {
self.wcag_results.violations.len()
}
pub fn passed(&self) -> bool {
self.score >= 70.0
&& !self
.wcag_results
.violations
.iter()
.any(|v| v.severity == crate::wcag::Severity::Critical)
}
pub fn overall_score(&self) -> u32 {
if let Some(ref vs) = self.viewport_scores {
let mut weighted = vs.weighted_overall as f64 * 90.0;
let mut total = 90.0;
if let Some(ref security) = self.security {
weighted += security.score as f64 * 10.0;
total += 10.0;
}
return (weighted / total).round() as u32;
}
let mut weighted_sum = self.score as f64 * 40.0;
let mut total_weight = 40.0;
if let Some(ref perf) = self.performance {
weighted_sum += perf.score.overall as f64 * 20.0;
total_weight += 20.0;
}
if let Some(ref seo) = self.seo {
weighted_sum += seo.score as f64 * 20.0;
total_weight += 20.0;
}
if let Some(ref security) = self.security {
weighted_sum += security.score as f64 * 10.0;
total_weight += 10.0;
}
if let Some(ref mobile) = self.mobile {
weighted_sum += mobile.score as f64 * 10.0;
total_weight += 10.0;
}
(weighted_sum / total_weight).round() as u32
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchReport {
pub reports: Vec<AuditReport>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<BatchError>,
pub summary: BatchSummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub crawl_diagnostics: Option<CrawlDiagnostics>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub consistency: Option<crate::audit::batch_consistency::BatchConsistencyAnalysis>,
pub total_duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BrokenLinkSeverity {
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedirectChain {
pub source_url: String,
pub target_url: String,
pub final_url: String,
pub hops: u8,
pub is_external: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrawlDiagnostics {
pub seed_url: String,
pub discovered_urls: usize,
pub checked_internal_links: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub broken_internal_links: Vec<BrokenLink>,
pub checked_external_links: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub broken_external_links: Vec<BrokenLink>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub redirect_chains: Vec<RedirectChain>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrokenLink {
pub source_url: String,
pub target_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_code: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub is_external: bool,
#[serde(default)]
pub redirect_hops: u8,
pub severity: BrokenLinkSeverity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchError {
pub url: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchSummary {
pub total_urls: usize,
pub passed: usize,
pub failed: usize,
pub average_score: f64,
pub total_violations: usize,
}
impl BatchReport {
pub fn from_reports(
reports: Vec<AuditReport>,
errors: Vec<BatchError>,
total_duration_ms: u64,
) -> Self {
let total_urls = reports.len();
let normalized_reports: Vec<_> = reports
.iter()
.map(crate::audit::normalized::normalize)
.collect();
let passed = normalized_reports
.iter()
.filter(|r| r.score >= 70 && r.severity_counts.critical == 0)
.count();
let failed = total_urls - passed;
let average_score = if total_urls > 0 {
normalized_reports
.iter()
.map(|r| r.score as f64)
.sum::<f64>()
/ total_urls as f64
} else {
0.0
};
let total_violations = normalized_reports
.iter()
.map(|r| r.severity_counts.total)
.sum();
let mut result = Self {
reports,
errors,
summary: BatchSummary {
total_urls,
passed,
failed,
average_score,
total_violations,
},
crawl_diagnostics: None,
consistency: None,
total_duration_ms,
};
result.consistency = crate::audit::batch_consistency::analyze(&result);
result
}
pub fn with_crawl_diagnostics(mut self, diagnostics: CrawlDiagnostics) -> Self {
self.crawl_diagnostics = Some(diagnostics);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wcag::WcagResults;
#[test]
fn test_audit_report_new() {
let results = WcagResults::new();
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
results,
500,
);
assert_eq!(report.url, "https://example.com");
assert_eq!(report.score, 100.0); assert_eq!(report.duration_ms, 500);
assert!(report.performance.is_none());
assert!(report.seo.is_none());
assert!(report.security.is_none());
assert!(report.mobile.is_none());
}
#[test]
fn test_batch_report() {
let reports = vec![
AuditReport::new(
"https://a.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
),
AuditReport::new(
"https://b.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
200,
),
];
let batch = BatchReport::from_reports(reports, vec![], 300);
assert_eq!(batch.summary.total_urls, 2);
assert_eq!(batch.summary.passed, 2);
assert_eq!(batch.summary.average_score, 100.0);
}
#[test]
fn test_passed_with_perfect_score() {
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
);
assert!(report.passed());
}
#[test]
fn test_passed_with_critical_violation() {
let mut results = WcagResults::new();
results.add_violation(crate::wcag::Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
crate::wcag::Severity::Critical,
"Missing alt",
"node-1",
));
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
results,
100,
);
assert!(!report.passed());
}
#[test]
fn test_overall_score_wcag_only() {
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
);
assert_eq!(report.overall_score(), 100);
}
#[test]
fn test_overall_score_weighted() {
let mut report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
);
report.security = Some(crate::security::SecurityAnalysis {
score: 50,
grade: "D".to_string(),
headers: Default::default(),
ssl: Default::default(),
issues: vec![],
recommendations: vec![],
protection: Default::default(),
});
assert_eq!(report.overall_score(), 90);
}
#[test]
fn test_with_builder_methods() {
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
);
let report = report.with_security(crate::security::SecurityAnalysis {
score: 80,
grade: "A".to_string(),
headers: Default::default(),
ssl: Default::default(),
issues: vec![],
recommendations: vec![],
protection: Default::default(),
});
assert!(report.security.is_some());
assert_eq!(report.security.as_ref().unwrap().score, 80);
}
#[test]
fn test_violation_count() {
let mut results = WcagResults::new();
results.add_violation(crate::wcag::Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
crate::wcag::Severity::High,
"Missing alt",
"node-1",
));
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
results,
100,
);
assert_eq!(report.violation_count(), 1);
}
}