waddling-errors 0.7.3

Structured, secure-by-default diagnostic codes for distributed systems with no_std and role-based documentation
Documentation
//! HTML documentation generation

mod customization;
pub(crate) mod template;

pub use customization::HtmlCustomization;

#[cfg(feature = "std")]
use std::{
    collections::HashMap,
    fs,
    path::Path,
    string::{String, ToString},
    vec::Vec,
};

use super::types::{ComponentMeta, ErrorDoc, PrimaryMeta, SequenceMeta};
use crate::{Severity, traits::Role};

// === CSS Modules (bundled at compile time) ===
const CSS_VARIABLES: &str = include_str!("../../assets/css/variables.css");
const CSS_BASE: &str = include_str!("../../assets/css/base.css");
const CSS_LAYOUT: &str = include_str!("../../assets/css/layout.css");
const CSS_HEADER: &str = include_str!("../../assets/css/header.css");
const CSS_SEARCH: &str = include_str!("../../assets/css/search.css");
const CSS_CARDS: &str = include_str!("../../assets/css/cards.css");
const CSS_SIDEBAR: &str = include_str!("../../assets/css/sidebar.css");
const CSS_BUILDER: &str = include_str!("../../assets/css/builder.css");
const CSS_TOAST: &str = include_str!("../../assets/css/toast.css");
const CSS_RESPONSIVE: &str = include_str!("../../assets/css/responsive.css");

// === JS Modules (bundled at compile time) ===
const JS_STATE: &str = include_str!("../../assets/js/state.js");
const JS_UTILS: &str = include_str!("../../assets/js/utils.js");
const JS_TOAST: &str = include_str!("../../assets/js/toast.js");
const JS_THEME: &str = include_str!("../../assets/js/theme.js");
const JS_VISIBILITY: &str = include_str!("../../assets/js/visibility.js");
const JS_SEARCH: &str = include_str!("../../assets/js/search.js");
const JS_RENDER: &str = include_str!("../../assets/js/render.js");
const JS_SIDEBAR: &str = include_str!("../../assets/js/sidebar.js");
const JS_BUILDER: &str = include_str!("../../assets/js/builder.js");
const JS_INIT: &str = include_str!("../../assets/js/init.js");

/// Get bundled CSS (all modules concatenated)
fn get_default_css() -> String {
    [
        CSS_VARIABLES,
        CSS_BASE,
        CSS_LAYOUT,
        CSS_HEADER,
        CSS_SEARCH,
        CSS_CARDS,
        CSS_SIDEBAR,
        CSS_BUILDER,
        CSS_TOAST,
        CSS_RESPONSIVE,
    ]
    .join("\n\n")
}

/// Get bundled JS (all modules concatenated in dependency order)
fn get_default_js() -> String {
    [
        JS_STATE,
        JS_UTILS,
        JS_TOAST,
        JS_THEME,
        JS_VISIBILITY,
        JS_SEARCH,
        JS_RENDER,
        JS_SIDEBAR,
        JS_BUILDER,
        JS_INIT,
    ]
    .join("\n\n")
}

/// Parse a role string into a Role enum
fn parse_role(role_str: &str) -> Role {
    match role_str {
        "Public" => Role::Public,
        "Developer" => Role::Developer,
        "Internal" => Role::Internal,
        _ => Role::Public, // Default to most restrictive
    }
}

/// Helper function to check if content is visible at a given role level
pub(super) fn is_visible_at_role(content_role: Option<&str>, viewing_role: Role) -> bool {
    let content_role_enum = content_role.map(parse_role);
    viewing_role.can_view(content_role_enum)
}

