rustio-core 1.8.2

RustIO runtime library: HTTP, router, Postgres ORM, admin, RBAC, search, migrations, AI planner.
Documentation
//! Phase 7a/2 — inline lucide stroke icons for the admin UI.
//!
//! Icons are baked at compile time via the `lucide!` macro below: each
//! constant is the inner `<path>`/`<circle>`/etc. of a 24×24 lucide
//! stroke icon. The minijinja function `icon(name, class="...")`,
//! registered in `templates.rs`, wraps these inner shapes in an
//! `<svg>` with `fill="none"`, `stroke="currentColor"`, and a default
//! `width`/`height` of 16, so colour is inherited from the rendering
//! context (sidebar link, button, etc.).
//!
//! Why inline + currentColor:
//! - No extra HTTP round trips (the framework's single-binary deploy
//!   invariant).
//! - Icons inherit text colour, so a hover that flips the link colour
//!   automatically flips the icon — no parallel CSS for stroke.
//! - Templates stay readable: `{{ icon("home") }}` not `<img src=…>`.
//!
//! Source: <https://lucide.dev>, MIT-licensed. The full library is
//! ~1k icons; we ship the ~16 we actually use to keep the binary
//! lean.

use std::collections::HashMap;
use once_cell::sync::Lazy;

/// `(name, inner-svg)` pairs, baked into the binary. Inner SVG is
/// the raw `<path>` / `<circle>` / `<line>` content of each lucide
/// icon's 24×24 viewBox — without the surrounding `<svg>` wrapper,
/// which `render_inline` adds at runtime so callers can override
/// `class` and `width`/`height` per usage site.
static ICONS: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
    let mut m = HashMap::new();

    // Sidebar nav
    m.insert("home",
        r#"<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>"#);
    m.insert("table",
        r#"<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/><path d="M9 3v18"/><path d="M15 3v18"/>"#);
    m.insert("users",
        r#"<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>"#);
    m.insert("users-2",
        r#"<path d="M14 19a6 6 0 0 0-12 0"/><circle cx="8" cy="9" r="4"/><path d="M22 19a6 6 0 0 0-6-6 4 4 0 1 0 0-8"/>"#);
    m.insert("database",
        r#"<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>"#);
    m.insert("clock",
        r#"<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>"#);
    m.insert("terminal",
        r#"<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>"#);

    // Action buttons
    m.insert("plus",
        r#"<path d="M5 12h14"/><path d="M12 5v14"/>"#);
    m.insert("pencil",
        r#"<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>"#);
    m.insert("trash",
        r#"<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>"#);
    m.insert("arrow-left",
        r#"<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>"#);
    m.insert("log-out",
        r#"<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>"#);
    m.insert("key",
        r#"<path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="m15.5 7.5 3 3L22 7l-3-3"/><path d="m13 10 1.5-1.5"/>"#);

    // Notices / hints
    m.insert("circle-alert",
        r#"<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>"#);
    m.insert("circle-x",
        r#"<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>"#);

    // Sidebar mobile toggle
    m.insert("menu",
        r#"<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>"#);

    m
});

/// Render an icon inline. Returns an empty string if `name` is
/// unknown (templates remain forgiving — a missing icon shouldn't
/// crash the page render).
///
/// `class` is inserted verbatim into the `<svg class="...">` so the
/// caller can reach for any utility (`w-4 h-4 text-rust`, etc.).
pub fn render_inline(name: &str, class: &str) -> String {
    let Some(inner) = ICONS.get(name) else {
        log::warn!("icon({name:?}) not found — rendering empty");
        return String::new();
    };
    format!(
        r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="{class}" aria-hidden="true">{inner}</svg>"#
    )
}


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

    #[test]
    fn known_icons_render() {
        let svg = render_inline("home", "w-4 h-4");
        assert!(svg.starts_with("<svg"));
        assert!(svg.contains(r#"class="w-4 h-4""#));
        assert!(svg.contains(r#"stroke="currentColor""#));
        assert!(svg.contains(r#"viewBox="0 0 24 24""#));
        assert!(svg.contains("</svg>"));
    }

    #[test]
    fn unknown_icon_returns_empty_not_panic() {
        // Templates may reference an icon that doesn't exist (typo,
        // future name). Don't fail the page render.
        let svg = render_inline("not-a-real-icon", "");
        assert_eq!(svg, "");
    }

    #[test]
    fn every_icon_we_ship_is_well_formed() {
        // Every entry must be inner-svg fragments that close their
        // own tags. The wrapper `<svg>` is added at render time, so
        // mismatched tags here would break the page.
        for (name, inner) in ICONS.iter() {
            let svg = render_inline(name, "");
            assert!(
                svg.contains(inner),
                "icon {name:?} inner SVG didn't make it into rendered output"
            );
            // Every shape we use is a self-closing element. If we ever
            // add icons with text or open paths, this check needs to
            // adapt.
            for tag in &["<path", "<circle", "<rect", "<line", "<polyline", "<ellipse"] {
                if inner.contains(tag) {
                    let opens = inner.matches(tag).count();
                    let closes = inner.matches("/>").count();
                    assert!(
                        closes >= opens,
                        "icon {name:?}: opens {opens} {tag}* but only {closes} self-closes"
                    );
                    break;
                }
            }
        }
    }
}