katana-canvas-forge 0.1.7

Versioned diagram rendering and document export runtime for KatanA (Mermaid, Draw.io, HTML/PDF/PNG/JPEG).
Documentation
use std::path::PathBuf;

pub const MERMAID_JS_VERSION: &str = "3.3.1";
pub const MERMAID_JS_CHECKSUM: &str =
    "217b66ef4279c33c141b4afe22effad10a91c02558dc70917be2c0981e78ed87";
pub const MERMAID_DOWNLOAD_URL: &str =
    "https://cdn.jsdelivr.net/npm/mermaid@3.3.1/dist/mermaid.min.js";

pub const MERMAID_ZENUML_JS_VERSION: &str = "0.2.2";
pub const MERMAID_ZENUML_JS_CHECKSUM: &str =
    "39143c3cb4e7a1dc53938de0c85e5fd2aee1533ec4e07d7d95a6ef639956ff1f";
pub const MERMAID_ZENUML_DOWNLOAD_URL: &str =
    "https://cdn.jsdelivr.net/npm/@mermaid-js/mermaid-zenuml@0.2.2/dist/mermaid-zenuml.min.js";

pub const ZENUML_CORE_JS_VERSION: &str = "3.47.9";
pub const ZENUML_CORE_JS_CHECKSUM: &str =
    "ece11a311907401113f965e110c25c04c6a9b3dcbbb234bf2cd593a3f3ebe3df";
pub const ZENUML_CORE_DOWNLOAD_URL: &str =
    "https://cdn.jsdelivr.net/npm/@zenuml/core@3.47.9/dist/zenuml.js";

pub const DRAWIO_JS_VERSION: &str = "29.7.10";
pub const DRAWIO_JS_CHECKSUM: &str =
    "a8b7897de995a4e7dd3a541a5e7250d64a295440f728f0ddae72179cdf5a83d5";
pub const DRAWIO_DOWNLOAD_URL: &str = "https://github.com/jgraph/drawio/releases";

pub(crate) struct RuntimeAsset {
    kind: &'static str,
    version: &'static str,
    filename: &'static str,
    bytes: &'static [u8],
}

impl RuntimeAsset {
    pub(crate) fn mermaid() -> Self {
        Self {
            kind: "mermaid",
            version: MERMAID_JS_VERSION,
            filename: "mermaid.min.js",
            bytes: include_bytes!("../../vendor/mermaid/3.3.1/mermaid.min.js"),
        }
    }

    pub(crate) fn drawio() -> Self {
        Self {
            kind: "drawio",
            version: DRAWIO_JS_VERSION,
            filename: "drawio.min.js",
            bytes: include_bytes!("../../vendor/drawio/29.7.10/drawio.min.js"),
        }
    }

    pub(crate) fn zenuml_core() -> Self {
        Self {
            kind: "zenuml-core",
            version: ZENUML_CORE_JS_VERSION,
            filename: "zenuml.js",
            bytes: include_bytes!("../../vendor/zenuml-core/3.47.9/zenuml.js"),
        }
    }

    pub(crate) fn materialized_path(&self) -> PathBuf {
        std::env::temp_dir()
            .join("katana-canvas-forge")
            .join("vendor")
            .join(self.kind)
            .join(self.version)
            .join(self.filename)
    }

    pub(crate) fn materialize_at(&self, path: PathBuf) -> Result<PathBuf, String> {
        if self.exists_with_same_bytes(&path)? {
            return Ok(path);
        }
        let Some(parent) = path.parent() else {
            return Err(format!("{} runtime asset path has no parent", self.kind));
        };
        std::fs::create_dir_all(parent).map_err(runtime_asset_error)?;
        std::fs::write(&path, self.bytes).map_err(runtime_asset_error)?;
        Ok(path)
    }

    fn exists_with_same_bytes(&self, path: &std::path::Path) -> Result<bool, String> {
        match std::fs::read(path) {
            Ok(existing) => Ok(existing == self.bytes),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
            Err(error) => Err(runtime_asset_error(error)),
        }
    }
}

fn runtime_asset_error(error: std::io::Error) -> String {
    error.to_string()
}

#[cfg(test)]
mod tests {
    use super::{
        DRAWIO_JS_CHECKSUM, MERMAID_JS_CHECKSUM, MERMAID_ZENUML_JS_CHECKSUM, RuntimeAsset,
        ZENUML_CORE_JS_CHECKSUM,
    };

