modde-sources 0.1.0

Download source implementations for modde
Documentation
use std::path::Path;

use anyhow::{Context, Result};
use futures::StreamExt;
use tokio::io::AsyncWriteExt;
use tracing::{debug, warn};

use crate::traits::{DownloadHandle, ProgressCallback, VerifiedFile};

pub const MAX_RETRIES: u32 = 3;
pub const BACKOFF_BASE_MS: u64 = 1000;

/// Stream a response body to a file, reporting progress.
pub async fn stream_to_file(
    resp: reqwest::Response,
    dest: &Path,
    total_hint: u64,
    progress: &ProgressCallback,
) -> Result<u64> {
    let total = resp.content_length().unwrap_or(total_hint);
    let mut file = tokio::fs::File::create(dest).await?;
    let mut downloaded: u64 = 0;

    let mut stream = resp.bytes_stream();
    while let Some(chunk) = stream.next().await {
        let chunk = chunk?;
        file.write_all(&chunk).await?;
        downloaded += chunk.len() as u64;
        progress(downloaded, total);
    }

    file.flush().await?;
    Ok(downloaded)
}

/// Verify hash (xxh64 then xxh3 fallback) and return a `VerifiedFile`.
pub async fn verify_and_wrap(dest: &Path, expected_hash: u64) -> Result<VerifiedFile> {
    modde_core::hash::verify_xxhash_compat(dest, expected_hash)
        .await
        .context("hash verification failed (tried xxh64 and xxh3)")?;
    Ok(VerifiedFile { path: dest.to_path_buf(), hash: expected_hash })
}

/// Ensure parent directory exists.
pub async fn ensure_parent(dest: &Path) -> Result<()> {
    if let Some(parent) = dest.parent() {
        tokio::fs::create_dir_all(parent).await?;
    }
    Ok(())
}

/// Execute an async operation with exponential backoff retries.
pub async fn with_retry<F, Fut, T>(label: &str, f: F) -> Result<T>
where
    F: Fn() -> Fut,
    Fut: std::future::Future<Output = Result<T>>,
{
    for attempt in 0..MAX_RETRIES {
        match f().await {
            Ok(val) => return Ok(val),
            Err(e) => {
                if attempt + 1 < MAX_RETRIES {
                    let base_delay = BACKOFF_BASE_MS * (1 << attempt);
                    // Add deterministic jitter: up to 50% of base delay, derived from attempt index
                    let jitter = base_delay / 4 + (base_delay / 2).wrapping_mul(attempt as u64 + 1) % (base_delay / 2 + 1);
                    let delay = base_delay + jitter;
                    warn!(attempt = attempt + 1, delay_ms = delay, error = %e, "{label} failed, retrying");
                    tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
                } else {
                    return Err(e).context(format!("{label} failed after {MAX_RETRIES} attempts"));
                }
            }
        }
    }
    unreachable!()
}

/// Simple download: GET, stream to file, verify hash, return `VerifiedFile`.
///
/// Handles `ensure_parent` internally — callers should not call it separately.
pub async fn simple_download(
    client: &reqwest::Client,
    handle: &DownloadHandle,
    dest: &Path,
    progress: &ProgressCallback,
) -> Result<VerifiedFile> {
    ensure_parent(dest).await?;

    let resp = client
        .get(&handle.url)
        .send()
        .await?
        .error_for_status()?;

    let downloaded = stream_to_file(resp, dest, handle.size_hint.unwrap_or(0), progress).await?;
    debug!(bytes = downloaded, "download complete");

    verify_and_wrap(dest, handle.expected_hash).await
}