use serde::{Deserialize, Serialize};
use crate::wcag::types::Violation;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WaivedViolation {
pub rule_id: String,
pub rule: String,
pub node_id: Option<String>,
pub reason: Option<String>,
pub waived_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Baseline {
pub url: String,
pub created_at: String,
pub violations: Vec<BaselineViolation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaselineViolation {
pub rule: String,
pub rule_id: Option<String>,
pub severity: String,
pub message: String,
pub node_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaselineDiff {
pub new_violations: Vec<Violation>,
pub resolved_violations: Vec<BaselineViolation>,
pub unchanged_count: usize,
}
impl Baseline {
pub fn from_violations(url: &str, violations: &[Violation]) -> Self {
let created_at = chrono_or_fallback();
Self {
url: url.to_string(),
created_at,
violations: violations
.iter()
.map(BaselineViolation::from_violation)
.collect(),
}
}
pub fn diff(&self, current: &[Violation]) -> BaselineDiff {
let mut new_violations = Vec::new();
let mut resolved_violations = Vec::new();
let mut unchanged_count = 0usize;
for violation in current {
let in_baseline = self.violations.iter().any(|bv| {
bv.rule == violation.rule
&& (bv.node_id == violation.node_id
|| bv.node_id.is_empty() && violation.node_id.is_empty())
});
if in_baseline {
unchanged_count += 1;
} else {
new_violations.push(violation.clone());
}
}
for bv in &self.violations {
let still_present = current.iter().any(|v| {
v.rule == bv.rule
&& (v.node_id == bv.node_id || v.node_id.is_empty() && bv.node_id.is_empty())
});
if !still_present {
resolved_violations.push(bv.clone());
}
}
BaselineDiff {
new_violations,
resolved_violations,
unchanged_count,
}
}
pub fn load(path: &std::path::Path) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn save(&self, path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
std::fs::write(path, json)?;
Ok(())
}
}
impl BaselineViolation {
fn from_violation(v: &Violation) -> Self {
Self {
rule: v.rule.clone(),
rule_id: v.rule_id.clone(),
severity: format!("{:?}", v.severity),
message: v.message.clone(),
node_id: v.node_id.clone(),
}
}
}
fn chrono_or_fallback() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(d) => {
let secs = d.as_secs();
let days = secs / 86400;
let years_since_epoch = days / 365; let year = 1970 + years_since_epoch;
let day_of_year = days % 365;
let month = (day_of_year / 30) + 1;
let day = (day_of_year % 30) + 1;
format!("{:04}-{:02}-{:02}", year, month.min(12), day.min(31))
}
Err(_) => "unknown".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::WcagLevel;
use crate::taxonomy::Severity;
fn make_violation(rule: &str, node_id: &str) -> Violation {
Violation::new(
rule,
"Test Rule",
WcagLevel::A,
Severity::Medium,
"Test violation",
node_id,
)
}
#[test]
fn test_baseline_from_violations() {
let violations = vec![
make_violation("1.1.1", "node-1"),
make_violation("4.1.2", "node-2"),
];
let baseline = Baseline::from_violations("https://example.com", &violations);
assert_eq!(baseline.url, "https://example.com");
assert_eq!(baseline.violations.len(), 2);
}
#[test]
fn test_diff_new_violations() {
let baseline_violations = vec![make_violation("1.1.1", "node-1")];
let baseline = Baseline::from_violations("https://example.com", &baseline_violations);
let current = vec![
make_violation("1.1.1", "node-1"), make_violation("4.1.2", "node-2"), ];
let diff = baseline.diff(¤t);
assert_eq!(diff.unchanged_count, 1);
assert_eq!(diff.new_violations.len(), 1);
assert_eq!(diff.new_violations[0].rule, "4.1.2");
assert!(diff.resolved_violations.is_empty());
}
#[test]
fn test_diff_resolved_violations() {
let baseline_violations = vec![
make_violation("1.1.1", "node-1"),
make_violation("4.1.2", "node-2"),
];
let baseline = Baseline::from_violations("https://example.com", &baseline_violations);
let current = vec![make_violation("1.1.1", "node-1")]; let diff = baseline.diff(¤t);
assert_eq!(diff.unchanged_count, 1);
assert!(diff.new_violations.is_empty());
assert_eq!(diff.resolved_violations.len(), 1);
assert_eq!(diff.resolved_violations[0].rule, "4.1.2");
}
#[test]
fn test_diff_all_unchanged() {
let violations = vec![make_violation("1.1.1", "node-1")];
let baseline = Baseline::from_violations("https://example.com", &violations);
let diff = baseline.diff(&violations);
assert_eq!(diff.unchanged_count, 1);
assert!(diff.new_violations.is_empty());
assert!(diff.resolved_violations.is_empty());
}
#[test]
fn test_baseline_save_load() {
let violations = vec![make_violation("1.1.1", "node-1")];
let baseline = Baseline::from_violations("https://example.com", &violations);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("baseline.json");
baseline.save(&path).expect("save should succeed");
let loaded = Baseline::load(&path).expect("load should succeed");
assert_eq!(loaded.url, baseline.url);
assert_eq!(loaded.violations.len(), 1);
}
}