Download
855 lines 28.9 KiBRustModified 2026-10-29 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855
use serde::Serialize;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};

use crate::config::{Config, SyncJob, SyncMode};

#[derive(Debug, Clone, Default, Serialize)]
pub struct BackupProgress {
    pub current_job: usize,
    pub total_jobs: usize,
    pub job_name: String,
    pub files_transferred: u64,
    pub files_total: u64,
    pub bytes_transferred: u64,
    pub bytes_total: u64,
    pub current_file: String,
    pub elapsed_secs: f64,
    pub estimated_total_secs: Option<f64>,
    pub finished: bool,
    pub cancelled: bool,
    pub error: Option<String>,
    pub log_lines: Vec<String>,
    pub child_pid: Option<u32>,
    pub paused: bool,
}

impl BackupProgress {
    pub fn fraction(&self) -> f32 {
        if self.bytes_total == 0 {
            0.0
        } else {
            (self.bytes_transferred as f32 / self.bytes_total as f32).clamp(0.0, 1.0)
        }
    }

    pub fn overall_fraction(&self) -> f32 {
        if self.total_jobs == 0 {
            return 0.0;
        }
        let per_job = 1.0 / self.total_jobs as f32;
        let completed = self.current_job as f32 * per_job;
        completed + self.fraction() * per_job
    }

    pub fn eta_string(&self) -> String {
        if let Some(total) = self.estimated_total_secs {
            let remaining = (total - self.elapsed_secs).max(0.0);
            format_duration(remaining as u64)
        } else {
            "calculating…".to_owned()
        }
    }
}

pub fn format_duration(secs: u64) -> String {
    if secs < 60 {
        format!("{secs}s")
    } else if secs < 3600 {
        format!("{}m {}s", secs / 60, secs % 60)
    } else {
        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
    }
}

pub type SharedProgress = Arc<Mutex<BackupProgress>>;

/// Remote shell used for remote sources. BatchMode makes ssh fail immediately instead of
/// waiting on a password or host-key prompt that a GUI app has no terminal to show, and
/// ConnectTimeout keeps an unreachable host from hanging the job or the file browser.
pub const RSYNC_RSH: &str = "--rsh=ssh -o BatchMode=yes -o ConnectTimeout=15";

/// The `--rsh` argument to pass for `source`, if it is reached over ssh. Daemon specs
/// (`rsync://…`, `host::module`) are excluded: with `--rsh` rsync would tunnel them over ssh
/// instead of connecting to the daemon.
pub fn rsh_arg(source: &str) -> Option<&'static str> {
    let daemon = source.starts_with("rsync://")
        || source
            .find("::")
            .is_some_and(|i| !source[..i].contains('/'));
    (is_remote(source) && !daemon).then_some(RSYNC_RSH)
}

/// Options needed to reach a remote source: the ssh shell and, if set, the remote rsync command.
pub fn remote_args(source: &str, rsync_path: Option<&str>) -> Vec<String> {
    let mut args: Vec<String> = rsh_arg(source).map(str::to_owned).into_iter().collect();
    if let Some(path) = rsync_path.map(str::trim).filter(|p| !p.is_empty()) {
        if is_remote(source) {
            args.push(format!("--rsync-path={path}"));
        }
    }
    args
}

const MAX_LOG_LINES: usize = 500;

fn push_log(p: &mut BackupProgress, line: String) {
    if p.log_lines.len() >= MAX_LOG_LINES {
        p.log_lines.remove(0);
    }
    p.log_lines.push(line);
}

/// Whether `source` is an rsync remote spec (`host:path`, `user@host:path` or `rsync://…`),
/// using the same rule as rsync: a colon that appears before any slash.
pub fn is_remote(source: &str) -> bool {
    if source.starts_with("rsync://") {
        return true;
    }
    match source.find(':') {
        Some(colon) => colon > 0 && !source[..colon].contains('/'),
        None => false,
    }
}

