Skip to main content

copyrust_cli/
lib.rs

1//! Command-line frontend shared by the standalone binary and Python console entry point.
2
3mod config;
4
5use anyhow::{Context, Result};
6use clap::Parser;
7use copyrust::{Cancellation, CopyEngine, Progress};
8use std::{
9    ffi::OsString,
10    io::{IsTerminal, Write},
11    path::PathBuf,
12};
13
14/// Copy multiple directory trees using timestamps or complete content hashes.
15#[derive(Parser, Debug)]
16#[command(name = "copyrust", version, about, arg_required_else_help = true)]
17pub struct Cli {
18    /// Map source directory contents into a destination; repeat for batch copying.
19    #[arg(long, num_args = 2, value_names = ["SOURCE", "DESTINATION"], action = clap::ArgAction::Append)]
20    pair: Vec<PathBuf>,
21    /// Load shared defaults and per-mapping overrides from strict TOML.
22    #[arg(long)]
23    config: Option<PathBuf>,
24    /// Select timestamp, strict hash, metadata-then-hash, or unconditional copying.
25    #[arg(long, value_parser = ["mtime", "hash", "auto", "always"])]
26    compare: Option<String>,
27    /// Select XXH3-64 (default), traditional XXH64, or BLAKE3 for content comparison.
28    #[arg(long, value_parser = ["xxh3", "xxh64", "blake3"])]
29    hash_algorithm: Option<String>,
30    /// Prune matching directory names or source-relative globs; repeat as needed.
31    #[arg(long)]
32    exclude_dir: Vec<String>,
33    /// Protect matching files from both copying and purge; repeat as needed.
34    #[arg(long)]
35    exclude_file: Vec<String>,
36    /// Select matching files only; patterns are relative to each source root.
37    #[arg(long)]
38    include_file: Vec<String>,
39    /// Recursively copy directories (the default).
40    #[arg(long, conflicts_with = "no_recursive")]
41    recursive: bool,
42    /// Limit each mapping to files directly under its source root.
43    #[arg(long)]
44    no_recursive: bool,
45    /// Preserve modification times after copying (the default).
46    #[arg(long, conflicts_with = "no_preserve_mtime")]
47    preserve_mtime: bool,
48    /// Leave newly copied files with their write time instead of source mtime.
49    #[arg(long)]
50    no_preserve_mtime: bool,
51    /// Stage each file and atomically replace its destination after successful validation.
52    #[arg(long, conflicts_with = "no_atomic")]
53    atomic: bool,
54    /// Write directly to destinations (the default); overrides TOML atomic mode.
55    #[arg(long)]
56    no_atomic: bool,
57    /// Match filter patterns case-sensitively, including on Windows.
58    #[arg(long)]
59    case_sensitive: bool,
60    /// Remove selected destination extras after successful copies; off by default.
61    #[arg(long)]
62    purge: bool,
63    /// Compare and plan all operations without modifying destination files.
64    #[arg(long)]
65    dry_run: bool,
66    /// Bound parallel source scans (default 8).
67    #[arg(long)]
68    scan_workers: Option<usize>,
69    /// Bound concurrent complete-content comparisons (default 4).
70    #[arg(long)]
71    hash_workers: Option<usize>,
72    /// Bound concurrent copies; zero selects up to 32 available CPUs (default).
73    #[arg(long)]
74    copy_workers: Option<usize>,
75    /// Retry each failed copy this many times (default 2).
76    #[arg(long)]
77    retries: Option<usize>,
78    /// Emit progress at this interval, such as 250ms or 1s (default 250ms).
79    #[arg(long)]
80    progress_interval: Option<String>,
81    /// Select timestamped text, newline-delimited JSON, or final-summary-only output.
82    #[arg(long, default_value = "auto", value_parser = ["auto", "plain", "json", "off"])]
83    progress: String,
84}
85
86impl Cli {
87    /// Parse arguments, run the batch, and distinguish usage errors from copy failures.
88    pub fn run(args: impl IntoIterator<Item = impl Into<OsString> + Clone>) -> i32 {
89        let subscriber = tracing_subscriber::fmt()
90            .with_target(false)
91            .with_writer(std::io::stderr)
92            .with_ansi(false)
93            .finish();
94        tracing::subscriber::with_default(subscriber, || {
95            let cli = match Self::try_parse_from(args) {
96                Ok(cli) => cli,
97                Err(error) => {
98                    let code = error.exit_code();
99                    if let Err(print_error) = error.print() {
100                        tracing::error!("Failed to print command help: {print_error}");
101                    }
102                    return code;
103                }
104            };
105            match cli.execute() {
106                Ok(true) => 0,
107                Ok(false) => 1,
108                Err(error) => {
109                    tracing::error!("{error:#}");
110                    1
111                }
112            }
113        })
114    }
115
116    /// Run native workers and keep progress rendering outside the copy engine.
117    fn execute(self) -> Result<bool> {
118        let (jobs, options) = config::Configuration::load(&self)?;
119        let cancellation = Cancellation::default();
120        let signal = cancellation.clone();
121        ctrlc::set_handler(move || signal.cancel())
122            .context("Failed to install cancellation handler")?;
123        let summary = CopyEngine::run(&jobs, &options, cancellation, |event| self.render(event))?;
124        for error in &summary.errors {
125            tracing::error!("{error}");
126        }
127        Ok(summary.success())
128    }
129
130    /// Emit JSON events to stdout or timestamped progress lines to stderr.
131    fn render(&self, event: &Progress) -> Result<()> {
132        if self.progress == "json" {
133            let mut output = std::io::stdout().lock();
134            serde_json::to_writer(&mut output, event).context("Failed to serialize progress")?;
135            writeln!(output).context("Failed to write progress event")?;
136        } else if self.progress != "off" || event.complete || event.phase == "failed" {
137            let subscriber = tracing_subscriber::fmt()
138                .with_target(false)
139                .with_writer(std::io::stderr)
140                .with_ansi(self.progress == "auto" && std::io::stderr().is_terminal())
141                .finish();
142            tracing::subscriber::with_default(subscriber, || {
143                tracing::info!(
144                    phase = %event.phase, scanned = event.scanned_files, compared = event.compared_files,
145                    copied_files = event.copied_files, copied_bytes = event.copied_bytes,
146                    hashed_bytes = event.hashed_bytes, skipped_mtime = event.skipped_mtime,
147                    skipped_hash = event.skipped_hash, deleted = event.deleted_files,
148                total_files = ?event.total_files, total_copy_bytes = ?event.total_copy_bytes,
149                planned_copy_files = event.planned_copy_files, planned_delete_files = event.planned_delete_files,
150                average_mib_per_second = format_args!("{:.2}", event.copied_bytes as f64 / event.elapsed_seconds.max(0.001) / 1048576.0),
151                    failed = event.failed_files, elapsed_seconds = format_args!("{:.3}", event.elapsed_seconds),
152                    "copy progress"
153                )
154            });
155        }
156        Ok(())
157    }
158}