Skip to main content

ripsync/
lib.rs

1//! ripsync CLI library: argument parsing, planning, applying, reporting.
2//!
3//! The `ripsync` binary and the opt-in `rs` short alias are both thin wrappers
4//! over [`main`], so the entire program lives here in the library.
5
6pub 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/// The fully-built clap [`clap::Command`] for ripsync. Used by the man-page and
30/// shell-completion generators (the hidden `_gen` subcommand and the `xtask`).
31#[must_use]
32pub fn command() -> clap::Command {
33    Args::command()
34}
35
36/// Write the man page (`ripsync.1`) and bash/zsh/fish/powershell completions
37/// into `dir`, creating it if needed.
38///
39/// # Errors
40///
41/// Returns an I/O error if the directory or any asset cannot be written.
42pub 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
57/// Handle the hidden `ripsync _gen <man|completions> [shell]` subcommand by
58/// streaming the requested asset to stdout. `rest` is argv after `_gen`.
59fn 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
81/// Process entry point shared by every binary: runs [`run`] and maps errors to
82/// exit codes (130 on cancellation, 1 otherwise).
83pub 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    // Hidden asset generator, intercepted before clap so it does not appear in
98    // help or pollute the flat argument parser: `ripsync _gen <man|completions>`.
99    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    // The far-end protocol peer is launched as `ripsync --server` with no
105    // positional paths, so it must be intercepted before clap's required-args
106    // parsing. It reads the role/root/options off the wire, not from argv.
107    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    // Parse via matches so we can tell which flags were given on the command
113    // line, then layer config-file defaults under them.
114    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    // `--bwlimit` throttles remote transfers (see remote::run_remote); it has no
120    // effect on local copies. `--partial` is accepted (writes always go to a temp
121    // file and atomically rename) but resume-from-partial is not yet implemented.
122    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    // Remote transfer: exactly one of src/dst is a `[user@]host:path` spec.
130    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    // Watch mode: continuously re-sync on filesystem change (local only). It
137    // drives the same one-shot pipeline after each debounced batch of events.
138    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
153/// Execute one complete local sync: plan → optional delete approval → apply →
154/// verify → persist the index. Shared by the one-shot path and `--watch`.
155pub(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    // Delete guard: no destination mutation starts before destructive approval.
182    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
286/// Print a compact, color-free summary block for `--stats` (non-TUI runs).
287fn 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
317/// Format a byte count with a binary unit suffix.
318fn 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
334/// Build the path [`Filter`] from `--filter`/`--include`/`--exclude`/`--files-from`.
335fn 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
370/// Whether either side of the transfer is a remote `[user@]host:path` spec.
371fn 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}