/// Append the trailing slash rsync needs to copy a directory's contents. A bare `host:`
/// (the remote home directory) is left alone, since `host:/` would mean the remote root.
pub fn dir_arg(path: &str) -> String {
    if path.ends_with('/') || (is_remote(path) && path.ends_with(':')) {
        path.to_owned()
    } else {
        format!("{path}/")
    }
}

/// Join a relative subpath onto a local or remote source path.
pub fn join_source(source: &str, sub: &str) -> String {
    if source.ends_with('/') || (is_remote(source) && source.ends_with(':')) {
        format!("{source}{sub}")
    } else {
        format!("{source}/{sub}")
    }
}

/// Forward a child's stderr into the log so errors (e.g. ssh failures) are visible. Join the
/// returned handle before reporting the child's exit status so its last lines are logged first.
fn log_stderr(
    child: &mut std::process::Child,
    progress: &SharedProgress,
) -> Option<std::thread::JoinHandle<()>> {
    let stderr = child.stderr.take()?;
    let progress = Arc::clone(progress);
    Some(std::thread::spawn(move || {
        for line in BufReader::new(stderr).lines() {
            let Ok(line) = line else { continue };
            let line = line.trim();
            if !line.is_empty() {
                push_log(&mut progress.lock().unwrap(), line.to_owned());
            }
        }
    }))
}

pub fn list_snapshot_names(drive_root: &Path) -> Vec<String> {
    let snapshots_dir = drive_root.join("snapshots");
    let mut names: Vec<String> = match std::fs::read_dir(&snapshots_dir) {
        Ok(rd) => rd
            .flatten()
            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
            .filter_map(|e| e.file_name().into_string().ok())
            .collect(),
        Err(_) => vec![],
    };
    names.sort();
    names
}

pub fn rsync_restore_args(
    job: &SyncJob,
    drive_root: &Path,
    snapshot: Option<&str>,
    delete_extra: bool,
    subpath: Option<&str>,
) -> Vec<String> {
    let mut args: Vec<String> = vec![
        "-av".to_owned(),
        "--info=progress2".to_owned(),
        "--no-inc-recursive".to_owned(),
        "--partial-dir=.rsync-partial".to_owned(),
    ];
    if delete_extra {
        args.push("--delete".to_owned());
    }
    let mut src_dir = match snapshot {
        Some(snap) => drive_root
            .join("snapshots")
            .join(snap)
            .join(&job.destination),
        None => drive_root.join(&job.destination),
    };
    let source = job.source.to_string_lossy();
    args.extend(remote_args(&source, job.remote_rsync_path.as_deref()));
    let mut dst_dir = source.into_owned();
    if let Some(sub) = subpath.filter(|s| !s.is_empty()) {
        src_dir = src_dir.join(sub);
        dst_dir = join_source(&dst_dir, sub);
    }
    // End option parsing so a path beginning with '-' is never read as an option.
    args.push("--".to_owned());
    args.push(format!("{}/", src_dir.display()));
    args.push(dir_arg(&dst_dir));
    args
}

