1use std::path::Path;
2
3use anyhow::{Result, anyhow, bail};
4use fs_err as fs;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use crate::error::{Error, ErrorCode};
9use crate::paths::ProjectPath;
10use crate::{Backend, Compression, DvsPaths, Hashes, Outcome, StoreRequest, StoreResult};
11
12#[derive(Debug, Serialize, Deserialize, Clone)]
14pub struct FileMetadata {
15 pub hashes: Hashes,
16 pub size: u64,
17 pub created_by: String,
18 pub add_time: jiff::Timestamp,
19 pub compression: Compression,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub message: Option<String>,
22}
23
24impl PartialEq for FileMetadata {
25 fn eq(&self, other: &Self) -> bool {
26 self.hashes == other.hashes && self.size == other.size
27 }
28}
29
30impl FileMetadata {
31 pub fn from_hashes(
32 hashes: Hashes,
33 size: u64,
34 compression: Compression,
35 message: Option<String>,
36 ) -> Self {
37 Self {
38 hashes,
39 size,
40 created_by: whoami::username().unwrap_or_else(|_| "unknown".to_string()),
41 add_time: jiff::Timestamp::now(),
42 compression,
43 message,
44 }
45 }
46
47 pub fn from_file(
48 path: impl AsRef<Path>,
49 compression: Compression,
50 message: Option<String>,
51 ) -> Result<Self> {
52 if !path.as_ref().is_file() {
53 bail!("Path {} is not a file", path.as_ref().display());
54 }
55
56 let (hashes, size) = Hashes::compute_from_path(path.as_ref(), &[])?;
57 let created_by = whoami::username()?;
58 let add_time = jiff::Timestamp::now();
59
60 Ok(Self {
61 hashes,
62 size,
63 created_by,
64 add_time,
65 message,
66 compression,
67 })
68 }
69
70 pub fn write(&self, paths: &DvsPaths, relative_path: impl AsRef<Path>) -> Result<()> {
72 let dvs_file_path = paths.metadata_path(relative_path.as_ref());
73 if let Some(parent) = dvs_file_path.parent() {
74 fs::create_dir_all(parent)?;
75 }
76 log::debug!("Writing metadata to {}", dvs_file_path.display());
77 fs::write(
78 &dvs_file_path,
79 serde_json::to_string_pretty(self).expect("valid json"),
80 )?;
81 Ok(())
82 }
83
84 pub(crate) fn is_saved(
87 &self,
88 backend: &Backend,
89 paths: &DvsPaths,
90 relative_path: &Path,
91 ) -> Result<bool> {
92 let dvs_file_path = paths.metadata_path(relative_path);
93 let dvs_file_exists = dvs_file_path.is_file();
94 let storage_exists = dvs_file_exists && backend.exists(&self.hashes)?;
97
98 log::debug!(
99 "Saving {}: metadata_exists={}, storage_exists={}",
100 relative_path.display(),
101 dvs_file_exists,
102 storage_exists
103 );
104
105 if dvs_file_exists && storage_exists {
106 let existing: FileMetadata = serde_json::from_reader(fs::File::open(&dvs_file_path)?)
108 .map_err(|e| {
109 Error::new(
110 ErrorCode::BadMetadata,
111 format!("cannot read {}: {e}", dvs_file_path.display()),
112 )
113 })?;
114 if existing == *self {
115 log::debug!("File {} is already in sync", relative_path.display());
116 return Ok(true);
117 }
118 }
119 Ok(false)
120 }
121
122 pub fn save(
126 &mut self,
127 operation_id: Uuid,
128 source_file: impl AsRef<Path>,
129 backend: &Backend,
130 paths: &DvsPaths,
131 relative_path: impl AsRef<Path>,
132 on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
133 ) -> Result<(Outcome, Option<StoreResult>)> {
134 if self.is_saved(backend, paths, relative_path.as_ref())? {
135 return Ok((Outcome::Present, None));
136 }
137
138 let stored = self.store_and_write(
139 operation_id,
140 source_file,
141 backend,
142 paths,
143 relative_path,
144 on_bytes,
145 )?;
146 Ok((Outcome::Copied, Some(stored)))
147 }
148
149 pub fn store_and_write(
152 &mut self,
153 operation_id: Uuid,
154 source_file: impl AsRef<Path>,
155 backend: &Backend,
156 paths: &DvsPaths,
157 relative_path: impl AsRef<Path>,
158 on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
159 ) -> Result<StoreResult> {
160 let dvs_file_path = paths.metadata_path(relative_path.as_ref());
161
162 let path = ProjectPath::from_path(relative_path.as_ref()).map_err(|e| anyhow!("{e}"))?;
165 let store_res = backend.store(StoreRequest {
166 hashes: &self.hashes,
167 source: source_file.as_ref(),
168 compression: self.compression,
169 path,
170 operation_id,
171 message: self.message.as_deref(),
172 on_bytes,
173 });
174
175 if let Ok(res) = &store_res {
178 self.compression = res.compression;
179 }
180
181 let old_metadata_content = fs::read(&dvs_file_path).ok();
183 let metadata_res = self.write(paths, relative_path.as_ref());
184
185 match (store_res, metadata_res) {
186 (Ok(res), Ok(_)) => Ok(res),
187 (Err(e), Ok(_)) => {
188 log::warn!(
189 "Storage failed, rolling back metadata for {}",
190 relative_path.as_ref().display()
191 );
192 if let Some(old) = old_metadata_content {
193 let _ = fs::write(&dvs_file_path, &old);
194 } else {
195 let _ = fs::remove_file(&dvs_file_path);
196 }
197 Err(e)
198 }
199 (Ok(res), Err(_)) => {
200 log::warn!(
201 "Metadata write failed, rolling back storage for {}",
202 relative_path.as_ref().display()
203 );
204 if let Some(old) = old_metadata_content {
205 let _ = fs::write(&dvs_file_path, &old);
206 } else {
207 let _ = fs::remove_file(&dvs_file_path);
208 }
209 if res.created {
211 let _ = backend.remove(&self.hashes);
212 }
213 bail!("Failed to write metadata file: {dvs_file_path:?}")
214 }
215 (Err(e), Err(_)) => {
216 log::warn!(
217 "Both storage and metadata failed, rolling back for {}",
218 relative_path.as_ref().display()
219 );
220 if let Some(old) = old_metadata_content {
221 let _ = fs::write(&dvs_file_path, &old);
222 } else {
223 let _ = fs::remove_file(&dvs_file_path);
224 }
225 bail!("Failed to write metadata file: {dvs_file_path:?}: {e}")
226 }
227 }
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::testutil::{create_file, create_temp_git_repo, init_dvs_repo};
235
236 fn make_paths(root: &Path, config: &crate::config::Config) -> DvsPaths {
237 DvsPaths::new(
238 root.to_path_buf(),
239 root.to_path_buf(),
240 config.metadata_folder_name(),
241 )
242 .unwrap()
243 }
244
245 #[test]
246 fn file_metadata_from_file_creates_hashes_and_message() {
247 let (_tmp, root) = create_temp_git_repo();
248 let file_path = create_file(&root, "test.txt", b"hello world");
249
250 let metadata = FileMetadata::from_file(
251 &file_path,
252 Compression::Zstd,
253 Some("test message".to_string()),
254 )
255 .unwrap();
256
257 assert_eq!(metadata.hashes.blake3.len(), 64);
258 assert_eq!(metadata.size, 11);
259 assert_eq!(metadata.message, Some("test message".to_string()));
260 }
261
262 #[test]
267 fn file_metadata_add_time_serde_roundtrip_rfc3339() {
268 let blake3 = "a".repeat(64);
269 let json = format!(
270 r#"{{
271 "hashes": {{"blake3": "{blake3}"}},
272 "size": 11,
273 "created_by": "tester",
274 "add_time": "2024-01-02T03:04:05Z",
275 "compression": "none"
276 }}"#
277 );
278 let meta: FileMetadata =
279 serde_json::from_str(&json).expect("parse FileMetadata with RFC 3339 timestamp");
280 assert_eq!(meta.add_time.to_string(), "2024-01-02T03:04:05Z");
281
282 let reserialized = serde_json::to_string(&meta).expect("serialize FileMetadata");
283 assert!(
284 reserialized.contains("\"add_time\":\"2024-01-02T03:04:05Z\""),
285 "add_time must serialize as RFC 3339 string; got: {reserialized}"
286 );
287 }
288
289 #[test]
290 fn file_metadata_from_nonexistent_file_fails() {
291 let tmp = tempfile::tempdir().unwrap();
292 let result =
293 FileMetadata::from_file(tmp.path().join("nonexistent.txt"), Compression::Zstd, None);
294 assert!(result.is_err());
295 }
296
297 #[test]
298 fn save_local_creates_storage_and_metadata() {
299 let (_tmp, root) = create_temp_git_repo();
300 let (config, dvs_dir) = init_dvs_repo(&root);
301 let backend = config.backend();
302 let paths = make_paths(&root, &config);
303 let file_path = create_file(&root, "data.bin", b"binary data");
304
305 let mut metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
306 let (outcome, stored_size) = metadata
307 .save(
308 Uuid::new_v4(),
309 &file_path,
310 backend,
311 &paths,
312 "data.bin",
313 None,
314 )
315 .unwrap();
316
317 assert_eq!(outcome, Outcome::Copied);
318 assert!(stored_size.is_some());
319 assert!(dvs_dir.join("data.bin.dvs").is_file());
321 assert!(backend.exists(&metadata.hashes).unwrap());
322 }
323
324 #[test]
325 fn save_reports_an_unreadable_record_as_bad_metadata() {
326 let (_tmp, root) = create_temp_git_repo();
327 let (config, dvs_dir) = init_dvs_repo(&root);
328 let backend = config.backend();
329 let paths = make_paths(&root, &config);
330 let file_path = create_file(&root, "data.bin", b"binary data");
331 let mut metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
332 metadata
333 .save(
334 Uuid::new_v4(),
335 &file_path,
336 backend,
337 &paths,
338 "data.bin",
339 None,
340 )
341 .unwrap();
342 let conflicted = "<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> theirs\n";
343 fs::write(dvs_dir.join("data.bin.dvs"), conflicted).unwrap();
344
345 let error = metadata
346 .save(
347 Uuid::new_v4(),
348 &file_path,
349 backend,
350 &paths,
351 "data.bin",
352 None,
353 )
354 .unwrap_err();
355 assert_eq!(
356 crate::backend_error_code(&error),
357 Some(ErrorCode::BadMetadata)
358 );
359 }
360
361 #[test]
362 fn save_local_returns_present_when_already_stored() {
363 let (_tmp, root) = create_temp_git_repo();
364 let (config, _dvs_dir) = init_dvs_repo(&root);
365 let backend = config.backend();
366 let paths = make_paths(&root, &config);
367 let file_path = create_file(&root, "data.bin", b"binary data");
368
369 let mut metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
370 metadata
371 .save(
372 Uuid::new_v4(),
373 &file_path,
374 backend,
375 &paths,
376 "data.bin",
377 None,
378 )
379 .unwrap();
380
381 let (outcome, stored_size) = metadata
383 .save(
384 Uuid::new_v4(),
385 &file_path,
386 backend,
387 &paths,
388 "data.bin",
389 None,
390 )
391 .unwrap();
392 assert_eq!(outcome, Outcome::Present);
393 assert!(stored_size.is_none());
394 }
395
396 #[test]
397 fn save_local_updates_metadata_when_content_matches_different_file() {
398 let (_tmp, root) = create_temp_git_repo();
404 let (config, dvs_dir) = init_dvs_repo(&root);
405 let backend = config.backend();
406 let paths = make_paths(&root, &config);
407
408 let file_a = create_file(&root, "a.txt", b"foo");
410 let mut metadata_a = FileMetadata::from_file(&file_a, Compression::Zstd, None).unwrap();
411 metadata_a
412 .save(Uuid::new_v4(), &file_a, backend, &paths, "a.txt", None)
413 .unwrap();
414 let hash_h1 = metadata_a.hashes.blake3.clone();
415
416 let file_b = create_file(&root, "b.txt", b"bar");
418 let mut metadata_b = FileMetadata::from_file(&file_b, Compression::Zstd, None).unwrap();
419 metadata_b
420 .save(Uuid::new_v4(), &file_b, backend, &paths, "b.txt", None)
421 .unwrap();
422 let hash_h2 = metadata_b.hashes.blake3.clone();
423 assert_ne!(hash_h1, hash_h2);
424
425 fs::write(&file_b, b"foo").unwrap();
427
428 let mut metadata_b_new = FileMetadata::from_file(&file_b, Compression::Zstd, None).unwrap();
430 assert_eq!(metadata_b_new.hashes.blake3, hash_h1);
431
432 metadata_b_new
433 .save(Uuid::new_v4(), &file_b, backend, &paths, "b.txt", None)
434 .unwrap();
435
436 let dvs_file = dvs_dir.join("b.txt.dvs");
438 let stored: FileMetadata =
439 serde_json::from_reader(fs::File::open(&dvs_file).unwrap()).unwrap();
440
441 assert_eq!(
442 stored.hashes.blake3, hash_h1,
443 "Metadata should be updated to new hash"
444 );
445 }
446}