renderreport 0.7.2

Data-driven report generation with Typst as embedded render engine — no CLI dependency
Documentation
//! Component registry for managing available components

use std::collections::HashMap;
use std::sync::Arc;

type ValidatorFn = Arc<dyn Fn(&serde_json::Value) -> bool + Send + Sync>;

/// Unique identifier for a component type
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ComponentId(pub String);

impl ComponentId {
    pub fn new(id: impl Into<String>) -> Self {
        Self(id.into())
    }
}

impl std::fmt::Display for ComponentId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl From<&str> for ComponentId {
    fn from(s: &str) -> Self {
        Self(s.to_string())
    }
}

/// Registry of available component types
#[derive(Default)]
pub struct ComponentRegistry {
    /// Component templates (Typst code)
    templates: HashMap<ComponentId, String>,
    /// Insertion order for deterministic output
    insertion_order: Vec<ComponentId>,
    /// Component factories for validation
    validators: HashMap<ComponentId, ValidatorFn>,
}

impl ComponentRegistry {
    /// Create a new empty registry
    pub fn new() -> Self {
        Self::default()
    }

    /// Create a registry with standard components
    pub fn with_standard_components() -> Self {
        let mut registry = Self::new();
        registry.register_standard_components();
        registry
    }

    /// Register standard built-in components from the [`crate::ComponentCatalog`].
    ///
    /// Dispatcher templates (`grid-component`, `flow-group`) are registered
    /// last because their Typst bodies reference all other component functions.
    pub fn register_standard_components(&mut self) {
        use crate::components::catalog::ComponentCatalog;

        // The catalog yields descriptors in `inventory` link order, which differs
        // across platforms (macOS vs Linux). Typst captures `#let` closures at
        // definition time, so a component whose template calls another component's
        // function (e.g. `card-dashboard` calls `metric-card`, the dispatchers call
        // everything) must be registered *after* its dependency. We therefore
        // topologically sort the templates by their cross-references, breaking ties
        // by id, which is both dependency-safe and deterministic across platforms.
        let descs: Vec<&'static crate::components::catalog::ComponentDescriptor> =
            ComponentCatalog::all().collect();
        let ids: Vec<&'static str> = descs.iter().map(|d| d.id).collect();
        let templates: HashMap<&'static str, String> = descs
            .iter()
            .map(|d| (d.id, standard_template(d, &[])))
            .collect();

        // Build edges: dep -> set of components that reference it.
        let references = |template: &str, candidate: &str| -> bool {
            // Match `candidate(` not preceded by an identifier char or hyphen,
            // so `metric-card` does not match inside `big-metric-card`.
            let needle = format!("{candidate}(");
            template.match_indices(&needle).any(|(abs, _)| {
                abs == 0
                    || !matches!(
                        template.as_bytes().get(abs - 1),
                        Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_')
                    )
            })
        };

        let mut indegree: HashMap<&'static str, usize> = ids.iter().map(|id| (*id, 0)).collect();
        let mut dependents: HashMap<&'static str, Vec<&'static str>> = HashMap::new();
        for d in &descs {
            for dep in &ids {
                if *dep != d.id && references(&templates[d.id], dep) {
                    dependents.entry(dep).or_default().push(d.id);
                    if let Some(deg) = indegree.get_mut(d.id) {
                        *deg += 1;
                    }
                }
            }
        }

        // Kahn's algorithm with an id-sorted ready set for deterministic output.
        let desc_by_id: HashMap<
            &'static str,
            &'static crate::components::catalog::ComponentDescriptor,
        > = descs.iter().map(|d| (d.id, *d)).collect();
        let mut ready: Vec<&'static str> = indegree
            .iter()
            .filter(|(_, &deg)| deg == 0)
            .map(|(id, _)| *id)
            .collect();
        ready.sort_unstable_by(|a, b| b.cmp(a));
        let mut emitted = 0usize;
        while let Some(id) = ready.pop() {
            // `ready` is sorted ascending; pop the lexicographically smallest by
            // keeping it sorted descending instead.
            if let Some(desc) = desc_by_id.get(id) {
                self.register(ComponentId::new(desc.id), templates[desc.id].clone());
                emitted += 1;
            }
            if let Some(children) = dependents.get(id) {
                let mut newly_ready = Vec::new();
                for child in children {
                    if let Some(deg) = indegree.get_mut(child) {
                        *deg -= 1;
                        if *deg == 0 {
                            newly_ready.push(*child);
                        }
                    }
                }
                for c in newly_ready {
                    ready.push(c);
                }
                ready.sort_unstable_by(|a, b| b.cmp(a));
            }
        }

        // Cycle fallback: if cross-references form a cycle (should not happen),
        // emit any stragglers in id order so nothing is silently dropped.
        if emitted < descs.len() {
            let mut remaining: Vec<&'static str> = descs
                .iter()
                .map(|d| d.id)
                .filter(|id| !self.templates.contains_key(&ComponentId::new(*id)))
                .collect();
            remaining.sort_unstable();
            for id in remaining {
                if let Some(desc) = desc_by_id.get(id) {
                    self.register(ComponentId::new(desc.id), templates[desc.id].clone());
                }
            }
        }
    }

    /// Register a component template
    pub fn register(&mut self, id: ComponentId, template: String) {
        if !self.templates.contains_key(&id) {
            self.insertion_order.push(id.clone());
        }
        self.templates.insert(id, template);
    }