pub fn run_restore(
    jobs: Vec<SyncJob>,
    subpaths: Vec<Option<String>>,
    drive_root: PathBuf,
    snapshot: Option<String>,
    delete_extra: bool,
    progress: SharedProgress,
) -> std::thread::JoinHandle<()> {
    let total_jobs = jobs.len();
    std::thread::spawn(move || {
        {
            let mut p = progress.lock().unwrap();
            p.total_jobs = total_jobs;
            p.current_job = 0;
            p.finished = false;
            p.cancelled = false;
            p.error = None;
            p.child_pid = None;
            p.paused = false;
        }

        let start = std::time::Instant::now();

        for (idx, (job, subpath)) in jobs.iter().zip(subpaths.iter()).enumerate() {
            if progress.lock().unwrap().cancelled {
                break;
            }
            {
                let mut p = progress.lock().unwrap();
                p.current_job = idx;
                p.job_name = job.name.clone();
                p.files_transferred = 0;
                p.files_total = 0;
                p.bytes_transferred = 0;
                p.bytes_total = 0;
                p.current_file.clear();
            }

            let args = rsync_restore_args(
                job,
                &drive_root,
                snapshot.as_deref(),
                delete_extra,
                subpath.as_deref(),
            );
            let mut child = match Command::new("rsync")
                .args(&args)
                .stdout(Stdio::piped())
                .stderr(Stdio::piped())
                .spawn()
            {
                Ok(c) => c,
                Err(e) => {
                    let mut p = progress.lock().unwrap();
                    p.error = Some(format!("failed to spawn rsync: {e}"));
                    return;
                }
            };

            progress.lock().unwrap().child_pid = Some(child.id());
            let stderr_thread = log_stderr(&mut child, &progress);

            if let Some(stdout) = child.stdout.take() {
                let reader = BufReader::new(stdout);
                for line in reader.lines() {
                    let Ok(line) = line else { continue };
                    let elapsed = start.elapsed().as_secs_f64();
                    for seg in line.split('\r') {
                        let seg = seg.trim();
                        if !seg.is_empty() {
                            parse_rsync_line(seg, &progress, elapsed);
                        }
                    }
                    let display = line
                        .split('\r')
                        .map(str::trim)
                        .filter(|s| !s.is_empty())
                        .last()
                        .unwrap_or("")
                        .to_owned();
                    if !display.is_empty() {
                        let mut p = progress.lock().unwrap();
                        p.elapsed_secs = elapsed;
                        push_log(&mut p, display);
                    }
                }
            }

            let wait_result = child.wait();
            if let Some(t) = stderr_thread {
                let _ = t.join();
            }
            let cancelled = {
                let mut p = progress.lock().unwrap();
                p.child_pid = None;
                p.cancelled
            };

            let status = match wait_result {
                Ok(s) => s,
                Err(e) => {
                    if cancelled {
                        break;
                    }
                    let mut p = progress.lock().unwrap();
                    p.error = Some(format!("rsync wait error: {e}"));
                    return;
                }
            };

            if cancelled {
                break;
            }
            if !status.success() {
                let code = status.code().unwrap_or(-1);
                // exit code 24 = partial transfer due to vanished source files; treat as success
                if code != 24 {
                    let mut p = progress.lock().unwrap();
                    p.error = Some(format!(
                        "rsync exited with code {code} for job \"{}\"",
                        job.name
                    ));
                    return;
                }
            }
        }

        let elapsed = start.elapsed().as_secs_f64();
        let mut p = progress.lock().unwrap();
        p.finished = true;
        p.elapsed_secs = elapsed;
        p.current_job = total_jobs;
    })
}

/// Signature that must begin a CACHEDIR.TAG file, per the Cache Directory Tagging Specification.
const CACHEDIR_TAG_SIGNATURE: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55";

fn is_valid_cachedir_tag(path: &Path) -> bool {
    use std::io::Read;
    let Ok(mut f) = std::fs::File::open(path) else {
        return false;
    };
    let mut buf = [0u8; 43];
    matches!(f.read_exact(&mut buf), Ok(())) && buf == CACHEDIR_TAG_SIGNATURE
}

/// Walk `source` (without following symlinks) and return the paths of all directories containing
/// a valid CACHEDIR.TAG, relative to `source`. Tagged directories are not descended into.
pub fn find_cache_dirs(source: &Path) -> Vec<PathBuf> {
    let mut found = Vec::new();
    let mut stack = vec![source.to_path_buf()];
    while let Some(dir) = stack.pop() {
        if is_valid_cachedir_tag(&dir.join("CACHEDIR.TAG")) {
            if let Ok(rel) = dir.strip_prefix(source) {
                found.push(rel.to_path_buf());
            }
            continue;
        }
        let Ok(rd) = std::fs::read_dir(&dir) else {
            continue;
        };
        for entry in rd.flatten() {
            // file_type() does not follow symlinks, so symlinked dirs are skipped (as rsync -a does).
            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
                stack.push(entry.path());
            }
        }
    }
    found.sort();
    found
}

