1pub mod args;
7mod config;
8mod remote;
9mod reporter;
10mod tui;
11mod watch;
12
13use std::path::PathBuf;
14
15use anyhow::{Context, Result, bail};
16use clap::{CommandFactory, FromArgMatches};
17
18use ripsync_core::apply::{ApplyOptions, MetadataOptions, apply_plan_controlled};
19use ripsync_core::filter::{Filter, FilterBuilder};
20use ripsync_core::index::Manifest;
21use ripsync_core::plan::{PlanOptions, build_plan_controlled};
22use ripsync_core::report::{Event, Reporter, RunPhase, RunStatus};
23use ripsync_core::verify::{VerificationSummary, verify};
24use ripsync_core::{Error, RunControl};
25
26use args::{Args, OutputFormat};
27use reporter::CliReporter;
28
29#[must_use]
32pub fn command() -> clap::Command {
33 Args::command()
34}
35
36pub fn write_assets(dir: &std::path::Path) -> std::io::Result<()> {
43 use clap_complete::Shell;
44
45 std::fs::create_dir_all(dir)?;
46
47 let mut man_out = Vec::new();
48 clap_mangen::Man::new(command()).render(&mut man_out)?;
49 std::fs::write(dir.join("ripsync.1"), man_out)?;
50
51 for shell in [Shell::Bash, Shell::Zsh, Shell::Fish, Shell::PowerShell] {
52 clap_complete::generate_to(shell, &mut command(), "ripsync", dir)?;
53 }
54 Ok(())
55}
56
57fn run_gen(rest: &[String]) -> Result<()> {
60 use std::io::stdout;
61
62 match rest.first().map(String::as_str) {
63 Some("man") => {
64 clap_mangen::Man::new(command())
65 .render(&mut stdout())
66 .context("rendering man page")?;
67 }
68 Some("completions") => {
69 let shell: clap_complete::Shell = rest
70 .get(1)
71 .ok_or_else(|| anyhow::anyhow!("usage: ripsync _gen completions <shell>"))?
72 .parse()
73 .map_err(|e| anyhow::anyhow!("unknown shell: {e}"))?;
74 clap_complete::generate(shell, &mut command(), "ripsync", &mut stdout());
75 }
76 _ => bail!("usage: ripsync _gen <man|completions> [shell]"),
77 }
78 Ok(())
79}
80
81pub fn main() {
84 if let Err(error) = run() {
85 if error
86 .downcast_ref::<Error>()
87 .is_some_and(|e| matches!(e, Error::Cancelled))
88 {
89 std::process::exit(130);
90 }
91 eprintln!("error: {error:#}");
92 std::process::exit(1);
93 }
94}
95
96fn run() -> Result<()> {
97 let raw: Vec<String> = std::env::args().collect();
100 if raw.get(1).map(String::as_str) == Some("_gen") {
101 return run_gen(&raw[2..]);
102 }
103
104 if raw.iter().skip(1).any(|a| a == "--server") {
108 init_tracing(raw.iter().filter(|a| a.as_str() == "-v").count().min(255) as u8);
109 return remote::run_server();
110 }
111
112 let matches = Args::command().get_matches();
115 let mut args = Args::from_arg_matches(&matches)?;
116 init_tracing(args.verbose);
117 config::apply_defaults(&mut args, &matches);
118
119 if args.bwlimit.is_some() && !is_remote_run(&args) {
123 tracing::warn!("--bwlimit has no effect on local copies; ignoring");
124 }
125
126 let threads = args.thread_count();
127 let filter = build_filter(&args)?;
128
129 let src_loc = remote::parse_location(&args.src.to_string_lossy());
131 let dst_loc = remote::parse_location(&args.dst.to_string_lossy());
132 if src_loc.is_remote() || dst_loc.is_remote() {
133 return remote::run_remote(&args, threads, &filter, &src_loc, &dst_loc);
134 }
135
136 if args.watch {
139 return watch::run_watch(&args, threads, &filter);
140 }
141
142 let use_tui = !args.no_tui
143 && args.output == OutputFormat::Human
144 && !args.quiet
145 && std::io::IsTerminal::is_terminal(&std::io::stdout());
146 if use_tui {
147 return tui::run(&args, threads, &filter);
148 }
149
150 run_local_once(&args, threads, &filter)
151}
152
153pub(crate) fn run_local_once(args: &Args, threads: usize, filter: &Filter) -> Result<()> {
156 let reporter = CliReporter::new(args.output, args.quiet, args.verbose, args.dry_run);
157 let control = RunControl::default();
158 let started = std::time::Instant::now();
159 let plan = build_plan_controlled(
160 &args.src,
161 &args.dst,
162 PlanOptions {
163 checksum: args.checksum,
164 delete: args.delete,
165 threads,
166 index: args.index,
167 hard_links: args.hard_links,
168 },
169 filter,
170 &control,
171 &reporter,
172 )
173 .with_context(|| {
174 format!(
175 "planning sync {} -> {}",
176 args.src.display(),
177 args.dst.display()
178 )
179 })?;
180
181 let mut yes = args.yes;
183 if args.delete {
184 reporter.print_delete_preview(&plan);
185 if !yes && !args.dry_run && !plan.deletions.is_empty() {
186 if args.output == OutputFormat::Human
187 && std::io::IsTerminal::is_terminal(&std::io::stdin())
188 {
189 eprint!("Type DELETE and press Enter to approve: ");
190 let mut approval = String::new();
191 std::io::stdin()
192 .read_line(&mut approval)
193 .context("reading delete approval")?;
194 yes = approval.trim_end() == "DELETE";
195 if !yes {
196 bail!("delete approval rejected before mutation");
197 }
198 } else {
199 bail!("--delete requires --yes in noninteractive mode");
200 }
201 }
202 }
203
204 let metadata = MetadataOptions {
205 hard_links: args.hard_links,
206 sparse: args.sparse,
207 xattrs: args.xattrs,
208 acls: args.acls,
209 owner: args.owner,
210 group: args.group,
211 };
212 let stats = apply_plan_controlled(
213 &plan,
214 &args.src,
215 &args.dst,
216 ApplyOptions {
217 dry_run: args.dry_run,
218 yes,
219 delete: args.delete,
220 threads,
221 reflink: args.reflink.into(),
222 fsync: args.fsync.into(),
223 backend: args.backend.into(),
224 metadata,
225 copy_buffer: None,
226 },
227 &reporter,
228 &control,
229 )
230 .context("applying sync plan")?;
231
232 let verification = if !args.dry_run && stats.errors == 0 {
233 verify(
234 &plan,
235 &args.src,
236 &args.dst,
237 args.verify.into(),
238 metadata,
239 threads,
240 &control,
241 &reporter,
242 )?
243 } else {
244 VerificationSummary::default()
245 };
246 let deletes_complete = !args.delete || args.yes || plan.deletions.is_empty();
247 let successful = stats.errors == 0 && verification.mismatches.is_empty();
248 if args.index && !args.dry_run && successful && deletes_complete {
249 reporter.event(Event::Phase(RunPhase::Finalizing));
250 Manifest::persist_after_plan(&plan, &args.dst, args.checksum)
251 .context("saving persistent index")?;
252 }
253
254 let status = if successful {
255 RunStatus::Success
256 } else {
257 RunStatus::Failed
258 };
259 reporter.event(Event::Phase(if successful {
260 RunPhase::Done
261 } else {
262 RunPhase::Failed
263 }));
264 reporter.event(Event::Finished { status });
265 reporter.finish(
266 &stats,
267 &args.src.display().to_string(),
268 &args.dst.display().to_string(),
269 status,
270 &verification,
271 );
272
273 if args.stats {
274 print_stats(args, &stats, &verification, started.elapsed());
275 }
276
277 if !verification.mismatches.is_empty() {
278 return Err(Error::Verification(verification.mismatches.len()).into());
279 }
280 if stats.errors > 0 {
281 bail!("sync completed with {} per-entry error(s)", stats.errors);
282 }
283 Ok(())
284}
285
286fn print_stats(
288 args: &Args,
289 stats: &ripsync_core::report::Stats,
290 verification: &VerificationSummary,
291 elapsed: std::time::Duration,
292) {
293 let backend = match args.backend {
294 args::BackendArg::Auto => "auto",
295 args::BackendArg::Uring => "uring",
296 args::BackendArg::Portable => "portable",
297 };
298 println!(
299 "stats: copied {} updated {} skipped {} deleted {} errors {}",
300 stats.copied, stats.updated, stats.skipped, stats.deleted, stats.errors
301 );
302 println!(
303 "stats: {} in {:.3}s ({} backend, {} threads)",
304 human_bytes(stats.bytes),
305 elapsed.as_secs_f64(),
306 backend,
307 args.thread_count(),
308 );
309 if !verification.mismatches.is_empty() {
310 println!(
311 "stats: {} verification mismatch(es)",
312 verification.mismatches.len()
313 );
314 }
315}
316
317fn human_bytes(bytes: u64) -> String {
319 const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
320 #[allow(clippy::cast_precision_loss)]
321 let mut value = bytes as f64;
322 let mut unit = 0;
323 while value >= 1024.0 && unit < UNITS.len() - 1 {
324 value /= 1024.0;
325 unit += 1;
326 }
327 if unit == 0 {
328 format!("{bytes} B")
329 } else {
330 format!("{value:.2} {}", UNITS[unit])
331 }
332}
333
334fn build_filter(args: &Args) -> Result<Filter> {
336 let mut builder = FilterBuilder::default();
337 for rule in &args.filter {
338 builder.filter_rule(rule)?;
339 }
340 for pat in &args.include {
341 builder.include(pat.clone());
342 }
343 for pat in &args.exclude {
344 builder.exclude(pat.clone());
345 }
346 if let Some(path) = &args.files_from {
347 use std::io::{BufRead, BufReader};
348
349 let file = std::fs::File::open(path).map_err(|e| Error::io(path, e))?;
350 let reader = BufReader::new(file);
351 const MAX_LINES: usize = 1_000_000;
352 let mut lines = Vec::new();
353 for (i, line) in reader.lines().enumerate() {
354 if i >= MAX_LINES {
355 return Err(Error::Filter(format!(
356 "--files-from: exceeded {} line limit",
357 MAX_LINES
358 )).into());
359 }
360 let line = line.map_err(|e| Error::io(path, e))?;
361 if !line.is_empty() && !line.starts_with('#') {
362 lines.push(PathBuf::from(line.trim()));
363 }
364 }
365 builder.files_from(lines);
366 }
367 Ok(builder.build()?)
368}
369
370fn is_remote_run(args: &Args) -> bool {
372 remote::parse_location(&args.src.to_string_lossy()).is_remote()
373 || remote::parse_location(&args.dst.to_string_lossy()).is_remote()
374}
375
376fn init_tracing(verbose: u8) {
377 let level = match verbose {
378 0 => "warn",
379 1 => "info",
380 2 => "debug",
381 _ => "trace",
382 };
383 let filter = tracing_subscriber::EnvFilter::try_from_default_env()
384 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level));
385 let _ = tracing_subscriber::fmt()
386 .with_env_filter(filter)
387 .with_writer(std::io::stderr)
388 .try_init();
389}