katana-render-runtime 0.3.2

Versioned render runtime for KatanA diagrams and math (Mermaid, Draw.io, ZenUML, PlantUML, MathJax).
Documentation
use super::api::RenderInput;
use crate::markdown::color_preset::DiagramColorPreset;
use serde_json::Value;
use std::hash::{Hash, Hasher};

pub(super) struct CacheFingerprintOps;

impl CacheFingerprintOps {
    pub(super) fn render(
        input: &RenderInput,
        runtime_version: &str,
        runtime_checksum: &str,
    ) -> String {
        let mut hasher = std::collections::hash_map::DefaultHasher::new();
        input.kind.hash(&mut hasher);
        input.source.hash(&mut hasher);
        Self::rendering_vendor_config(&input.config.vendor_config).hash(&mut hasher);
        input.context.theme_fingerprint.hash(&mut hasher);
        Self::hash_effective_theme(&mut hasher, input);
        runtime_version.hash(&mut hasher);
        runtime_checksum.hash(&mut hasher);
        format!("{:016x}", hasher.finish())
    }

    fn hash_effective_theme(hasher: &mut impl Hasher, input: &RenderInput) {
        let preset = DiagramColorPreset::for_render_input(input);
        preset.dark_mode.hash(hasher);
        preset.background.hash(hasher);
        preset.text.hash(hasher);
        preset.fill.hash(hasher);
        preset.stroke.hash(hasher);
        preset.arrow.hash(hasher);
        preset.drawio_label_color.hash(hasher);
        preset.mermaid_theme.hash(hasher);
        preset.plantuml_class_bg.hash(hasher);
        preset.plantuml_note_bg.hash(hasher);
        preset.plantuml_note_text.hash(hasher);
        preset.syntax_theme_dark.hash(hasher);
        preset.syntax_theme_light.hash(hasher);
        preset.preview_text.hash(hasher);
        preset.proportional_font_candidates.hash(hasher);
        preset.monospace_font_candidates.hash(hasher);
        preset.emoji_font_candidates.hash(hasher);
        preset.editor_font_size.to_bits().hash(hasher);
    }

