Skip to main content

fxcp_core/
version_store.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4// fxcp-core/src/version_store.rs  --  Versioning presentation layer with JSON index
5
6//! Hybrid point-in-time + per-file version store with JSON manifest.
7//! Wraps the core versioning engine to provide human-readable naming,
8//! dirvish-style browsable tree snapshots, and machine-readable indexes.
9
10#![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
25/// Filesystem-safe timestamp format (no colons).
26fn 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
34/// ISO 8601 timestamp for JSON.
35fn 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
43/// Parse a filesystem-safe timestamp back to SystemTime.
44fn 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// -----------------------------------------------------------------------
51// JSON schema types
52// -----------------------------------------------------------------------
53
54/// JSON-serializable snapshot index with schema version and snapshot list.
55#[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/// Metadata for a single point-in-time snapshot entry.
64#[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/// Expiration and policy rules for snapshot retention.
82#[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/// Aggregate storage statistics across all snapshots.
108#[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/// Statistics returned from snapshot pruning operations.
123#[derive(Debug, Default)]
124pub struct PruneStats {
125    pub snapshots_removed: u64,
126    pub bytes_freed: u64,
127}
128
129/// Compute apparent size (st_size) and actual disk usage (st_blocks * 512) for a directory tree.
130/// Returns (apparent_bytes, disk_bytes).
131pub 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
151// -----------------------------------------------------------------------
152// VersionStore
153// -----------------------------------------------------------------------
154
155/// Manages point-in-time snapshots with browsable tree directories and JSON manifest.
156pub struct VersionStore {
157    root: PathBuf,
158}
159
160impl VersionStore {
161    /// Open or create a version store at `{target}/.foxing_versions/`.
162    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    // ---- Point-in-time operations ----
174
175    /// Create a point-in-time snapshot by reflinking all files from source->target
176    /// into a timestamped tree directory.
177    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        // Walk target and reflink each file into the snapshot tree
198        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            // Skip our own version store
204            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        // Compute actual disk usage (CoW savings)
228        let (apparent, disk_used) = compute_disk_usage(&tree_dir);
229        let savings = savings_percent(apparent, disk_used);
230
231        // Write summary.json
232        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        // Create per-file symlinks
251        self.create_file_symlinks(&ts_dir, &tree_dir, target);
252
253        // Update index
254        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            // Relative symlink back to the tree
289            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    /// List all snapshots (point-in-time directories).
297    pub fn list_snapshots(&self) -> Vec<SnapshotEntry> {
298        // Try index.json first
299        if let Ok(index) = self.read_index() {
300            return index.snapshots;
301        }
302        // Fallback: scan directory
303        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            // Read summary.json if available
318            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                        // Compute on demand for old-format summaries
325                        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            // Minimal entry  --  compute disk usage from tree if available
349            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    /// Delete a snapshot by timestamp.
376    pub fn delete_snapshot(&self, timestamp: &str) -> crate::Result<u64> {
377        // Find directory matching timestamp (filesystem-safe format)
378        let snap_dir = self.root.join(timestamp);
379        if !snap_dir.exists() {
380            // Try ISO format -> filesystem-safe conversion
381            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    /// Assigns a tag to a snapshot, exempting it from automatic pruning.
394    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        // Rebuild index
447        if let Err(e) = self.rebuild_index() {
448            warn!("version store: rebuild_index failed: {}", e);
449        }
450        Ok(freed)
451    }
452
453    // ---- Index management ----
454
455    /// Reads and deserializes the JSON snapshot index from disk.
456    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    /// Writes the snapshot index to disk as pretty-printed JSON.
478    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    /// Rebuilds the snapshot index by scanning on-disk snapshot directories.
486    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    // ---- Maintenance / Pruning ----
501
502    /// Remove snapshots older than `max_age`.
503    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                    // Skip tagged snapshots
513                    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    /// Keep only the last `max_count` snapshots (oldest removed first).
534    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        // Sort oldest first, remove from front
540        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    /// Remove oldest snapshots until total size is under `max_bytes`.
562    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    // ---- Snapshot directory enumeration ----
593
594    /// Collect snapshot directories for export. Returns paths to snapshot dirs.
595    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    /// Get the version store root path.
614    pub fn root(&self) -> &Path {
615        &self.root
616    }
617
618    // ---- Export / Import ----
619
620    /// Export snapshots as a .fxar archive (content-addressable chunk-dedup).
621    /// Writes to the provided writer (file or stdout).
622    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        // Collect snapshot directories to export
632        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        // Track unique chunks for dedup
648        let mut seen_chunks: std::collections::HashSet<String> = std::collections::HashSet::new();
649
650        // Add index.json
651        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            // Add summary.json
661            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            // Walk tree and add files as chunks
669            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                // Try to get BLAKE3 hash for chunk dedup
686                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                    // Chunk already in archive  --  just record in manifest
694                    stats.dedup_chunks += 1;
695                    stats.dedup_bytes += file_size;
696                    // Add a small manifest entry instead of file data
697                    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                    // New unique chunk  --  add file data
708                    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    /// Import snapshots from a .fxar archive.
741    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                // Restore index.json
761                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                // Restore summary.json files
766                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                // Dedup reference  --  skip (file already exists from another snapshot)
777                stats.dedup_refs += 1;
778            } else {
779                // Regular file data  --  restore to version store
780                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        // Rebuild index from restored state
792        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    /// List contents of a .fxar archive without extracting.
803    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// -----------------------------------------------------------------------
828// Export / Import types and compression helpers
829// -----------------------------------------------------------------------
830
831/// Statistics from an archive export operation.
832#[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/// Statistics from an archive import operation.
844#[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/// A single entry from an inspected archive listing.
853#[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            // Default to zstd level 3
877            let enc = zstd::stream::Encoder::new(writer, 3).expect("zstd encoder init failed");
878            Box::new(enc.auto_finish())
879        }
880    }
881}
882
883/// Wraps a reader with the appropriate decompressor based on the compression format string.
884pub 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            // For auto-detect, we'd need to peek at magic bytes.
896            // Default to raw (caller should specify or use file extension).
897            Box::new(reader)
898        }
899    }
900}
901
902// -----------------------------------------------------------------------
903// Display helpers
904// -----------------------------------------------------------------------
905
906fn format_size(bytes: u64) -> String {
907    crate::fmt::format_size(bytes)
908}
909
910/// Print snapshots as a formatted table with CoW storage stats.
911pub 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
954/// Compute aggregate storage statistics across all snapshots.
955/// If `store_root` is provided, computes BLAKE3 content dedup across all snapshot files.
956pub 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    // BLAKE3 content dedup: hash all files across all snapshots,
963    // track unique hashes to find content-identical files
964    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
984/// Walk all snapshot tree files, compute BLAKE3 hashes, return (unique_bytes, dedup_savings_pct).
985fn 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        // Parse filesystem-safe timestamp from ISO
994        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            // Try to read BLAKE3 hash from xattr (fast path  --  already computed)
1006            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            // Fall back to quick hash (head+tail sampling)
1010            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                // Can't hash  --  count as unique
1023                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
1037/// Print detailed storage stats.
1038pub 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); // 2026-03-12T08:00:00 UTC
1067        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}