/// Escape rsync pattern metacharacters so a literal path matches only itself.
fn rsync_escape_pattern(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if matches!(c, '*' | '?' | '[' | '\\') {
            out.push('\\');
        }
        out.push(c);
    }
    out
}

/// Filter rules that skip a cache directory's contents while keeping the directory and its tag,
/// matching GNU tar's `--exclude-caches` behaviour.
pub fn cache_dir_rules(cache_dirs: &[PathBuf]) -> Vec<String> {
    let mut rules = Vec::new();
    for dir in cache_dirs {
        let rel = rsync_escape_pattern(&dir.to_string_lossy());
        let prefix = if rel.is_empty() {
            String::new()
        } else {
            format!("/{rel}")
        };
        rules.push(format!("--include={prefix}/CACHEDIR.TAG"));
        rules.push(format!("--exclude={prefix}/*"));
    }
    rules
}

pub fn rsync_args(job: &SyncJob, drive_root: &Path, cache_dirs: &[PathBuf]) -> Vec<String> {
    let mut args: Vec<String> = vec![
        "-av".to_owned(),
        "--info=progress2".to_owned(),
        "--no-inc-recursive".to_owned(),
        "--partial-dir=.rsync-partial".to_owned(),
    ];
    if job.mode == SyncMode::Backup {
        args.push("--delete".to_owned());
    }
    for excl in &job.excludes {
        args.push(format!("--exclude={excl}"));
    }
    args.extend(cache_dir_rules(cache_dirs));
    let source = job.source.to_string_lossy();
    args.extend(remote_args(&source, job.remote_rsync_path.as_deref()));
    let dest = drive_root.join(&job.destination);
    // End option parsing so a path beginning with '-' is never read as an option.
    args.push("--".to_owned());
    args.push(dir_arg(&source));
    args.push(format!("{}/", dest.display()));
    args
}

/// Human-readable command for the preview screen. Cache directories are discovered at run time,
/// so they are shown as a placeholder rather than scanned here. Remote sources can't be scanned.
pub fn rsync_command_string(job: &SyncJob, drive_root: &Path) -> String {
    let args = rsync_args(job, drive_root, &[]);
    let mut quoted: Vec<String> = args.iter().map(|a| shell_quote(a)).collect();
    if job.exclude_caches && !is_remote(&job.source.to_string_lossy()) {
        let n = quoted.len() - 3; // before `--` and the two paths
        quoted.insert(n, "[--exclude for each CACHEDIR.TAG directory]".to_owned());
    }
    format!("rsync {}", quoted.join(" "))
}

fn shell_quote(s: &str) -> String {
    if s.contains(|c: char| {
        c.is_whitespace()
            || matches!(
                c,
                '"' | '\''
                    | '\\'
                    | '$'
                    | '`'
                    | '('
                    | ')'
                    | '&'
                    | '|'
                    | ';'
                    | '<'
                    | '>'
                    | '!'
                    | '#'
                    | '~'
                    | '*'
                    | '?'
            )
    }) {
        format!("'{}'", s.replace('\'', "'\\''"))
    } else {
        s.to_owned()
    }
}

