auditmysite 0.9.27

Resource-efficient WCAG 2.1 Accessibility Checker in Rust
Documentation
//! WCAG Types - Common types for WCAG rule checking
//!
//! Defines violations, severities, WCAG levels, and rule metadata.

use serde::{Deserialize, Serialize};

use crate::cli::WcagLevel;

// Re-export Severity from taxonomy module (single source of truth)
pub use crate::taxonomy::Severity;

/// A WCAG violation found during audit
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Violation {
    /// The WCAG rule that was violated (e.g., "1.1.1")
    pub rule: String,
    /// Human-readable rule name
    pub rule_name: String,
    /// WCAG conformance level (A, AA, AAA)
    pub level: WcagLevel,
    /// Severity of the violation
    pub severity: Severity,
    /// Description of the violation
    pub message: String,
    /// The AXTree node ID that has the violation
    pub node_id: String,
    /// The element's role
    pub role: Option<String>,
    /// The element's accessible name (if any)
    pub name: Option<String>,
    /// HTML selector or description for locating the element
    pub selector: Option<String>,
    /// Suggested fix for the violation
    pub fix_suggestion: Option<String>,
    /// Link to WCAG documentation
    pub help_url: Option<String>,
    /// Stable axe-core-compatible rule ID (e.g., "landmark-one-main", "aria-required-attr")
    #[serde(default)]
    pub rule_id: Option<String>,
    /// Rule tags (e.g., ["wcag2a", "wcag412", "best-practice"])
    #[serde(default)]
    pub tags: Vec<String>,
    /// Impact level in axe-core terms: "critical", "serious", "moderate", "minor"
    #[serde(default)]
    pub impact: Option<String>,
    /// Raw outer HTML of the problematic element (fetched from live DOM during enrichment)
    #[serde(default)]
    pub html_snippet: Option<String>,
    /// Concrete code fix — the corrected HTML showing how the element should look
    #[serde(default)]
    pub suggested_code: Option<String>,
}

impl Violation {
    /// Create a new violation
    pub fn new(
        rule: impl Into<String>,
        rule_name: impl Into<String>,
        level: WcagLevel,
        severity: Severity,
        message: impl Into<String>,
        node_id: impl Into<String>,
    ) -> Self {
        let sev = severity;
        let impact = Some(Self::severity_to_impact(sev).to_string());
        Self {
            rule: rule.into(),
            rule_name: rule_name.into(),
            level,
            severity,
            message: message.into(),
            node_id: node_id.into(),
            role: None,
            name: None,
            selector: None,
            fix_suggestion: None,
            help_url: None,
            rule_id: None,
            tags: Vec::new(),
            impact,
            html_snippet: None,
            suggested_code: None,
        }
    }

    /// Convert severity to axe-core impact string
    fn severity_to_impact(severity: Severity) -> &'static str {
        match severity {
            Severity::Critical => "critical",
            Severity::High => "serious",
            Severity::Medium => "moderate",
            Severity::Low => "minor",
        }
    }

    /// Derive impact string from the violation's current severity
    pub fn impact_str(&self) -> &str {
        Self::severity_to_impact(self.severity)
    }

    /// Add element role
    pub fn with_role(mut self, role: Option<String>) -> Self {
        self.role = role;
        self
    }

    /// Add element name
    pub fn with_name(mut self, name: Option<String>) -> Self {
        self.name = name;
        self
    }

    /// Add selector
    pub fn with_selector(mut self, selector: impl Into<String>) -> Self {
        self.selector = Some(selector.into());
        self
    }

    /// Add fix suggestion
    pub fn with_fix(mut self, fix: impl Into<String>) -> Self {
        self.fix_suggestion = Some(fix.into());
        self
    }

    /// Add help URL
    pub fn with_help_url(mut self, url: impl Into<String>) -> Self {
        self.help_url = Some(url.into());
        self
    }

    /// Set the stable axe-core-compatible rule ID
    pub fn with_rule_id(mut self, id: impl Into<String>) -> Self {
        self.rule_id = Some(id.into());
        self
    }

    /// Set rule tags
    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
        self.tags = tags;
        self
    }

    /// Set raw HTML snippet of the affected element
    pub fn with_html_snippet(mut self, snippet: impl Into<String>) -> Self {
        self.html_snippet = Some(snippet.into());
        self
    }

    /// Set the concrete code fix
    pub fn with_suggested_code(mut self, code: impl Into<String>) -> Self {
        self.suggested_code = Some(code.into());
        self
    }
}