    #[test]
    fn materialized_paths_are_versioned() {
        let mermaid = RuntimeAsset::mermaid().materialized_path();
        let drawio = RuntimeAsset::drawio().materialized_path();
        let zenuml_core = RuntimeAsset::zenuml_core().materialized_path();

        assert!(mermaid.ends_with("vendor/mermaid/3.3.1/mermaid.min.js"));
        assert!(drawio.ends_with("vendor/drawio/29.7.10/drawio.min.js"));
        assert!(zenuml_core.ends_with("vendor/zenuml-core/3.47.9/zenuml.js"));
    }

    #[test]
    fn pinned_checksums_are_sha256_hex() {
        assert_eq!(MERMAID_JS_CHECKSUM.len(), 64);
        assert_eq!(MERMAID_ZENUML_JS_CHECKSUM.len(), 64);
        assert_eq!(DRAWIO_JS_CHECKSUM.len(), 64);
        assert_eq!(ZENUML_CORE_JS_CHECKSUM.len(), 64);
        assert!(MERMAID_JS_CHECKSUM.chars().all(|it| it.is_ascii_hexdigit()));
        assert!(
            MERMAID_ZENUML_JS_CHECKSUM
                .chars()
                .all(|it| it.is_ascii_hexdigit())
        );
        assert!(DRAWIO_JS_CHECKSUM.chars().all(|it| it.is_ascii_hexdigit()));
        assert!(
            ZENUML_CORE_JS_CHECKSUM
                .chars()
                .all(|it| it.is_ascii_hexdigit())
        );
    }

    #[test]
    fn materialize_writes_missing_asset_file() {
        let path = test_path("missing-mermaid.min.js");
        remove_parent(&path);

        let result = RuntimeAsset::mermaid().materialize_at(path.clone());

        assert!(matches!(result, Ok(written) if written == path));
        assert!(path.exists());
        remove_parent(&path);
    }

    #[test]
    fn materialize_reports_empty_path_and_read_errors() {
        let empty_path = RuntimeAsset::mermaid().materialize_at(std::path::PathBuf::new());
        assert!(matches!(empty_path, Err(error) if error.contains("parent")));

        let path = test_path("runtime-directory");
        let _ = std::fs::remove_dir_all(&path);
        let create_result = std::fs::create_dir_all(&path);
        assert!(create_result.is_ok());

        let read_error = RuntimeAsset::mermaid().materialize_at(path.clone());
        assert!(read_error.is_err());
        let _ = std::fs::remove_dir_all(&path);
        remove_parent(&path);
    }

    #[test]
    fn materialize_replaces_different_existing_asset_file() {
        let path = test_path("stale-mermaid.min.js");
        remove_parent(&path);
        let parent = path.parent();
        assert!(matches!(parent, Some(it) if std::fs::create_dir_all(it).is_ok()));
        let write_result = std::fs::write(&path, b"stale");
        assert!(write_result.is_ok());

        let result = RuntimeAsset::mermaid().materialize_at(path.clone());

        assert!(result.is_ok());
        let stored = std::fs::read(path.clone());
        assert!(matches!(stored, Ok(bytes) if bytes != b"stale"));
        remove_parent(&path);
    }

    #[test]
    fn materialize_keeps_same_existing_asset_file() {
        let path = test_path("current-mermaid.min.js");
        remove_parent(&path);
        let first = RuntimeAsset::mermaid().materialize_at(path.clone());
        assert!(matches!(first, Ok(written) if written == path));

        let second = RuntimeAsset::mermaid().materialize_at(path.clone());

        assert!(matches!(second, Ok(written) if written == path));
        remove_parent(&path);
    }

    #[test]
    fn runtime_asset_error_keeps_io_error_message() {
        let error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");

        let message = super::runtime_asset_error(error);

        assert_eq!(message, "denied");
    }

    #[test]
    fn remove_parent_accepts_path_without_parent() {
        remove_parent(std::path::Path::new(""));
    }

    fn test_path(name: &str) -> std::path::PathBuf {
        let slug = name.replace(['.', '/'], "-");
        std::env::temp_dir()
            .join(format!(
                "kcf-runtime-assets-test-{}-{slug}",
                std::process::id()
            ))
            .join(name)
    }

    fn remove_parent(path: &std::path::Path) {
        if let Some(parent) = path.parent() {
            let _ = std::fs::remove_dir_all(parent);
        }
    }
}