pub fn run_backup(
    config: &Config,
    drive_root: &Path,
    progress: SharedProgress,
) -> std::thread::JoinHandle<()> {
    let jobs: Vec<SyncJob> = config.jobs.iter().filter(|j| j.enabled).cloned().collect();
    let drive_root = drive_root.to_path_buf();
    let total_jobs = jobs.len();

    std::thread::spawn(move || {
        {
            let mut p = progress.lock().unwrap();
            p.total_jobs = total_jobs;
            p.current_job = 0;
            p.finished = false;
            p.cancelled = false;
            p.error = None;
            p.child_pid = None;
            p.paused = false;
        }

        let start = std::time::Instant::now();

        for (idx, job) in jobs.iter().enumerate() {
            if progress.lock().unwrap().cancelled {
                break;
            }
            {
                let mut p = progress.lock().unwrap();
                p.current_job = idx;
                p.job_name = job.name.clone();
                p.files_transferred = 0;
                p.files_total = 0;
                p.bytes_transferred = 0;
                p.bytes_total = 0;
                p.current_file.clear();
            }

            let dest = drive_root.join(&job.destination);
            if let Err(e) = std::fs::create_dir_all(&dest) {
                let mut p = progress.lock().unwrap();
                p.error = Some(format!("mkdir {}: {e}", dest.display()));
                return;
            }

            let remote = is_remote(&job.source.to_string_lossy());
            let cache_dirs = if job.exclude_caches && !remote {
                find_cache_dirs(&job.source)
            } else {
                vec![]
            };
            if job.exclude_caches && remote {
                push_log(
                    &mut progress.lock().unwrap(),
                    "Remote source: CACHEDIR.TAG directories are not skipped".to_owned(),
                );
            }
            if !cache_dirs.is_empty() {
                let mut p = progress.lock().unwrap();
                p.log_lines.push(format!(
                    "Skipping {} cache director{} (CACHEDIR.TAG):",
                    cache_dirs.len(),
                    if cache_dirs.len() == 1 { "y" } else { "ies" }
                ));
                for d in &cache_dirs {
                    p.log_lines.push(format!("  {}", d.display()));
                }
            }

            let args = rsync_args(job, &drive_root, &cache_dirs);
            let mut child = match Command::new("rsync")
                .args(&args)
                .stdout(Stdio::piped())
                .stderr(Stdio::piped())
                .spawn()
            {
                Ok(c) => c,
                Err(e) => {
                    let mut p = progress.lock().unwrap();
                    p.error = Some(format!("failed to spawn rsync: {e}"));
                    return;
                }
            };

            progress.lock().unwrap().child_pid = Some(child.id());
            let stderr_thread = log_stderr(&mut child, &progress);

            if let Some(stdout) = child.stdout.take() {
                let reader = BufReader::new(stdout);
                for line in reader.lines() {
                    let Ok(line) = line else { continue };
                    let elapsed = start.elapsed().as_secs_f64();
                    for seg in line.split('\r') {
                        let seg = seg.trim();
                        if !seg.is_empty() {
                            parse_rsync_line(seg, &progress, elapsed);
                        }
                    }
                    let display = line
                        .split('\r')
                        .map(str::trim)
                        .filter(|s| !s.is_empty())
                        .last()
                        .unwrap_or("")
                        .to_owned();
                    if !display.is_empty() {
                        let mut p = progress.lock().unwrap();
                        p.elapsed_secs = elapsed;
                        push_log(&mut p, display);
                    }
                }
            }

            let wait_result = child.wait();
            if let Some(t) = stderr_thread {
                let _ = t.join();
            }
            let cancelled = {
                let mut p = progress.lock().unwrap();
                p.child_pid = None;
                p.cancelled
            };

            let status = match wait_result {
                Ok(s) => s,
                Err(e) => {
                    if cancelled {
                        break;
                    }
                    let mut p = progress.lock().unwrap();
                    p.error = Some(format!("rsync wait error: {e}"));
                    return;
                }
            };

            if cancelled {
                break;
            }
            if !status.success() {
                let code = status.code().unwrap_or(-1);
                // exit code 24 = partial transfer due to vanished source files; treat as success
                if code != 24 {
                    let mut p = progress.lock().unwrap();
                    p.error = Some(format!(
                        "rsync exited with code {code} for job \"{}\"",
                        job.name
                    ));
                    return;
                }
            }
        }

        if progress.lock().unwrap().cancelled {
            let elapsed = start.elapsed().as_secs_f64();
            let mut p = progress.lock().unwrap();
            p.elapsed_secs = elapsed;
            p.current_job = total_jobs;
            return;
        }

        if !is_btrfs(&drive_root) {
            let elapsed = start.elapsed().as_secs_f64();
            let mut p = progress.lock().unwrap();
            p.finished = true;
            p.elapsed_secs = elapsed;
            p.current_job = total_jobs;
            return;
        }

        let snapshot_name = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S").to_string();
        let snapshots_dir = drive_root.join("snapshots");
        let _ = std::fs::create_dir_all(&snapshots_dir);

        let snap_output = Command::new("doas")
            .args([
                "btrfs",
                "subvolume",
                "snapshot",
                "-r",
                ".",
                snapshots_dir.join(&snapshot_name).to_str().unwrap_or("."),
            ])
            .current_dir(&drive_root)
            .output();

        let elapsed = start.elapsed().as_secs_f64();
        let mut p = progress.lock().unwrap();
        p.finished = true;
        p.elapsed_secs = elapsed;
        p.current_job = total_jobs;

        match snap_output {
            Err(e) => {
                p.log_lines
                    .push(format!("Warning: btrfs snapshot failed: {e}"));
            }
            Ok(out) if !out.status.success() => {
                let stderr = String::from_utf8_lossy(&out.stderr);
                let stderr = stderr.trim();
                if stderr.is_empty() {
                    p.log_lines.push(format!(
                        "Warning: btrfs snapshot exited {}",
                        out.status.code().unwrap_or(-1)
                    ));
                } else {
                    p.log_lines
                        .push(format!("Warning: btrfs snapshot failed: {stderr}"));
                }
                if let Ok(mnt) = Command::new("findmnt")
                    .args([
                        "--output=TARGET,OPTIONS",
                        "--target",
                        drive_root.to_str().unwrap_or("."),
                    ])
                    .output()
                {
                    let out = String::from_utf8_lossy(&mnt.stdout);
                    let out = out.trim();
                    if !out.is_empty() {
                        p.log_lines.push(format!("  mount: {out}"));
                    }
                }
                if let Ok(sv) = Command::new("doas")
                    .args([
                        "btrfs",
                        "subvolume",
                        "show",
                        drive_root.to_str().unwrap_or("."),
                    ])
                    .output()
                {
                    let out = String::from_utf8_lossy(&sv.stdout);
                    for line in out.lines().take(6) {
                        p.log_lines.push(format!("  {line}"));
                    }
                }
            }
            Ok(_) => {
                p.log_lines
                    .push(format!("Snapshot created: {snapshot_name}"));
            }
        }
    })
}