// Severity enum is now defined in crate::taxonomy::severity
// and re-exported above. Old variants mapping:
// Minor → Low, Moderate → Medium, Serious → High, Critical → Critical

/// Metadata for a WCAG rule
#[derive(Debug, Clone)]
pub struct RuleMetadata {
    /// WCAG success criterion (e.g., "1.1.1")
    pub id: &'static str,
    /// Human-readable name
    pub name: &'static str,
    /// WCAG conformance level
    pub level: WcagLevel,
    /// Default severity for violations
    pub severity: Severity,
    /// Brief description
    pub description: &'static str,
    /// URL to WCAG documentation
    pub help_url: &'static str,
    /// Stable axe-core-compatible ID
    pub axe_id: &'static str,
    /// Tags for this rule
    pub tags: &'static [&'static str],
}

/// Result of running all WCAG checks
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WcagResults {
    /// All violations found
    pub violations: Vec<Violation>,
    /// Number of rules that passed
    pub passes: usize,
    /// Number of elements that couldn't be checked
    pub incomplete: usize,
    /// Total nodes checked
    pub nodes_checked: usize,
}

impl WcagResults {
    /// Create new empty results
    pub fn new() -> Self {
        Self {
            violations: Vec::new(),
            passes: 0,
            incomplete: 0,
            nodes_checked: 0,
        }
    }

    /// Add a violation
    pub fn add_violation(&mut self, violation: Violation) {
        self.violations.push(violation);
    }

    /// Count violations by severity
    pub fn count_by_severity(&self, severity: Severity) -> usize {
        self.violations
            .iter()
            .filter(|v| v.severity == severity)
            .count()
    }

    /// Count violations by level
    pub fn count_by_level(&self, level: WcagLevel) -> usize {
        self.violations.iter().filter(|v| v.level == level).count()
    }

    /// Get all critical violations
    pub fn critical_violations(&self) -> Vec<&Violation> {
        self.violations
            .iter()
            .filter(|v| v.severity == Severity::Critical)
            .collect()
    }

    /// Check if the page passes at a given WCAG level
    pub fn passes_level(&self, level: WcagLevel) -> bool {
        !self.violations.iter().any(|v| {
            match level {
                WcagLevel::A => v.level == WcagLevel::A,
                WcagLevel::AA => v.level == WcagLevel::A || v.level == WcagLevel::AA,
                WcagLevel::AAA => true, // AAA includes all levels
            }
        })
    }

    /// Merge results from another check
    pub fn merge(&mut self, other: WcagResults) {
        self.violations.extend(other.violations);
        self.passes += other.passes;
        self.incomplete += other.incomplete;
        self.nodes_checked += other.nodes_checked;
    }
}

impl Default for WcagResults {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn test_violation_builder() {
        let violation = Violation::new(
            "1.1.1",
            "Non-text Content",
            WcagLevel::A,
            Severity::High,
            "Image missing alt text",
            "node-123",
        )
        .with_role(Some("image".to_string()))
        .with_fix("Add alt attribute to the image");

        assert_eq!(violation.rule, "1.1.1");
        assert_eq!(violation.role, Some("image".to_string()));
        assert!(violation.fix_suggestion.is_some());
    }

    #[test]
    fn test_severity_ordering() {
        // More severe = greater value
        assert!(Severity::Critical > Severity::High);
        assert!(Severity::High > Severity::Medium);
        assert!(Severity::Medium > Severity::Low);
    }
}