use std::collections::BTreeMap;
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::{
AnimationAnalysis, ContentWeight, CoverageAnalysis, CriticalChain, MinificationAnalysis,
PerformanceScore, RenderBlockingAnalysis, ThirdPartyAttribution, WebVitals,
};
use crate::security::SecurityAnalysis;
use crate::seo::SeoAnalysis;
use crate::ux::UxAnalysis;
use crate::wcag::WcagResults;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionStatus {
Completed,
Partial,
Failed,
Skipped,
NotApplicable,
#[default]
NotRequested,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ModuleRun {
pub module: String,
pub status: ExecutionStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub viewports: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subchecks: Vec<SubcheckRun>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubcheckRun {
pub subcheck: String,
pub status: ExecutionStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason_code: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditScope {
#[serde(default)]
pub requested_modules: Vec<String>,
pub full_audit: bool,
pub interactive_mode: String,
pub journey_budget_ms: u64,
#[serde(default)]
pub throttling_profiles: Vec<String>,
#[serde(default)]
pub viewports: Vec<ViewportDefinition>,
pub dismiss_consent: bool,
pub capture_screenshots: bool,
pub capture_element_evidence: bool,
#[serde(default)]
pub display_mode: crate::display::AuditedDisplayMode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewportDefinition {
pub name: String,
pub width: u32,
pub height: u32,
pub device_scale_factor: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExecutionEnvironment {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_version: Option<String>,
pub headless: bool,
#[serde(default)]
pub source: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NavigationSnapshot {
pub requested_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub main_document_status: Option<u16>,
#[serde(default)]
pub redirect_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ready_state: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stability: Vec<crate::interaction::stability::StabilityProvenance>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub meta_refresh: Vec<ViewportMetaRefresh>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewportMetaRefresh {
pub viewport: String,
#[serde(flatten)]
pub refresh: crate::browser::MetaRefresh,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditedContentState {
BeforeConsent,
AfterConsent,
#[default]
Unknown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConsentAuditState {
pub detected: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cmp: Option<String>,
pub dismissal_requested: bool,
pub dismissed: bool,
pub audited_content_state: AuditedContentState,
#[serde(default)]
pub status: Option<crate::browser::consent::ConsentHandlingStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditQualityStatus {
#[default]
Complete,
Partial,
Insufficient,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditQuality {
pub status: AuditQualityStatus,
pub qualified_results: bool,
#[serde(default)]
pub failed_rule_checks: usize,
#[serde(default)]
pub partial_or_failed_modules: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reasons: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditExecution {
#[serde(default)]
pub scope: AuditScope,
#[serde(default)]
pub environment: ExecutionEnvironment,
#[serde(default)]
pub navigation: NavigationSnapshot,
#[serde(default)]
pub consent: ConsentAuditState,
#[serde(default)]
pub module_runs: Vec<ModuleRun>,
#[serde(default)]
pub quality: AuditQuality,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclusions: Option<crate::audit::exclusion::ExclusionReport>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_modes: Option<crate::display::DisplayModesInfo>,
}
#[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)]
pub struct ConsentPrivacySnapshot {
pub before_interaction: Vec<ConsentCookieSignal>,
pub after_interaction: Vec<ConsentCookieSignal>,
pub added_after_interaction: Vec<ConsentCookieSignal>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct ConsentCookieSignal {
pub name: String,
pub domain: String,
pub secure: bool,
pub http_only: bool,
pub same_site: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "state", content = "reason")]
pub enum ScreenshotStatus {
Captured,
Failed(String),
#[default]
NotRequested,
}
#[derive(Debug, Clone)]
pub struct ViewportScreenshot {
pub bytes: Vec<u8>,
pub width: u32,
pub height: u32,
pub device_scale_factor: f64,
pub scroll_x: f64,
pub scroll_y: f64,
}
#[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>,
pub screenshot: Option<ViewportScreenshot>,
pub module_runs: Vec<ModuleRun>,
}
#[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,
}
impl ViewportScores {
pub fn weighted_accessibility(&self) -> u32 {
(self.mobile.accessibility as f64 * 0.7 + self.desktop.accessibility as f64 * 0.3).round()
as u32
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExperienceSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub mobile: Option<MobileFriendliness>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dark_mode: Option<DarkModeAnalysis>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub design_quality: Option<crate::design_quality::DesignQualityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub ai_transparency: Option<crate::ai_transparency::AiTransparencyAnalysis>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub network_dns: Option<crate::network::dns::NetworkDnsAnalysis>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub budget_violations: Vec<crate::audit::budget::BudgetViolation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessibilitySection {
pub wcag_results: WcagResults,
pub score: f32,
pub grade: String,
pub certificate: String,
pub statistics: ViolationStatistics,
pub nodes_analyzed: usize,
#[serde(default)]
pub execution: AuditExecution,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiscoverabilitySection {
#[serde(skip_serializing_if = "Option::is_none")]
pub seo: Option<SeoAnalysis>,
#[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 source_quality: Option<crate::source_quality::SourceQualityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_stack: Option<crate::tech_stack::TechStackAnalysis>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditReport {
pub url: String,
pub wcag_level: WcagLevel,
pub timestamp: DateTime<Utc>,
pub accessibility: AccessibilitySection,
pub duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub performance: Option<PerformanceResults>,
#[serde(skip_serializing_if = "Option::is_none")]
pub security: Option<SecurityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub html_conform: Option<crate::html_conform::HtmlConformAnalysis>,
#[serde(default)]
pub experience: ExperienceSection,
#[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(default)]
pub discoverability: DiscoverabilitySection,
#[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,
#[serde(skip_serializing_if = "Option::is_none")]
pub best_practices: Option<crate::best_practices::BestPracticesAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commerce: Option<crate::commerce::CommerceAnalysis>,
#[serde(default)]
pub consent_banner_detected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub consent_banner_cmp: Option<String>,
#[serde(default)]
pub consent_banner_dismissed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub consent_privacy: Option<ConsentPrivacySnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub accessibility_journey: Option<crate::audit::normalized::AccessibilityJourney>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub interactive_findings: Vec<crate::audit::normalized::InteractiveFinding>,
#[serde(skip)]
pub screen_reader_audit: Option<crate::screen_reader::SrAuditReport>,
}
#[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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub third_party: Option<ThirdPartyAttribution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub critical_chain: Option<CriticalChain>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minification: Option<MinificationAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub animations: Option<AnimationAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coverage: Option<CoverageAnalysis>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub measurement_warnings: Vec<String>,
}
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(),
accessibility: AccessibilitySection {
wcag_results,
score,
grade,
certificate,
statistics,
nodes_analyzed,
execution: AuditExecution::default(),
},
duration_ms,
performance: None,
security: None,
html_conform: None,
experience: ExperienceSection::default(),
ux: None,
journey: None,
discoverability: DiscoverabilitySection {
seo: None,
ai_visibility: None,
content_visibility: None,
source_quality: None,
tech_stack: None,
},
page_screenshots: None,
dual_viewport: None,
viewport_scores: None,
throttled_performance: Vec::new(),
patterns: None,
screenshot_status: ScreenshotStatus::NotRequested,
best_practices: None,
commerce: None,
consent_banner_detected: false,
consent_banner_cmp: None,
consent_banner_dismissed: false,
consent_privacy: None,
accessibility_journey: None,
interactive_findings: Vec::new(),
screen_reader_audit: None,
}
}
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.discoverability.seo = Some(seo);
self
}
pub fn with_security(mut self, security: SecurityAnalysis) -> Self {
self.security = Some(security);
self
}
pub fn with_html_conform(
mut self,
html_conform: crate::html_conform::HtmlConformAnalysis,
) -> Self {
self.html_conform = Some(html_conform);
self
}
pub fn with_mobile(mut self, mobile: MobileFriendliness) -> Self {
self.experience.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.experience.dark_mode = Some(dark_mode);
self
}
pub fn with_design_quality(
mut self,
design_quality: crate::design_quality::DesignQualityAnalysis,
) -> Self {
self.experience.design_quality = Some(design_quality);
self
}
pub fn with_ai_transparency(
mut self,
ai_transparency: crate::ai_transparency::AiTransparencyAnalysis,
) -> Self {
self.experience.ai_transparency = Some(ai_transparency);
self
}
pub fn with_network_dns(
mut self,
network_dns: crate::network::dns::NetworkDnsAnalysis,
) -> Self {
self.experience.network_dns = Some(network_dns);
self
}
pub fn with_tech_stack(mut self, tech_stack: crate::tech_stack::TechStackAnalysis) -> Self {
self.discoverability.tech_stack = Some(tech_stack);
self
}
pub fn with_best_practices(mut self, bp: crate::best_practices::BestPracticesAnalysis) -> Self {
self.best_practices = Some(bp);
self
}
pub fn violation_count(&self) -> usize {
self.accessibility.wcag_results.violations.len()
}
pub fn passed(&self) -> bool {
self.accessibility.score >= 70.0
&& !self
.accessibility
.wcag_results
.violations
.iter()
.any(|v| v.severity == crate::wcag::Severity::Critical)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchReport {
pub reports: Vec<AuditReport>,
#[serde(default, 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 sitemap_diagnostics: Option<SitemapDiagnostics>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub consistency: Option<crate::audit::batch_consistency::BatchConsistencyAnalysis>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub sample: Option<SampleMetadata>,
pub total_duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SampleMetadata {
pub source: String,
pub total_discovered: usize,
pub audited: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_limit: Option<usize>,
pub selection: String,
pub is_sample: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SitemapDiagnostics {
pub checked_urls: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub http_issues: Vec<SitemapHttpIssue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub orphan_sitemap_urls: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub linked_not_in_sitemap: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub robots_conflicts: Vec<RobotsSitemapConflict>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub crawl_depths: Option<CrawlDepthDiagnostics>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrawlDepthDiagnostics {
pub start_url: String,
pub depth_histogram: BTreeMap<usize, usize>,
pub deepest_pages: Vec<CrawlDepthEntry>,
pub unreachable_pages: Vec<String>,
pub partial_batch: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrawlDepthEntry {
pub url: String,
pub depth: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RobotsSitemapConflict {
pub url: String,
pub rule: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SitemapHttpIssue {
pub kind: String,
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_code: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
pub detail: String,
}
#[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(default, skip_serializing_if = "Vec::is_empty")]
pub broken_internal_links: Vec<BrokenLink>,
pub checked_external_links: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub broken_external_links: Vec<BrokenLink>,
#[serde(default, 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,
#[serde(skip)]
pub blocked_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurringRule {
pub rule_id: String,
pub title: String,
pub wcag_criterion: String,
pub wcag_level: String,
pub severity: crate::taxonomy::Severity,
pub affected_pages: usize,
pub total_occurrences: usize,
}
#[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,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub top_recurring_rules: Vec<RecurringRule>,
#[serde(default)]
pub violated_rule_count: usize,
#[serde(default)]
pub legal_flags: usize,
#[serde(default)]
pub blocking_issues: usize,
#[serde(default)]
pub risk: crate::audit::normalized::RiskLevel,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub verdict_key: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub template_clusters: Vec<crate::audit::template_dedup::TemplateCluster>,
#[serde(default)]
pub audit_quality: AuditQuality,
}
pub fn compute_recurring_rules(
reports: &[crate::audit::normalized::NormalizedReport],
) -> (Vec<RecurringRule>, usize) {
use std::collections::{HashMap, HashSet};
let violated_rule_count = reports
.iter()
.flat_map(|r| r.findings.iter().map(|f| f.rule_id.as_str()))
.collect::<HashSet<_>>()
.len();
struct Acc {
title: String,
wcag_criterion: String,
wcag_level: String,
severity: crate::taxonomy::Severity,
affected_pages: usize,
total_occurrences: usize,
}
let mut by_rule: HashMap<String, Acc> = HashMap::new();
for report in reports {
for f in report.findings.iter().filter(|f| f.category == "wcag") {
let entry = by_rule.entry(f.rule_id.clone()).or_insert(Acc {
title: f.title.clone(),
wcag_criterion: f.wcag_criterion.clone(),
wcag_level: f.wcag_level.clone(),
severity: f.severity,
affected_pages: 0,
total_occurrences: 0,
});
entry.affected_pages += 1;
entry.total_occurrences += f.occurrence_count;
}
}
let mut rules: Vec<RecurringRule> = by_rule
.into_iter()
.map(|(rule_id, a)| RecurringRule {
rule_id,
title: a.title,
wcag_criterion: a.wcag_criterion,
wcag_level: a.wcag_level,
severity: a.severity,
affected_pages: a.affected_pages,
total_occurrences: a.total_occurrences,
})
.collect();
rules.sort_by(|a, b| {
b.affected_pages
.cmp(&a.affected_pages)
.then_with(|| b.total_occurrences.cmp(&a.total_occurrences))
.then_with(|| b.severity.cmp(&a.severity))
.then_with(|| a.rule_id.cmp(&b.rule_id))
});
rules.truncate(10);
(rules, violated_rule_count)
}
fn compute_legal_flags(reports: &[crate::audit::normalized::NormalizedReport]) -> usize {
use crate::taxonomy::Severity;
use std::collections::HashSet;
let mut rule_ids: HashSet<&str> = reports
.iter()
.flat_map(|r| r.findings.iter())
.filter(|f| {
f.wcag_level == "A" && matches!(f.severity, Severity::Critical | Severity::High)
})
.map(|f| f.rule_id.as_str())
.collect();
let sr_only_legal_exposure = reports.iter().any(|r| {
let findings_based = r
.findings
.iter()
.filter(|f| {
f.wcag_level == "A" && matches!(f.severity, Severity::Critical | Severity::High)
})
.count();
r.risk.legal_flags > findings_based
});
if sr_only_legal_exposure {
rule_ids.insert("screen_reader.bfsg_noncompliant");
}
rule_ids.len()
}
pub fn compute_worst_risk(
reports: &[crate::audit::normalized::NormalizedReport],
) -> crate::audit::normalized::RiskLevel {
use crate::audit::normalized::RiskLevel;
use std::collections::HashMap;
let page_count = reports.len().max(1);
let mut counts: HashMap<RiskLevel, usize> = HashMap::new();
for r in reports {
*counts.entry(r.risk.level).or_insert(0) += 1;
}
let critical = *counts.get(&RiskLevel::Critical).unwrap_or(&0);
let high = *counts.get(&RiskLevel::High).unwrap_or(&0);
let medium = *counts.get(&RiskLevel::Medium).unwrap_or(&0);
if critical > 0 {
RiskLevel::Critical
} else if high * 5 >= page_count {
RiskLevel::High
} else if (high + medium) * 5 >= page_count {
RiskLevel::Medium
} else {
RiskLevel::Low
}
}
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<crate::audit::normalized::NormalizedReport> = reports
.iter()
.map(|r| crate::audit::normalized::normalize(r).normalized)
.collect();
let passed = normalized_reports
.iter()
.filter(|r| r.score >= 80 && r.severity_counts.critical == 0 && r.risk.legal_flags == 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()
.flat_map(|r| r.findings.iter())
.map(|f| f.occurrence_count)
.sum();
let (top_recurring_rules, violated_rule_count) =
compute_recurring_rules(&normalized_reports);
let template_clusters =
crate::audit::template_dedup::detect_template_clusters(&normalized_reports);
let legal_flags = compute_legal_flags(&normalized_reports);
let blocking_issues = normalized_reports
.iter()
.map(|r| r.risk.blocking_issues)
.sum();
let risk = compute_worst_risk(&normalized_reports);
let audit_quality = {
let failed_rule_checks = normalized_reports
.iter()
.map(|report| report.execution.quality.failed_rule_checks)
.sum();
let partial_or_failed_modules = normalized_reports
.iter()
.map(|report| report.execution.quality.partial_or_failed_modules)
.sum();
let status = if normalized_reports
.iter()
.any(|report| report.execution.quality.status == AuditQualityStatus::Insufficient)
{
AuditQualityStatus::Insufficient
} else if normalized_reports
.iter()
.any(|report| report.execution.quality.status == AuditQualityStatus::Partial)
{
AuditQualityStatus::Partial
} else {
AuditQualityStatus::Complete
};
AuditQuality {
status,
qualified_results: status != AuditQualityStatus::Complete,
failed_rule_checks,
partial_or_failed_modules,
reasons: Vec::new(),
}
};
let verdict_key = {
let s = average_score.round() as u32;
if s >= 90 {
"verdict-batch-excellent"
} else if s >= 70 {
"verdict-batch-solid"
} else if s >= 50 {
"verdict-batch-deficient"
} else {
"verdict-batch-critical"
}
.to_string()
};
let mut result = Self {
reports,
errors,
summary: BatchSummary {
total_urls,
passed,
failed,
average_score,
total_violations,
top_recurring_rules,
violated_rule_count,
legal_flags,
blocking_issues,
risk,
verdict_key,
template_clusters,
audit_quality,
},
crawl_diagnostics: None,
sitemap_diagnostics: None,
consistency: None,
sample: 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
}
pub fn with_sitemap_diagnostics(mut self, diagnostics: SitemapDiagnostics) -> Self {
self.sitemap_diagnostics = Some(diagnostics);
self
}
pub fn with_sample(mut self, sample: SampleMetadata) -> Self {
self.sample = Some(sample);
self
}
}
#[cfg(test)]
mod tests {
#[test]
fn meta_refresh_serializes_flat_per_viewport() {
let nav = NavigationSnapshot {
meta_refresh: vec![ViewportMetaRefresh {
viewport: "desktop".to_string(),
refresh: crate::browser::MetaRefresh {
outcome: crate::browser::MetaRefreshOutcome::InterstitialFollowed,
hops: 1,
},
}],
..Default::default()
};
let json = serde_json::to_value(&nav).unwrap();
assert_eq!(
json["meta_refresh"],
serde_json::json!([{"viewport": "desktop", "outcome": "interstitial_followed", "hops": 1}])
);
let empty = serde_json::to_value(NavigationSnapshot::default()).unwrap();
assert!(empty.get("meta_refresh").is_none());
}
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.accessibility.score, 100.0); assert_eq!(report.duration_ms, 500);
assert!(report.performance.is_none());
assert!(report.discoverability.seo.is_none());
assert!(report.security.is_none());
assert!(report.experience.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_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(),
sourcemap_leaks: 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);
}
#[test]
fn batch_summary_has_recurring_rules_and_risk() {
let reports = vec![
AuditReport::new(
"https://a.com".into(),
WcagLevel::AA,
WcagResults::new(),
100,
),
AuditReport::new(
"https://b.com".into(),
WcagLevel::AA,
WcagResults::new(),
100,
),
];
let batch = BatchReport::from_reports(reports, vec![], 0);
assert!(batch.summary.top_recurring_rules.is_empty());
assert_eq!(batch.summary.violated_rule_count, 0);
assert_eq!(batch.summary.legal_flags, 0);
assert_eq!(batch.summary.risk, crate::audit::normalized::RiskLevel::Low);
}
#[test]
fn compute_recurring_rules_aggregates_across_pages() {
use crate::audit::normalized::normalize;
use crate::wcag::Violation;
let mut results1 = WcagResults::new();
results1.add_violation(Violation::new(
"1.1.1",
"Alt",
WcagLevel::A,
crate::wcag::Severity::High,
"Missing alt",
"n1",
));
let mut results2 = WcagResults::new();
results2.add_violation(Violation::new(
"1.1.1",
"Alt",
WcagLevel::A,
crate::wcag::Severity::High,
"Missing alt",
"n2",
));
let report1 = AuditReport::new("https://a.com".into(), WcagLevel::AA, results1, 100);
let report2 = AuditReport::new("https://b.com".into(), WcagLevel::AA, results2, 100);
let r1 = normalize(&report1);
let r2 = normalize(&report2);
let (rules, violated_count) = compute_recurring_rules(&[r1.normalized, r2.normalized]);
assert_eq!(violated_count, 1, "one distinct rule fired");
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].affected_pages, 2, "rule appeared on both pages");
assert_eq!(rules[0].total_occurrences, 2);
}
#[test]
fn compute_recurring_rules_breaks_ties_by_rule_id() {
use crate::audit::normalized::normalize;
use crate::wcag::Violation;
let mut results = WcagResults::new();
results.add_violation(Violation::new(
"1.1.1",
"Alt",
WcagLevel::A,
crate::wcag::Severity::High,
"Missing alt",
"n1",
));
let report = AuditReport::new("https://a.com".into(), WcagLevel::AA, results, 100);
let mut normalized = normalize(&report).normalized;
let base = normalized.findings[0].clone();
normalized.findings = (0..12)
.rev()
.map(|i| {
let mut f = base.clone();
f.rule_id = format!("rule-{i:02}");
f
})
.collect();
let (rules, violated_count) = compute_recurring_rules(&[normalized]);
assert_eq!(violated_count, 12);
let ids: Vec<&str> = rules.iter().map(|r| r.rule_id.as_str()).collect();
let expected: Vec<String> = (0..10).map(|i| format!("rule-{i:02}")).collect();
assert_eq!(ids, expected);
}
#[test]
fn batch_report_with_empty_lists_round_trips_through_json() {
let mut batch = BatchReport::from_reports(
vec![AuditReport::new(
"https://a.com".into(),
WcagLevel::AA,
WcagResults::new(),
100,
)],
vec![],
0,
);
batch.crawl_diagnostics = Some(CrawlDiagnostics {
seed_url: "https://a.com".into(),
discovered_urls: 1,
checked_internal_links: 0,
broken_internal_links: vec![],
checked_external_links: 0,
broken_external_links: vec![],
redirect_chains: vec![],
});
batch.sitemap_diagnostics = Some(SitemapDiagnostics::default());
let json = serde_json::to_value(&batch).unwrap();
assert!(json.get("errors").is_none());
let reparsed: BatchReport =
serde_json::from_value(json.clone()).expect("serialized BatchReport must deserialize");
assert_eq!(serde_json::to_value(&reparsed).unwrap(), json);
}
#[test]
fn compute_worst_risk_critical_if_any_critical_page() {
use crate::audit::normalized::{normalize, RiskLevel};
use crate::wcag::Violation;
let empty_report = AuditReport::new(
"https://a.com".into(),
WcagLevel::AA,
WcagResults::new(),
100,
);
let empty = normalize(&empty_report);
let mut results = WcagResults::new();
for i in 0..3 {
results.add_violation(Violation::new(
"1.1.1",
"Alt",
WcagLevel::A,
crate::wcag::Severity::Critical,
"missing",
format!("n{i}"),
));
}
let risky_report = AuditReport::new("https://b.com".into(), WcagLevel::AA, results, 50);
let risky = normalize(&risky_report);
let risk = compute_worst_risk(&[empty.normalized, risky.normalized]);
assert!(
matches!(
risk,
RiskLevel::Critical | RiskLevel::High | RiskLevel::Medium
),
"batch risk must be elevated when one page has critical issues, got {:?}",
risk
);
}
}