1use crate::store::{FileStore, Key, StoreError};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct ManifestEntry {
7 pub path: PathBuf,
8 pub content_hash: Key,
9 pub mode: u32,
10 pub mtime_secs: i64,
11 pub mtime_nanos: u32,
12}
13
14impl ManifestEntry {
15 pub fn entry_for(path: PathBuf, content_hash: Key, mode: u32, mtime: (i64, u32)) -> Self {
16 Self {
17 path,
18 content_hash,
19 mode,
20 mtime_secs: mtime.0,
21 mtime_nanos: mtime.1,
22 }
23 }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct TreeManifest {
28 pub entries: Vec<ManifestEntry>,
29 pub deleted: Vec<PathBuf>,
30}
31
32impl TreeManifest {
33 pub fn new(entries: Vec<ManifestEntry>, deleted: Vec<PathBuf>) -> Self {
34 Self { entries, deleted }
35 }
36}
37
38#[derive(Debug, thiserror::Error)]
39pub enum CasError {
40 #[error("store error: {0}")]
41 Store(#[from] StoreError),
42 #[error("json error: {0}")]
43 Json(#[from] serde_json::Error),
44}
45
46pub struct TreeBlobStore<'a> {
47 store: &'a FileStore,
48}
49
50impl<'a> TreeBlobStore<'a> {
51 pub fn new(store: &'a FileStore) -> Self {
52 Self { store }
53 }
54
55 pub fn put_file_blob(&self, content: &[u8]) -> Result<Key, CasError> {
56 let key = Key::from_bytes(content);
57 if !self.store.contains(&key) {
58 self.store.persist(&key, content, "cas-blob", vec![])?;
59 }
60 Ok(key)
61 }
62
63 pub fn get_file_blob(&self, key: &Key) -> Result<Option<Vec<u8>>, CasError> {
64 Ok(self.store.lookup(key)?.map(|p| p.bytes))
65 }
66
67 pub fn put_manifest(&self, manifest: &TreeManifest) -> Result<Key, CasError> {
68 let bytes = serde_json::to_vec(manifest)?;
69 self.put_file_blob(&bytes)
70 }
71
72 pub fn get_manifest(&self, key: &Key) -> Result<Option<TreeManifest>, CasError> {
73 match self.store.lookup(key)? {
74 None => Ok(None),
75 Some(payload) => {
76 let manifest = serde_json::from_slice(&payload.bytes)?;
77 Ok(Some(manifest))
78 }
79 }
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use crate::store::FileStore;
87 use std::path::PathBuf;
88 use tempfile::TempDir;
89
90 fn setup() -> (TempDir, FileStore) {
91 let dir = TempDir::new().unwrap();
92 let store = FileStore::open(dir.path()).unwrap();
93 (dir, store)
94 }
95
96 #[test]
97 fn manifest_round_trip() {
98 let (_dir, store) = setup();
99 let cas = TreeBlobStore::new(&store);
100
101 let hash_a = Key::from_bytes(b"file-a-content");
102 let hash_b = Key::from_bytes(b"file-b-content");
103 let hash_c = Key::from_bytes(b"file-c-content");
104
105 let entries = vec![
106 ManifestEntry::entry_for(
107 PathBuf::from("src/main.rs"),
108 hash_a,
109 0o644,
110 (1_700_000_000, 123_456_789),
111 ),
112 ManifestEntry::entry_for(
113 PathBuf::from("src/lib.rs"),
114 hash_b,
115 0o755,
116 (1_700_000_001, 0),
117 ),
118 ManifestEntry::entry_for(
119 PathBuf::from("build.rs"),
120 hash_c,
121 0o600,
122 (-86400, 999_999_999),
123 ),
124 ];
125 let deleted = vec![PathBuf::from("old/removed.rs")];
126 let manifest = TreeManifest::new(entries, deleted);
127
128 let key = cas.put_manifest(&manifest).unwrap();
129 let loaded = cas
130 .get_manifest(&key)
131 .unwrap()
132 .expect("manifest must exist");
133
134 assert_eq!(loaded, manifest);
135 assert_eq!(loaded.entries[0].mode, 0o644);
136 assert_eq!(loaded.entries[0].mtime_secs, 1_700_000_000);
137 assert_eq!(loaded.entries[0].mtime_nanos, 123_456_789);
138 assert_eq!(loaded.entries[2].mtime_secs, -86400);
139 assert_eq!(loaded.deleted.len(), 1);
140 assert_eq!(loaded.deleted[0], PathBuf::from("old/removed.rs"));
141 }
142
143 #[test]
144 fn blob_round_trip() {
145 let (_dir, store) = setup();
146 let cas = TreeBlobStore::new(&store);
147
148 let key = cas.put_file_blob(b"hello").unwrap();
149 let fetched = cas.get_file_blob(&key).unwrap();
150 assert_eq!(fetched, Some(b"hello".to_vec()));
151 }
152
153 #[test]
154 fn blob_dedup() {
155 let (_dir, store) = setup();
156 let cas = TreeBlobStore::new(&store);
157
158 let key1 = cas.put_file_blob(b"same content").unwrap();
159 let key2 = cas.put_file_blob(b"same content").unwrap();
160 assert_eq!(key1, key2);
161
162 let root = store.root();
163 let shard = &key1.as_str()[..2];
164 let shard_path = root.join(shard);
165 let count = std::fs::read_dir(&shard_path)
166 .unwrap()
167 .filter_map(|e| e.ok())
168 .filter(|e| e.file_name().to_string_lossy().ends_with(".payload"))
169 .count();
170 assert_eq!(
171 count, 1,
172 "dedup: only one payload file for identical content"
173 );
174 }
175
176 #[test]
177 fn get_missing_key_returns_none() {
178 let (_dir, store) = setup();
179 let cas = TreeBlobStore::new(&store);
180
181 let phantom_key = Key::from_bytes(b"never stored");
182 assert_eq!(cas.get_file_blob(&phantom_key).unwrap(), None);
183 assert!(cas.get_manifest(&phantom_key).unwrap().is_none());
184 }
185
186 #[test]
187 fn mtime_nanosecond_fidelity() {
188 let (_dir, store) = setup();
189 let cas = TreeBlobStore::new(&store);
190
191 let hash = Key::from_bytes(b"precision-test");
192 let entry = ManifestEntry::entry_for(
193 PathBuf::from("file.txt"),
194 hash,
195 0o644,
196 (1_000_000_000, 123_456_789),
197 );
198 let manifest = TreeManifest::new(vec![entry], vec![]);
199
200 let key = cas.put_manifest(&manifest).unwrap();
201 let loaded = cas.get_manifest(&key).unwrap().unwrap();
202
203 assert_eq!(loaded.entries[0].mtime_nanos, 123_456_789);
204 assert_eq!(loaded.entries[0].mtime_secs, 1_000_000_000);
205 }
206}