sparsync 0.1.12

rsync-style high-performance file synchronization over QUIC and Spargio
use anyhow::{Context, Result, anyhow, bail};
use spargio::{RuntimeHandle, fs};
use std::path::{Component, Path, PathBuf};

pub const QUICK_CHECK_TOKEN_PREFIX: &str = "qc:";

pub fn relative_path_string(root: &Path, absolute: &Path) -> Result<String> {
    let relative = absolute.strip_prefix(root).map_err(|_| {
        anyhow::anyhow!(
            "path {} is outside root {}",
            absolute.display(),
            root.display()
        )
    })?;

    let mut parts = Vec::new();
    for component in relative.components() {
        match component {
            Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
            Component::CurDir => {}
            _ => bail!("invalid relative path component in {}", relative.display()),
        }
    }

    if parts.is_empty() {
        bail!("empty relative path for {}", absolute.display());
    }

    Ok(parts.join("/"))
}

pub fn sanitize_relative(path: &str) -> Result<PathBuf> {
    if path.is_empty() {
        bail!("empty relative path");
    }

    let input = Path::new(path);
    if input.is_absolute() {
        bail!("absolute paths are not allowed: {path}");
    }

    let mut clean = PathBuf::new();
    for component in input.components() {
        match component {
            Component::Normal(part) => clean.push(part),
            Component::CurDir => {}
            Component::ParentDir => bail!("parent path traversal is not allowed: {path}"),
            Component::RootDir | Component::Prefix(_) => {
                bail!("absolute path component is not allowed: {path}")
            }
        }
    }

    if clean.as_os_str().is_empty() {
        bail!("path resolves to empty relative path: {path}");
    }

    Ok(clean)
}

pub fn partial_path(partial_root: &Path, relative: &Path) -> PathBuf {
    let mut out = partial_root.join(relative);
    let ext = out
        .extension()
        .and_then(|v| v.to_str())
        .map(|v| format!("{v}.part"))
        .unwrap_or_else(|| "part".to_string());
    out.set_extension(ext);
    out
}

pub fn runtime_error(context: &str, err: spargio::RuntimeError) -> anyhow::Error {
    anyhow!("{context}: {err:?}")
}

pub fn join_error(context: &str, err: spargio::JoinError) -> anyhow::Error {
    anyhow!("{context}: {err:?}")
}

pub fn quick_check_token(size: u64, mtime_sec: i64) -> String {
    format!("{QUICK_CHECK_TOKEN_PREFIX}{size}:{mtime_sec}")
}

pub fn parse_quick_check_token(value: &str) -> Option<(u64, i64)> {
    let rest = value.strip_prefix(QUICK_CHECK_TOKEN_PREFIX)?;
    let (size, mtime_sec) = rest.split_once(':')?;
    let size = size.parse::<u64>().ok()?;
    let mtime_sec = mtime_sec.parse::<i64>().ok()?;
    Some((size, mtime_sec))
}

pub fn is_quick_check_token(value: &str) -> bool {
    parse_quick_check_token(value).is_some()
}

pub fn total_chunks_for_size(size: u64, chunk_size: usize) -> usize {
    if size == 0 {
        return 0;
    }
    let chunk = chunk_size.max(1) as u64;
    ((size + chunk - 1) / chunk) as usize
}

pub async fn remove_dir_tree(handle: &RuntimeHandle, root: &Path) -> Result<()> {
    let mut pending = vec![root.to_path_buf()];
    let mut remove_order = Vec::new();

    while let Some(dir) = pending.pop() {
        remove_order.push(dir.clone());
        let entries = fs::read_dir(handle, &dir)
            .await
            .with_context(|| format!("read directory {}", dir.display()))?;
        for entry in entries {
            match entry.entry_type {
                fs::DirEntryType::Directory => pending.push(entry.path),
                fs::DirEntryType::File | fs::DirEntryType::Symlink => {
                    fs::remove_file(handle, &entry.path)
                        .await
                        .with_context(|| format!("remove {}", entry.path.display()))?;
                }
                _ => {}
            }
        }
    }

    for dir in remove_order.into_iter().rev() {
        fs::remove_dir(handle, &dir)
            .await
            .with_context(|| format!("remove directory {}", dir.display()))?;
    }
    Ok(())
}