murr 0.2.0-rc2

Columnar in-memory cache for AI/ML inference workloads
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

use crate::core::{MurrError, TableSchema};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Manifest {
    pub version: u64,
    pub updated_at: u64,
    pub tables: HashMap<String, TableSchema>,
}

impl Default for Manifest {
    fn default() -> Self {
        Self {
            version: 1,
            updated_at: now_secs(),
            tables: HashMap::new(),
        }
    }
}

impl Manifest {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn from_file(path: &Path) -> Result<Self, MurrError> {
        match fs::read(path) {
            Ok(bytes) => serde_json::from_slice(&bytes)
                .map_err(|e| MurrError::IoError(format!("manifest parse: {e}"))),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new()),
            Err(e) => Err(MurrError::IoError(e.to_string())),
        }
    }

    pub fn to_file(&self, path: &Path) -> Result<(), MurrError> {
        let bytes = serde_json::to_vec_pretty(self)
            .map_err(|e| MurrError::IoError(format!("manifest serialize: {e}")))?;
        let tmp = match path.extension() {
            Some(ext) => {
                let mut s = ext.to_os_string();
                s.push(".tmp");
                path.with_extension(s)
            }
            None => path.with_extension("tmp"),
        };
        fs::write(&tmp, &bytes)?;
        fs::rename(&tmp, path)?;
        Ok(())
    }

    pub fn add_table(&mut self, name: &str, schema: &TableSchema) -> Result<(), MurrError> {
        if self.tables.contains_key(name) {
            return Err(MurrError::TableAlreadyExists(name.to_string()));
        }
        self.tables.insert(name.to_string(), schema.clone());
        self.updated_at = now_secs();
        Ok(())
    }

    pub fn del_table(&mut self, name: &str) -> Result<(), MurrError> {
        if self.tables.remove(name).is_none() {
            return Err(MurrError::TableNotFound(name.to_string()));
        }
        self.updated_at = now_secs();
        Ok(())
    }

    pub fn contains(&self, name: &str) -> bool {
        self.tables.contains_key(name)
    }

    pub fn schema(&self, name: &str) -> Option<&TableSchema> {
        self.tables.get(name)
    }
}

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

#[cfg(all(test, feature = "testutil"))]
mod tests {
    use super::*;
    use crate::core::{ColumnSchema, DType, TableSchema};
    use indexmap::IndexMap;
    use tempfile::TempDir;

    fn schema_id_score() -> TableSchema {
        let mut columns = IndexMap::new();
        columns.insert(
            "id".into(),
            ColumnSchema {
                dtype: DType::Utf8,
                nullable: false,
            },
        );
        columns.insert(
            "score".into(),
            ColumnSchema {
                dtype: DType::Float32,
                nullable: true,
            },
        );
        TableSchema {
            key: "id".into(),
            columns,
        }
    }

    #[test]
    fn round_trip_through_file() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("manifest.json");

        let mut m = Manifest::new();
        m.add_table("users", &schema_id_score()).unwrap();
        m.to_file(&path).unwrap();

        let loaded = Manifest::from_file(&path).unwrap();
        assert_eq!(loaded.tables.len(), 1);
        assert_eq!(loaded.schema("users"), Some(&schema_id_score()));
    }

    #[test]
    fn from_missing_file_returns_empty() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("does-not-exist.json");
        let m = Manifest::from_file(&path).unwrap();
        assert!(m.tables.is_empty());
    }

    #[test]
    fn add_duplicate_errors() {
        let mut m = Manifest::new();
        m.add_table("t", &schema_id_score()).unwrap();
        let err = m.add_table("t", &schema_id_score()).unwrap_err();
        assert!(matches!(err, MurrError::TableAlreadyExists(_)));
    }

    #[test]
    fn del_missing_errors() {
        let mut m = Manifest::new();
        let err = m.del_table("nope").unwrap_err();
        assert!(matches!(err, MurrError::TableNotFound(_)));
    }

    #[test]
    fn add_then_del() {
        let mut m = Manifest::new();
        m.add_table("t", &schema_id_score()).unwrap();
        assert!(m.contains("t"));
        m.del_table("t").unwrap();
        assert!(!m.contains("t"));
    }
}