use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConsentState {
OptIn,
OptOut,
Pending,
}
impl ConsentState {
pub fn is_allowed(&self) -> bool {
matches!(self, ConsentState::OptIn)
}
pub fn transition(&self, grant: bool) -> ConsentState {
if grant {
ConsentState::OptIn
} else {
ConsentState::OptOut
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConsentCategory {
Analytics,
Marketing,
Functional,
Custom(String),
}
impl ConsentCategory {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"analytics" => ConsentCategory::Analytics,
"marketing" => ConsentCategory::Marketing,
"functional" => ConsentCategory::Functional,
other => ConsentCategory::Custom(other.to_string()),
}
}
pub fn name(&self) -> &str {
match self {
ConsentCategory::Analytics => "analytics",
ConsentCategory::Marketing => "marketing",
ConsentCategory::Functional => "functional",
ConsentCategory::Custom(s) => s.as_str(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsentGate {
pub id: String,
pub category: ConsentCategory,
pub state: ConsentState,
pub description: String,
pub gdpr_required: bool,
pub ccpa_required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum WCAGLevel {
A,
AA,
AAA,
}
impl WCAGLevel {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"A" => Some(WCAGLevel::A),
"AA" => Some(WCAGLevel::AA),
"AAA" => Some(WCAGLevel::AAA),
_ => None,
}
}
pub fn min_contrast_ratio(&self) -> f64 {
match self {
WCAGLevel::A => 3.0,
WCAGLevel::AA => 4.5,
WCAGLevel::AAA => 7.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ViolationKind {
InsufficientContrast,
MissingAltText,
MissingAriaLabel,
MissingFormLabel,
HeadingHierarchy,
KeyboardInaccessible,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessibilityViolation {
pub kind: ViolationKind,
pub level: WCAGLevel,
pub file: String,
pub line: Option<usize>,
pub message: String,
pub criterion: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Locale {
pub tag: String,
}
impl Locale {
pub fn new(tag: &str) -> Self {
Locale {
tag: tag.to_string(),
}
}
pub fn language(&self) -> &str {
self.tag.split('-').next().unwrap_or(&self.tag)
}
pub fn region(&self) -> Option<&str> {
let parts: Vec<&str> = self.tag.split('-').collect();
if parts.len() > 1 {
Some(parts[1])
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct I18nString {
pub key: String,
pub default_value: String,
pub source_file: String,
pub line: usize,
pub context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocaleFile {
pub locale: Locale,
pub translations: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Severity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub severity: Severity,
pub category: String,
pub message: String,
pub file: Option<String>,
pub line: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceReport {
pub project_name: String,
pub findings: Vec<Finding>,
pub consent_gates_count: usize,
pub accessibility_violations_count: usize,
pub i18n_strings_count: usize,
pub wcag_level: WCAGLevel,
pub passes: bool,
}
impl ComplianceReport {
pub fn new(project_name: &str, wcag_level: WCAGLevel) -> Self {
ComplianceReport {
project_name: project_name.to_string(),
findings: Vec::new(),
consent_gates_count: 0,
accessibility_violations_count: 0,
i18n_strings_count: 0,
wcag_level,
passes: true,
}
}
pub fn add_finding(&mut self, finding: Finding) {
if finding.severity == Severity::Error {
self.passes = false;
}
self.findings.push(finding);
}
pub fn summary(&self) -> (usize, usize, usize) {
let errors = self.findings.iter().filter(|f| f.severity == Severity::Error).count();
let warnings = self.findings.iter().filter(|f| f.severity == Severity::Warning).count();
let infos = self.findings.iter().filter(|f| f.severity == Severity::Info).count();
(errors, warnings, infos)
}
pub fn to_text(&self) -> String {
let mut out = String::new();
out.push_str(&format!("=== Compliance Report: {} ===\n", self.project_name));
out.push_str(&format!("WCAG Level: {:?}\n", self.wcag_level));
out.push_str(&format!("Consent gates: {}\n", self.consent_gates_count));
out.push_str(&format!("Accessibility violations: {}\n", self.accessibility_violations_count));
out.push_str(&format!("I18n strings: {}\n", self.i18n_strings_count));
let (errors, warnings, infos) = self.summary();
out.push_str(&format!("Findings: {} errors, {} warnings, {} info\n", errors, warnings, infos));
out.push_str(&format!("Result: {}\n\n", if self.passes { "PASS" } else { "FAIL" }));
for finding in &self.findings {
let loc = match (&finding.file, finding.line) {
(Some(f), Some(l)) => format!("{}:{}", f, l),
(Some(f), None) => f.clone(),
_ => "unknown".to_string(),
};
out.push_str(&format!("[{:?}] [{}] {} ({})\n", finding.severity, finding.category, finding.message, loc));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_consent_state_allowed() {
assert!(ConsentState::OptIn.is_allowed());
assert!(!ConsentState::OptOut.is_allowed());
assert!(!ConsentState::Pending.is_allowed());
}
#[test]
fn test_consent_state_transitions() {
let pending = ConsentState::Pending;
assert_eq!(pending.transition(true), ConsentState::OptIn);
assert_eq!(pending.transition(false), ConsentState::OptOut);
let opted_in = ConsentState::OptIn;
assert_eq!(opted_in.transition(false), ConsentState::OptOut);
let opted_out = ConsentState::OptOut;
assert_eq!(opted_out.transition(true), ConsentState::OptIn);
}
#[test]
fn test_consent_category_parsing() {
assert_eq!(ConsentCategory::from_str("analytics"), ConsentCategory::Analytics);
assert_eq!(ConsentCategory::from_str("MARKETING"), ConsentCategory::Marketing);
assert_eq!(ConsentCategory::from_str("functional"), ConsentCategory::Functional);
assert_eq!(ConsentCategory::from_str("telemetry"), ConsentCategory::Custom("telemetry".to_string()));
}
#[test]
fn test_wcag_level_parsing() {
assert_eq!(WCAGLevel::from_str("A"), Some(WCAGLevel::A));
assert_eq!(WCAGLevel::from_str("aa"), Some(WCAGLevel::AA));
assert_eq!(WCAGLevel::from_str("AAA"), Some(WCAGLevel::AAA));
assert_eq!(WCAGLevel::from_str("B"), None);
}
#[test]
fn test_wcag_contrast_ratios() {
assert!((WCAGLevel::A.min_contrast_ratio() - 3.0).abs() < f64::EPSILON);
assert!((WCAGLevel::AA.min_contrast_ratio() - 4.5).abs() < f64::EPSILON);
assert!((WCAGLevel::AAA.min_contrast_ratio() - 7.0).abs() < f64::EPSILON);
}
#[test]
fn test_locale_parsing() {
let locale = Locale::new("en-GB");
assert_eq!(locale.language(), "en");
assert_eq!(locale.region(), Some("GB"));
let lang_only = Locale::new("fr");
assert_eq!(lang_only.language(), "fr");
assert_eq!(lang_only.region(), None);
}
#[test]
fn test_compliance_report_pass_fail() {
let mut report = ComplianceReport::new("test-project", WCAGLevel::AA);
assert!(report.passes);
report.add_finding(Finding {
severity: Severity::Warning,
category: "accessibility".to_string(),
message: "Consider adding aria-label".to_string(),
file: Some("index.html".to_string()),
line: Some(10),
});
assert!(report.passes);
report.add_finding(Finding {
severity: Severity::Error,
category: "accessibility".to_string(),
message: "Missing alt text".to_string(),
file: Some("index.html".to_string()),
line: Some(20),
});
assert!(!report.passes); }
}