1#![allow(clippy::expect_used)]
14mod engine;
15mod tree;
16mod stdin;
17#[cfg(feature = "cloud")]
18mod s3;
19
20pub 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
33const FICLONE: u64 = crate::constants::FICLONE_IOCTL;
35
36#[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 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
103pub 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 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
129lazy_static::lazy_static! {
131 static ref ACTIVE_PROGRESS: parking_lot::Mutex<Option<std::sync::Arc<ProgressTracker>>> = parking_lot::Mutex::new(None);
132}
133
134fn 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
154fn 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!(); } })
213}
214
215fn 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 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
222 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#[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 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
295pub 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
313use 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 #[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#[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 {
443 path: Option<String>,
445 #[arg(long)]
447 json: bool,
448 },
449 Show { timestamp: String },
451 Revert { path: String, epoch: u64 },
453 Copy { path: String, epoch: u64, destination: String },
455 Prune {
457 #[arg(long)]
459 older_than: Option<String>,
460 #[arg(long)]
462 keep_last: Option<usize>,
463 #[arg(long)]
465 max_size: Option<String>,
466 #[arg(default_value = ".")]
468 path: String,
469 },
470 Tag { timestamp: String, tag: String },
472 RebuildIndex {
474 #[arg(default_value = ".")]
476 path: String,
477 },
478 Stats {
480 #[arg(default_value = ".")]
482 path: String,
483 #[arg(long)]
485 json: bool,
486 },
487 Export {
489 path: String,
491 #[arg(short, long)]
493 output: Option<String>,
494 #[arg(long, default_value = "zstd")]
496 compress: String,
497 #[arg(long)]
499 timestamp: Option<String>,
500 #[arg(long, default_value = "fxar2")]
502 format: String,
503 #[arg(long)]
505 include_vectors: bool,
506 #[arg(long)]
508 from_cas: bool,
509 },
510 Import {
512 path: String,
514 #[arg(short, long)]
516 input: Option<String>,
517 #[arg(long, default_value = "auto")]
519 compress: String,
520 #[arg(long)]
522 generate_sigs: bool,
523 #[arg(long)]
525 raw: bool,
526 #[arg(short, long)]
528 verbose: bool,
529 #[arg(long)]
531 to_cas: bool,
532 },
533 Inspect {
535 archive: String,
537 #[arg(long)]
539 list: bool,
540 #[arg(long)]
542 json: bool,
543 #[arg(long)]
545 file: Option<String>,
546 },
547 Restore {
549 archive: String,
551 #[arg(long)]
553 file: Option<String>,
554 #[arg(long)]
556 date: Option<String>,
557 #[arg(long)]
559 latest: bool,
560 #[arg(long)]
562 all_versions: bool,
563 #[arg(short, long, default_value = ".")]
565 output: String,
566 },
567 #[cfg(feature = "tui")]
569 Browse {
570 #[arg(default_value = ".")]
572 path: String,
573 #[arg(long)]
575 ai: bool,
576 },
577}
578
579pub fn cli_main() -> anyhow::Result<()> {
585 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 let _ = tracing_subscriber::fmt()
597 .with_env_filter(tracing_subscriber::EnvFilter::new(log_filter))
598 .with_target(false)
599 .try_init();
600
601 if cli.checksums.is_some() || cli.generate_sigs || cli.verify {
603 crate::hashing::log_crypto_capabilities();
604 }
605
606 let (sources, destination) = crate::filter::split_paths(cli.paths)?;
608
609 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 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 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 #[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 #[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 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 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 if total_stats.errors > 0 {
829 if total_stats.files_copied > 0 {
830 std::process::exit(1); } else {
832 std::process::exit(2); }
834 }
835 Ok(())
836}
837
838fn cli_snap_main() -> anyhow::Result<()> {
840 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(×tamp)) {
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(×tamp, &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
973fn 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
1015fn 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 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 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
1116fn 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 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 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
1225fn 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
1316fn 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 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
1455fn 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
1471fn 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
1486fn format_bytes(b: u64) -> String {
1491 crate::fmt::format_size(b)
1492}
1493
1494pub 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 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 let store = crate::version_store::VersionStore::open(root);
1594 store.tag_snapshot(ts, "pre-migration").unwrap();
1595
1596 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 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 let store = crate::version_store::VersionStore::open(root);
1616 store.tag_snapshot(ts_tagged, "keep-me").unwrap();
1617
1618 let stats = store.prune_by_count(0).unwrap();
1620 assert_eq!(stats.snapshots_removed, 1);
1621
1622 let tagged_dir = root.join(".foxing_versions").join(ts_tagged);
1624 assert!(tagged_dir.exists(), "Tagged snapshot should survive prune");
1625
1626 let untagged_dir = root.join(".foxing_versions").join(ts_untagged);
1628 assert!(!untagged_dir.exists(), "Untagged snapshot should be pruned");
1629 }
1630}