pub mod module;
pub use module::HtmlConformModule;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::error::{AuditError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HtmlConformAnalysis {
pub score: u32,
pub checked: bool,
pub error_count: u32,
pub warning_count: u32,
pub info_count: u32,
pub findings: Vec<HtmlConformFinding>,
#[serde(skip)]
pub raw_html: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HtmlConformFinding {
pub rule_id: String,
pub severity: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub byte_offset: Option<usize>,
}
pub async fn analyze_html_conform(page: &Page) -> Result<HtmlConformAnalysis> {
let html = extract_document_html(page).await?;
let report = match html_conform::check(&html) {
Ok(report) => report,
Err(e) => {
warn!("HTML conformance check setup failed: {}", e);
return Ok(not_measured());
}
};
let mut error_count = 0u32;
let mut warning_count = 0u32;
let mut info_count = 0u32;
let findings: Vec<HtmlConformFinding> = report
.findings
.into_iter()
.map(|f| {
let severity = match f.severity {
html_conform::Severity::Error => {
error_count += 1;
"error"
}
html_conform::Severity::Warning => {
warning_count += 1;
"warning"
}
html_conform::Severity::Info => {
info_count += 1;
"info"
}
};
HtmlConformFinding {
rule_id: f.rule_id,
severity: severity.to_string(),
message: f.message,
location: f.location.map(|l| l.to_string()),
byte_offset: f.location.map(|l| l.byte_offset),
}
})
.collect();
let penalty = (error_count * 10 + warning_count * 4 + info_count).min(100);
let score = 100 - penalty;
Ok(HtmlConformAnalysis {
score,
checked: true,
error_count,
warning_count,
info_count,
findings,
raw_html: Some(html),
})
}
fn not_measured() -> HtmlConformAnalysis {
HtmlConformAnalysis {
score: 100,
checked: false,
error_count: 0,
warning_count: 0,
info_count: 0,
findings: Vec::new(),
raw_html: None,
}
}
async fn extract_document_html(page: &Page) -> Result<String> {
let js = r#"
(() => {
const d = document.doctype;
const doctype = d
? `<!DOCTYPE ${d.name}${d.publicId ? ` PUBLIC "${d.publicId}"` : ''}${d.systemId ? ` "${d.systemId}"` : ''}>`
: '<!DOCTYPE html>';
return doctype + '\n' + document.documentElement.outerHTML;
})()
"#;
let result = page.evaluate(js).await.map_err(|e| {
AuditError::CdpError(format!(
"HTML extraction for conformance check failed: {}",
e
))
})?;
result
.value()
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| {
AuditError::CdpError(
"HTML extraction for conformance check returned no value".to_string(),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn penalty_caps_at_100_and_floors_score_at_0() {
let error_count = 20u32;
let warning_count = 0u32;
let info_count = 0u32;
let penalty = (error_count * 10 + warning_count * 4 + info_count).min(100);
assert_eq!(penalty, 100);
assert_eq!(100u32.saturating_sub(penalty), 0);
}
#[test]
fn not_measured_has_full_score_and_is_excluded() {
let a = not_measured();
assert!(!a.checked);
assert_eq!(a.score, 100);
assert!(a.findings.is_empty());
}
}