1#![allow(clippy::expect_used)]
11use std::path::{Path, PathBuf};
12use std::fs;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14use serde::{Serialize, Deserialize};
15use tracing::{info, warn, debug};
16
17const VERSIONS_DIR: &str = ".foxing_versions";
18#[allow(dead_code)]
19const LIVE_DIR: &str = "live";
20const INDEX_FILE: &str = "index.json";
21const SUMMARY_FILE: &str = "summary.json";
22const TREE_DIR: &str = "tree";
23const FILES_DIR: &str = "files";
24
25fn format_timestamp(t: SystemTime) -> String {
27 let d = t.duration_since(UNIX_EPOCH).unwrap_or_default();
28 let secs = d.as_secs();
29 let dt = chrono::DateTime::from_timestamp(secs as i64, 0)
30 .unwrap_or_default();
31 dt.format("%Y-%m-%dT%H%M%S").to_string()
32}
33
34fn format_iso(t: SystemTime) -> String {
36 let d = t.duration_since(UNIX_EPOCH).unwrap_or_default();
37 let secs = d.as_secs();
38 let dt = chrono::DateTime::from_timestamp(secs as i64, 0)
39 .unwrap_or_default();
40 dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
41}
42
43fn parse_timestamp(s: &str) -> Option<SystemTime> {
45 let dt = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H%M%S").ok()?;
46 let ts = dt.and_utc().timestamp();
47 Some(UNIX_EPOCH + Duration::from_secs(ts as u64))
48}
49
50#[derive(Debug, Serialize, Deserialize)]
56pub struct SnapshotIndex {
57 pub version: u32,
58 pub target: String,
59 pub created: String,
60 pub snapshots: Vec<SnapshotEntry>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SnapshotEntry {
66 pub timestamp: String,
67 #[serde(rename = "type")]
68 pub snap_type: String,
69 pub tag: Option<String>,
70 pub files: u64,
71 pub size_bytes: u64,
72 #[serde(default)]
73 pub disk_usage_bytes: u64,
74 #[serde(default)]
75 pub savings_pct: f64,
76 pub source: String,
77 pub trigger: String,
78 pub retention: Option<RetentionPolicy>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct RetentionPolicy {
84 pub expires: Option<String>,
85 pub policy: String,
86}
87
88#[derive(Debug, Serialize, Deserialize)]
89pub struct SnapshotSummary {
90 pub timestamp: String,
91 pub status: String,
92 #[serde(rename = "type")]
93 pub snap_type: String,
94 pub tag: Option<String>,
95 pub source: String,
96 pub trigger: String,
97 pub files: u64,
98 pub size_bytes: u64,
99 #[serde(default)]
100 pub disk_usage_bytes: u64,
101 #[serde(default)]
102 pub savings_pct: f64,
103 pub reference: Option<String>,
104 pub elapsed_ms: u64,
105}
106
107#[derive(Debug, Serialize, Deserialize)]
109pub struct SnapshotStoreStats {
110 pub snapshots: usize,
111 pub total_files: u64,
112 pub total_apparent_bytes: u64,
113 pub total_disk_bytes: u64,
114 pub unique_content_bytes: u64,
115 pub cow_savings_pct: f64,
116 pub dedup_savings_pct: f64,
117 pub savings_pct: f64,
118 pub oldest: Option<String>,
119 pub newest: Option<String>,
120}
121
122#[derive(Debug, Default)]
124pub struct PruneStats {
125 pub snapshots_removed: u64,
126 pub bytes_freed: u64,
127}
128
129pub fn compute_disk_usage(dir: &Path) -> (u64, u64) {
132 use std::os::unix::fs::MetadataExt;
133 let mut apparent = 0u64;
134 let mut disk = 0u64;
135 for entry in walkdir::WalkDir::new(dir).follow_links(false) {
136 let entry = match entry { Ok(e) => e, Err(_) => continue };
137 if !entry.file_type().is_file() { continue; }
138 if let Ok(meta) = entry.metadata() {
139 apparent += meta.len();
140 disk += meta.blocks() * 512;
141 }
142 }
143 (apparent, disk)
144}
145
146fn savings_percent(apparent: u64, disk: u64) -> f64 {
147 if apparent == 0 { return 0.0; }
148 ((1.0 - (disk as f64 / apparent as f64)) * 1000.0).round() / 10.0
149}
150
151pub struct VersionStore {
157 root: PathBuf,
158}
159
160impl VersionStore {
161 pub fn open(target_root: &Path) -> Self {
163 let root = target_root.join(VERSIONS_DIR);
164 Self { root }
165 }
166
167 fn ensure_dirs(&self) -> std::io::Result<()> {
168 fs::create_dir_all(&self.root)?;
169 fs::create_dir_all(self.root.join(FILES_DIR))?;
170 Ok(())
171 }
172
173 pub fn create_snapshot(
178 &self,
179 source: &Path,
180 target: &Path,
181 tag: Option<&str>,
182 trigger: &str,
183 ) -> crate::Result<SnapshotEntry> {
184 let now = SystemTime::now();
185 let ts_dir = format_timestamp(now);
186 let ts_iso = format_iso(now);
187 let snap_dir = self.root.join(&ts_dir);
188 let tree_dir = snap_dir.join(TREE_DIR);
189
190 self.ensure_dirs().map_err(crate::error::FxcpError::Io)?;
191 fs::create_dir_all(&tree_dir).map_err(crate::error::FxcpError::Io)?;
192
193 let start = std::time::Instant::now();
194 let mut files = 0u64;
195 let mut size_bytes = 0u64;
196
197 for entry in walkdir::WalkDir::new(target).follow_links(false) {
199 let entry = match entry { Ok(e) => e, Err(_) => continue };
200 let rel = match entry.path().strip_prefix(target) { Ok(r) => r, Err(_) => continue };
201 if rel.as_os_str().is_empty() { continue; }
202
203 let rel_str = rel.to_string_lossy();
205 if rel_str.starts_with(VERSIONS_DIR) || rel_str.starts_with(".foxing") { continue; }
206
207 let snap_path = tree_dir.join(rel);
208
209 if entry.file_type().is_dir() {
210 if let Err(e) = fs::create_dir_all(&snap_path) {
211 warn!("version store: create_dir_all failed: {}", e);
212 }
213 } else if entry.file_type().is_file() {
214 if let Some(parent) = snap_path.parent()
215 && let Err(e) = fs::create_dir_all(parent) {
216 warn!("version store: create_dir_all failed: {}", e);
217 }
218 if crate::operations::reflink_or_copy(entry.path(), &snap_path).is_ok() {
219 files += 1;
220 size_bytes += entry.metadata().map(|m| m.len()).unwrap_or(0);
221 }
222 }
223 }
224
225 let elapsed_ms = start.elapsed().as_millis() as u64;
226
227 let (apparent, disk_used) = compute_disk_usage(&tree_dir);
229 let savings = savings_percent(apparent, disk_used);
230
231 let summary = SnapshotSummary {
233 timestamp: ts_iso.clone(),
234 status: "success".into(),
235 snap_type: "full".into(),
236 tag: tag.map(|t| t.to_string()),
237 source: source.to_string_lossy().into(),
238 trigger: trigger.into(),
239 files,
240 size_bytes,
241 disk_usage_bytes: disk_used,
242 savings_pct: savings,
243 reference: None,
244 elapsed_ms,
245 };
246 let summary_json = serde_json::to_string_pretty(&summary)
247 .unwrap_or_default();
248 let _ = fs::write(snap_dir.join(SUMMARY_FILE), summary_json);
249
250 self.create_file_symlinks(&ts_dir, &tree_dir, target);
252
253 let entry = SnapshotEntry {
255 timestamp: ts_iso,
256 snap_type: "full".into(),
257 tag: tag.map(|t| t.to_string()),
258 files,
259 size_bytes,
260 disk_usage_bytes: disk_used,
261 savings_pct: savings,
262 source: source.to_string_lossy().into(),
263 trigger: trigger.into(),
264 retention: None,
265 };
266 self.append_to_index(&entry);
267
268 info!("Snapshot created: {} ({} files, {} bytes, {}ms)",
269 ts_dir, files, size_bytes, elapsed_ms);
270
271 Ok(entry)
272 }
273
274 fn create_file_symlinks(&self, ts_dir: &str, tree_dir: &Path, _target: &Path) {
275 let files_root = self.root.join(FILES_DIR);
276 for entry in walkdir::WalkDir::new(tree_dir).follow_links(false) {
277 let entry = match entry { Ok(e) => e, Err(_) => continue };
278 if !entry.file_type().is_file() { continue; }
279 let rel = match entry.path().strip_prefix(tree_dir) { Ok(r) => r, Err(_) => continue };
280
281 let symlink_name = format!("{}~{}", rel.to_string_lossy(), ts_dir);
282 let symlink_path = files_root.join(&symlink_name);
283 if let Some(parent) = symlink_path.parent()
284 && let Err(e) = fs::create_dir_all(parent) {
285 warn!("version store: create_dir_all failed: {}", e);
286 }
287
288 let target_rel = pathdiff::diff_paths(entry.path(), symlink_path.parent().unwrap_or(&files_root));
290 if let Some(target_rel) = target_rel {
291 let _ = std::os::unix::fs::symlink(&target_rel, &symlink_path);
292 }
293 }
294 }
295
296 pub fn list_snapshots(&self) -> Vec<SnapshotEntry> {
298 if let Ok(index) = self.read_index() {
300 return index.snapshots;
301 }
302 self.scan_snapshots()
304 }
305
306 fn scan_snapshots(&self) -> Vec<SnapshotEntry> {
307 let mut snapshots = Vec::new();
308 let entries = match fs::read_dir(&self.root) {
309 Ok(e) => e,
310 Err(_) => return snapshots,
311 };
312 for entry in entries.flatten() {
313 let name = entry.file_name().to_string_lossy().into_owned();
314 if parse_timestamp(&name).is_none() { continue; }
315 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
316
317 let summary_path = entry.path().join(SUMMARY_FILE);
319 if let Ok(data) = fs::read_to_string(&summary_path)
320 && let Ok(summary) = serde_json::from_str::<SnapshotSummary>(&data) {
321 let (disk_usage, savings) = if summary.disk_usage_bytes > 0 {
322 (summary.disk_usage_bytes, summary.savings_pct)
323 } else {
324 let tree = entry.path().join(TREE_DIR);
326 if tree.exists() {
327 let (app, dsk) = compute_disk_usage(&tree);
328 (dsk, savings_percent(app, dsk))
329 } else {
330 (0, 0.0)
331 }
332 };
333 snapshots.push(SnapshotEntry {
334 timestamp: summary.timestamp,
335 snap_type: summary.snap_type,
336 tag: summary.tag,
337 files: summary.files,
338 size_bytes: summary.size_bytes,
339 disk_usage_bytes: disk_usage,
340 savings_pct: savings,
341 source: summary.source,
342 trigger: summary.trigger,
343 retention: None,
344 });
345 continue;
346 }
347
348 let tree = entry.path().join(TREE_DIR);
350 let (apparent, disk, files_count) = if tree.exists() {
351 let (a, d) = compute_disk_usage(&tree);
352 let fc = walkdir::WalkDir::new(&tree).into_iter()
353 .filter_map(|e| e.ok()).filter(|e| e.file_type().is_file()).count() as u64;
354 (a, d, fc)
355 } else {
356 (0, 0, 0)
357 };
358 snapshots.push(SnapshotEntry {
359 timestamp: name,
360 snap_type: "unknown".into(),
361 tag: None,
362 files: files_count,
363 size_bytes: apparent,
364 disk_usage_bytes: disk,
365 savings_pct: savings_percent(apparent, disk),
366 source: String::new(),
367 trigger: "unknown".into(),
368 retention: None,
369 });
370 }
371 snapshots.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
372 snapshots
373 }
374
375 pub fn delete_snapshot(&self, timestamp: &str) -> crate::Result<u64> {
377 let snap_dir = self.root.join(timestamp);
379 if !snap_dir.exists() {
380 let fs_safe = timestamp.replace(':', "").replace("-T", "T");
382 let alt_dir = self.root.join(&fs_safe);
383 if alt_dir.exists() {
384 return self.remove_snapshot_dir(&alt_dir);
385 }
386 return Err(crate::error::FxcpError::Config(
387 format!("Snapshot not found: {}", timestamp)
388 ));
389 }
390 self.remove_snapshot_dir(&snap_dir)
391 }
392
393 pub fn tag_snapshot(&self, timestamp: &str, tag: &str) -> crate::Result<()> {
395 let snap_dir = self.root.join(timestamp);
396 if !snap_dir.exists() {
397 let fs_safe = timestamp.replace(':', "").replace("-T", "T");
398 let alt_dir = self.root.join(&fs_safe);
399 if !alt_dir.exists() {
400 return Err(crate::error::FxcpError::Config(
401 format!("Snapshot not found: {}", timestamp)
402 ));
403 }
404 return self.tag_snapshot_dir(&alt_dir, tag);
405 }
406 self.tag_snapshot_dir(&snap_dir, tag)
407 }
408
409 fn tag_snapshot_dir(&self, snap_dir: &Path, tag: &str) -> crate::Result<()> {
410 use std::io::Write;
411
412 let summary_path = snap_dir.join(SUMMARY_FILE);
413 let data = fs::read_to_string(&summary_path).map_err(crate::error::FxcpError::Io)?;
414 let mut summary: SnapshotSummary = serde_json::from_str(&data).map_err(|e|
415 crate::error::FxcpError::Config(format!("Invalid summary.json: {}", e))
416 )?;
417 summary.tag = Some(tag.to_string());
418
419 let tmp_path = summary_path.with_extension("tmp");
420 let mut f = fs::File::create(&tmp_path).map_err(crate::error::FxcpError::Io)?;
421 serde_json::to_writer_pretty(&mut f, &summary).map_err(|e|
422 crate::error::FxcpError::Config(format!("Failed to serialize summary: {}", e))
423 )?;
424 f.flush().map_err(crate::error::FxcpError::Io)?;
425 f.sync_all().map_err(crate::error::FxcpError::Io)?;
426 drop(f);
427 fs::rename(&tmp_path, &summary_path).map_err(crate::error::FxcpError::Io)?;
428 if let Some(parent) = summary_path.parent()
429 && let Ok(dir) = fs::File::open(parent) {
430 let _ = dir.sync_all();
431 }
432 Ok(())
433 }
434
435 fn remove_snapshot_dir(&self, dir: &Path) -> crate::Result<u64> {
436 let mut freed = 0u64;
437 for entry in walkdir::WalkDir::new(dir).contents_first(true) {
438 let entry = match entry { Ok(e) => e, Err(_) => continue };
439 if entry.file_type().is_file() || entry.file_type().is_symlink() {
440 freed += entry.metadata().map(|m| m.len()).unwrap_or(0);
441 let _ = fs::remove_file(entry.path());
442 } else if entry.file_type().is_dir() {
443 let _ = fs::remove_dir(entry.path());
444 }
445 }
446 if let Err(e) = self.rebuild_index() {
448 warn!("version store: rebuild_index failed: {}", e);
449 }
450 Ok(freed)
451 }
452
453 pub fn read_index(&self) -> crate::Result<SnapshotIndex> {
457 let path = self.root.join(INDEX_FILE);
458 let data = fs::read_to_string(&path).map_err(crate::error::FxcpError::Io)?;
459 serde_json::from_str(&data).map_err(|e|
460 crate::error::FxcpError::Config(format!("Invalid index.json: {}", e))
461 )
462 }
463
464 fn append_to_index(&self, entry: &SnapshotEntry) {
465 let mut index = self.read_index().unwrap_or(SnapshotIndex {
466 version: 1,
467 target: self.root.parent()
468 .map(|p| p.to_string_lossy().into())
469 .unwrap_or_default(),
470 created: format_iso(SystemTime::now()),
471 snapshots: vec![],
472 });
473 index.snapshots.push(entry.clone());
474 let _ = self.write_index(&index);
475 }
476
477 pub fn write_index(&self, index: &SnapshotIndex) -> crate::Result<()> {
479 let _ = self.ensure_dirs();
480 let json = serde_json::to_string_pretty(index)
481 .map_err(|e| crate::error::FxcpError::Config(format!("JSON error: {}", e)))?;
482 fs::write(self.root.join(INDEX_FILE), json).map_err(crate::error::FxcpError::Io)
483 }
484
485 pub fn rebuild_index(&self) -> crate::Result<SnapshotIndex> {
487 let snapshots = self.scan_snapshots();
488 let index = SnapshotIndex {
489 version: 1,
490 target: self.root.parent()
491 .map(|p| p.to_string_lossy().into())
492 .unwrap_or_default(),
493 created: format_iso(SystemTime::now()),
494 snapshots,
495 };
496 self.write_index(&index)?;
497 Ok(index)
498 }
499
500 pub fn prune_by_age(&self, max_age: Duration) -> crate::Result<PruneStats> {
504 let cutoff = SystemTime::now() - max_age;
505 let mut stats = PruneStats::default();
506 let snapshots = self.scan_snapshots();
507 for snap in &snapshots {
508 let ts_fs = snap.timestamp.replace(':', "").replace("-T", "T")
509 .trim_end_matches('Z').to_string();
510 if let Some(t) = parse_timestamp(&ts_fs)
511 && t < cutoff {
512 if snap.tag.is_some() {
514 debug!("Skipping tagged snapshot: {}", snap.timestamp);
515 continue;
516 }
517 match self.delete_snapshot(&ts_fs) {
518 Ok(freed) => {
519 stats.snapshots_removed += 1;
520 stats.bytes_freed += freed;
521 info!("Pruned snapshot: {}", snap.timestamp);
522 }
523 Err(e) => warn!("Failed to prune {}: {}", snap.timestamp, e),
524 }
525 }
526 }
527 if let Err(e) = self.rebuild_index() {
528 warn!("version store: rebuild_index failed: {}", e);
529 }
530 Ok(stats)
531 }
532
533 pub fn prune_by_count(&self, max_count: usize) -> crate::Result<PruneStats> {
535 let mut stats = PruneStats::default();
536 let mut snapshots = self.scan_snapshots();
537 if snapshots.len() <= max_count { return Ok(stats); }
538
539 snapshots.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
541 let to_remove = snapshots.len() - max_count;
542 for snap in snapshots.iter().take(to_remove) {
543 if snap.tag.is_some() { continue; }
544 let ts_fs = snap.timestamp.replace(':', "").replace("-T", "T")
545 .trim_end_matches('Z').to_string();
546 match self.delete_snapshot(&ts_fs) {
547 Ok(freed) => {
548 stats.snapshots_removed += 1;
549 stats.bytes_freed += freed;
550 info!("Pruned snapshot: {}", snap.timestamp);
551 }
552 Err(e) => warn!("Failed to prune {}: {}", snap.timestamp, e),
553 }
554 }
555 if let Err(e) = self.rebuild_index() {
556 warn!("version store: rebuild_index failed: {}", e);
557 }
558 Ok(stats)
559 }
560
561 pub fn prune_by_size(&self, max_bytes: u64) -> crate::Result<PruneStats> {
563 let mut stats = PruneStats::default();
564 let mut snapshots = self.scan_snapshots();
565 snapshots.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
566
567 let total: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
568 if total <= max_bytes { return Ok(stats); }
569
570 let mut remaining = total;
571 for snap in &snapshots {
572 if remaining <= max_bytes { break; }
573 if snap.tag.is_some() { continue; }
574 let ts_fs = snap.timestamp.replace(':', "").replace("-T", "T")
575 .trim_end_matches('Z').to_string();
576 match self.delete_snapshot(&ts_fs) {
577 Ok(freed) => {
578 stats.snapshots_removed += 1;
579 stats.bytes_freed += freed;
580 remaining = remaining.saturating_sub(snap.size_bytes);
581 info!("Pruned snapshot: {} (freed {} bytes)", snap.timestamp, freed);
582 }
583 Err(e) => warn!("Failed to prune {}: {}", snap.timestamp, e),
584 }
585 }
586 if let Err(e) = self.rebuild_index() {
587 warn!("version store: rebuild_index failed: {}", e);
588 }
589 Ok(stats)
590 }
591
592 pub fn collect_snap_dirs(&self, timestamp_filter: Option<&str>) -> crate::Result<Vec<std::path::PathBuf>> {
596 let snap_dirs: Vec<_> = if let Some(ts) = timestamp_filter {
597 let dir = self.root.join(ts);
598 if dir.exists() { vec![dir] } else {
599 return Err(crate::error::FxcpError::Config(format!("Snapshot not found: {}", ts)));
600 }
601 } else {
602 fs::read_dir(&self.root).ok()
603 .map(|entries| entries.flatten()
604 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
605 .filter(|e| parse_timestamp(&e.file_name().to_string_lossy()).is_some())
606 .map(|e| e.path())
607 .collect())
608 .unwrap_or_default()
609 };
610 Ok(snap_dirs)
611 }
612
613 pub fn root(&self) -> &Path {
615 &self.root
616 }
617
618 pub fn export<W: std::io::Write + 'static>(
623 &self,
624 writer: W,
625 compress: &str,
626 timestamp_filter: Option<&str>,
627 ) -> crate::Result<ArchiveExportStats> {
628 let mut stats = ArchiveExportStats::default();
629 let mut builder = tar::Builder::new(wrap_compressor(writer, compress));
630
631 let snap_dirs: Vec<_> = if let Some(ts) = timestamp_filter {
633 let dir = self.root.join(ts);
634 if dir.exists() { vec![dir] } else {
635 return Err(crate::error::FxcpError::Config(format!("Snapshot not found: {}", ts)));
636 }
637 } else {
638 fs::read_dir(&self.root).ok()
639 .map(|entries| entries.flatten()
640 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
641 .filter(|e| parse_timestamp(&e.file_name().to_string_lossy()).is_some())
642 .map(|e| e.path())
643 .collect())
644 .unwrap_or_default()
645 };
646
647 let mut seen_chunks: std::collections::HashSet<String> = std::collections::HashSet::new();
649
650 let index_path = self.root.join(INDEX_FILE);
652 if index_path.exists() {
653 builder.append_path_with_name(&index_path, INDEX_FILE)
654 .map_err(crate::error::FxcpError::Io)?;
655 }
656
657 for snap_dir in &snap_dirs {
658 let snap_name = snap_dir.file_name().unwrap_or_default().to_string_lossy().into_owned();
659
660 let summary_path = snap_dir.join(SUMMARY_FILE);
662 if summary_path.exists() {
663 let archive_path = format!("meta/{}/{}", snap_name, SUMMARY_FILE);
664 builder.append_path_with_name(&summary_path, &archive_path)
665 .map_err(crate::error::FxcpError::Io)?;
666 }
667
668 let tree_dir = snap_dir.join(TREE_DIR);
670 if !tree_dir.exists() { continue; }
671
672 for entry in walkdir::WalkDir::new(&tree_dir).follow_links(false) {
673 let entry = match entry { Ok(e) => e, Err(_) => continue };
674 if !entry.file_type().is_file() { continue; }
675
676 let rel = match entry.path().strip_prefix(&self.root) {
677 Ok(r) => r.to_path_buf(),
678 Err(_) => continue,
679 };
680
681 stats.total_files += 1;
682 let file_size = entry.metadata().map(|m| m.len()).unwrap_or(0);
683 stats.total_apparent_bytes += file_size;
684
685 let hash = crate::hashing::hash_file_full(entry.path())
687 .ok()
688 .flatten()
689 .map(|h| hex::encode(h.as_bytes()))
690 .unwrap_or_default();
691
692 if !hash.is_empty() && seen_chunks.contains(&hash) {
693 stats.dedup_chunks += 1;
695 stats.dedup_bytes += file_size;
696 let manifest_entry = format!("{}|{}|{}\n", rel.display(), hash, file_size);
698 let data = manifest_entry.as_bytes();
699 let mut header = tar::Header::new_gnu();
700 header.set_size(data.len() as u64);
701 header.set_mode(0o644);
702 header.set_cksum();
703 let dedup_path = format!("dedup/{}", rel.display());
704 builder.append_data(&mut header, &dedup_path, data)
705 .map_err(crate::error::FxcpError::Io)?;
706 } else {
707 if !hash.is_empty() { seen_chunks.insert(hash.clone()); }
709 stats.unique_chunks += 1;
710 stats.unique_bytes += file_size;
711
712 let mut file = fs::File::open(entry.path()).map_err(crate::error::FxcpError::Io)?;
713 let mut header = tar::Header::new_gnu();
714 header.set_size(file_size);
715 header.set_mode(entry.metadata().map(|m| {
716 use std::os::unix::fs::PermissionsExt;
717 m.permissions().mode()
718 }).unwrap_or(0o644));
719 header.set_mtime(entry.metadata().map(|m| {
720 use std::os::unix::fs::MetadataExt;
721 m.mtime() as u64
722 }).unwrap_or(0));
723 header.set_cksum();
724 builder.append_data(&mut header, rel.to_string_lossy().as_ref(), &mut file)
725 .map_err(crate::error::FxcpError::Io)?;
726 }
727 }
728 stats.snapshots_exported += 1;
729 }
730
731 builder.finish().map_err(crate::error::FxcpError::Io)?;
732
733 info!("Export: {} snapshots, {} files ({} unique, {} deduped), apparent {} -> archive {}",
734 stats.snapshots_exported, stats.total_files, stats.unique_chunks, stats.dedup_chunks,
735 format_size(stats.total_apparent_bytes), format_size(stats.unique_bytes));
736
737 Ok(stats)
738 }
739
740 pub fn import<R: std::io::Read + 'static>(
742 &self,
743 reader: R,
744 compress: &str,
745 ) -> crate::Result<ImportStats> {
746 let mut stats = ImportStats::default();
747 let decompressed = wrap_import_decompressor(reader, compress);
748 let mut archive = tar::Archive::new(decompressed);
749
750 self.ensure_dirs().map_err(crate::error::FxcpError::Io)?;
751
752 for entry in archive.entries().map_err(crate::error::FxcpError::Io)? {
753 let mut entry = match entry { Ok(e) => e, Err(_) => continue };
754 let path = entry.path()
755 .map(|p| p.to_path_buf())
756 .unwrap_or_default();
757 let path_str = path.to_string_lossy().into_owned();
758
759 if path_str == INDEX_FILE {
760 entry.unpack(self.root.join(INDEX_FILE))
762 .map_err(crate::error::FxcpError::Io)?;
763 stats.metadata_files += 1;
764 } else if path_str.starts_with("meta/") {
765 let dest = self.root.join(path_str.trim_start_matches("meta/")
767 .split('/').next().unwrap_or(""))
768 .join(SUMMARY_FILE);
769 if let Some(parent) = dest.parent()
770 && let Err(e) = fs::create_dir_all(parent) {
771 warn!("version store: create_dir_all failed: {}", e);
772 }
773 entry.unpack(&dest).map_err(crate::error::FxcpError::Io)?;
774 stats.metadata_files += 1;
775 } else if path_str.starts_with("dedup/") {
776 stats.dedup_refs += 1;
778 } else {
779 let dest = self.root.join(&path_str);
781 if let Some(parent) = dest.parent()
782 && let Err(e) = fs::create_dir_all(parent) {
783 warn!("version store: create_dir_all failed: {}", e);
784 }
785 entry.unpack(&dest).map_err(crate::error::FxcpError::Io)?;
786 stats.files_restored += 1;
787 stats.bytes_restored += entry.header().size().unwrap_or(0);
788 }
789 }
790
791 if let Err(e) = self.rebuild_index() {
793 warn!("version store: rebuild_index failed: {}", e);
794 }
795
796 info!("Import: {} files restored, {} bytes, {} dedup refs, {} metadata files",
797 stats.files_restored, stats.bytes_restored, stats.dedup_refs, stats.metadata_files);
798
799 Ok(stats)
800 }
801
802 pub fn inspect_archive<R: std::io::Read + 'static>(reader: R, compress: &str) -> crate::Result<Vec<ArchiveEntry>> {
804 let decompressed = wrap_import_decompressor(reader, compress);
805 let mut archive = tar::Archive::new(decompressed);
806 let mut entries = Vec::new();
807
808 for entry in archive.entries().map_err(crate::error::FxcpError::Io)? {
809 let entry = match entry { Ok(e) => e, Err(_) => continue };
810 let path = entry.path().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
811 let size = entry.header().size().unwrap_or(0);
812 let is_dedup = path.starts_with("dedup/");
813 let is_meta = path.starts_with("meta/") || path == INDEX_FILE;
814
815 entries.push(ArchiveEntry {
816 path,
817 size,
818 is_dedup_ref: is_dedup,
819 is_metadata: is_meta,
820 });
821 }
822
823 Ok(entries)
824 }
825}
826
827#[derive(Debug, Default, Serialize, Deserialize)]
833pub struct ArchiveExportStats {
834 pub snapshots_exported: u64,
835 pub total_files: u64,
836 pub unique_chunks: u64,
837 pub dedup_chunks: u64,
838 pub total_apparent_bytes: u64,
839 pub unique_bytes: u64,
840 pub dedup_bytes: u64,
841}
842
843#[derive(Debug, Default, Serialize, Deserialize)]
845pub struct ImportStats {
846 pub files_restored: u64,
847 pub bytes_restored: u64,
848 pub dedup_refs: u64,
849 pub metadata_files: u64,
850}
851
852#[derive(Debug, Serialize, Deserialize)]
854pub struct ArchiveEntry {
855 pub path: String,
856 pub size: u64,
857 pub is_dedup_ref: bool,
858 pub is_metadata: bool,
859}
860
861fn wrap_compressor<W: std::io::Write + 'static>(writer: W, compress: &str) -> Box<dyn std::io::Write> {
862 match compress {
863 "none" => Box::new(writer),
864 s if s.starts_with("zstd") => {
865 let level = s.strip_prefix("zstd:").and_then(|l| l.parse().ok()).unwrap_or(3);
866 let enc = zstd::stream::Encoder::new(writer, level).expect("zstd encoder init failed");
867 Box::new(enc.auto_finish())
868 }
869 "lz4" => Box::new(lz4_flex::frame::FrameEncoder::new(writer)),
870 "gzip" => Box::new(flate2::write::GzEncoder::new(writer, flate2::Compression::default())),
871 s if s.starts_with("xz") => {
872 let level = s.strip_prefix("xz:").and_then(|l| l.parse().ok()).unwrap_or(6);
873 Box::new(liblzma::write::XzEncoder::new(writer, level))
874 }
875 _ => {
876 let enc = zstd::stream::Encoder::new(writer, 3).expect("zstd encoder init failed");
878 Box::new(enc.auto_finish())
879 }
880 }
881}
882
883pub fn wrap_import_decompressor<R: std::io::Read + 'static>(reader: R, compress: &str) -> Box<dyn std::io::Read> {
885 match compress {
886 "none" => Box::new(reader),
887 "zstd" => {
888 let dec = zstd::stream::Decoder::new(reader).expect("zstd decoder init failed");
889 Box::new(dec)
890 }
891 "lz4" => Box::new(lz4_flex::frame::FrameDecoder::new(reader)),
892 "gzip" => Box::new(flate2::read::GzDecoder::new(reader)),
893 "xz" => Box::new(liblzma::read::XzDecoder::new(reader)),
894 "auto" | _ => {
895 Box::new(reader)
898 }
899 }
900}
901
902fn format_size(bytes: u64) -> String {
907 crate::fmt::format_size(bytes)
908}
909
910pub fn print_snapshot_table(snapshots: &[SnapshotEntry]) {
912 use comfy_table::{Table, Cell, CellAlignment};
913
914 if snapshots.is_empty() {
915 println!("No snapshots found.");
916 return;
917 }
918
919 let mut table = Table::new();
920 table.set_header(vec![
921 Cell::new("Timestamp").set_alignment(CellAlignment::Left),
922 Cell::new("Type").set_alignment(CellAlignment::Left),
923 Cell::new("Files").set_alignment(CellAlignment::Right),
924 Cell::new("Apparent").set_alignment(CellAlignment::Right),
925 Cell::new("On-Disk").set_alignment(CellAlignment::Right),
926 Cell::new("Savings").set_alignment(CellAlignment::Right),
927 Cell::new("Tag").set_alignment(CellAlignment::Left),
928 ]);
929
930 let mut total_apparent = 0u64;
931 let mut total_disk = 0u64;
932
933 for snap in snapshots {
934 total_apparent += snap.size_bytes;
935 total_disk += snap.disk_usage_bytes;
936
937 table.add_row(vec![
938 Cell::new(&snap.timestamp),
939 Cell::new(&snap.snap_type),
940 Cell::new(snap.files.to_string()),
941 Cell::new(format_size(snap.size_bytes)),
942 Cell::new(format_size(snap.disk_usage_bytes)),
943 Cell::new(format!("{:.1}%", snap.savings_pct)),
944 Cell::new(snap.tag.as_deref().unwrap_or("")),
945 ]);
946 }
947
948 println!("{table}");
949 let total_savings = savings_percent(total_apparent, total_disk);
950 println!("\n{} snapshots | Apparent: {} | On-Disk: {} | Savings: {:.1}%",
951 snapshots.len(), format_size(total_apparent), format_size(total_disk), total_savings);
952}
953
954pub fn compute_store_stats(snapshots: &[SnapshotEntry], store_root: Option<&Path>) -> SnapshotStoreStats {
957 let total_apparent: u64 = snapshots.iter().map(|s| s.size_bytes).sum();
958 let total_disk: u64 = snapshots.iter().map(|s| s.disk_usage_bytes).sum();
959 let total_files: u64 = snapshots.iter().map(|s| s.files).sum();
960 let cow_savings = savings_percent(total_apparent, total_disk);
961
962 let (unique_content, dedup_savings) = if let Some(root) = store_root {
965 compute_blake3_dedup(root, snapshots)
966 } else {
967 (total_apparent, 0.0)
968 };
969
970 SnapshotStoreStats {
971 snapshots: snapshots.len(),
972 total_files,
973 total_apparent_bytes: total_apparent,
974 total_disk_bytes: total_disk,
975 unique_content_bytes: unique_content,
976 cow_savings_pct: cow_savings,
977 dedup_savings_pct: dedup_savings,
978 savings_pct: savings_percent(total_apparent, total_disk),
979 oldest: snapshots.first().map(|s| s.timestamp.clone()),
980 newest: snapshots.last().map(|s| s.timestamp.clone()),
981 }
982}
983
984fn compute_blake3_dedup(store_root: &Path, snapshots: &[SnapshotEntry]) -> (u64, f64) {
986 use std::collections::HashSet;
987
988 let mut seen_hashes: HashSet<String> = HashSet::new();
989 let mut unique_bytes = 0u64;
990 let mut total_bytes = 0u64;
991
992 for snap in snapshots {
993 let ts_fs = snap.timestamp.replace(':', "").replace("-T", "T")
995 .trim_end_matches('Z').to_string();
996 let tree_dir = store_root.join(&ts_fs).join(TREE_DIR);
997 if !tree_dir.exists() { continue; }
998
999 for entry in walkdir::WalkDir::new(&tree_dir).follow_links(false) {
1000 let entry = match entry { Ok(e) => e, Err(_) => continue };
1001 if !entry.file_type().is_file() { continue; }
1002 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
1003 total_bytes += size;
1004
1005 let hash = crate::sidecar::get_metadata(entry.path(), "user.foxing.content_hash")
1007 .and_then(|b| if b.len() >= 8 { Some(hex::encode(&b[..8])) } else { None });
1008
1009 let hash = hash.or_else(|| {
1011 crate::hashing::hash_file_lite(entry.path(), size)
1012 .ok()
1013 .flatten()
1014 .map(|h| hex::encode(&h.as_bytes()[..8]))
1015 });
1016
1017 if let Some(h) = hash {
1018 if seen_hashes.insert(h) {
1019 unique_bytes += size;
1020 }
1021 } else {
1022 unique_bytes += size;
1024 }
1025 }
1026 }
1027
1028 let dedup = if total_bytes > 0 {
1029 savings_percent(total_bytes, unique_bytes)
1030 } else {
1031 0.0
1032 };
1033
1034 (unique_bytes, dedup)
1035}
1036
1037pub fn print_store_stats(stats: &SnapshotStoreStats, path: &Path) {
1039 println!("Snapshot Store: {}/.foxing_versions/", path.display());
1040 println!(" Snapshots: {}", stats.snapshots);
1041 println!(" Total Files: {}", stats.total_files);
1042 println!(" Apparent Size: {} (if all copies were independent)", format_size(stats.total_apparent_bytes));
1043 println!(" On-Disk (CoW): {} (actual exclusive blocks)", format_size(stats.total_disk_bytes));
1044 println!(" Unique Content: {} (BLAKE3 distinct)", format_size(stats.unique_content_bytes));
1045 let cow_saved = stats.total_apparent_bytes.saturating_sub(stats.total_disk_bytes);
1046 println!(" CoW Savings: {:.1}% ({} via reflinks)", stats.cow_savings_pct, format_size(cow_saved));
1047 if stats.dedup_savings_pct > 0.0 {
1048 let dedup_saved = stats.total_apparent_bytes.saturating_sub(stats.unique_content_bytes);
1049 println!(" Dedup Savings: {:.1}% ({} identical across snapshots)", stats.dedup_savings_pct, format_size(dedup_saved));
1050 }
1051 if let Some(ref oldest) = stats.oldest {
1052 println!(" Oldest: {}", oldest);
1053 }
1054 if let Some(ref newest) = stats.newest {
1055 println!(" Newest: {}", newest);
1056 }
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1062 use super::*;
1063
1064 #[test]
1065 fn test_format_timestamp() {
1066 let t = UNIX_EPOCH + Duration::from_secs(1773484800); let s = format_timestamp(t);
1068 assert!(s.starts_with("2026"), "expected 2026, got: {}", s);
1069 assert!(!s.contains(':'), "should not contain colons: {}", s);
1070 assert!(s.contains('T'), "should contain T separator: {}", s);
1071 }
1072
1073 #[test]
1074 fn test_parse_timestamp_roundtrip() {
1075 let t = SystemTime::now();
1076 let s = format_timestamp(t);
1077 let parsed = parse_timestamp(&s);
1078 assert!(parsed.is_some());
1079 }
1080
1081 #[test]
1082 fn test_format_iso() {
1083 let t = UNIX_EPOCH + Duration::from_secs(1773484800);
1084 let s = format_iso(t);
1085 assert!(s.contains(':'));
1086 assert!(s.ends_with('Z'));
1087 }
1088
1089 #[test]
1090 fn test_version_store_open() {
1091 let store = VersionStore::open(Path::new("/tmp/test"));
1092 assert_eq!(store.root, PathBuf::from("/tmp/test/.foxing_versions"));
1093 }
1094
1095 #[test]
1096 fn test_snapshot_entry_json_roundtrip() {
1097 let entry = SnapshotEntry {
1098 timestamp: "2026-03-12T08:45:00Z".into(),
1099 snap_type: "full".into(),
1100 tag: Some("pre-migration".into()),
1101 files: 100,
1102 size_bytes: 1048576,
1103 disk_usage_bytes: 4096,
1104 savings_pct: 99.6,
1105 source: "/mnt/source".into(),
1106 trigger: "fxcp --snapshot".into(),
1107 retention: None,
1108 };
1109 let json = serde_json::to_string(&entry).unwrap();
1110 let parsed: SnapshotEntry = serde_json::from_str(&json).unwrap();
1111 assert_eq!(parsed.files, 100);
1112 assert_eq!(parsed.tag, Some("pre-migration".into()));
1113 }
1114
1115 #[test]
1116 fn test_index_json_roundtrip() {
1117 let index = SnapshotIndex {
1118 version: 1,
1119 target: "/mnt/backup".into(),
1120 created: "2026-03-12T08:00:00Z".into(),
1121 snapshots: vec![],
1122 };
1123 let json = serde_json::to_string_pretty(&index).unwrap();
1124 let parsed: SnapshotIndex = serde_json::from_str(&json).unwrap();
1125 assert_eq!(parsed.version, 1);
1126 }
1127}