    /// Register a component with validator
    pub fn register_with_validator(
        &mut self,
        id: ComponentId,
        template: String,
        validator: impl Fn(&serde_json::Value) -> bool + Send + Sync + 'static,
    ) {
        self.templates.insert(id.clone(), template);
        self.validators.insert(id, Arc::new(validator));
    }

    /// Get a component template
    pub fn get_template(&self, id: &ComponentId) -> Option<&String> {
        self.templates.get(id)
    }

    /// Check if a component is registered
    pub fn has_component(&self, id: &ComponentId) -> bool {
        self.templates.contains_key(id)
    }

    /// Validate component data
    pub fn validate(&self, id: &ComponentId, data: &serde_json::Value) -> bool {
        self.validators.get(id).map(|v| v(data)).unwrap_or(true)
    }

    /// List all registered component IDs in insertion order
    pub fn list_components(&self) -> Vec<&ComponentId> {
        self.insertion_order.iter().collect()
    }
}

/// The template a built-in component is registered with: its catalog template,
/// prefixed for the dispatchers (`grid-component`, `flow-group`) with a
/// dispatch function over every catalog component plus `extra_ids`
/// (components defined outside the catalog, e.g. by a pack).
fn standard_template(
    desc: &crate::components::catalog::ComponentDescriptor,
    extra_ids: &[&str],
) -> String {
    match desc.id {
        "grid-component" => format!(
            "// Grid Component\n// Dispatch generated from the component catalog.\n\n{}\n\n{}",
            dispatch_function("_grid-dispatch", GRID_FALLBACK, extra_ids),
            desc.template.trim_start()
        ),
        "flow-group" => format!(
            "// Flow Group Component\n// Dispatch generated from the component catalog.\n\n{}\n\n{}",
            dispatch_function("_flow-dispatch", FLOW_FALLBACK, extra_ids),
            desc.template.trim_start()
        ),
        _ => desc.template.to_string(),
    }
}

/// Whether `id` is a dispatcher, whose template calls every component.
pub(crate) fn is_dispatcher(id: &str) -> bool {
    matches!(id, "grid-component" | "flow-group")
}

/// The template of dispatcher `id` with `extra_ids` added to its dispatch.
pub(crate) fn dispatcher_template(id: &str, extra_ids: &[&str]) -> Option<String> {
    let desc = crate::components::catalog::ComponentCatalog::get(id)?;
    is_dispatcher(id).then(|| standard_template(desc, extra_ids))
}

/// `#let {name}(c)` calling the component function for `c.type`, sorted by id
/// for deterministic output; `fallback` continues the `if` chain for items
/// that are not `{type, data}` components.
fn dispatch_function(name: &str, fallback: &str, extra_ids: &[&str]) -> String {
    use crate::components::catalog::ComponentCatalog;

    let mut entries: Vec<(&str, &str)> = ComponentCatalog::all()
        .map(|d| (d.id, d.fn_name))
        .chain(extra_ids.iter().map(|id| (*id, *id)))
        .collect();
    entries.sort_unstable();

    let mut s = format!(
        "#let {name}(c) = {{\n  if type(c) == dictionary and \"type\" in c and \"data\" in c {{\n    let comp-type = c.at(\"type\")\n    let comp-data = c.at(\"data\")\n"
    );
    for (i, (id, fn_name)) in entries.iter().enumerate() {
        let kw = if i == 0 { "if" } else { "else if" };
        s.push_str(&format!(
            "    {kw} comp-type == \"{id}\" {{ {fn_name}(comp-data) }}\n"
        ));
    }
    s.push_str(fallback);
    s
}

const FLOW_FALLBACK: &str = "    else {\n      text(size: 9pt, fill: gray, \"[\" + comp-type + \"]\")\n    }\n  } else if type(c) == str {\n    text(size: 10pt, c)\n  } else {\n    [#c]\n  }\n}";

const GRID_FALLBACK: &str = "    else {\n      // Fallback for unknown types\n      text(size: 9pt, fill: gray, \"[\" + comp-type + \"]\")\n    }\n  } else if type(c) == dictionary {\n    // Raw data without type wrapper — generic display\n    let inner = c\n    if inner.at(\"title\", default: none) != none {\n      text(weight: \"bold\", size: 10pt, inner.title)\n      if inner.at(\"score\", default: none) != none {\n        v(4pt)\n        text(size: 20pt, weight: \"bold\", str(inner.score))\n        if inner.at(\"max_score\", default: none) != none {\n          text(size: 10pt, fill: gray, \" / \" + str(inner.max_score))\n        }\n      }\n      if inner.at(\"description\", default: none) != none {\n        v(4pt)\n        text(size: 9pt, fill: gray, inner.description)\n      }\n    } else {\n      for (key, val) in inner {\n        if type(val) == str or type(val) == int or type(val) == float {\n          text(size: 9pt)[#text(weight: \"bold\")[#key:] #str(val)]\n          linebreak()\n        }\n      }\n    }\n  } else if type(c) == str {\n    text(size: 10pt, c)\n  } else {\n    [#c]\n  }\n}";

impl std::fmt::Debug for ComponentRegistry {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ComponentRegistry")
            .field("templates", &self.templates.keys().collect::<Vec<_>>())
            .field("validators_count", &self.validators.len())
            .finish()
    }
}