use serde::{Deserialize, Serialize};
use crate::cli::WcagLevel;
pub use crate::taxonomy::Severity;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FindingKind {
#[default]
Violation,
Warning,
Positive,
NotTestable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViolationEvidence {
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
}
impl ViolationEvidence {
pub fn ax_tree(value: impl Into<String>) -> Self {
Self {
source: "ax_tree".to_string(),
field: None,
value: Some(value.into()),
}
}
pub fn dom_attribute(field: impl Into<String>, value: Option<String>) -> Self {
Self {
source: "dom_attribute".to_string(),
field: Some(field.into()),
value,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Violation {
pub rule: String,
pub rule_name: String,
pub level: WcagLevel,
pub severity: Severity,
pub message: String,
pub node_id: String,
pub role: Option<String>,
pub name: Option<String>,
pub selector: Option<String>,
pub fix_suggestion: Option<String>,
pub help_url: Option<String>,
#[serde(default)]
pub rule_id: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub impact: Option<String>,
#[serde(default)]
pub html_snippet: Option<String>,
#[serde(default)]
pub suggested_code: Option<String>,
#[serde(default)]
pub kind: FindingKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub evidence: Vec<ViolationEvidence>,
}
impl 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,
kind: FindingKind::Violation,
evidence: Vec::new(),
}
}
fn severity_to_impact(severity: Severity) -> &'static str {
match severity {
Severity::Critical => "critical",
Severity::High => "serious",
Severity::Medium => "moderate",
Severity::Low => "minor",
}
}
pub fn impact_str(&self) -> &str {
Self::severity_to_impact(self.severity)
}
pub fn with_role(mut self, role: Option<String>) -> Self {
self.role = role;
self
}
pub fn with_name(mut self, name: Option<String>) -> Self {
self.name = name;
self
}
pub fn with_selector(mut self, selector: impl Into<String>) -> Self {
self.selector = Some(selector.into());
self
}
pub fn with_fix(mut self, fix: impl Into<String>) -> Self {
self.fix_suggestion = Some(fix.into());
self
}
pub fn with_help_url(mut self, url: impl Into<String>) -> Self {
self.help_url = Some(url.into());
self
}
pub fn with_rule_id(mut self, id: impl Into<String>) -> Self {
self.rule_id = Some(id.into());
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn with_html_snippet(mut self, snippet: impl Into<String>) -> Self {
self.html_snippet = Some(snippet.into());
self
}
pub fn with_suggested_code(mut self, code: impl Into<String>) -> Self {
self.suggested_code = Some(code.into());
self
}
pub fn with_kind(mut self, kind: FindingKind) -> Self {
self.kind = kind;
self
}
pub fn with_evidence_item(mut self, ev: ViolationEvidence) -> Self {
self.evidence.push(ev);
self
}
pub fn as_warning(self) -> Self {
self.with_kind(FindingKind::Warning)
}
pub fn as_positive(self) -> Self {
self.with_kind(FindingKind::Positive)
}
}
#[derive(Debug, Clone)]
pub struct RuleMetadata {
pub id: &'static str,
pub name: &'static str,
pub level: WcagLevel,
pub severity: Severity,
pub description: &'static str,
pub help_url: &'static str,
pub axe_id: &'static str,
pub tags: &'static [&'static str],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WcagResults {
pub violations: Vec<Violation>,
#[serde(default)]
pub warnings: Vec<Violation>,
#[serde(default)]
pub positives: Vec<Violation>,
#[serde(default)]
pub not_testables: Vec<Violation>,
pub passes: usize,
pub incomplete: usize,
pub nodes_checked: usize,
}
impl WcagResults {
pub fn new() -> Self {
Self {
violations: Vec::new(),
warnings: Vec::new(),
positives: Vec::new(),
not_testables: Vec::new(),
passes: 0,
incomplete: 0,
nodes_checked: 0,
}
}
pub fn add_violation(&mut self, finding: Violation) {
match finding.kind {
FindingKind::Warning => self.warnings.push(finding),
FindingKind::Positive => self.positives.push(finding),
FindingKind::NotTestable => self.not_testables.push(finding),
FindingKind::Violation => self.violations.push(finding),
}
}
pub fn extend_findings(&mut self, findings: Vec<Violation>) {
for f in findings {
self.add_violation(f);
}
}
pub fn add_warning(&mut self, mut finding: Violation) {
finding.kind = FindingKind::Warning;
self.warnings.push(finding);
}
pub fn add_positive(&mut self, mut finding: Violation) {
finding.kind = FindingKind::Positive;
self.positives.push(finding);
}
pub fn add_not_testable(&mut self, mut finding: Violation) {
finding.kind = FindingKind::NotTestable;
self.not_testables.push(finding);
}
pub fn count_by_severity(&self, severity: Severity) -> usize {
self.violations
.iter()
.filter(|v| v.severity == severity)
.count()
}
pub fn count_by_level(&self, level: WcagLevel) -> usize {
self.violations.iter().filter(|v| v.level == level).count()
}
pub fn critical_violations(&self) -> Vec<&Violation> {
self.violations
.iter()
.filter(|v| v.severity == Severity::Critical)
.collect()
}
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, }
})
}
pub fn merge(&mut self, other: WcagResults) {
self.violations.extend(other.violations);
self.warnings.extend(other.warnings);
self.positives.extend(other.positives);
self.not_testables.extend(other.not_testables);
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() {
assert!(Severity::Critical > Severity::High);
assert!(Severity::High > Severity::Medium);
assert!(Severity::Medium > Severity::Low);
}
#[test]
fn violation_evidence_ax_tree_constructor() {
let ev = ViolationEvidence::ax_tree("img#logo");
assert_eq!(ev.source, "ax_tree");
assert_eq!(ev.value.as_deref(), Some("img#logo"));
assert!(ev.field.is_none());
}
#[test]
fn violation_evidence_dom_attribute_constructor() {
let ev = ViolationEvidence::dom_attribute("alt", Some("".to_string()));
assert_eq!(ev.source, "dom_attribute");
assert_eq!(ev.field.as_deref(), Some("alt"));
assert_eq!(ev.value.as_deref(), Some(""));
}
#[test]
fn violation_evidence_dom_attribute_no_value() {
let ev = ViolationEvidence::dom_attribute("role", None);
assert_eq!(ev.source, "dom_attribute");
assert!(ev.value.is_none());
}
#[test]
fn violation_with_evidence_item_accumulates() {
let v = Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::High,
"msg",
"n1",
)
.with_evidence_item(ViolationEvidence::ax_tree("img.hero"))
.with_evidence_item(ViolationEvidence::dom_attribute("alt", None));
assert_eq!(v.evidence.len(), 2);
assert_eq!(v.evidence[0].source, "ax_tree");
assert_eq!(v.evidence[1].source, "dom_attribute");
}
#[test]
fn violation_new_starts_with_empty_evidence() {
let v = Violation::new(
"2.1.1",
"Keyboard",
WcagLevel::A,
Severity::High,
"msg",
"n1",
);
assert!(v.evidence.is_empty());
}
}