Skip to main content

fxcp_core/sync/
mod.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/sync/mod.rs  --  cli_main() dispatch + public entry points
5
6//! Shared copy/sync engine used by both fxcp CLI and foxingd sync command.
7//!
8//! Provides recursive directory copy with auto-adaptive strategy selection
9//! (reflink -> copy_file_range -> sendfile -> io_uring), stdin pipe mode with
10//! compression auto-detection, delta copy via BLAKE3 Merkle trees, and
11//! foxingd-compatible signature generation.
12
13#![allow(clippy::expect_used)]
14mod engine;
15mod tree;
16mod stdin;
17#[cfg(feature = "cloud")]
18mod s3;
19
20// Re-exports  --  preserve public API surface for external crates
21pub use tree::{store_foxing_signatures, preserve_metadata};
22#[allow(unused_imports)]
23pub(crate) use tree::create_copier;
24#[cfg(feature = "cloud")]
25pub use s3::{sync_from_s3, SyncFromS3Stats, parse_s3_url, build_s3_store};
26
27use std::path::{Path, PathBuf};
28use std::sync::atomic::Ordering;
29use tracing::{info, error};
30
31use crate::hashing;
32
33// Auto-adaptive thresholds
34const FICLONE: u64 = crate::constants::FICLONE_IOCTL;
35
36// -----------------------------------------------------------------------
37// Public types
38// -----------------------------------------------------------------------
39
40/// Options for a sync/copy operation (replaces CLI flags).
41#[derive(Debug, Clone)]
42pub struct SyncOptions {
43    pub source: PathBuf,
44    pub destination: PathBuf,
45    pub archive: bool,
46    pub recursive: bool,
47    pub delete: bool,
48    pub dry_run: bool,
49    pub exclude: Vec<String>,
50    pub include: Vec<String>,
51    pub generate_sigs: bool,
52    pub snapshot: bool,
53    pub throttle: bool,
54    pub cleanup: bool,
55    // stdin-specific
56    pub size: Option<u64>,
57    pub checkpoint_interval: Option<u64>,
58    pub checkpoint_keep: usize,
59    pub zero_copy: bool,
60    pub verify: bool,
61    pub checksums: Vec<hashing::ChecksumType>,
62    pub checksums_overwrite: bool,
63    pub cid_output: bool,
64    pub owner: bool,
65    pub group: bool,
66    pub perms: bool,
67    pub xattrs: bool,
68    pub acls: bool,
69}
70
71impl Default for SyncOptions {
72    fn default() -> Self {
73        Self {
74            source: PathBuf::new(),
75            destination: PathBuf::new(),
76            archive: false,
77            recursive: false,
78            delete: false,
79            dry_run: false,
80            exclude: vec![],
81            include: vec![],
82            generate_sigs: false,
83            snapshot: false,
84            throttle: false,
85            cleanup: false,
86            size: None,
87            checkpoint_interval: None,
88            checkpoint_keep: 5,
89            zero_copy: false,
90            verify: false,
91            checksums: Vec::new(),
92            checksums_overwrite: false,
93            cid_output: false,
94            owner: true,
95            group: true,
96            perms: true,
97            xattrs: true,
98            acls: false,
99        }
100    }
101}
102
103/// Progress tracking for live reporting during copy operations.
104pub struct ProgressTracker {
105    pub files_done: std::sync::atomic::AtomicU64,
106    pub files_total: std::sync::atomic::AtomicU64,
107    pub bytes_done: std::sync::atomic::AtomicU64,
108    pub bytes_total: std::sync::atomic::AtomicU64,
109    pub active: std::sync::atomic::AtomicBool,
110    /// 0 = counting, 1 = syncing, 2 = finalizing (dir hashes)
111    pub phase: std::sync::atomic::AtomicU8,
112    pub status_detail: parking_lot::Mutex<String>,
113}
114
115impl ProgressTracker {
116    pub fn new() -> std::sync::Arc<Self> {
117        std::sync::Arc::new(Self {
118            files_done: std::sync::atomic::AtomicU64::new(0),
119            files_total: std::sync::atomic::AtomicU64::new(0),
120            bytes_done: std::sync::atomic::AtomicU64::new(0),
121            bytes_total: std::sync::atomic::AtomicU64::new(0),
122            active: std::sync::atomic::AtomicBool::new(true),
123            phase: std::sync::atomic::AtomicU8::new(0),
124            status_detail: parking_lot::Mutex::new(String::new()),
125        })
126    }
127}
128
129// Global progress tracker  --  set by cli_main when --progress is used.
130lazy_static::lazy_static! {
131    static ref ACTIVE_PROGRESS: parking_lot::Mutex<Option<std::sync::Arc<ProgressTracker>>> = parking_lot::Mutex::new(None);
132}
133
134/// Record a file completion for progress reporting.
135fn record_file_progress(bytes: u64) {
136    if let Some(ref progress) = *ACTIVE_PROGRESS.lock() {
137        progress.files_done.fetch_add(1, Ordering::Relaxed);
138        progress.bytes_done.fetch_add(bytes, Ordering::Relaxed);
139    }
140}
141
142fn set_progress_phase(phase: u8) {
143    if let Some(ref progress) = *ACTIVE_PROGRESS.lock() {
144        progress.phase.store(phase, Ordering::Relaxed);
145    }
146}
147
148fn set_progress_detail(detail: &str) {
149    if let Some(ref progress) = *ACTIVE_PROGRESS.lock() {
150        *progress.status_detail.lock() = detail.to_string();
151    }
152}
153
154/// Spawn a progress reporter thread that prints updates to stderr.
155/// Returns a join handle. Set active=false to stop.
156fn spawn_progress_reporter(progress: std::sync::Arc<ProgressTracker>, json_mode: bool) -> std::thread::JoinHandle<()> {
157    std::thread::spawn(move || {
158        let start = std::time::Instant::now();
159        while progress.active.load(Ordering::Relaxed) {
160            std::thread::sleep(std::time::Duration::from_millis(if json_mode { crate::constants::PROGRESS_INTERVAL_JSON_MS } else { crate::constants::PROGRESS_INTERVAL_HUMAN_MS }));
161
162            let done = progress.files_done.load(Ordering::Relaxed);
163            let total = progress.files_total.load(Ordering::Relaxed);
164            let bytes_done = progress.bytes_done.load(Ordering::Relaxed);
165            let bytes_total = progress.bytes_total.load(Ordering::Relaxed);
166
167            if total == 0 { continue; }
168
169            let pct = if bytes_total > 0 { (bytes_done as f64 / bytes_total as f64) * 100.0 } else { 0.0 };
170            let elapsed = start.elapsed().as_secs_f64();
171            let throughput = if elapsed > 0.0 { bytes_done as f64 / elapsed / 1_048_576.0 } else { 0.0 };
172            let eta = if throughput > 0.0 && bytes_total > bytes_done {
173                ((bytes_total - bytes_done) as f64 / (throughput * 1_048_576.0)) as u64
174            } else { 0 };
175
176            let phase = progress.phase.load(Ordering::Relaxed);
177
178            if json_mode {
179                let json = serde_json::json!({
180                    "progress_pct": (pct * 10.0).round() / 10.0,
181                    "bytes_done": bytes_done,
182                    "bytes_total": bytes_total,
183                    "files_done": done,
184                    "files_total": total,
185                    "throughput_mbps": (throughput * 10.0).round() / 10.0,
186                    "eta_secs": eta,
187                    "phase": match phase { 2 => "finalizing", _ => "syncing" },
188                });
189                eprintln!("{}", serde_json::to_string(&json).unwrap_or_default());
190            } else if phase >= 2 {
191                let detail = progress.status_detail.lock().clone();
192                let label = if detail.is_empty() {
193                    "storing dir hashes...".to_string()
194                } else {
195                    let max_path = 40;
196                    if detail.len() > max_path {
197                        format!("...{}", &detail[detail.len() - max_path..])
198                    } else {
199                        detail
200                    }
201                };
202                eprint!("\r[done] {} files | {:<60}",
203                        done, label);
204            } else {
205                let done_str = format_size_compact(bytes_done);
206                let total_str = format_size_compact(bytes_total);
207                eprint!("\r[{:>5.1}%] {} / {} | {} / {} files | {:.0} MB/s | ETA {}s   ",
208                        pct, done_str, total_str, done, total, throughput, eta);
209            }
210        }
211        if !json_mode { eprintln!(); } // Final newline
212    })
213}
214
215/// Check available disk space on the filesystem containing `path`.
216/// Returns None if statvfs fails (e.g., FUSE without statvfs support).
217fn check_available_space(path: &Path) -> Option<u64> {
218    use std::ffi::CString;
219    let c_path = CString::new(path.to_string_lossy().as_bytes()).ok()?;
220    // SAFETY: statvfs is a C struct where all-zeros is a valid initial state.
221    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
222    // SAFETY: c_path is a valid null-terminated C string, stat is a valid pointer.
223    let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
224    if rc == 0 {
225        Some(stat.f_bavail * stat.f_bsize)
226    } else {
227        None
228    }
229}
230
231fn format_size_compact(bytes: u64) -> String {
232    crate::fmt::format_size(bytes)
233}
234
235/// Copy statistics returned from sync operations.
236#[derive(Debug, Default, serde::Serialize)]
237pub struct SyncStats {
238    pub files_copied: u64,
239    pub files_reflinked: u64,
240    pub files_cfr: u64,
241    pub files_small: u64,
242    pub files_skipped: u64,
243    pub files_delta: u64,
244    pub files_deleted: u64,
245    pub dirs_created: u64,
246    pub dirs_pruned: u64,
247    pub bytes_copied: u64,
248    pub bytes_reflinked: u64,
249    pub bytes_cfr: u64,
250    pub bytes_small: u64,
251    pub bytes_delta: u64,
252    pub errors: u64,
253    pub sigs_stored: u64,
254    pub symlinks_replicated: u64,
255    pub dirs_hashed: u64,
256    pub files_verified: u64,
257    pub verify_failures: u64,
258    #[cfg(feature = "nfs-bypass")]
259    pub files_nfs_bypass: u64,
260    #[cfg(feature = "nfs-bypass")]
261    pub bytes_nfs_bypass: u64,
262}
263
264impl SyncStats {
265    /// Accumulate stats from another run (for multi-source operations).
266    pub fn merge(&mut self, other: &SyncStats) {
267        self.files_copied += other.files_copied;
268        self.files_reflinked += other.files_reflinked;
269        self.files_cfr += other.files_cfr;
270        self.files_small += other.files_small;
271        self.files_skipped += other.files_skipped;
272        self.files_delta += other.files_delta;
273        self.files_deleted += other.files_deleted;
274        self.dirs_created += other.dirs_created;
275        self.dirs_pruned += other.dirs_pruned;
276        self.bytes_copied += other.bytes_copied;
277        self.bytes_reflinked += other.bytes_reflinked;
278        self.bytes_cfr += other.bytes_cfr;
279        self.bytes_small += other.bytes_small;
280        self.bytes_delta += other.bytes_delta;
281        self.errors += other.errors;
282        self.sigs_stored += other.sigs_stored;
283        self.symlinks_replicated += other.symlinks_replicated;
284        self.dirs_hashed += other.dirs_hashed;
285        self.files_verified += other.files_verified;
286        self.verify_failures += other.verify_failures;
287        #[cfg(feature = "nfs-bypass")]
288        {
289            self.files_nfs_bypass += other.files_nfs_bypass;
290            self.bytes_nfs_bypass += other.bytes_nfs_bypass;
291        }
292    }
293}
294
295// -----------------------------------------------------------------------
296// Main entry point
297// -----------------------------------------------------------------------
298
299/// Run a sync operation with the given options.
300pub async fn run(opts: SyncOptions) -> crate::Result<SyncStats> {
301    if opts.cleanup {
302        tree::run_cleanup(&opts.source);
303        return Ok(SyncStats::default());
304    }
305
306    if opts.source.as_os_str() == "-" {
307        return stdin::run_stdin_to_file(&opts).await;
308    }
309
310    engine::run_sync(&opts).await
311}
312
313// -----------------------------------------------------------------------
314// CLI definition (public for man page / completion generation)
315// -----------------------------------------------------------------------
316
317use clap::{Parser, Subcommand};
318
319#[derive(Parser)]
320#[command(name = "fxcp", version,
321    about = "Smart filesystem copy with CoW/reflink/io_uring support",
322    long_about = "Smart filesystem copy with CoW/reflink/io_uring support.\n\
323\n\
324Automatically selects the optimal copy strategy: FICLONE (instant CoW clone), \
325copy_file_range (server-side NFS copy), sendfile (kernel-optimized for small files), \
326or io_uring (async pipelined for large/cross-device files). Generates BLAKE3 Merkle \
327signatures for incremental resync and is compatible with foxingd daemon signatures.\n\
328\n\
329Sparse files are detected via SEEK_HOLE/SEEK_DATA and replicated without materializing holes.\n\
330Multiple sources can be specified with the last argument as destination.\n\
331\n\
332DIRECTORY SEMANTICS (rsync-compatible):\n\
333  fxcp -a /src  /dest    no trailing slash, dest exists   -> /dest/src/\n\
334  fxcp -a /src/ /dest/   trailing slash on source         -> /dest/{contents}\n\
335  fxcp -a /src  /newdir  dest does not yet exist          -> /newdir/\n\
336\n\
337This matches rsync behavior. The trailing slash on the source controls whether the\n\
338source directory itself appears in the destination."
339)]
340#[command(after_help = r#"SUBCOMMANDS:
341  snap             Snapshot management (MARS versioning -- list, export, restore, prune)
342  index            Index files for semantic search (requires --features ai)
343  search           Semantic search across indexed files (requires --features ai)
344  enrich           Enrich files with LLM-generated metadata (requires --features enrichment)
345  restore          Restore files from S3 CAS store
346  export-vectors   Export embedding vectors to pgvector (requires --features pgvector)
347
348Run 'fxcp <SUBCOMMAND> --help' for more information on a specific subcommand.
349
350DIRECTORY SEMANTICS:
351  fxcp -a /src  /dest     # dest exists, no trailing slash -> /dest/src/
352  fxcp -a /src/ /dest/    # trailing slash -> /dest/{files}
353  fxcp -a /src  /newdir   # dest absent -> /newdir/ (rename semantics)
354
355  Matches rsync behavior. Use trailing slash on source to copy contents only."#)]
356pub struct FxcpCli {
357    /// Source path(s) and destination  --  last argument is destination (use '-' for stdin).
358    /// Trailing slash on source controls directory behavior (rsync-compatible):
359    /// 'fxcp src dest' creates dest/src/; 'fxcp src/ dest' copies contents into dest/
360    #[arg(required = true, num_args = 2..)]
361    pub paths: Vec<PathBuf>,
362    #[arg(short = 'a', long, help = "Archive mode (recursive, preserve attributes)")]
363    pub archive: bool,
364    #[arg(short = 'r', long, help = "Recursive copy (implied by -a)")]
365    pub recursive: bool,
366    #[arg(short = 'v', long, help = "BLAKE3 verification after copy")]
367    pub verify: bool,
368    #[arg(long, help = "Delete files in target not present in source")]
369    pub delete: bool,
370    #[arg(short = 'n', long, help = "Dry run -- show what would be copied")]
371    pub dry_run: bool,
372    #[arg(short = 'e', long, help = "Exclude pattern (glob)")]
373    pub exclude: Vec<String>,
374    #[arg(long, help = "Include pattern -- override excludes (glob)")]
375    pub include: Vec<String>,
376    #[arg(long, help = "Read exclude patterns from FILE (one per line)")]
377    pub exclude_from: Option<PathBuf>,
378    #[arg(long, help = "Read include patterns from FILE (one per line)")]
379    pub include_from: Option<PathBuf>,
380    #[arg(long, help = "Create reflink snapshots of target files before overwriting (versioning)")]
381    pub snapshot: bool,
382    #[arg(long, help = "Enable PSI-based system stress throttling")]
383    pub throttle: bool,
384    #[arg(long, help = "Clean orphaned .tmp files and stale dirty flags")]
385    pub cleanup: bool,
386    #[arg(long, help = "Expected size in bytes (for stdin pre-allocation)")]
387    pub size: Option<u64>,
388    #[arg(long, help = "Interval in seconds to create CoW checkpoints of stdin stream")]
389    pub checkpoint_interval: Option<u64>,
390    #[arg(long, default_value = "5", help = "Number of stream checkpoints to keep")]
391    pub checkpoint_keep: usize,
392    #[arg(long, help = "Use zero-copy splice (mutually exclusive with sparse detection)")]
393    pub zero_copy: bool,
394    #[arg(long, default_value_t = false, help = "Increase verbosity")]
395    pub debug: bool,
396    #[arg(long, help = "Output copy results as JSON (machine-readable)")]
397    pub json: bool,
398    #[arg(long, help = "Show live progress during copy")]
399    pub progress: bool,
400    #[arg(long, help = "Generate foxingd-compatible sync signatures (xattr/sidecar) for fast resync")]
401    pub generate_sigs: bool,
402    #[arg(long, value_name = "TYPES", default_missing_value = "blake3", num_args = 0..=1,
403          help = "Write checksum sidecar files alongside copies. Comma-separated: blake3 (FREE), sha256, sha1, md5. Default: blake3")]
404    pub checksums: Option<String>,
405    #[arg(long, help = "Overwrite existing checksum sidecar files (default: skip if present)")]
406    pub checksums_overwrite: bool,
407    #[arg(long, help = "Output BLAKE3 checksums as CID base32lower strings instead of hex")]
408    pub cid: bool,
409    #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(u8).range(0..=2),
410          help = "Chunk normalization level (0=legacy, 1=normalized, 2=aggressive). WARNING: changing this breaks CAS dedup compatibility with existing data")]
411    pub normalization_level: u8,
412    #[arg(long, default_value_t = true, action = clap::ArgAction::Set,
413          help = "Preserve file owner (use --no-owner to disable)")]
414    pub owner: bool,
415    #[arg(long, default_value_t = true, action = clap::ArgAction::Set,
416          help = "Preserve file group (use --no-group to disable)")]
417    pub group: bool,
418    #[arg(long, default_value_t = true, action = clap::ArgAction::Set,
419          help = "Preserve permissions (use --no-perms to disable)")]
420    pub perms: bool,
421    #[arg(long, default_value_t = true, action = clap::ArgAction::Set,
422          help = "Preserve extended attributes (use --no-xattrs to disable)")]
423    pub xattrs: bool,
424    #[arg(long, default_value_t = false, action = clap::ArgAction::Set,
425          help = "Preserve ACLs (subset of xattrs, explicit opt-in)")]
426    pub acls: bool,
427}
428
429/// Snapshot management subcommands (MARS versioning)
430#[derive(Parser)]
431#[command(name = "fxcp", version, about = "Smart filesystem copy with CoW/reflink/io_uring support")]
432pub struct FxcpSnapCli {
433    #[command(subcommand)]
434    pub command: SnapCommand,
435    #[arg(long, default_value_t = false, help = "Increase verbosity")]
436    pub debug: bool,
437}
438
439#[derive(Subcommand)]
440pub enum SnapCommand {
441    /// List snapshots or versions of a specific file
442    List {
443        /// Target directory or specific file path
444        path: Option<String>,
445        /// Output as JSON (machine-readable)
446        #[arg(long)]
447        json: bool,
448    },
449    /// Show details of a specific snapshot
450    Show { timestamp: String },
451    /// Revert a file to a previous version (atomic reflink swap)
452    Revert { path: String, epoch: u64 },
453    /// Copy a specific version to a new file
454    Copy { path: String, epoch: u64, destination: String },
455    /// Remove old snapshots by age, count, or size
456    Prune {
457        /// Delete snapshots older than this duration (e.g. 30d, 7d, 24h)
458        #[arg(long)]
459        older_than: Option<String>,
460        /// Keep only the last N snapshots
461        #[arg(long)]
462        keep_last: Option<usize>,
463        /// Delete oldest until total size is under this limit (e.g. 50G, 10G)
464        #[arg(long)]
465        max_size: Option<String>,
466        /// Target directory containing .foxing_versions
467        #[arg(default_value = ".")]
468        path: String,
469    },
470    /// Tag a snapshot (tagged snapshots are exempt from auto-pruning)
471    Tag { timestamp: String, tag: String },
472    /// Rebuild index.json from on-disk snapshot state
473    RebuildIndex {
474        /// Target directory containing .foxing_versions
475        #[arg(default_value = ".")]
476        path: String,
477    },
478    /// Show aggregate storage statistics (apparent vs on-disk, CoW savings)
479    Stats {
480        /// Target directory containing .foxing_versions
481        #[arg(default_value = ".")]
482        path: String,
483        /// Output as JSON
484        #[arg(long)]
485        json: bool,
486    },
487    /// Export snapshots as a .fxar archive (content-addressable, deduped)
488    Export {
489        /// Source directory containing .foxing_versions or .foxing_cas
490        path: String,
491        /// Output file (default: stdout)
492        #[arg(short, long)]
493        output: Option<String>,
494        /// Compression: zstd (default), zstd:N, lz4, gzip, xz, xz:N, none
495        #[arg(long, default_value = "zstd")]
496        compress: String,
497        /// Export only a specific snapshot timestamp
498        #[arg(long)]
499        timestamp: Option<String>,
500        /// Archive format: fxar2 (default, chunk-dedup) or tar (legacy whole-file)
501        #[arg(long, default_value = "fxar2")]
502        format: String,
503        /// Bundle .foxing_index/ AI sections (embeddings, HNSW) into the archive
504        #[arg(long)]
505        include_vectors: bool,
506        /// Export from .foxing_cas/ CAS store instead of .foxing_versions/
507        #[arg(long)]
508        from_cas: bool,
509    },
510    /// Import snapshots from a .fxar archive
511    Import {
512        /// Target directory to restore into
513        path: String,
514        /// Input file (default: stdin)
515        #[arg(short, long)]
516        input: Option<String>,
517        /// Decompression: auto (default), zstd, lz4, gzip, xz, none
518        #[arg(long, default_value = "auto")]
519        compress: String,
520        /// Generate foxingd-compatible signatures for fast incremental sync
521        #[arg(long)]
522        generate_sigs: bool,
523        /// Preserve raw snapshot/tree/ path structure (default: extract latest version with flat paths)
524        #[arg(long)]
525        raw: bool,
526        /// Show per-file version info during extraction
527        #[arg(short, long)]
528        verbose: bool,
529        /// Import into .foxing_cas/ CAS store (filesystem-agnostic, no xattr needed)
530        #[arg(long)]
531        to_cas: bool,
532    },
533    /// Inspect a .fxar archive without extracting
534    Inspect {
535        /// Archive file
536        archive: String,
537        /// Show full file listing
538        #[arg(long)]
539        list: bool,
540        /// Output as JSON
541        #[arg(long)]
542        json: bool,
543        /// Show versions of a specific file
544        #[arg(long)]
545        file: Option<String>,
546    },
547    /// Restore specific files/snapshots from a .fxar archive
548    Restore {
549        /// Archive file
550        archive: String,
551        /// File path pattern to restore (glob)
552        #[arg(long)]
553        file: Option<String>,
554        /// Snapshot date to restore from (prefix match)
555        #[arg(long)]
556        date: Option<String>,
557        /// Restore most recent version
558        #[arg(long)]
559        latest: bool,
560        /// Restore all versions of matched files
561        #[arg(long)]
562        all_versions: bool,
563        /// Output directory
564        #[arg(short, long, default_value = ".")]
565        output: String,
566    },
567    /// Interactive MC-style dual-pane browser for snapshots and archives
568    #[cfg(feature = "tui")]
569    Browse {
570        /// Directory with .foxing_versions or .fxar archive file
571        #[arg(default_value = ".")]
572        path: String,
573        /// Load AI index from archive for vector search (requires --features ai)
574        #[arg(long)]
575        ai: bool,
576    },
577}
578
579// -----------------------------------------------------------------------
580// CLI entry point (for symlink dispatch from foxingd)
581// -----------------------------------------------------------------------
582
583/// Parse CLI args and run  --  used when foxingd is called as `fxcp` via symlink.
584pub fn cli_main() -> anyhow::Result<()> {
585    // Pre-parse: check if first arg is "snap" for subcommand dispatch
586    let args: Vec<String> = std::env::args().collect();
587    if args.len() > 1 && args[1] == "snap" {
588        return cli_snap_main();
589    }
590
591    let cli = FxcpCli::parse();
592
593
594    let log_filter = if cli.debug { "debug" } else { "info" };
595    // Use try_init()  --  subscriber may already be set by the calling binary (e.g. fxcp/main.rs)
596    let _ = tracing_subscriber::fmt()
597        .with_env_filter(tracing_subscriber::EnvFilter::new(log_filter))
598        .with_target(false)
599        .try_init();
600
601    // Log detected crypto acceleration (once at startup)
602    if cli.checksums.is_some() || cli.generate_sigs || cli.verify {
603        crate::hashing::log_crypto_capabilities();
604    }
605
606    // Split positional args: all-but-last = sources, last = destination
607    let (sources, destination) = crate::filter::split_paths(cli.paths)?;
608
609    // Merge file-based patterns into CLI patterns
610    let mut excludes = cli.exclude;
611    if let Some(ref file) = cli.exclude_from {
612        excludes.extend(crate::filter::read_patterns(file)?);
613    }
614    let mut includes = cli.include;
615    if let Some(ref file) = cli.include_from {
616        includes.extend(crate::filter::read_patterns(file)?);
617    }
618
619    // Stdin mode: only valid with a single source
620    if sources.len() > 1 && sources.iter().any(|s| s.as_os_str() == "-") {
621        anyhow::bail!("stdin (-) cannot be used with multiple sources");
622    }
623
624    let rt = tokio::runtime::Builder::new_multi_thread()
625        .enable_all()
626        .build()
627        .expect("Failed to create tokio runtime");
628
629    // Set up progress reporting if requested
630    let progress_handle = if cli.progress {
631        let tracker = ProgressTracker::new();
632        *ACTIVE_PROGRESS.lock() = Some(tracker.clone());
633        Some(spawn_progress_reporter(tracker, cli.json))
634    } else {
635        None
636    };
637
638    let mut total_stats = SyncStats::default();
639    let sync_start = std::time::Instant::now();
640
641    // S3 source detection  --  sync from S3 to local
642    #[cfg(feature = "cloud")]
643    {
644        if !sources.is_empty() {
645            let src_str = sources[0].to_string_lossy();
646            if src_str.starts_with("s3://") {
647                let (endpoint, bucket, prefix) = s3::parse_s3_url(&src_str)?;
648                let dest_path = &destination;
649                info!(
650                    "S3 source detected: endpoint={}, bucket={}, prefix={} -> {}",
651                    endpoint, bucket, prefix, dest_path.display()
652                );
653
654                let s3_stats = rt.block_on(s3::sync_from_s3_inner(
655                    &endpoint, &bucket, &prefix, dest_path,
656                    None, cli.delete,
657                ))?;
658
659                if let Some(handle) = progress_handle {
660                    if let Some(ref progress) = *ACTIVE_PROGRESS.lock() {
661                        progress.active.store(false, Ordering::Relaxed);
662                    }
663                    let _ = handle.join();
664                    *ACTIVE_PROGRESS.lock() = None;
665                }
666
667                if cli.json {
668                    let json_output = serde_json::json!({
669                        "status": if s3_stats.errors == 0 { "success" } else { "partial" },
670                        "source": "s3",
671                        "endpoint": endpoint,
672                        "bucket": bucket,
673                        "files_synced": s3_stats.files_synced,
674                        "bytes_synced": s3_stats.bytes_synced,
675                        "dirs_skipped": s3_stats.dirs_skipped,
676                        "dirs_synced": s3_stats.dirs_synced,
677                        "errors": s3_stats.errors,
678                    });
679                    println!("{}", serde_json::to_string_pretty(&json_output).unwrap_or_default());
680                } else {
681                    println!(
682                        "S3 sync from: {} files ({} bytes), {} dirs synced, {} dirs skipped, {} errors",
683                        s3_stats.files_synced,
684                        format_bytes(s3_stats.bytes_synced),
685                        s3_stats.dirs_synced,
686                        s3_stats.dirs_skipped,
687                        s3_stats.errors,
688                    );
689                }
690
691                if s3_stats.errors > 0 {
692                    std::process::exit(1);
693                }
694                return Ok(());
695            }
696        }
697    }
698
699    // S3 target detection  --  route to S3CasStore instead of POSIX sync
700    #[cfg(feature = "cloud")]
701    {
702        let dest_str = destination.to_string_lossy();
703        if dest_str.starts_with("s3://") {
704            let (endpoint, bucket, prefix) = s3::parse_s3_url(&dest_str)?;
705            info!("S3 target detected: endpoint={}, bucket={}, prefix={}", endpoint, bucket, prefix);
706
707            let s3_stats = rt.block_on(s3::sync_to_s3(
708                &sources, &endpoint, &bucket, &prefix, cli.normalization_level,
709            ))?;
710
711            total_stats.files_copied = s3_stats.files_copied;
712            total_stats.bytes_copied = s3_stats.bytes_copied;
713            total_stats.errors = s3_stats.errors;
714
715            if let Some(handle) = progress_handle {
716                if let Some(ref progress) = *ACTIVE_PROGRESS.lock() {
717                    progress.active.store(false, Ordering::Relaxed);
718                }
719                let _ = handle.join();
720                *ACTIVE_PROGRESS.lock() = None;
721            }
722
723            if cli.json {
724                let json_output = serde_json::json!({
725                    "status": if total_stats.errors == 0 { "success" } else { "partial" },
726                    "target": "s3",
727                    "endpoint": endpoint,
728                    "bucket": bucket,
729                    "files_copied": total_stats.files_copied,
730                    "bytes_copied": total_stats.bytes_copied,
731                    "errors": total_stats.errors,
732                });
733                println!("{}", serde_json::to_string_pretty(&json_output).unwrap_or_default());
734            } else {
735                println!(
736                    "S3 sync: {} files, {} bytes, {} errors",
737                    total_stats.files_copied, total_stats.bytes_copied, total_stats.errors,
738                );
739            }
740
741            if total_stats.errors > 0 {
742                std::process::exit(1);
743            }
744            return Ok(());
745        }
746    }
747
748    for source in &sources {
749        let opts = SyncOptions {
750            source: source.clone(),
751            destination: destination.clone(),
752            archive: cli.archive,
753            recursive: cli.recursive || cli.archive,
754            delete: cli.delete,
755            dry_run: cli.dry_run,
756            exclude: excludes.clone(),
757            include: includes.clone(),
758            generate_sigs: cli.generate_sigs,
759            checksums: cli.checksums.as_deref().map(hashing::ChecksumType::parse_list).unwrap_or_default(),
760            checksums_overwrite: cli.checksums_overwrite,
761            cid_output: cli.cid,
762            snapshot: cli.snapshot,
763            throttle: cli.throttle,
764            cleanup: cli.cleanup,
765            size: cli.size,
766            checkpoint_interval: cli.checkpoint_interval,
767            checkpoint_keep: cli.checkpoint_keep,
768            zero_copy: cli.zero_copy,
769            verify: cli.verify,
770            owner: cli.archive || cli.owner,
771            group: cli.archive || cli.group,
772            perms: cli.archive || cli.perms,
773            xattrs: cli.archive || cli.xattrs,
774            acls: cli.acls,
775        };
776
777        match rt.block_on(run(opts)) {
778            Ok(stats) => total_stats.merge(&stats),
779            Err(e) => {
780                error!("fxcp failed for {}: {}", source.display(), e);
781                total_stats.errors += 1;
782            }
783        }
784    }
785
786    // Stop progress reporter
787    if let Some(handle) = progress_handle {
788        if let Some(ref progress) = *ACTIVE_PROGRESS.lock() {
789            progress.active.store(false, Ordering::Relaxed);
790        }
791        let _ = handle.join();
792        *ACTIVE_PROGRESS.lock() = None;
793    }
794
795    if cli.json {
796        // Machine-readable JSON output
797        let _total_files = total_stats.files_copied + total_stats.files_skipped;
798        let status = if total_stats.errors == 0 { "success" }
799                     else if total_stats.files_copied > 0 { "partial" }
800                     else { "failed" };
801        let json_output = serde_json::json!({
802            "status": status,
803            "files_copied": total_stats.files_copied,
804            "files_reflinked": total_stats.files_reflinked,
805            "files_skipped": total_stats.files_skipped,
806            "files_deleted": total_stats.files_deleted,
807            "symlinks_replicated": total_stats.symlinks_replicated,
808            "dirs_created": total_stats.dirs_created,
809            "dirs_pruned": total_stats.dirs_pruned,
810            "bytes_copied": total_stats.bytes_copied,
811            "bytes_reflinked": total_stats.bytes_reflinked,
812            "errors": total_stats.errors,
813            "method_breakdown": {
814                "reflink": total_stats.files_reflinked,
815                "copy_file_range": total_stats.files_cfr,
816                "sendfile": total_stats.files_small,
817                "io_uring": total_stats.files_copied.saturating_sub(
818                    total_stats.files_reflinked + total_stats.files_cfr + total_stats.files_small
819                ),
820            },
821        });
822        println!("{}", serde_json::to_string_pretty(&json_output).unwrap_or_default());
823    } else {
824        print_summary(&total_stats, sync_start.elapsed());
825    }
826
827    // Granular exit codes
828    if total_stats.errors > 0 {
829        if total_stats.files_copied > 0 {
830            std::process::exit(1);  // Partial success
831        } else {
832            std::process::exit(2);  // Complete failure
833        }
834    }
835    Ok(())
836}
837
838/// Snapshot management subcommand handler.
839fn cli_snap_main() -> anyhow::Result<()> {
840    // Re-parse with snap-aware CLI (skip argv[0], "snap" is the subcommand)
841    let snap_cli = FxcpSnapCli::parse_from(
842        std::iter::once("fxcp-snap".to_string())
843            .chain(std::env::args().skip(2))
844    );
845
846    let log_filter = if snap_cli.debug { "debug" } else { "info" };
847    let _ = tracing_subscriber::fmt()
848        .with_env_filter(tracing_subscriber::EnvFilter::new(log_filter))
849        .with_target(false)
850        .try_init();
851
852    let _rt = tokio::runtime::Builder::new_multi_thread()
853        .enable_all()
854        .build()
855        .expect("Failed to create tokio runtime");
856
857    match snap_cli.command {
858        SnapCommand::List { path, json } => handle_snap_list(path, json)?,
859        SnapCommand::Show { timestamp } => {
860            let store = crate::version_store::VersionStore::open(&std::env::current_dir().unwrap_or_default());
861            let snapshots = store.list_snapshots();
862            if let Some(snap) = snapshots.iter().find(|s| s.timestamp.contains(&timestamp)) {
863                println!("{}", serde_json::to_string_pretty(snap).unwrap_or_default());
864            } else {
865                anyhow::bail!("Snapshot not found: {}", timestamp);
866            }
867        }
868        SnapCommand::Revert { path, epoch } => {
869            let p = std::path::PathBuf::from(&path);
870            crate::versioning::revert_file(&p, epoch)?;
871            info!("Reverted {:?} to epoch {}", path, epoch);
872        }
873        SnapCommand::Copy { path, epoch, destination } => {
874            let p = std::path::PathBuf::from(&path);
875            let d = std::path::PathBuf::from(&destination);
876            crate::versioning::copy_version_to_path(&p, epoch, &d)?;
877            info!("Copied version {} of {:?} to {:?}", epoch, path, destination);
878        }
879        SnapCommand::Prune { older_than, keep_last, max_size, path } => {
880            let p = std::path::PathBuf::from(&path);
881            let store = crate::version_store::VersionStore::open(&p);
882            let mut total = crate::version_store::PruneStats::default();
883
884            if let Some(ref age_str) = older_than {
885                let duration = parse_duration(age_str)?;
886                let stats = store.prune_by_age(duration)?;
887                total.snapshots_removed += stats.snapshots_removed;
888                total.bytes_freed += stats.bytes_freed;
889            }
890            if let Some(count) = keep_last {
891                let stats = store.prune_by_count(count)?;
892                total.snapshots_removed += stats.snapshots_removed;
893                total.bytes_freed += stats.bytes_freed;
894            }
895            if let Some(ref size_str) = max_size {
896                let bytes = parse_size(size_str)?;
897                let stats = store.prune_by_size(bytes)?;
898                total.snapshots_removed += stats.snapshots_removed;
899                total.bytes_freed += stats.bytes_freed;
900            }
901
902            if total.snapshots_removed > 0 {
903                info!("Pruned {} snapshots, freed {} bytes", total.snapshots_removed, total.bytes_freed);
904            } else {
905                info!("Nothing to prune.");
906            }
907        }
908        SnapCommand::Tag { timestamp, tag } => {
909            let cwd = std::env::current_dir().unwrap_or_default();
910            let store = crate::version_store::VersionStore::open(&cwd);
911            store.tag_snapshot(&timestamp, &tag)?;
912            info!("Tagged snapshot {} as '{}'", timestamp, tag);
913        }
914        SnapCommand::RebuildIndex { path } => {
915            let p = std::path::PathBuf::from(&path);
916            let store = crate::version_store::VersionStore::open(&p);
917            let index = store.rebuild_index()?;
918            info!("Rebuilt index: {} snapshots", index.snapshots.len());
919        }
920        SnapCommand::Stats { path, json } => {
921            let p = std::path::PathBuf::from(&path);
922            let store = crate::version_store::VersionStore::open(&p);
923            let snapshots = store.list_snapshots();
924            let versions_root = p.join(".foxing_versions");
925            let stats = crate::version_store::compute_store_stats(
926                &snapshots,
927                if versions_root.exists() { Some(&versions_root) } else { None }
928            );
929            if json {
930                println!("{}", serde_json::to_string_pretty(&stats).unwrap_or_default());
931            } else {
932                crate::version_store::print_store_stats(&stats, &p);
933            }
934        }
935        SnapCommand::Export { path, output, compress, timestamp, format, include_vectors, from_cas } => {
936            handle_snap_export(&path, output.as_deref(), &compress, timestamp.as_deref(), &format, include_vectors, from_cas)?;
937        }
938        SnapCommand::Import { path, input, compress, generate_sigs, raw, verbose, to_cas } => {
939            handle_snap_import(&path, input.as_deref(), &compress, generate_sigs, raw, verbose, to_cas)?;
940        }
941        SnapCommand::Inspect { archive, list, json, file } => {
942            handle_snap_inspect(&archive, list, json, file.as_deref())?;
943        }
944        SnapCommand::Restore { archive, file, date, latest, all_versions: _, output } => {
945            handle_snap_restore(&archive, file.as_deref(), date.as_deref(), latest, &output)?;
946        }
947        #[cfg(feature = "tui")]
948        SnapCommand::Browse { path, ai: _ } => {
949            let p = std::path::PathBuf::from(&path);
950            let is_fxar = path.ends_with(".fxar") || {
951                std::fs::File::open(&p).ok()
952                    .and_then(|mut f| {
953                        let mut magic = [0u8; 4];
954                        use std::io::Read;
955                        f.read_exact(&mut magic).ok()?;
956                        Some(magic == *b"FXAR")
957                    })
958                    .unwrap_or(false)
959            };
960            if is_fxar {
961                let mut app = crate::browser::BrowserApp::new_archive(&p, &p)
962                    .map_err(|e| anyhow::anyhow!("Failed to open archive: {}", e))?;
963                app.run().map_err(|e| anyhow::anyhow!("TUI error: {}", e))?;
964            } else {
965                let mut app = crate::browser::BrowserApp::new(&p);
966                app.run().map_err(|e| anyhow::anyhow!("TUI error: {}", e))?;
967            }
968        }
969    }
970    Ok(())
971}
972
973/// Handle `fxcp snap list`  --  list snapshots or file versions.
974fn handle_snap_list(path: Option<String>, json: bool) -> anyhow::Result<()> {
975    if let Some(ref p) = path {
976        let p = std::path::PathBuf::from(p);
977        if p.is_file() {
978            let versions = crate::versioning::list_versions(&p)?;
979            if json {
980                let json_versions: Vec<serde_json::Value> = versions.iter().map(|v| {
981                    serde_json::json!({
982                        "epoch": v.epoch_seq,
983                        "timestamp": v.timestamp,
984                        "size": v.size,
985                        "path": v.path.to_string_lossy(),
986                    })
987                }).collect();
988                println!("{}", serde_json::to_string_pretty(&json_versions).unwrap_or_default());
989            } else if versions.is_empty() {
990                println!("No versions found for {:?}", p);
991            } else {
992                crate::versioning::print_versions_table(versions, 50);
993            }
994        } else {
995            let store = crate::version_store::VersionStore::open(&p);
996            let snapshots = store.list_snapshots();
997            if json {
998                println!("{}", serde_json::to_string_pretty(&snapshots).unwrap_or_default());
999            } else {
1000                crate::version_store::print_snapshot_table(&snapshots);
1001            }
1002        }
1003    } else {
1004        let store = crate::version_store::VersionStore::open(&std::env::current_dir().unwrap_or_default());
1005        let snapshots = store.list_snapshots();
1006        if json {
1007            println!("{}", serde_json::to_string_pretty(&snapshots).unwrap_or_default());
1008        } else {
1009            crate::version_store::print_snapshot_table(&snapshots);
1010        }
1011    }
1012    Ok(())
1013}
1014
1015/// Handle `fxcp snap export`  --  export as FXAR v2 or legacy tar.
1016///
1017/// Auto-detects source type:
1018/// - If `{path}/.foxing_versions/` exists -> export from VersionStore (snapshot-aware)
1019/// - Otherwise -> export directory directly (bare paths, no snapshot prefix)
1020fn handle_snap_export(
1021    path: &str,
1022    output: Option<&str>,
1023    compress: &str,
1024    timestamp: Option<&str>,
1025    format: &str,
1026    include_vectors: bool,
1027    from_cas: bool,
1028) -> anyhow::Result<()> {
1029    let p = std::path::PathBuf::from(path);
1030
1031    // --from-cas: export from .foxing_cas/ CAS store
1032    if from_cas {
1033        let cas = crate::cas_store::CasStore::open(&p)?;
1034        let out_path = output.ok_or_else(|| anyhow::anyhow!("--from-cas requires -o <output.fxar>"))?;
1035        let tmp_path = format!("{}.tmp", out_path);
1036        let file = std::fs::File::create(&tmp_path)
1037            .map_err(|e| anyhow::anyhow!("Cannot create {}: {}", tmp_path, e))?;
1038        let stats = cas.export_fxar(file, compress)?;
1039        std::fs::rename(&tmp_path, out_path)
1040            .map_err(|e| anyhow::anyhow!("Atomic rename {} -> {}: {}", tmp_path, out_path, e))?;
1041        info!("Exported from CAS to {} (FXAR v2): {} files, {} unique chunks, {:.1}% dedup",
1042              out_path, stats.total_files, stats.unique_chunks, stats.dedup_ratio() * 100.0);
1043        return Ok(());
1044    }
1045
1046    let has_version_store = p.join(".foxing_versions").is_dir();
1047
1048    if format == "tar" {
1049        if !has_version_store {
1050            anyhow::bail!("tar format requires .foxing_versions/ (use fxar2 format for direct directory export)");
1051        }
1052        let store = crate::version_store::VersionStore::open(&p);
1053        if let Some(out_path) = output {
1054            let file = std::fs::File::create(out_path)
1055                .map_err(|e| anyhow::anyhow!("Cannot create {}: {}", out_path, e))?;
1056            let stats = store.export(file, compress, timestamp)?;
1057            info!("Exported to {} (tar): {} snapshots, {} unique files",
1058                  out_path, stats.snapshots_exported, stats.unique_chunks);
1059        } else {
1060            let stdout = std::io::stdout().lock();
1061            let stats = store.export(stdout, compress, timestamp)?;
1062            eprintln!("Exported {} snapshots, {} unique files ({} deduped)",
1063                      stats.snapshots_exported, stats.unique_chunks, stats.dedup_chunks);
1064        }
1065    } else if has_version_store {
1066        let store = crate::version_store::VersionStore::open(&p);
1067        let index_dir = p.join(".foxing_index");
1068        let ai_index_dir = if include_vectors && index_dir.is_dir() {
1069            Some(index_dir.as_path())
1070        } else {
1071            None
1072        };
1073
1074        if let Some(out_path) = output {
1075            let tmp_path = format!("{}.tmp", out_path);
1076            let file = std::fs::File::create(&tmp_path)
1077                .map_err(|e| anyhow::anyhow!("Cannot create {}: {}", tmp_path, e))?;
1078            let stats = if ai_index_dir.is_some() {
1079                crate::fxar::write_archive_with_ai_index(&store, file, compress, timestamp, ai_index_dir, include_vectors)?
1080            } else {
1081                crate::fxar::write_archive_seekable(&store, file, compress, timestamp)?
1082            };
1083            std::fs::rename(&tmp_path, out_path)
1084                .map_err(|e| anyhow::anyhow!("Atomic rename {} -> {}: {}", tmp_path, out_path, e))?;
1085            info!("Exported to {} (FXAR v2): {} snapshots, {} files, {} unique chunks, {:.1}% dedup",
1086                  out_path, stats.snapshots_exported, stats.total_files,
1087                  stats.unique_chunks, stats.dedup_ratio() * 100.0);
1088        } else {
1089            let stdout = std::io::stdout().lock();
1090            let stats = crate::fxar::write_archive(&store, stdout, compress, timestamp)?;
1091            eprintln!("Exported {} snapshots, {} files ({} unique chunks, {:.1}% dedup)",
1092                      stats.snapshots_exported, stats.total_files,
1093                      stats.unique_chunks, stats.dedup_ratio() * 100.0);
1094        }
1095    } else {
1096        // Direct directory export  --  no VersionStore required
1097        if let Some(out_path) = output {
1098            let tmp_path = format!("{}.tmp", out_path);
1099            let file = std::fs::File::create(&tmp_path)
1100                .map_err(|e| anyhow::anyhow!("Cannot create {}: {}", tmp_path, e))?;
1101            let stats = crate::fxar::write_archive_from_directory(&p, file, compress)?;
1102            std::fs::rename(&tmp_path, out_path)
1103                .map_err(|e| anyhow::anyhow!("Atomic rename {} -> {}: {}", tmp_path, out_path, e))?;
1104            info!("Exported to {} (FXAR v2, directory): {} files, {} unique chunks, {:.1}% dedup",
1105                  out_path, stats.total_files, stats.unique_chunks, stats.dedup_ratio() * 100.0);
1106        } else {
1107            let stdout = std::io::stdout().lock();
1108            let stats = crate::fxar::write_archive_from_directory_stream(&p, stdout, compress)?;
1109            eprintln!("Exported {} files ({} unique chunks, {:.1}% dedup)",
1110                      stats.total_files, stats.unique_chunks, stats.dedup_ratio() * 100.0);
1111        }
1112    }
1113    Ok(())
1114}
1115
1116/// Handle `fxcp snap import`  --  import from FXAR v2 or legacy tar (file or stdin).
1117///
1118/// Default: `restore_latest` (flat paths, latest version of each file, like tar -xvf).
1119/// With `--raw`: `restore_all` (full snapshot/tree/ path structure preserved).
1120fn handle_snap_import(
1121    path: &str,
1122    input: Option<&str>,
1123    compress: &str,
1124    generate_sigs: bool,
1125    raw: bool,
1126    verbose: bool,
1127    to_cas: bool,
1128) -> anyhow::Result<()> {
1129    let p = std::path::PathBuf::from(path);
1130
1131    // --to-cas: import FXAR into .foxing_cas/ CAS store (filesystem-agnostic)
1132    if to_cas {
1133        let in_path = input.ok_or_else(|| anyhow::anyhow!("--to-cas requires -i <archive.fxar>"))?;
1134        let file = std::fs::File::open(in_path)
1135            .map_err(|e| anyhow::anyhow!("Cannot open {}: {}", in_path, e))?;
1136        let cas = crate::cas_store::CasStore::open(&p)?;
1137        let stats = cas.import_fxar(file)?;
1138        info!("Imported to CAS from {} (FXAR): {} chunks written, {} deduped",
1139              in_path, stats.chunks_written, stats.chunks_deduped);
1140        return Ok(());
1141    }
1142
1143    if let Some(in_path) = input {
1144        let mut file = std::fs::File::open(in_path)
1145            .map_err(|e| anyhow::anyhow!("Cannot open {}: {}", in_path, e))?;
1146        let mut magic = [0u8; 4];
1147        use std::io::Read;
1148        file.read_exact(&mut magic)
1149            .map_err(|e| anyhow::anyhow!("Cannot read {}: {}", in_path, e))?;
1150
1151        if &magic == b"FXAR" {
1152            drop(file);
1153            let file = std::fs::File::open(in_path)
1154                .map_err(|e| anyhow::anyhow!("Cannot reopen {}: {}", in_path, e))?;
1155            let mut reader = crate::fxar::FxarReader::open(file)
1156                .map_err(|e| anyhow::anyhow!("FXAR open: {}", e))?;
1157            let stats = if raw {
1158                reader.restore_all(&p, generate_sigs)
1159                    .map_err(|e| anyhow::anyhow!("FXAR import (raw): {}", e))?
1160            } else {
1161                reader.restore_latest(&p, generate_sigs, verbose)
1162                    .map_err(|e| anyhow::anyhow!("FXAR import: {}", e))?
1163            };
1164            info!("Imported from {} (FXAR v2): {} files, {} bytes",
1165                  in_path, stats.files_restored, stats.bytes_restored);
1166        } else {
1167            drop(file);
1168            let file = std::fs::File::open(in_path)
1169                .map_err(|e| anyhow::anyhow!("Cannot reopen {}: {}", in_path, e))?;
1170            let store = crate::version_store::VersionStore::open(&p);
1171            let comp = if compress == "auto" {
1172                if in_path.ends_with(".zst") || in_path.ends_with(".zstd") { "zstd" }
1173                else if in_path.ends_with(".lz4") { "lz4" }
1174                else if in_path.ends_with(".gz") { "gzip" }
1175                else if in_path.ends_with(".xz") { "xz" }
1176                else { "none" }
1177            } else { compress };
1178            let stats = store.import(file, comp)?;
1179            info!("Imported from {} (tar): {} files restored", in_path, stats.files_restored);
1180        }
1181    } else {
1182        let stdin = std::io::stdin().lock();
1183        let mut buf_reader = std::io::BufReader::new(stdin);
1184        let mut magic = [0u8; 4];
1185        use std::io::Read;
1186        buf_reader.read_exact(&mut magic)
1187            .map_err(|e| anyhow::anyhow!("Cannot read stdin: {}", e))?;
1188
1189        if &magic == b"FXAR" {
1190            if raw {
1191                let chain = std::io::Cursor::new(magic.to_vec()).chain(buf_reader);
1192                let stats = crate::fxar::read_archive_stream(chain, &p, generate_sigs)
1193                    .map_err(|e| anyhow::anyhow!("FXAR stream import (raw): {}", e))?;
1194                info!("Imported from stdin (FXAR v2, raw): {} files", stats.files_restored);
1195            } else {
1196                // restore_latest needs Seek  --  spool stdin to a temp file
1197                let tmp_path = std::env::temp_dir().join(format!("fxcp-import-{}.fxar", std::process::id()));
1198                {
1199                    let mut tmp = std::fs::File::create(&tmp_path)
1200                        .map_err(|e| anyhow::anyhow!("Cannot create temp file: {}", e))?;
1201                    use std::io::Write;
1202                    tmp.write_all(&magic)?;
1203                    std::io::copy(&mut buf_reader, &mut tmp)?;
1204                }
1205                let tmp_file = std::fs::File::open(&tmp_path)
1206                    .map_err(|e| anyhow::anyhow!("Cannot reopen temp file: {}", e))?;
1207                let mut reader = crate::fxar::FxarReader::open(tmp_file)
1208                    .map_err(|e| anyhow::anyhow!("FXAR open: {}", e))?;
1209                let stats = reader.restore_latest(&p, generate_sigs, verbose)
1210                    .map_err(|e| anyhow::anyhow!("FXAR stream import: {}", e))?;
1211                let _ = std::fs::remove_file(&tmp_path);
1212                info!("Imported from stdin (FXAR v2): {} files", stats.files_restored);
1213            }
1214        } else {
1215            let store = crate::version_store::VersionStore::open(&p);
1216            let comp = if compress == "auto" { "none" } else { compress };
1217            let chain = std::io::Cursor::new(magic.to_vec()).chain(buf_reader);
1218            let stats = store.import(chain, comp)?;
1219            info!("Imported from stdin (tar): {} files", stats.files_restored);
1220        }
1221    }
1222    Ok(())
1223}
1224
1225/// Handle `fxcp snap inspect`  --  inspect archive contents without extracting.
1226fn handle_snap_inspect(
1227    archive: &str,
1228    list: bool,
1229    json: bool,
1230    file: Option<&str>,
1231) -> anyhow::Result<()> {
1232    let mut f = std::fs::File::open(archive)
1233        .map_err(|e| anyhow::anyhow!("Cannot open {}: {}", archive, e))?;
1234    let mut magic = [0u8; 4];
1235    use std::io::Read;
1236    f.read_exact(&mut magic)
1237        .map_err(|e| anyhow::anyhow!("Cannot read {}: {}", archive, e))?;
1238    drop(f);
1239
1240    if &magic == b"FXAR" {
1241        let f = std::fs::File::open(archive)
1242            .map_err(|e| anyhow::anyhow!("Cannot reopen {}: {}", archive, e))?;
1243        let result = crate::fxar::inspect_archive(f)
1244            .map_err(|e| anyhow::anyhow!("FXAR inspect: {}", e))?;
1245
1246        let filtered: Vec<_> = if let Some(pattern) = file {
1247            result.files.iter().filter(|f| f.path.contains(pattern)).collect()
1248        } else {
1249            result.files.iter().collect()
1250        };
1251
1252        if json {
1253            println!("{}", serde_json::to_string_pretty(&result).unwrap_or_default());
1254        } else if list {
1255            for f in &filtered {
1256                println!("{:>10}  {}  [{}]", f.size, f.path, &f.blake3[..16]);
1257            }
1258            println!("\n{} files, {} chunks ({:.1}% dedup ratio)",
1259                     filtered.len(), result.chunk_count, result.dedup_ratio * 100.0);
1260        } else {
1261            println!("Archive: {} (FXAR v2)", archive);
1262            println!("  Format:      FXAR v{}", result.version);
1263            println!("  Snapshots:   {}", result.snapshots.len());
1264            println!("  Files:       {}", result.total_files);
1265            println!("  Unique files:{}", result.unique_files);
1266            println!("  Chunks:      {}", result.chunk_count);
1267            println!("  Apparent:    {} bytes", result.total_apparent_bytes);
1268            println!("  Chunk data:  {} bytes", result.total_chunk_bytes);
1269            println!("  Compressed:  {} bytes", result.total_compressed_bytes);
1270            println!("  Dedup ratio: {:.1}%", result.dedup_ratio * 100.0);
1271            for snap in &result.snapshots {
1272                println!("  Snapshot:    {}", snap);
1273            }
1274        }
1275    } else {
1276        let f = std::fs::File::open(archive)
1277            .map_err(|e| anyhow::anyhow!("Cannot reopen {}: {}", archive, e))?;
1278        let comp = if archive.ends_with(".zst") { "zstd" }
1279                   else if archive.ends_with(".lz4") { "lz4" }
1280                   else if archive.ends_with(".gz") { "gzip" }
1281                   else if archive.ends_with(".xz") { "xz" }
1282                   else { "none" };
1283        let entries = crate::version_store::VersionStore::inspect_archive(f, comp)?;
1284
1285        let filtered: Vec<_> = if let Some(pattern) = file {
1286            entries.into_iter().filter(|e| e.path.contains(pattern)).collect()
1287        } else {
1288            entries
1289        };
1290
1291        if json {
1292            println!("{}", serde_json::to_string_pretty(&filtered).unwrap_or_default());
1293        } else if list {
1294            for e in &filtered {
1295                let marker = if e.is_dedup_ref { " [dedup]" }
1296                             else if e.is_metadata { " [meta]" }
1297                             else { "" };
1298                println!("{:>10}  {}{}", e.size, e.path, marker);
1299            }
1300            let total_files = filtered.iter().filter(|e| !e.is_dedup_ref && !e.is_metadata).count();
1301            let dedup_refs = filtered.iter().filter(|e| e.is_dedup_ref).count();
1302            println!("\n{} files, {} dedup references", total_files, dedup_refs);
1303        } else {
1304            let total_files = filtered.iter().filter(|e| !e.is_dedup_ref && !e.is_metadata).count();
1305            let dedup_refs = filtered.iter().filter(|e| e.is_dedup_ref).count();
1306            let total_size: u64 = filtered.iter().map(|e| e.size).sum();
1307            println!("Archive: {} (tar)", archive);
1308            println!("  Files:       {}", total_files);
1309            println!("  Dedup refs:  {}", dedup_refs);
1310            println!("  Total size:  {} bytes", total_size);
1311        }
1312    }
1313    Ok(())
1314}
1315
1316/// Handle `fxcp snap restore`  --  restore specific files from an archive.
1317fn handle_snap_restore(
1318    archive: &str,
1319    file: Option<&str>,
1320    date: Option<&str>,
1321    latest: bool,
1322    output: &str,
1323) -> anyhow::Result<()> {
1324    let mut f = std::fs::File::open(archive)
1325        .map_err(|e| anyhow::anyhow!("Cannot open {}: {}", archive, e))?;
1326    let mut magic = [0u8; 4];
1327    use std::io::Read;
1328    f.read_exact(&mut magic)
1329        .map_err(|e| anyhow::anyhow!("Cannot read {}: {}", archive, e))?;
1330    drop(f);
1331
1332    let out_dir = std::path::PathBuf::from(output);
1333
1334    if &magic == b"FXAR" {
1335        let f = std::fs::File::open(archive)
1336            .map_err(|e| anyhow::anyhow!("Cannot reopen {}: {}", archive, e))?;
1337        let mut reader = crate::fxar::FxarReader::open(f)
1338            .map_err(|e| anyhow::anyhow!("FXAR open: {}", e))?;
1339        let manifest = reader.read_manifest()
1340            .map_err(|e| anyhow::anyhow!("FXAR manifest: {}", e))?;
1341
1342        let mut matched: Vec<_> = manifest.files.iter()
1343            .filter(|e| {
1344                if let Some(pattern) = file {
1345                    glob::Pattern::new(pattern).map(|p| p.matches(&e.path)).unwrap_or(false)
1346                        || e.path.contains(pattern)
1347                } else { true }
1348            })
1349            .filter(|e| {
1350                if let Some(d) = date { e.path.contains(d) }
1351                else { true }
1352            })
1353            .cloned()
1354            .collect();
1355
1356        if latest {
1357            matched.sort_by(|a, b| b.path.cmp(&a.path));
1358            matched.truncate(1);
1359        }
1360
1361        if matched.is_empty() {
1362            anyhow::bail!("No matching files found in archive");
1363        }
1364
1365        info!("Restoring {} files to {}", matched.len(), output);
1366
1367        for entry in &matched {
1368            let data = reader.restore_file(&entry.path)
1369                .map_err(|e| anyhow::anyhow!("FXAR restore {}: {}", entry.path, e))?;
1370            let dest = out_dir.join(std::path::Path::new(&entry.path).file_name().unwrap_or_default());
1371            if let Some(parent) = dest.parent() {
1372                let _ = std::fs::create_dir_all(parent);
1373            }
1374            std::fs::write(&dest, &data)?;
1375
1376            // Restore permissions, ownership, mtime, and xattrs from manifest
1377            use std::os::unix::fs::PermissionsExt;
1378            let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(entry.mode));
1379            if unsafe { libc::geteuid() } == 0 {
1380                use nix::unistd::{chown, Uid, Gid};
1381                let _ = chown(&dest, Some(Uid::from_raw(entry.uid)), Some(Gid::from_raw(entry.gid)));
1382            }
1383            let mtime = filetime::FileTime::from_unix_time(entry.mtime, 0);
1384            let _ = filetime::set_file_mtime(&dest, mtime);
1385
1386            for (key, hex_val) in &entry.xattr {
1387                if let Ok(val) = hex::decode(hex_val) {
1388                    if key.starts_with("user.foxing") {
1389                        let _ = crate::sidecar::set_metadata(&dest, key, &val);
1390                    } else {
1391                        let _ = xattr::set(&dest, key, &val);
1392                    }
1393                }
1394            }
1395
1396            info!("Restored: {} ({} bytes, BLAKE3 verified)", dest.display(), data.len());
1397        }
1398    } else {
1399        let f = std::fs::File::open(archive)
1400            .map_err(|e| anyhow::anyhow!("Cannot reopen {}: {}", archive, e))?;
1401        let comp = if archive.ends_with(".zst") { "zstd" }
1402                   else if archive.ends_with(".lz4") { "lz4" }
1403                   else if archive.ends_with(".gz") { "gzip" }
1404                   else if archive.ends_with(".xz") { "xz" }
1405                   else { "none" };
1406        let entries = crate::version_store::VersionStore::inspect_archive(f, comp)?;
1407
1408        let mut matched: Vec<_> = entries.into_iter()
1409            .filter(|e| !e.is_metadata && !e.is_dedup_ref)
1410            .filter(|e| {
1411                if let Some(pattern) = file {
1412                    glob::Pattern::new(pattern).map(|p| p.matches(&e.path)).unwrap_or(false)
1413                        || e.path.contains(pattern)
1414                } else { true }
1415            })
1416            .filter(|e| {
1417                if let Some(d) = date { e.path.contains(d) }
1418                else { true }
1419            })
1420            .collect();
1421
1422        if latest {
1423            matched.sort_by(|a, b| b.path.cmp(&a.path));
1424            matched.truncate(1);
1425        }
1426
1427        if matched.is_empty() {
1428            anyhow::bail!("No matching files found in archive");
1429        }
1430
1431        info!("Restoring {} files to {}", matched.len(), output);
1432
1433        let f2 = std::fs::File::open(archive)
1434            .map_err(|e| anyhow::anyhow!("Cannot reopen {}: {}", archive, e))?;
1435        let decompressed = crate::version_store::wrap_import_decompressor(f2, comp);
1436        let mut tar_archive = tar::Archive::new(decompressed);
1437        let match_paths: std::collections::HashSet<String> = matched.iter().map(|e| e.path.clone()).collect();
1438
1439        for entry in tar_archive.entries().map_err(|e| anyhow::anyhow!("tar error: {}", e))? {
1440            let mut entry = match entry { Ok(e) => e, Err(_) => continue };
1441            let path = entry.path().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
1442            if match_paths.contains(&path) {
1443                let dest = out_dir.join(std::path::Path::new(&path).file_name().unwrap_or_default());
1444                if let Some(parent) = dest.parent() {
1445                    let _ = std::fs::create_dir_all(parent);
1446                }
1447                entry.unpack(&dest).map_err(|e| anyhow::anyhow!("unpack error: {}", e))?;
1448                info!("Restored: {}", dest.display());
1449            }
1450        }
1451    }
1452    Ok(())
1453}
1454
1455/// Parse a human-readable duration string (e.g., "30d", "7d", "24h", "2w").
1456fn parse_duration(s: &str) -> anyhow::Result<std::time::Duration> {
1457    let s = s.trim();
1458    let (num_str, unit) = s.split_at(s.len().saturating_sub(1));
1459    let num: u64 = num_str.parse().map_err(|_| anyhow::anyhow!("Invalid duration: {}", s))?;
1460    let secs = match unit {
1461        "s" => num,
1462        "m" => num * 60,
1463        "h" => num * 3600,
1464        "d" => num * 86400,
1465        "w" => num * 604800,
1466        _ => anyhow::bail!("Unknown duration unit '{}' (use s/m/h/d/w)", unit),
1467    };
1468    Ok(std::time::Duration::from_secs(secs))
1469}
1470
1471/// Parse a human-readable size string (e.g., "50G", "10G", "500M").
1472fn parse_size(s: &str) -> anyhow::Result<u64> {
1473    let s = s.trim();
1474    let (num_str, unit) = s.split_at(s.len().saturating_sub(1));
1475    let num: u64 = num_str.parse().map_err(|_| anyhow::anyhow!("Invalid size: {}", s))?;
1476    let bytes = match unit.to_uppercase().as_str() {
1477        "K" => num * 1024,
1478        "M" => num * 1024 * 1024,
1479        "G" => num * 1024 * 1024 * 1024,
1480        "T" => num * 1024 * 1024 * 1024 * 1024,
1481        _ => anyhow::bail!("Unknown size unit '{}' (use K/M/G/T)", unit),
1482    };
1483    Ok(bytes)
1484}
1485
1486// -----------------------------------------------------------------------
1487// Formatting & display helpers
1488// -----------------------------------------------------------------------
1489
1490fn format_bytes(b: u64) -> String {
1491    crate::fmt::format_size(b)
1492}
1493
1494/// Print a human-readable summary of sync results.
1495pub fn print_summary(stats: &SyncStats, elapsed: std::time::Duration) {
1496    #[allow(unused_mut)]
1497    let mut total_bytes = stats.bytes_copied + stats.bytes_reflinked + stats.bytes_cfr + stats.bytes_small + stats.bytes_delta;
1498    #[allow(unused_mut)]
1499    let mut total_files = stats.files_copied + stats.files_reflinked + stats.files_cfr + stats.files_small + stats.files_delta;
1500    #[cfg(feature = "nfs-bypass")]
1501    {
1502        total_bytes += stats.bytes_nfs_bypass;
1503        total_files += stats.files_nfs_bypass;
1504    }
1505
1506    let secs = elapsed.as_secs_f64();
1507    let throughput_mb = if secs > 0.0 { total_bytes as f64 / 1_048_576.0 / secs } else { 0.0 };
1508
1509    println!("fxcp sync complete:");
1510    println!("  Elapsed:       {}", format_duration(elapsed));
1511    println!("  Files synced:  {}", total_files);
1512    #[cfg(feature = "nfs-bypass")]
1513    if stats.files_nfs_bypass > 0 {
1514        println!("  - NFS bypass:  {} ({}, compound RPC)", stats.files_nfs_bypass, format_bytes(stats.bytes_nfs_bypass));
1515    }
1516    if stats.files_reflinked > 0 {
1517        println!("  - reflink:     {} ({}, instant CoW)", stats.files_reflinked, format_bytes(stats.bytes_reflinked));
1518    }
1519    if stats.files_cfr > 0 {
1520        println!("  - server copy: {} ({}, copy_file_range)", stats.files_cfr, format_bytes(stats.bytes_cfr));
1521    }
1522    if stats.files_small > 0 {
1523        println!("  - sendfile:    {} ({})", stats.files_small, format_bytes(stats.bytes_small));
1524    }
1525    if stats.files_copied > 0 {
1526        println!("  - io_uring:    {} ({})", stats.files_copied, format_bytes(stats.bytes_copied));
1527    }
1528    if stats.files_delta > 0 {
1529        println!("  - delta:       {} ({}, chunk-level)", stats.files_delta, format_bytes(stats.bytes_delta));
1530    }
1531    if stats.files_skipped > 0 { println!("  Skipped:       {} (unchanged)", stats.files_skipped); }
1532    if stats.dirs_pruned > 0 { println!("  Dirs pruned:   {} (hash match)", stats.dirs_pruned); }
1533    if stats.symlinks_replicated > 0 { println!("  Symlinks:      {}", stats.symlinks_replicated); }
1534    if stats.files_deleted > 0 { println!("  Deleted:       {}", stats.files_deleted); }
1535    if stats.dirs_created > 0 { println!("  Dirs created:  {}", stats.dirs_created); }
1536    if stats.dirs_hashed > 0 { println!("  Dirs hashed:   {}", stats.dirs_hashed); }
1537    if stats.sigs_stored > 0 { println!("  Sigs stored:   {} (foxingd-compatible)", stats.sigs_stored); }
1538    println!("  Total:         {} in {} ({:.0} MB/s)",
1539             format_bytes(total_bytes), format_duration(elapsed), throughput_mb);
1540    if stats.files_verified > 0 { println!("  Verified:      {} (BLAKE3)", stats.files_verified); }
1541    if stats.verify_failures > 0 { println!("  Verify FAIL:   {}", stats.verify_failures); }
1542    if stats.errors > 0 { println!("  Errors:        {}", stats.errors); }
1543}
1544
1545fn format_duration(d: std::time::Duration) -> String {
1546    let secs = d.as_secs();
1547    if secs >= 3600 {
1548        format!("{}h{:02}m{:02}s", secs / 3600, (secs % 3600) / 60, secs % 60)
1549    } else if secs >= 60 {
1550        format!("{}m{:02}.{:01}s", secs / 60, secs % 60, d.subsec_millis() / 100)
1551    } else if secs > 0 {
1552        format!("{}.{:02}s", secs, d.subsec_millis() / 10)
1553    } else {
1554        format!("{}ms", d.as_millis())
1555    }
1556}
1557
1558#[cfg(test)]
1559mod tests {
1560    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1561    /// Helper: create a minimal snapshot directory with summary.json (no tag).
1562    fn create_test_snapshot(root: &std::path::Path, timestamp: &str) {
1563        let versions_dir = root.join(".foxing_versions");
1564        let snap_dir = versions_dir.join(timestamp);
1565        let tree_dir = snap_dir.join("tree");
1566        std::fs::create_dir_all(&tree_dir).unwrap();
1567        let summary = crate::version_store::SnapshotSummary {
1568            timestamp: timestamp.to_string(),
1569            status: "success".into(),
1570            snap_type: "full".into(),
1571            tag: None,
1572            source: "/tmp/src".into(),
1573            trigger: "test".into(),
1574            files: 1,
1575            size_bytes: 100,
1576            disk_usage_bytes: 100,
1577            savings_pct: 0.0,
1578            reference: None,
1579            elapsed_ms: 10,
1580        };
1581        let json = serde_json::to_string_pretty(&summary).unwrap();
1582        std::fs::write(snap_dir.join("summary.json"), json).unwrap();
1583    }
1584
1585    #[test]
1586    fn test_snap_tag_persists() {
1587        let tmp = tempfile::tempdir().unwrap();
1588        let root = tmp.path();
1589        let ts = "2026-04-25T120000";
1590        create_test_snapshot(root, ts);
1591
1592        // Tag the snapshot
1593        let store = crate::version_store::VersionStore::open(root);
1594        store.tag_snapshot(ts, "pre-migration").unwrap();
1595
1596        // Read summary.json back and verify tag is present
1597        let summary_path = root.join(".foxing_versions").join(ts).join("summary.json");
1598        let data = std::fs::read_to_string(&summary_path).unwrap();
1599        let summary: crate::version_store::SnapshotSummary = serde_json::from_str(&data).unwrap();
1600        assert_eq!(summary.tag, Some("pre-migration".to_string()));
1601    }
1602
1603    #[test]
1604    fn test_tagged_snapshot_survives_prune() {
1605        let tmp = tempfile::tempdir().unwrap();
1606        let root = tmp.path();
1607
1608        // Create two snapshots with old timestamps
1609        let ts_tagged = "2024-01-01T000000";
1610        let ts_untagged = "2024-01-02T000000";
1611        create_test_snapshot(root, ts_tagged);
1612        create_test_snapshot(root, ts_untagged);
1613
1614        // Tag one snapshot
1615        let store = crate::version_store::VersionStore::open(root);
1616        store.tag_snapshot(ts_tagged, "keep-me").unwrap();
1617
1618        // Prune by count (keep only 0  --  should delete untagged, keep tagged)
1619        let stats = store.prune_by_count(0).unwrap();
1620        assert_eq!(stats.snapshots_removed, 1);
1621
1622        // Tagged snapshot directory must still exist
1623        let tagged_dir = root.join(".foxing_versions").join(ts_tagged);
1624        assert!(tagged_dir.exists(), "Tagged snapshot should survive prune");
1625
1626        // Untagged snapshot directory must be gone
1627        let untagged_dir = root.join(".foxing_versions").join(ts_untagged);
1628        assert!(!untagged_dir.exists(), "Untagged snapshot should be pruned");
1629    }
1630}