#[cfg(feature = "serde")]
fn generate_data_json(
    project_name: &str,
    version: &str,
    errors: &[&ErrorDoc],
    components: &HashMap<String, ComponentMeta>,
    primaries: &HashMap<String, PrimaryMeta>,
    sequences: &HashMap<u32, SequenceMeta>,
    filter_role: Option<Role>,
) -> String {
    use serde_json::json;

    // Determine target role for filtering hints/tags/related
    let target_role = filter_role.unwrap_or(Role::Internal);

    // Build errors with role-filtered hints, tags, related_codes, see_also
    // This is needed because the gated fields are not serialized directly
    let filtered_errors: HashMap<String, serde_json::Value> = errors
        .iter()
        .map(|e| {
            #[cfg(feature = "runtime-hash")]
            let error_json = json!({
                "code": e.code,
                "severity": e.severity,
                "component": e.component,
                "primary": e.primary,
                "sequence": e.sequence,
                "description": e.description,
                "message": e.message,
                "fields": e.fields,
                "hash": e.hash,
                "namespace": e.namespace,
                "namespace_hash": e.namespace_hash,
                "hints": e.hints_for_role(target_role),
                "tags": e.tags_for_role(target_role),
                "introduced": e.introduced,
                "deprecated": e.deprecated,
                "docs_url": e.docs_url,
                "related_codes": e.related_codes_for_role(target_role),
                "see_also": e.see_also_for_role(target_role),
                "role": e.role,
                "code_snippets": e.code_snippets,
            });
            #[cfg(not(feature = "runtime-hash"))]
            let error_json = json!({
                "code": e.code,
                "severity": e.severity,
                "component": e.component,
                "primary": e.primary,
                "sequence": e.sequence,
                "description": e.description,
                "message": e.message,
                "fields": e.fields,
                "namespace": e.namespace,
                "hints": e.hints_for_role(target_role),
                "tags": e.tags_for_role(target_role),
                "introduced": e.introduced,
                "deprecated": e.deprecated,
                "docs_url": e.docs_url,
                "related_codes": e.related_codes_for_role(target_role),
                "see_also": e.see_also_for_role(target_role),
                "role": e.role,
                "code_snippets": e.code_snippets,
            });
            (e.code.clone(), error_json)
        })
        .collect();

    // Generate severity metadata
    let all_severities = [
        Severity::Error,
        Severity::Warning,
        Severity::Critical,
        Severity::Blocked,
        Severity::Help,
        Severity::Success,
        Severity::Completed,
        Severity::Info,
        Severity::Trace,
    ];

    // Build severities as an object keyed by char for O(1) lookup
    let sev_data: HashMap<String, serde_json::Value> = all_severities
        .iter()
        .map(|s| {
            let char_key = s.as_char().to_string();
            let emoji = match s {
                Severity::Error => "",
                Severity::Warning => "⚠️",
                Severity::Critical => "🔥",
                Severity::Blocked => "🚫",
                Severity::Help => "💡",
                Severity::Success => "",
                Severity::Completed => "✔️",
                Severity::Info => "ℹ️",
                Severity::Trace => "🔍",
            };
            let value = json!({
                "name": s.as_str(),
                "description": s.description(),
                "color": s.hex_color(),
                "bg_color": s.hex_bg_color(),
                "emoji": emoji,
            });
            (char_key, value)
        })
        .collect();

    // Build a set of visible error codes for O(1) lookup
    let visible_codes: std::collections::HashSet<String> =
        filtered_errors.keys().cloned().collect();

    // Filter component/primary/sequence metadata to only include visible errors
    let filtered_components: HashMap<String, ComponentMeta> = components
        .iter()
        .map(|(name, comp)| {
            let mut filtered_comp = comp.clone();
            filtered_comp.errors = comp
                .errors
                .iter()
                .filter(|code| visible_codes.contains(*code))
                .cloned()
                .collect();
            filtered_comp.error_count = filtered_comp.errors.len();
            (name.clone(), filtered_comp)
        })
        .collect();

    let filtered_primaries: HashMap<String, PrimaryMeta> = primaries
        .iter()
        .map(|(name, prim)| {
            let mut filtered_prim = prim.clone();
            filtered_prim.errors = prim
                .errors
                .iter()
                .filter(|code| visible_codes.contains(*code))
                .cloned()
                .collect();
            filtered_prim.error_count = filtered_prim.errors.len();
            (name.clone(), filtered_prim)
        })
        .collect();

    let filtered_sequences: HashMap<u32, SequenceMeta> = sequences
        .iter()
        .map(|(key, seq)| {
            let mut filtered_seq = seq.clone();
            filtered_seq.errors = seq
                .errors
                .iter()
                .filter(|code| visible_codes.contains(*code))
                .cloned()
                .collect();
            filtered_seq.error_count = filtered_seq.errors.len();
            (*key, filtered_seq)
        })
        .collect();

    // Create reverse hash lookup map (hash → code) for O(1) search by compact ID
    #[cfg(feature = "runtime-hash")]
    let hash_lookup: HashMap<String, String> = errors
        .iter()
        .filter(|e| is_visible_at_role(e.role.as_deref(), filter_role.unwrap_or(Role::Internal)))
        .filter_map(|e| e.hash.as_ref().map(|h| (h.clone(), e.code.clone())))
        .collect();

    #[cfg(feature = "runtime-hash")]
    let combined = json!({
        "_html_format_version": "2.0-hashmap",
        "project": project_name,
        "version": version,
        "role": filter_role.map(|r| std::format!("{:?}", r)),
        "errors": filtered_errors,
        "components": filtered_components,
        "primaries": filtered_primaries,
        "sequences": filtered_sequences,
        "#": hash_lookup,
        "severities": sev_data,
    });

    #[cfg(not(feature = "runtime-hash"))]
    let combined = json!({
        "_html_format_version": "2.0-hashmap",
        "project": project_name,
        "version": version,
        "role": filter_role.map(|r| std::format!("{:?}", r)),
        "errors": filtered_errors,
        "components": filtered_components,
        "primaries": filtered_primaries,
        "sequences": filtered_sequences,
        "severities": sev_data,
    });

    serde_json::to_string(&combined).unwrap_or_else(|_| "{}".to_string())
}

