pub mod args;
mod config;
mod remote;
mod reporter;
mod tui;
mod watch;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::{CommandFactory, FromArgMatches};
use ripsync_core::apply::{ApplyOptions, MetadataOptions, apply_plan_controlled};
use ripsync_core::filter::{Filter, FilterBuilder};
use ripsync_core::index::Manifest;
use ripsync_core::plan::{PlanOptions, build_plan_controlled};
use ripsync_core::report::{Event, Reporter, RunPhase, RunStatus};
use ripsync_core::verify::{VerificationSummary, verify};
use ripsync_core::{Error, RunControl};
use args::{Args, OutputFormat};
use reporter::CliReporter;
#[must_use]
pub fn command() -> clap::Command {
Args::command()
}
pub fn write_assets(dir: &std::path::Path) -> std::io::Result<()> {
use clap_complete::Shell;
std::fs::create_dir_all(dir)?;
let mut man_out = Vec::new();
clap_mangen::Man::new(command()).render(&mut man_out)?;
std::fs::write(dir.join("ripsync.1"), man_out)?;
for shell in [Shell::Bash, Shell::Zsh, Shell::Fish, Shell::PowerShell] {
clap_complete::generate_to(shell, &mut command(), "ripsync", dir)?;
}
Ok(())
}
fn run_gen(rest: &[String]) -> Result<()> {
use std::io::stdout;
match rest.first().map(String::as_str) {
Some("man") => {
clap_mangen::Man::new(command())
.render(&mut stdout())
.context("rendering man page")?;
}
Some("completions") => {
let shell: clap_complete::Shell = rest
.get(1)
.ok_or_else(|| anyhow::anyhow!("usage: ripsync _gen completions <shell>"))?
.parse()
.map_err(|e| anyhow::anyhow!("unknown shell: {e}"))?;
clap_complete::generate(shell, &mut command(), "ripsync", &mut stdout());
}
_ => bail!("usage: ripsync _gen <man|completions> [shell]"),
}
Ok(())
}
pub fn main() {
if let Err(error) = run() {
if error
.downcast_ref::<Error>()
.is_some_and(|e| matches!(e, Error::Cancelled))
{
std::process::exit(130);
}
eprintln!("error: {error:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let raw: Vec<String> = std::env::args().collect();
if raw.get(1).map(String::as_str) == Some("_gen") {
return run_gen(&raw[2..]);
}
if raw.iter().skip(1).any(|a| a == "--server") {
init_tracing(raw.iter().filter(|a| a.as_str() == "-v").count().min(255) as u8);
return remote::run_server();
}
let matches = Args::command().get_matches();
let mut args = Args::from_arg_matches(&matches)?;
init_tracing(args.verbose);
config::apply_defaults(&mut args, &matches);
if args.bwlimit.is_some() && !is_remote_run(&args) {
tracing::warn!("--bwlimit has no effect on local copies; ignoring");
}
let threads = args.thread_count();
let filter = build_filter(&args)?;
let src_loc = remote::parse_location(&args.src.to_string_lossy());
let dst_loc = remote::parse_location(&args.dst.to_string_lossy());
if src_loc.is_remote() || dst_loc.is_remote() {
return remote::run_remote(&args, threads, &filter, &src_loc, &dst_loc);
}
if args.watch {
return watch::run_watch(&args, threads, &filter);
}
let use_tui = !args.no_tui
&& args.output == OutputFormat::Human
&& !args.quiet
&& std::io::IsTerminal::is_terminal(&std::io::stdout());
if use_tui {
return tui::run(&args, threads, &filter);
}
run_local_once(&args, threads, &filter)
}
pub(crate) fn run_local_once(args: &Args, threads: usize, filter: &Filter) -> Result<()> {
let reporter = CliReporter::new(args.output, args.quiet, args.verbose, args.dry_run);
let control = RunControl::default();
let started = std::time::Instant::now();
let plan = build_plan_controlled(
&args.src,
&args.dst,
PlanOptions {
checksum: args.checksum,
delete: args.delete,
threads,
index: args.index,
hard_links: args.hard_links,
},
filter,
&control,
&reporter,
)
.with_context(|| {
format!(
"planning sync {} -> {}",
args.src.display(),
args.dst.display()
)
})?;
let mut yes = args.yes;
if args.delete {
reporter.print_delete_preview(&plan);
if !yes && !args.dry_run && !plan.deletions.is_empty() {
if args.output == OutputFormat::Human
&& std::io::IsTerminal::is_terminal(&std::io::stdin())
{
eprint!("Type DELETE and press Enter to approve: ");
let mut approval = String::new();
std::io::stdin()
.read_line(&mut approval)
.context("reading delete approval")?;
yes = approval.trim_end() == "DELETE";
if !yes {
bail!("delete approval rejected before mutation");
}
} else {
bail!("--delete requires --yes in noninteractive mode");
}
}
}
let metadata = MetadataOptions {
hard_links: args.hard_links,
sparse: args.sparse,
xattrs: args.xattrs,
acls: args.acls,
owner: args.owner,
group: args.group,
};
let stats = apply_plan_controlled(
&plan,
&args.src,
&args.dst,
ApplyOptions {
dry_run: args.dry_run,
yes,
delete: args.delete,
threads,
reflink: args.reflink.into(),
fsync: args.fsync.into(),
backend: args.backend.into(),
metadata,
copy_buffer: None,
},
&reporter,
&control,
)
.context("applying sync plan")?;
let verification = if !args.dry_run && stats.errors == 0 {
verify(
&plan,
&args.src,
&args.dst,
args.verify.into(),
metadata,
threads,
&control,
&reporter,
)?
} else {
VerificationSummary::default()
};
let deletes_complete = !args.delete || args.yes || plan.deletions.is_empty();
let successful = stats.errors == 0 && verification.mismatches.is_empty();
if args.index && !args.dry_run && successful && deletes_complete {
reporter.event(Event::Phase(RunPhase::Finalizing));
Manifest::persist_after_plan(&plan, &args.dst, args.checksum)
.context("saving persistent index")?;
}
let status = if successful {
RunStatus::Success
} else {
RunStatus::Failed
};
reporter.event(Event::Phase(if successful {
RunPhase::Done
} else {
RunPhase::Failed
}));
reporter.event(Event::Finished { status });
reporter.finish(
&stats,
&args.src.display().to_string(),
&args.dst.display().to_string(),
status,
&verification,
);
if args.stats {
print_stats(args, &stats, &verification, started.elapsed());
}
if !verification.mismatches.is_empty() {
return Err(Error::Verification(verification.mismatches.len()).into());
}
if stats.errors > 0 {
bail!("sync completed with {} per-entry error(s)", stats.errors);
}
Ok(())
}
fn print_stats(
args: &Args,
stats: &ripsync_core::report::Stats,
verification: &VerificationSummary,
elapsed: std::time::Duration,
) {
let backend = match args.backend {
args::BackendArg::Auto => "auto",
args::BackendArg::Uring => "uring",
args::BackendArg::Portable => "portable",
};
println!(
"stats: copied {} updated {} skipped {} deleted {} errors {}",
stats.copied, stats.updated, stats.skipped, stats.deleted, stats.errors
);
println!(
"stats: {} in {:.3}s ({} backend, {} threads)",
human_bytes(stats.bytes),
elapsed.as_secs_f64(),
backend,
args.thread_count(),
);
if !verification.mismatches.is_empty() {
println!(
"stats: {} verification mismatch(es)",
verification.mismatches.len()
);
}
}
fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
#[allow(clippy::cast_precision_loss)]
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.2} {}", UNITS[unit])
}
}
fn build_filter(args: &Args) -> Result<Filter> {
let mut builder = FilterBuilder::default();
for rule in &args.filter {
builder.filter_rule(rule)?;
}
for pat in &args.include {
builder.include(pat.clone());
}
for pat in &args.exclude {
builder.exclude(pat.clone());
}
if let Some(path) = &args.files_from {
use std::io::{BufRead, BufReader};
let file = std::fs::File::open(path).map_err(|e| Error::io(path, e))?;
let reader = BufReader::new(file);
const MAX_LINES: usize = 1_000_000;
let mut lines = Vec::new();
for (i, line) in reader.lines().enumerate() {
if i >= MAX_LINES {
return Err(Error::Filter(format!(
"--files-from: exceeded {} line limit",
MAX_LINES
)).into());
}
let line = line.map_err(|e| Error::io(path, e))?;
if !line.is_empty() && !line.starts_with('#') {
lines.push(PathBuf::from(line.trim()));
}
}
builder.files_from(lines);
}
Ok(builder.build()?)
}
fn is_remote_run(args: &Args) -> bool {
remote::parse_location(&args.src.to_string_lossy()).is_remote()
|| remote::parse_location(&args.dst.to_string_lossy()).is_remote()
}
fn init_tracing(verbose: u8) {
let level = match verbose {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.try_init();
}