verdant-cache-runtime 0.4.2

Live cache runtime for the verdant agent-loop cache: content-addressed payload store + DDG
Documentation
use crate::store::{FileStore, Key, StoreError};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestEntry {
    pub path: PathBuf,
    pub content_hash: Key,
    pub mode: u32,
    pub mtime_secs: i64,
    pub mtime_nanos: u32,
}

impl ManifestEntry {
    pub fn entry_for(path: PathBuf, content_hash: Key, mode: u32, mtime: (i64, u32)) -> Self {
        Self {
            path,
            content_hash,
            mode,
            mtime_secs: mtime.0,
            mtime_nanos: mtime.1,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeManifest {
    pub entries: Vec<ManifestEntry>,
    pub deleted: Vec<PathBuf>,
}

impl TreeManifest {
    pub fn new(entries: Vec<ManifestEntry>, deleted: Vec<PathBuf>) -> Self {
        Self { entries, deleted }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum CasError {
    #[error("store error: {0}")]
    Store(#[from] StoreError),
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
}

pub struct TreeBlobStore<'a> {
    store: &'a FileStore,
}

impl<'a> TreeBlobStore<'a> {
    pub fn new(store: &'a FileStore) -> Self {
        Self { store }
    }

    pub fn put_file_blob(&self, content: &[u8]) -> Result<Key, CasError> {
        let key = Key::from_bytes(content);
        if !self.store.contains(&key) {
            self.store.persist(&key, content, "cas-blob", vec![])?;
        }
        Ok(key)
    }

    pub fn get_file_blob(&self, key: &Key) -> Result<Option<Vec<u8>>, CasError> {
        Ok(self.store.lookup(key)?.map(|p| p.bytes))
    }

    pub fn put_manifest(&self, manifest: &TreeManifest) -> Result<Key, CasError> {
        let bytes = serde_json::to_vec(manifest)?;
        self.put_file_blob(&bytes)
    }

    pub fn get_manifest(&self, key: &Key) -> Result<Option<TreeManifest>, CasError> {
        match self.store.lookup(key)? {
            None => Ok(None),
            Some(payload) => {
                let manifest = serde_json::from_slice(&payload.bytes)?;
                Ok(Some(manifest))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::store::FileStore;
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn setup() -> (TempDir, FileStore) {
        let dir = TempDir::new().unwrap();
        let store = FileStore::open(dir.path()).unwrap();
        (dir, store)
    }

    #[test]
    fn manifest_round_trip() {
        let (_dir, store) = setup();
        let cas = TreeBlobStore::new(&store);

        let hash_a = Key::from_bytes(b"file-a-content");
        let hash_b = Key::from_bytes(b"file-b-content");
        let hash_c = Key::from_bytes(b"file-c-content");

        let entries = vec![
            ManifestEntry::entry_for(
                PathBuf::from("src/main.rs"),
                hash_a,
                0o644,
                (1_700_000_000, 123_456_789),
            ),
            ManifestEntry::entry_for(
                PathBuf::from("src/lib.rs"),
                hash_b,
                0o755,
                (1_700_000_001, 0),
            ),
            ManifestEntry::entry_for(
                PathBuf::from("build.rs"),
                hash_c,
                0o600,
                (-86400, 999_999_999),
            ),
        ];
        let deleted = vec![PathBuf::from("old/removed.rs")];
        let manifest = TreeManifest::new(entries, deleted);

        let key = cas.put_manifest(&manifest).unwrap();
        let loaded = cas
            .get_manifest(&key)
            .unwrap()
            .expect("manifest must exist");

        assert_eq!(loaded, manifest);
        assert_eq!(loaded.entries[0].mode, 0o644);
        assert_eq!(loaded.entries[0].mtime_secs, 1_700_000_000);
        assert_eq!(loaded.entries[0].mtime_nanos, 123_456_789);
        assert_eq!(loaded.entries[2].mtime_secs, -86400);
        assert_eq!(loaded.deleted.len(), 1);
        assert_eq!(loaded.deleted[0], PathBuf::from("old/removed.rs"));
    }

    #[test]
    fn blob_round_trip() {
        let (_dir, store) = setup();
        let cas = TreeBlobStore::new(&store);

        let key = cas.put_file_blob(b"hello").unwrap();
        let fetched = cas.get_file_blob(&key).unwrap();
        assert_eq!(fetched, Some(b"hello".to_vec()));
    }

    #[test]
    fn blob_dedup() {
        let (_dir, store) = setup();
        let cas = TreeBlobStore::new(&store);

        let key1 = cas.put_file_blob(b"same content").unwrap();
        let key2 = cas.put_file_blob(b"same content").unwrap();
        assert_eq!(key1, key2);

        let root = store.root();
        let shard = &key1.as_str()[..2];
        let shard_path = root.join(shard);
        let count = std::fs::read_dir(&shard_path)
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_name().to_string_lossy().ends_with(".payload"))
            .count();
        assert_eq!(
            count, 1,
            "dedup: only one payload file for identical content"
        );
    }

    #[test]
    fn get_missing_key_returns_none() {
        let (_dir, store) = setup();
        let cas = TreeBlobStore::new(&store);

        let phantom_key = Key::from_bytes(b"never stored");
        assert_eq!(cas.get_file_blob(&phantom_key).unwrap(), None);
        assert!(cas.get_manifest(&phantom_key).unwrap().is_none());
    }

    #[test]
    fn mtime_nanosecond_fidelity() {
        let (_dir, store) = setup();
        let cas = TreeBlobStore::new(&store);

        let hash = Key::from_bytes(b"precision-test");
        let entry = ManifestEntry::entry_for(
            PathBuf::from("file.txt"),
            hash,
            0o644,
            (1_000_000_000, 123_456_789),
        );
        let manifest = TreeManifest::new(vec![entry], vec![]);

        let key = cas.put_manifest(&manifest).unwrap();
        let loaded = cas.get_manifest(&key).unwrap().unwrap();

        assert_eq!(loaded.entries[0].mtime_nanos, 123_456_789);
        assert_eq!(loaded.entries[0].mtime_secs, 1_000_000_000);
    }
}