sitevet-cli 0.2.1

Command-line website audits built on the sitevet library.
//! `sitevet rules`: every rule with its category, severity and whether it needs the browser.

use std::process::ExitCode;

use sitevet::{Severity, Stage};

use crate::args::{ListFormat, RulesArgs};

struct Row {
    id: &'static str,
    category: &'static str,
    title: &'static str,
    severity: &'static str,
    browser: bool,
}

fn rows(category: Option<&str>) -> Vec<Row> {
    sitevet::all_rules()
        .iter()
        .map(|r| r.meta())
        .filter(|m| category.is_none_or(|c| m.category == c))
        .map(|m| Row {
            id: m.id,
            category: m.category,
            title: m.title,
            severity: match m.severity {
                Severity::Error => "error",
                Severity::Warning => "warning",
                _ => "info",
            },
            browser: m.stage == Stage::Rendered,
        })
        .collect()
}

pub fn run(args: &RulesArgs) -> anyhow::Result<ExitCode> {
    let rows = rows(args.category.as_deref());
    if rows.is_empty() {
        anyhow::bail!("no rules in category `{}`", args.category.as_deref().unwrap_or(""));
    }
    print!("{}", render(&rows, args.format));
    Ok(ExitCode::SUCCESS)
}

fn render(rows: &[Row], format: ListFormat) -> String {
    match format {
        ListFormat::Json => {
            let items: Vec<serde_json::Value> = rows
                .iter()
                .map(|r| {
                    serde_json::json!({"id": r.id, "category": r.category, "title": r.title, "severity": r.severity, "browser": r.browser})
                })
                .collect();
            serde_json::to_string_pretty(&items).unwrap_or_default() + "\n"
        }
        ListFormat::Md => {
            let mut out = format!(
                "# Rules\n\nsitevet runs {} rules. Generated by `sitevet rules --format md`. Rules marked \"browser\" need `--browser` or a PageSpeed Insights key.\n\n| ID | Category | Title | Severity | Needs |\n| --- | --- | --- | --- | --- |\n",
                rows.len()
            );
            for r in rows {
                let needs = if r.browser { "browser" } else { "" };
                out.push_str(&format!(
                    "| `{}` | {} | {} | {} | {needs} |\n",
                    r.id,
                    r.category,
                    r.title.replace('|', "\\|"),
                    r.severity
                ));
            }
            out
        }
        ListFormat::Table => {
            let w = |f: fn(&Row) -> usize| rows.iter().map(f).max().unwrap_or(0);
            let (wi, wc, ws) = (w(|r| r.id.len()), w(|r| r.category.len()), w(|r| r.severity.len()));
            let mut out = format!("{:<wi$}  {:<wc$}  {:<ws$}  BROWSER\n", "ID", "CATEGORY", "SEVERITY");
            for r in rows {
                out.push_str(&format!(
                    "{:<wi$}  {:<wc$}  {:<ws$}  {}\n",
                    r.id,
                    r.category,
                    r.severity,
                    if r.browser { "yes" } else { "no" }
                ));
            }
            out.push_str(&format!("{} rules\n", rows.len()));
            out
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// FR-CLI-06: the list has the registry's count, and docs/rules.md is current.
    #[test]
    fn list_matches_registry_and_docs() {
        assert_eq!(rows(None).len(), sitevet::all_rules().len());
        let md = render(&rows(None), ListFormat::Md);
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/rules.md");
        if std::env::var_os("UPDATE_DOCS").is_some() {
            std::fs::write(&path, &md).unwrap();
        }
        let committed = std::fs::read_to_string(path).unwrap().replace("\r\n", "\n");
        assert_eq!(committed, md, "run UPDATE_DOCS=1 cargo test -p sitevet-cli");
        assert_eq!(rows(Some("security")).len(), 12);
    }
}