zen-engine 1.0.0-beta.11

Business rules engine
Documentation
use std::collections::HashMap;
use std::sync::Arc;

use serde::{Deserialize, Serialize};

use crate::loader::{DynamicLoader, FilesystemLoader, FilesystemLoaderOptions, MemoryLoader};
use crate::model::DecisionContent;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(
    tag = "type",
    rename_all = "camelCase",
    rename_all_fields = "camelCase"
)]
pub enum LoaderConfig {
    Static {
        content: HashMap<String, DecisionContent>,
    },
    #[serde(rename = "fs")]
    Filesystem {
        path: String,
    },
    Zip {
        bytes: Vec<u8>,
    },
}

impl LoaderConfig {
    pub fn into_loader(self) -> anyhow::Result<DynamicLoader> {
        match self {
            LoaderConfig::Static { content } => {
                let loader = MemoryLoader::default();
                for (key, decision_content) in content {
                    loader.add(key, decision_content);
                }
                Ok(Arc::new(loader))
            }
            LoaderConfig::Filesystem { path } => {
                Ok(Arc::new(FilesystemLoader::new(FilesystemLoaderOptions {
                    root: path,
                })))
            }
            LoaderConfig::Zip { bytes } => Self::loader_from_zip(&bytes),
        }
    }

    fn loader_from_zip(bytes: &[u8]) -> anyhow::Result<DynamicLoader> {
        use std::io::Read;

        let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))?;
        let loader = MemoryLoader::default();
        for index in 0..archive.len() {
            let mut entry = archive.by_index(index)?;
            if !entry.is_file() || !entry.name().ends_with(".json") {
                continue;
            }

            let key = entry.name().to_string();
            let mut buffer = Vec::with_capacity(entry.size() as usize);
            entry.read_to_end(&mut buffer)?;
            let content: DecisionContent = serde_json::from_slice(&buffer)?;
            loader.add(key, content);
        }

        Ok(Arc::new(loader))
    }
}

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

    const GRAPH_JSON: &str = r#"{"nodes":[],"edges":[]}"#;

    #[tokio::test]
    async fn static_config_serves_decisions_by_key() {
        let mut content = HashMap::new();
        content.insert(
            "graph.json".to_string(),
            serde_json::from_str::<DecisionContent>(GRAPH_JSON).unwrap(),
        );

        let loader = LoaderConfig::Static { content }.into_loader().unwrap();
        assert!(loader.load("graph.json").await.is_ok());
        assert!(loader.load("missing.json").await.is_err());
    }

    #[test]
    fn fs_config_reads_path() {
        let config: LoaderConfig = serde_json::from_str(r#"{"type":"fs","path":"p"}"#).unwrap();

        let LoaderConfig::Filesystem { path } = config else {
            panic!("expected filesystem loader config");
        };

        assert_eq!(path, "p");
    }

    #[tokio::test]
    async fn zip_config_decompresses_and_serves_decisions() {
        use std::io::Write;

        let mut cursor = std::io::Cursor::new(Vec::new());
        {
            let mut writer = zip::ZipWriter::new(&mut cursor);
            let options = zip::write::SimpleFileOptions::default()
                .compression_method(zip::CompressionMethod::Deflated);
            writer.start_file("graph.json", options).unwrap();
            writer.write_all(GRAPH_JSON.as_bytes()).unwrap();
            writer.finish().unwrap();
        }

        let loader = LoaderConfig::Zip {
            bytes: cursor.into_inner(),
        }
        .into_loader()
        .unwrap();

        assert!(loader.load("graph.json").await.is_ok());
        assert!(loader.load("missing.json").await.is_err());
    }
}