use serde::{Deserialize, Serialize};
use crate::audit::report::{AuditReport, BatchReport};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BatchConsistencyAnalysis {
pub navigation: NavigationConsistency,
pub headings: HeadingConsistency,
pub canonical: CanonicalConsistency,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NavigationConsistency {
pub pages_with_main_nav: usize,
pub pages_with_skip_link: usize,
pub total_pages: usize,
pub findings: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HeadingConsistency {
pub pages_with_single_h1: usize,
pub pages_with_no_h1: usize,
pub pages_with_multiple_h1: usize,
pub total_pages: usize,
pub findings: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CanonicalConsistency {
pub www_count: usize,
pub non_www_count: usize,
pub missing_count: usize,
pub total_pages: usize,
pub findings: Vec<String>,
}
pub fn analyze(batch: &BatchReport) -> Option<BatchConsistencyAnalysis> {
if batch.reports.len() < 2 {
return None;
}
Some(BatchConsistencyAnalysis {
navigation: analyze_navigation(&batch.reports),
headings: analyze_headings(&batch.reports),
canonical: analyze_canonical(&batch.reports),
})
}
fn analyze_navigation(reports: &[AuditReport]) -> NavigationConsistency {
let total_pages = reports.len();
let mut pages_with_main_nav = 0;
let mut pages_with_skip_link = 0;
let mut missing_nav = Vec::new();
let mut missing_skip = Vec::new();
for r in reports {
let recognized = r
.patterns
.as_ref()
.map(|p| p.recognized.as_slice())
.unwrap_or(&[]);
let has_nav = recognized.iter().any(|p| p.pattern == "MainNavigation");
let has_skip = recognized.iter().any(|p| p.pattern == "SkipLink");
if has_nav {
pages_with_main_nav += 1;
} else {
missing_nav.push(r.url.clone());
}
if has_skip {
pages_with_skip_link += 1;
} else {
missing_skip.push(r.url.clone());
}
}
let mut findings = Vec::new();
if pages_with_main_nav > 0 && pages_with_main_nav < total_pages {
let missing = total_pages - pages_with_main_nav;
findings.push(format!(
"{missing} of {total_pages} page(s) have no recognized main navigation landmark — inconsistent navigation structure across the site."
));
}
if pages_with_skip_link > 0 && pages_with_skip_link < total_pages {
let missing = total_pages - pages_with_skip_link;
findings.push(format!(
"Skip link present on {pages_with_skip_link} of {total_pages} page(s); missing on {missing}. Skip links should appear on every page."
));
}
NavigationConsistency {
pages_with_main_nav,
pages_with_skip_link,
total_pages,
findings,
}
}
fn analyze_headings(reports: &[AuditReport]) -> HeadingConsistency {
let total_pages = reports.len();
let mut single = 0;
let mut none = 0;
let mut multi = 0;
let mut findings = Vec::new();
for r in reports {
let h1_count = r.seo.as_ref().map(|s| s.headings.h1_count).unwrap_or(0);
match h1_count {
0 => none += 1,
1 => single += 1,
_ => multi += 1,
}
}
if none > 0 {
findings.push(format!(
"{none} of {total_pages} page(s) have no H1 heading. Every page should start with a single H1."
));
}
if multi > 0 {
findings.push(format!(
"{multi} of {total_pages} page(s) have multiple H1 headings. Use exactly one H1 per page."
));
}
HeadingConsistency {
pages_with_single_h1: single,
pages_with_no_h1: none,
pages_with_multiple_h1: multi,
total_pages,
findings,
}
}
fn analyze_canonical(reports: &[AuditReport]) -> CanonicalConsistency {
let total_pages = reports.len();
let mut www = 0;
let mut non_www = 0;
let mut missing = 0;
let mut findings = Vec::new();
for r in reports {
let canonical = r
.seo
.as_ref()
.and_then(|s| s.technical.canonical_url.as_deref());
match canonical {
None => missing += 1,
Some(url) => match canonical_host(url) {
Some(host) if host.starts_with("www.") => www += 1,
Some(_) => non_www += 1,
None => missing += 1,
},
}
}
if www > 0 && non_www > 0 {
findings.push(format!(
"Mixed canonical strategy: {www} page(s) canonicalize to www, {non_www} to non-www. Pick one variant and use it everywhere."
));
}
if missing > 0 {
findings.push(format!(
"{missing} of {total_pages} page(s) have no canonical URL. Set <link rel=\"canonical\"> on every page."
));
}
CanonicalConsistency {
www_count: www,
non_www_count: non_www,
missing_count: missing,
total_pages,
findings,
}
}
fn canonical_host(url: &str) -> Option<String> {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_lowercase()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::report::AuditReport;
use crate::cli::WcagLevel;
use crate::patterns::{PatternAnalysis, PatternConfidence, RecognizedPattern};
use crate::seo::technical::TechnicalSeo;
use crate::seo::{HeadingStructure, SeoAnalysis};
use crate::wcag::WcagResults;
fn make_report(
url: &str,
h1_count: usize,
canonical: Option<&str>,
recognized: Vec<&str>,
) -> AuditReport {
let mut report = AuditReport::new(url.into(), WcagLevel::AA, WcagResults::new(), 100);
report.seo = Some(SeoAnalysis {
headings: HeadingStructure {
h1_count,
..Default::default()
},
technical: TechnicalSeo {
canonical_url: canonical.map(String::from),
..Default::default()
},
..Default::default()
});
report.patterns = Some(PatternAnalysis {
recognized: recognized
.into_iter()
.map(|p| RecognizedPattern {
pattern: p.to_string(),
message: "test".to_string(),
confidence: PatternConfidence::Strong,
})
.collect(),
violations: vec![],
});
report
}
#[test]
fn test_consistent_pages_no_findings() {
let reports = vec![
make_report(
"https://a.com/",
1,
Some("https://a.com/"),
vec!["MainNavigation", "SkipLink"],
),
make_report(
"https://a.com/x",
1,
Some("https://a.com/x"),
vec!["MainNavigation", "SkipLink"],
),
];
let batch = BatchReport::from_reports(reports, vec![], 100);
let a = analyze(&batch).expect("batch ≥ 2");
assert!(a.navigation.findings.is_empty());
assert!(a.headings.findings.is_empty());
assert!(a.canonical.findings.is_empty());
}
#[test]
fn test_mixed_canonical_strategy_flagged() {
let reports = vec![
make_report("https://a.com/", 1, Some("https://www.a.com/"), vec![]),
make_report("https://a.com/x", 1, Some("https://a.com/x"), vec![]),
];
let batch = BatchReport::from_reports(reports, vec![], 100);
let a = analyze(&batch).expect("batch ≥ 2");
assert!(a
.canonical
.findings
.iter()
.any(|f| f.contains("Mixed canonical")));
}
#[test]
fn test_inconsistent_navigation_flagged() {
let reports = vec![
make_report(
"https://a.com/",
1,
None,
vec!["MainNavigation", "SkipLink"],
),
make_report("https://a.com/x", 1, None, vec!["MainNavigation"]),
];
let batch = BatchReport::from_reports(reports, vec![], 100);
let a = analyze(&batch).expect("batch ≥ 2");
assert!(a
.navigation
.findings
.iter()
.any(|f| f.contains("Skip link")));
}
#[test]
fn test_missing_h1_flagged() {
let reports = vec![
make_report("https://a.com/", 0, None, vec![]),
make_report("https://a.com/x", 1, None, vec![]),
];
let batch = BatchReport::from_reports(reports, vec![], 100);
let a = analyze(&batch).expect("batch ≥ 2");
assert!(a.headings.findings.iter().any(|f| f.contains("no H1")));
}
#[test]
fn test_single_page_returns_none() {
let reports = vec![make_report("https://a.com/", 1, None, vec![])];
let batch = BatchReport::from_reports(reports, vec![], 100);
assert!(analyze(&batch).is_none());
}
}