mod config;
use anyhow::{Context, Result};
use clap::Parser;
use copyrust::{Cancellation, CopyEngine, Progress};
use std::{
ffi::OsString,
io::{IsTerminal, Write},
path::PathBuf,
};
#[derive(Parser, Debug)]
#[command(name = "copyrust", version, about, arg_required_else_help = true)]
pub struct Cli {
#[arg(long, num_args = 2, value_names = ["SOURCE", "DESTINATION"], action = clap::ArgAction::Append)]
pair: Vec<PathBuf>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long, value_parser = ["mtime", "hash", "auto", "always"])]
compare: Option<String>,
#[arg(long, value_parser = ["xxh3", "xxh64", "blake3"])]
hash_algorithm: Option<String>,
#[arg(long)]
exclude_dir: Vec<String>,
#[arg(long)]
exclude_file: Vec<String>,
#[arg(long)]
include_file: Vec<String>,
#[arg(long, conflicts_with = "no_recursive")]
recursive: bool,
#[arg(long)]
no_recursive: bool,
#[arg(long, conflicts_with = "no_preserve_mtime")]
preserve_mtime: bool,
#[arg(long)]
no_preserve_mtime: bool,
#[arg(long, conflicts_with = "no_atomic")]
atomic: bool,
#[arg(long)]
no_atomic: bool,
#[arg(long)]
case_sensitive: bool,
#[arg(long)]
purge: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
scan_workers: Option<usize>,
#[arg(long)]
hash_workers: Option<usize>,
#[arg(long)]
copy_workers: Option<usize>,
#[arg(long)]
retries: Option<usize>,
#[arg(long)]
progress_interval: Option<String>,
#[arg(long, default_value = "auto", value_parser = ["auto", "plain", "json", "off"])]
progress: String,
}
impl Cli {
pub fn run(args: impl IntoIterator<Item = impl Into<OsString> + Clone>) -> i32 {
let subscriber = tracing_subscriber::fmt()
.with_target(false)
.with_writer(std::io::stderr)
.with_ansi(false)
.finish();
tracing::subscriber::with_default(subscriber, || {
let cli = match Self::try_parse_from(args) {
Ok(cli) => cli,
Err(error) => {
let code = error.exit_code();
if let Err(print_error) = error.print() {
tracing::error!("Failed to print command help: {print_error}");
}
return code;
}
};
match cli.execute() {
Ok(true) => 0,
Ok(false) => 1,
Err(error) => {
tracing::error!("{error:#}");
1
}
}
})
}
fn execute(self) -> Result<bool> {
let (jobs, options) = config::Configuration::load(&self)?;
let cancellation = Cancellation::default();
let signal = cancellation.clone();
ctrlc::set_handler(move || signal.cancel())
.context("Failed to install cancellation handler")?;
let summary = CopyEngine::run(&jobs, &options, cancellation, |event| self.render(event))?;
for error in &summary.errors {
tracing::error!("{error}");
}
Ok(summary.success())
}
fn render(&self, event: &Progress) -> Result<()> {
if self.progress == "json" {
let mut output = std::io::stdout().lock();
serde_json::to_writer(&mut output, event).context("Failed to serialize progress")?;
writeln!(output).context("Failed to write progress event")?;
} else if self.progress != "off" || event.complete || event.phase == "failed" {
let subscriber = tracing_subscriber::fmt()
.with_target(false)
.with_writer(std::io::stderr)
.with_ansi(self.progress == "auto" && std::io::stderr().is_terminal())
.finish();
tracing::subscriber::with_default(subscriber, || {
tracing::info!(
phase = %event.phase, scanned = event.scanned_files, compared = event.compared_files,
copied_files = event.copied_files, copied_bytes = event.copied_bytes,
hashed_bytes = event.hashed_bytes, skipped_mtime = event.skipped_mtime,
skipped_hash = event.skipped_hash, deleted = event.deleted_files,
total_files = ?event.total_files, total_copy_bytes = ?event.total_copy_bytes,
planned_copy_files = event.planned_copy_files, planned_delete_files = event.planned_delete_files,
average_mib_per_second = format_args!("{:.2}", event.copied_bytes as f64 / event.elapsed_seconds.max(0.001) / 1048576.0),
failed = event.failed_files, elapsed_seconds = format_args!("{:.3}", event.elapsed_seconds),
"copy progress"
)
});
}
Ok(())
}
}