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