auditmysite 1.4.0

Resource-efficient WCAG 2.1 AA (+ select 2.2 criteria) Accessibility Checker in Rust
Documentation
//! WCAG criterion coverage manifest (issue #37).
//!
//! Explicit list of WCAG 2.1 / 2.2 criteria that this tool checks
//! automatically vs. those that fundamentally require behavioral testing.
//! Surfaced in the report so users understand the scope of automated audit.

use std::sync::OnceLock;

use crate::taxonomy::rules::RULES;

/// WCAG 2.1 AA totals — Level A: 30, Level AA: 20, sum 50.
/// WCAG 2.2 adds 6 more at AA (the 2.2.x criteria).
pub const WCAG_AA_TOTAL: usize = 50;

/// Success criteria WCAG 2.2 added that didn't exist in 2.1 — excluded from
/// `coverage_stats()`'s ratio since that's explicitly scoped to `WCAG_AA_TOTAL`'s
/// 2.1-only 50 criteria; counting a 2.2-only rule (e.g. 2.5.8) against that
/// denominator would overstate the 2.1 coverage ratio. Still included in
/// `automated_criteria()`'s full listing (e.g. the PDF's "automatically checked"
/// tag cloud), which isn't scoped to 2.1 alone.
const WCAG_22_ONLY_CRITERIA: &[&str] = &[
    "2.4.11", "2.4.12", "2.4.13", "2.5.7", "2.5.8", "3.2.6", "3.3.7", "3.3.8", "3.3.9",
];

/// Sort key for WCAG criterion ids ("2.5.10" must sort after "2.5.9", not
/// before it, so this can't be a plain string compare).
fn wcag_id_order(id: &str) -> Vec<u32> {
    id.split('.').filter_map(|p| p.parse().ok()).collect()
}

/// WCAG criteria with at least one automated rule in this tool, derived from
/// the rule catalog's own `external_ref`/`external_level` (#QA-038) instead
/// of a hand-maintained duplicate list — that list had drifted from the real
/// catalog (under-counting by ~20 criteria) and mislabeled several
/// implemented criteria (e.g. 2.5.1/2.5.2/2.5.4) as manual-review-only.
pub fn automated_criteria() -> &'static [(&'static str, &'static str)] {
    static CACHE: OnceLock<Vec<(&'static str, &'static str)>> = OnceLock::new();
    CACHE.get_or_init(|| {
        let mut out: Vec<(&'static str, &'static str)> = Vec::new();
        for rule in RULES {
            let (Some(ext_ref), Some(level)) = (rule.external_ref, rule.external_level) else {
                continue;
            };
            let Some(id) = ext_ref.strip_prefix("WCAG ") else {
                continue;
            };
            if !out.iter().any(|(existing, _)| *existing == id) {
                out.push((id, level));
            }
        }
        out.sort_by_key(|a| wcag_id_order(a.0));
        out
    })
}

/// Candidate WCAG criteria that fundamentally require behavioral / manual
/// review. Filtered against `automated_criteria()` at read time (see
/// `manual_review_criteria`) so a criterion that gains automated coverage —
/// or turns out to already have it, as 2.5.1/2.5.2/2.5.4 did — can't stay
/// stuck here through a forgotten manual edit.
const MANUAL_REVIEW_CRITERIA_RAW: &[(&str, &str, &str)] = &[
    ("1.2.1", "A", "Audio-only and Video-only (Prerecorded)"),
    ("1.2.2", "A", "Captions (Prerecorded)"),
    ("1.2.3", "A", "Audio Description or Media Alternative"),
    ("1.2.5", "AA", "Audio Description (Prerecorded)"),
    ("1.4.2", "A", "Audio Control"),
    ("1.4.5", "AA", "Images of Text"),
    ("2.1.4", "A", "Character Key Shortcuts"),
    ("2.2.2", "A", "Pause, Stop, Hide"),
    ("2.3.1", "A", "Three Flashes or Below Threshold"),
    ("2.5.1", "A", "Pointer Gestures"),
    ("2.5.2", "A", "Pointer Cancellation"),
    ("2.5.4", "A", "Motion Actuation"),
    ("3.2.3", "AA", "Consistent Navigation"),
    ("3.2.4", "AA", "Consistent Identification"),
    ("3.3.3", "AA", "Error Suggestion"),
    ("3.3.4", "AA", "Error Prevention (Legal, Financial, Data)"),
];