/// Configuration for HTML generation
#[cfg(feature = "serde")]
pub(super) struct HtmlConfig<'a> {
    pub html_path: &'a Path,
    pub project_name: &'a str,
    pub version: &'a str,
    pub errors: &'a [&'a ErrorDoc],
    pub components: &'a HashMap<String, ComponentMeta>,
    pub primaries: &'a HashMap<String, PrimaryMeta>,
    pub sequences: &'a HashMap<u32, SequenceMeta>,
    pub filter_role: Option<Role>,
    pub customization: Option<&'a HtmlCustomization>,
}

/// Generate HTML with inline JSON and separate JSON file
#[cfg(feature = "serde")]
pub(super) fn generate_html(config: HtmlConfig) -> std::io::Result<()> {
    let HtmlConfig {
        html_path,
        project_name,
        version,
        errors,
        components,
        primaries,
        sequences,
        filter_role,
        customization,
    } = config;
    let role_badge = match filter_role {
        Some(Role::Public) => r#"<div class="role-badge role-public">📘 PUBLIC DOCS</div>"#,
        Some(Role::Developer) => {
            r#"<div class="role-badge role-developer">👨‍💻 DEVELOPER DOCS</div>"#
        }
        Some(Role::Internal) => r#"<div class="role-badge role-internal">🔒 INTERNAL DOCS</div>"#,
        None => "",
    };

    let data_json = generate_data_json(
        project_name,
        version,
        errors,
        components,
        primaries,
        sequences,
        filter_role,
    );

    // Collect unique languages used in code snippets
    let mut languages = std::collections::HashSet::new();
    for error in errors {
        for snippet in &error.code_snippets {
            if let Some(lang) = &snippet.language {
                languages.insert(lang.as_str());
            }
        }
    }
    let languages: Vec<&str> = languages.into_iter().collect();

    let html = template::generate_template(
        role_badge,
        project_name,
        version,
        &data_json,
        &languages,
        customization,
    );

    // Also write separate JSON file for API/external use
    let json_path = Path::new(html_path).with_extension("json");

    if let Some(parent) = Path::new(html_path).parent() {
        fs::create_dir_all(parent)?;
    }

    fs::write(html_path, html)?;
    fs::write(json_path, data_json)?;

    Ok(())
}