katana-canvas-forge 0.1.7

Versioned diagram rendering and document export runtime for KatanA (Mermaid, Draw.io, HTML/PDF/PNG/JPEG).
Documentation
use super::api::RenderInput;
use crate::markdown::color_preset::DiagramColorPreset;
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);
        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);
    }
}

#[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);
    }

    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 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(),
        }
    }
}