/// WCAG criteria that fundamentally require behavioral / manual review and
/// cannot be reliably verified by an automated tool.
pub fn manual_review_criteria() -> &'static [(&'static str, &'static str, &'static str)] {
    static CACHE: OnceLock<Vec<(&'static str, &'static str, &'static str)>> = OnceLock::new();
    CACHE.get_or_init(|| {
        let automated = automated_criteria();
        MANUAL_REVIEW_CRITERIA_RAW
            .iter()
            .filter(|(id, _, _)| !automated.iter().any(|(aid, _)| aid == id))
            .copied()
            .collect()
    })
}

/// Returns (automated_count, total_aa_criteria), scoped to WCAG 2.1's A/AA
/// criteria to match `WCAG_AA_TOTAL`.
pub fn coverage_stats() -> (usize, usize) {
    let aa_count = automated_criteria()
        .iter()
        .filter(|(id, l)| (*l == "A" || *l == "AA") && !WCAG_22_ONLY_CRITERIA.contains(id))
        .count();
    (aa_count, WCAG_AA_TOTAL)
}

/// True if `id` is a WCAG 2.2-only success criterion (didn't exist in WCAG
/// 2.1) — lets output layers flag an automated criterion or a finding as
/// falling outside the WCAG-2.1-scoped `coverage_stats()` ratio, instead of
/// presenting it as an undifferentiated "AA" criterion (#572).
pub fn is_wcag22_only(id: &str) -> bool {
    WCAG_22_ONLY_CRITERIA.contains(&id)
}

/// German title for a manual-review criterion, reused from the canonical
/// BIK/DIAS WCAG 2.1 translation table (`en301549::EN301549_WEB_CLAUSES`)
/// instead of maintaining a second, independently drifting translation
/// (#572). Falls back to `fallback_en` if a criterion id is ever missing
/// from that table — shouldn't happen, since every `MANUAL_REVIEW_CRITERIA_RAW`
/// id is a WCAG 2.1 A/AA criterion, which that table covers in full.
pub fn manual_review_criterion_name_de(id: &str, fallback_en: &'static str) -> &'static str {
    crate::wcag::en301549::EN301549_WEB_CLAUSES
        .iter()
        .find(|c| c.wcag == id)
        .map(|c| c.title_de)
        .unwrap_or(fallback_en)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn manual_review_criterion_name_de_has_a_real_translation_for_every_current_entry() {
        // #572 guard: the PDF's manual-review tag cloud used to render the
        // English criterion name unconditionally (no locale branching at
        // all). Every currently-listed manual-review criterion must resolve
        // to a German name distinct from its English fallback — this fails
        // loudly (instead of silently falling back to English) if a future
        // `MANUAL_REVIEW_CRITERIA_RAW` entry's id is missing from
        // `en301549::EN301549_WEB_CLAUSES`.
        for (id, _level, name_en) in manual_review_criteria() {
            let name_de = manual_review_criterion_name_de(id, name_en);
            assert_ne!(
                name_de, *name_en,
                "expected a German translation for WCAG {id}, got the English fallback"
            );
        }
    }

    #[test]
    fn manual_review_criterion_name_de_matches_known_translation() {
        assert_eq!(
            manual_review_criterion_name_de("1.2.1", "Audio-only and Video-only (Prerecorded)"),
            "Nur Audio oder nur Video (aufgezeichnet)"
        );
    }

    #[test]
    fn manual_review_criterion_name_de_falls_back_for_unknown_id() {
        assert_eq!(
            manual_review_criterion_name_de("9.9.9", "Unknown Criterion"),
            "Unknown Criterion"
        );
    }
}