fn is_btrfs(path: &Path) -> bool {
    Command::new("findmnt")
        .args(["--output=FSTYPE", "--noheadings", "--target"])
        .arg(path)
        .output()
        .map(|out| String::from_utf8_lossy(&out.stdout).trim() == "btrfs")
        .unwrap_or(false)
}

fn parse_rsync_line(line: &str, progress: &SharedProgress, elapsed: f64) {
    let trimmed = line.trim();

    if trimmed.contains("to-chk=") || trimmed.contains("xfr#") {
        if let Some(pct_pos) = trimmed.find('%') {
            let before_pct: &str = &trimmed[..pct_pos];
            let parts: Vec<&str> = before_pct.split_whitespace().collect();
            if parts.len() >= 2 {
                let bytes_str = parts[parts.len() - 2].replace(',', "");
                let pct_str = parts[parts.len() - 1];
                if let (Ok(bytes), Ok(pct)) = (bytes_str.parse::<u64>(), pct_str.parse::<f64>()) {
                    let mut p = progress.lock().unwrap();
                    p.bytes_transferred = bytes;
                    if pct > 0.0 {
                        p.bytes_total = (bytes as f64 * 100.0 / pct) as u64;
                    }
                    if pct > 1.0 {
                        let estimated = elapsed / (pct / 100.0);
                        p.estimated_total_secs = Some(estimated);
                    }
                }
            }
        }
        if let Some(pos) = trimmed.find("to-chk=") {
            let rest = &trimmed[pos + 7..];
            let nums: Vec<&str> = rest.split('/').collect();
            if nums.len() >= 2 {
                let remaining: u64 = nums[0].trim_end_matches(')').parse().unwrap_or(0);
                let total: u64 = nums[1]
                    .trim_end_matches(')')
                    .split_whitespace()
                    .next()
                    .unwrap_or("0")
                    .parse()
                    .unwrap_or(0);
                let mut p = progress.lock().unwrap();
                p.files_total = total;
                p.files_transferred = total.saturating_sub(remaining);
            }
        }
        return;
    }

    if trimmed.contains("ir-chk=") {
        return;
    }

    if !trimmed.is_empty()
        && !trimmed.starts_with("sending")
        && !trimmed.starts_with("receiving")
        && !trimmed.starts_with("sent")
        && !trimmed.starts_with("total")
        && !trimmed.contains('%')
    {
        let mut p = progress.lock().unwrap();
        p.current_file = trimmed.to_owned();
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn finds_tagged_dirs_and_prunes_them() {
        let tmp = std::env::temp_dir().join(format!("bu-cachetest-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(tmp.join("a/cache/inner")).unwrap();
        std::fs::create_dir_all(tmp.join("b/fake")).unwrap();
        std::fs::write(tmp.join("a/cache/CACHEDIR.TAG"), CACHEDIR_TAG_SIGNATURE).unwrap();
        std::fs::write(
            tmp.join("a/cache/inner/CACHEDIR.TAG"),
            CACHEDIR_TAG_SIGNATURE,
        )
        .unwrap();
        std::fs::write(tmp.join("b/fake/CACHEDIR.TAG"), b"not a real tag").unwrap();

        let found = find_cache_dirs(&tmp);
        assert_eq!(found, vec![PathBuf::from("a/cache")]);
        assert_eq!(
            cache_dir_rules(&found),
            vec!["--include=/a/cache/CACHEDIR.TAG", "--exclude=/a/cache/*"]
        );
        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[test]
    fn detects_remote_sources() {
        assert!(is_remote("mail.example.com:/var/mail"));
        assert!(is_remote("me@host:Maildir"));
        assert!(is_remote("host:"));
        assert!(is_remote("rsync://host/module"));
        assert!(!is_remote("/home/me"));
        assert!(!is_remote("/home/me/a:b"));
        assert!(!is_remote("./a:b"));
        assert!(!is_remote(":foo"));
        assert_eq!(rsh_arg("me@host:Maildir"), Some(RSYNC_RSH));
        assert_eq!(rsh_arg("rsync://host/module"), None);
        assert_eq!(rsh_arg("host::module/dir"), None);
        assert_eq!(rsh_arg("/home/me"), None);
        let doas = Some("doas -n -u vmail /usr/bin/rsync");
        assert_eq!(
            remote_args("me@host:/var/vmail", doas),
            vec![
                RSYNC_RSH.to_owned(),
                "--rsync-path=doas -n -u vmail /usr/bin/rsync".to_owned()
            ]
        );
        assert!(remote_args("/var/vmail", doas).is_empty());
        assert_eq!(
            remote_args("host:x", Some("  ")),
            vec![RSYNC_RSH.to_owned()]
        );
    }

    #[test]
    fn remote_paths_keep_home_relative_meaning() {
        assert_eq!(dir_arg("host:/var/mail"), "host:/var/mail/");
        assert_eq!(dir_arg("host:"), "host:");
        assert_eq!(join_source("host:", "Maildir"), "host:Maildir");
        assert_eq!(join_source("host:/var/mail", "me"), "host:/var/mail/me");
        assert_eq!(join_source("/home/me", "x"), "/home/me/x");
    }

    #[test]
    fn escapes_rsync_metacharacters() {
        assert_eq!(rsync_escape_pattern("x[1]*?"), "x\\[1]\\*\\?");
    }
}