use serde::{Deserialize, Serialize};
use crate::audit::{normalize, report::AuditReport, scoring::AccessibilityScorer};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComparisonReport {
pub entries: Vec<ComparisonEntry>,
pub total_duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComparisonEntry {
pub url: String,
pub domain: String,
pub overall_score: u32,
pub accessibility_score: u32,
pub seo_score: Option<u32>,
pub performance_score: Option<u32>,
pub security_score: Option<u32>,
pub mobile_score: Option<u32>,
pub critical_violations: usize,
pub total_violations: usize,
pub grade: String,
pub top_issues: Vec<String>,
}
impl ComparisonReport {
pub fn from_reports(reports: Vec<AuditReport>, total_duration_ms: u64) -> Self {
let mut entries: Vec<ComparisonEntry> =
reports.iter().map(ComparisonEntry::from_report).collect();
entries.sort_by_key(|b| std::cmp::Reverse(b.overall_score));
Self {
entries,
total_duration_ms,
}
}
pub fn rank_for(&self, url: &str) -> u32 {
self.entries
.iter()
.position(|e| e.url == url)
.map(|i| i as u32 + 1)
.unwrap_or(0)
}
}
impl ComparisonEntry {
fn from_report(report: &AuditReport) -> Self {
let domain = extract_domain(&report.url);
let normalized = normalize(report);
let accessibility_score = normalized.score;
let overall_score = normalized.overall_score;
let module_score = |name: &str| {
normalized
.module_scores
.iter()
.find(|m| m.name == name)
.map(|m| m.score)
};
let seo_score = module_score("SEO");
let performance_score = module_score("Performance");
let security_score = module_score("Security");
let mobile_score = module_score("Mobile");
let critical_violations = normalized.severity_counts.critical;
let top_issues: Vec<String> = normalized
.findings
.iter()
.take(3)
.map(|f| f.title.clone())
.collect();
Self {
url: report.url.clone(),
domain,
overall_score,
accessibility_score,
seo_score,
performance_score,
security_score,
mobile_score,
critical_violations,
total_violations: normalized.severity_counts.total,
grade: AccessibilityScorer::calculate_grade(overall_score as f32).to_string(),
top_issues,
}
}
}
fn extract_domain(url: &str) -> String {
url::Url::parse(url)
.ok()
.and_then(|u| {
u.host_str()
.map(|h| h.strip_prefix("www.").unwrap_or(h).to_string())
})
.unwrap_or_else(|| url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_domain_strips_www() {
assert_eq!(
extract_domain("https://www.example.com/page"),
"example.com"
);
}
#[test]
fn test_extract_domain_keeps_subdomain() {
assert_eq!(
extract_domain("https://shop.example.com"),
"shop.example.com"
);
}
#[test]
fn test_comparison_report_sorts_by_score() {
use crate::cli::WcagLevel;
use crate::wcag::WcagResults;
let make =
|url: &str| AuditReport::new(url.to_string(), WcagLevel::AA, WcagResults::default(), 0);
let reports = vec![make("https://a.com"), make("https://b.com")];
let report = ComparisonReport::from_reports(reports, 1000);
assert_eq!(report.entries.len(), 2);
}
}