vastlint-core 0.4.21

VAST XML validator, SIMID linter, and VPAID detector — IAB VAST 2.0–4.3, 121 rules, zero runtime dependencies
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use vastlint_core::{all_rules, Severity};

fn rules_markdown_path() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../RULES.md")
}

fn vscode_readme_path() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../vscode/README.md")
}

fn read_text(path: &Path) -> String {
    fs::read_to_string(path)
        .unwrap_or_else(|error| panic!("could not read {}: {error}", path.display()))
}

fn severity_label(severity: Severity) -> &'static str {
    match severity {
        Severity::Error => "error",
        Severity::Warning => "warning",
        Severity::Info => "info",
    }
}

fn parse_rule_id(cell: &str) -> Option<&str> {
    let (_, rest) = cell.split_once('[')?;
    let (rule_id, _) = rest.split_once(']')?;
    Some(rule_id)
}

fn parse_rule_rows(path: &Path) -> BTreeMap<String, (String, String)> {
    let text = read_text(path);
    let mut rows = BTreeMap::new();

    for line in text.lines() {
        if !line.starts_with("| [") {
            continue;
        }

        let parts: Vec<_> = line.trim_matches('|').split('|').map(str::trim).collect();
        assert!(
            parts.len() >= 3,
            "malformed rule row in {}: {}",
            path.display(),
            line
        );

        let rule_id = parse_rule_id(parts[0]).unwrap_or_else(|| {
            panic!(
                "could not parse rule id from {} row: {}",
                path.display(),
                line
            )
        });

        rows.insert(
            rule_id.to_owned(),
            (parts[1].to_owned(), parts[2].to_owned()),
        );
    }

    rows
}

fn parse_vscode_readme_embedded_count() -> usize {
    let path = vscode_readme_path();
    let text = read_text(&path);

    text.lines()
        .find_map(|line| {
            let line = line.trim();
            let count = line.strip_prefix("<summary>All ")?;
            let count = count.strip_suffix(" rules</summary>")?;
            count.parse::<usize>().ok()
        })
        .unwrap_or_else(|| panic!("could not find embedded rule count in {}", path.display()))
}

fn catalog_severities() -> BTreeMap<String, String> {
    all_rules()
        .iter()
        .map(|rule| {
            (
                rule.id.to_owned(),
                severity_label(rule.default_severity).to_owned(),
            )
        })
        .collect()
}

fn assert_rule_rows_match_catalog(
    label: &str,
    declared_count: usize,
    rows: &BTreeMap<String, (String, String)>,
) {
    let catalog = catalog_severities();

    assert_eq!(
        declared_count,
        catalog.len(),
        "{label} declared count drifted from all_rules()"
    );
    assert_eq!(
        rows.len(),
        catalog.len(),
        "{label} row count drifted from all_rules()"
    );

    let missing: Vec<_> = catalog
        .keys()
        .filter(|rule_id| !rows.contains_key(*rule_id))
        .cloned()
        .collect();
    let extra: Vec<_> = rows
        .keys()
        .filter(|rule_id| !catalog.contains_key(*rule_id))
        .cloned()
        .collect();
    let severity_mismatches: Vec<_> = catalog
        .iter()
        .filter_map(|(rule_id, expected)| {
            rows.get(rule_id).and_then(|(actual, _)| {
                if actual == expected {
                    None
                } else {
                    Some((rule_id.clone(), expected.clone(), actual.clone()))
                }
            })
        })
        .collect();

    assert!(
        missing.is_empty() && extra.is_empty() && severity_mismatches.is_empty(),
        "{label} drifted from all_rules()\nmissing: {:?}\nextra: {:?}\nseverity mismatches: {:?}",
        missing,
        extra,
        severity_mismatches
    );
}

fn parse_rules_markdown() -> (usize, BTreeMap<String, (String, String)>) {
    let path = rules_markdown_path();
    let text = read_text(&path);

    let declared_count = text
        .lines()
        .find_map(|line| {
            if !line.contains(" rules across ") {
                return None;
            }

            line.split_whitespace().next()?.parse::<usize>().ok()
        })
        .unwrap_or_else(|| panic!("could not find declared rule count in {}", path.display()));

    (declared_count, parse_rule_rows(&path))
}

#[test]
fn rules_markdown_matches_catalog_ids_and_severities() {
    let (_, rows) = parse_rules_markdown();
    let (declared_count, _) = parse_rules_markdown();
    assert_rule_rows_match_catalog("RULES.md", declared_count, &rows);
}

#[test]
fn vscode_readme_rule_list_matches_catalog_ids_and_severities() {
    let path = vscode_readme_path();
    let rows = parse_rule_rows(&path);
    let declared_count = parse_vscode_readme_embedded_count();

    assert_rule_rows_match_catalog("vscode/README.md", declared_count, &rows);
}