    fn rendering_vendor_config(value: &Value) -> String {
        let Value::Object(map) = value else {
            return value.to_string();
        };
        let mut rendering_map = map.clone();
        rendering_map.remove("plantuml_cache_dir");
        rendering_map.remove("plantumlCacheDir");
        if rendering_map.is_empty() {
            return Value::Null.to_string();
        }
        Value::Object(rendering_map).to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::CacheFingerprintOps;
    use crate::markdown::color_preset::DiagramColorPreset;
    use crate::renderer::api::{
        DiagramKind, RenderConfig, RenderContext, RenderInput, RenderPolicy, RenderThemeMode,
        RenderThemeSnapshot,
    };
    use std::sync::{Mutex, MutexGuard};

    static MODE_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn render_fingerprint_changes_with_theme_context() {
        let default = CacheFingerprintOps::render(&input(None), "runtime", "checksum");
        let themed = CacheFingerprintOps::render(&input(Some("theme-a")), "runtime", "checksum");

        assert_ne!(default, themed);
    }

    #[test]
    fn render_fingerprint_changes_with_current_theme() {
        let _guard = mode_guard();
        let original = DiagramColorPreset::is_dark_mode();
        DiagramColorPreset::set_dark_mode(false);
        let light = CacheFingerprintOps::render(&input(None), "runtime", "checksum");
        DiagramColorPreset::set_dark_mode(true);
        let dark = CacheFingerprintOps::render(&input(None), "runtime", "checksum");
        DiagramColorPreset::set_dark_mode(original);

        assert_ne!(light, dark);
    }

    #[test]
    fn render_fingerprint_changes_with_render_input_theme() {
        let light = CacheFingerprintOps::render(
            &input_with_theme(Some(theme_snapshot(RenderThemeMode::Light))),
            "runtime",
            "checksum",
        );
        let dark = CacheFingerprintOps::render(
            &input_with_theme(Some(theme_snapshot(RenderThemeMode::Dark))),
            "runtime",
            "checksum",
        );

        assert_ne!(light, dark);
    }

    #[test]
    fn render_input_theme_ignores_global_state_for_fingerprint() {
        let _guard = mode_guard();
        DiagramColorPreset::set_dark_mode(false);
        let light_global = CacheFingerprintOps::render(
            &input_with_theme(Some(theme_snapshot(RenderThemeMode::Light))),
            "runtime",
            "checksum",
        );
        DiagramColorPreset::set_dark_mode(true);
        let dark_global = CacheFingerprintOps::render(
            &input_with_theme(Some(theme_snapshot(RenderThemeMode::Light))),
            "runtime",
            "checksum",
        );

        assert_eq!(light_global, dark_global);
    }

    #[test]
    fn mode_guard_accepts_poisoned_lock() {
        let poison = std::panic::catch_unwind(|| {
            let _guard = mode_guard();
            std::panic::resume_unwind(Box::new("poison mode guard"));
        });

        assert!(poison.is_err());
        let _guard = mode_guard();
    }

    #[test]
    fn render_fingerprint_changes_with_runtime_checksum() {
        let checksum_a = CacheFingerprintOps::render(&input(None), "runtime", "checksum-a");
        let checksum_b = CacheFingerprintOps::render(&input(None), "runtime", "checksum-b");

        assert_ne!(checksum_a, checksum_b);
    }

    #[test]
    fn render_fingerprint_changes_with_vendor_config() {
        let default =
            CacheFingerprintOps::render(&input_with_vendor_config(None), "runtime", "checksum");
        let themed = CacheFingerprintOps::render(
            &input_with_vendor_config(Some("cyborg")),
            "runtime",
            "checksum",
        );

        assert_ne!(default, themed);
    }

    #[test]
    fn render_fingerprint_ignores_plantuml_cache_dir() {
        let _guard = mode_guard();
        let default =
            CacheFingerprintOps::render(&input_with_vendor_config(None), "runtime", "checksum");
        let cache_dir = CacheFingerprintOps::render(
            &input_with_plantuml_cache_dir("/tmp/kdr-plantuml-cache"),
            "runtime",
            "checksum",
        );

        assert_eq!(default, cache_dir);
    }

    fn input(theme_fingerprint: Option<&str>) -> RenderInput {
        RenderInput {
            kind: DiagramKind::Mermaid,
            source: "graph TD; A-->B".to_string(),
            config: RenderConfig::default(),
            policy: RenderPolicy::default(),
            context: RenderContext {
                theme_fingerprint: theme_fingerprint.map(ToString::to_string),
                document_id: None,
                theme: None,
            },
        }
    }

    fn input_with_theme(theme: Option<RenderThemeSnapshot>) -> RenderInput {
        RenderInput {
            kind: DiagramKind::Mermaid,
            source: "graph TD; A-->B".to_string(),
            config: RenderConfig::default(),
            policy: RenderPolicy::default(),
            context: RenderContext {
                theme_fingerprint: None,
                document_id: None,
                theme,
            },
        }
    }

    fn input_with_vendor_config(theme: Option<&str>) -> RenderInput {
        let vendor_config = theme.map_or(serde_json::Value::Null, |theme| {
            serde_json::json!({
                "plantuml_theme": theme,
            })
        });
        RenderInput {
            kind: DiagramKind::PlantUml,
            source: "@startuml\nclass A\n@enduml".to_string(),
            config: RenderConfig { vendor_config },
            policy: RenderPolicy::default(),
            context: RenderContext {
                theme_fingerprint: None,
                document_id: None,
                theme: None,
            },
        }
    }

    fn input_with_plantuml_cache_dir(cache_dir: &str) -> RenderInput {
        RenderInput {
            kind: DiagramKind::PlantUml,
            source: "@startuml\nclass A\n@enduml".to_string(),
            config: RenderConfig {
                vendor_config: serde_json::json!({
                    "plantuml_cache_dir": cache_dir,
                }),
            },
            policy: RenderPolicy::default(),
            context: RenderContext {
                theme_fingerprint: None,
                document_id: None,
                theme: None,
            },
        }
    }

    fn theme_snapshot(mode: RenderThemeMode) -> RenderThemeSnapshot {
        let preset = match mode {
            RenderThemeMode::Light => DiagramColorPreset::light(),
            RenderThemeMode::Dark => DiagramColorPreset::dark(),
        };
        RenderThemeSnapshot {
            mode,
            background: preset.background.to_string(),
            text: preset.text.to_string(),
            fill: preset.fill.to_string(),
            stroke: preset.stroke.to_string(),
            arrow: preset.arrow.to_string(),
            drawio_label_color: preset.drawio_label_color.to_string(),
            mermaid_theme: preset.mermaid_theme.to_string(),
            plantuml_class_bg: preset.plantuml_class_bg.to_string(),
            plantuml_note_bg: preset.plantuml_note_bg.to_string(),
            plantuml_note_text: preset.plantuml_note_text.to_string(),
            syntax_theme_dark: preset.syntax_theme_dark.to_string(),
            syntax_theme_light: preset.syntax_theme_light.to_string(),
            preview_text: preset.preview_text.to_string(),
        }
    }

    fn mode_guard() -> MutexGuard<'static, ()> {
        match MODE_LOCK.lock() {
            Ok(guard) => guard,
            Err(error) => error.into_inner(),
        }
    }
}