rusty-cat 0.2.0

Async HTTP client for resumable file upload and download.
Documentation
use crate::{InnerErrorCode, MeowError, TransferTask};
use reqwest::header::HeaderMap;

/// Header merge context for download HEAD request.
pub struct DownloadHeadCtx<'a> {
    /// Immutable task snapshot.
    pub task: &'a TransferTask,
    /// Mutable base headers cloned from task.
    pub base: &'a mut HeaderMap,
}

/// Header merge context for download range GET request.
pub struct DownloadRangeGetCtx<'a> {
    /// Immutable task snapshot.
    pub task: &'a TransferTask,
    /// Fully formatted `Range` header value, for example `bytes=0-1048575`.
    pub range_value: &'a str,
    /// Mutable base headers cloned from task.
    pub base: &'a mut HeaderMap,
}

/// Custom breakpoint download protocol.
///
/// Implementors control HEAD/range-GET URL and header semantics, and parse
/// remote total size from HEAD response headers. Executor handles HTTP sending,
/// response validation, file writes, retries, progress, pause/resume, and state.
///
/// # Typical call flow
///
/// 1. Prepare stage: executor sends HEAD after `head_url` and
///    `merge_head_headers`.
/// 2. Chunk stage: executor sends range GET after `merge_range_get_headers`.
///
/// # Executor integration contract
///
/// - Default implementation uses task-level `range_accept` as `Accept` header.
/// - `range_value` is generated by executor and should usually be preserved.
/// - `total_size_from_head` failure terminates prepare stage.
///
/// # Examples
///
/// ```no_run
/// use rusty_cat::api::{
///     BreakpointDownload, DownloadHeadCtx, DownloadRangeGetCtx, MeowError, StandardRangeDownload,
/// };
///
/// #[derive(Default)]
/// struct MyDownloadProtocol;
///
/// impl BreakpointDownload for MyDownloadProtocol {
///     fn merge_head_headers(&self, _ctx: DownloadHeadCtx<'_>) -> Result<(), MeowError> {
///         Ok(())
///     }
///
///     fn merge_range_get_headers(&self, ctx: DownloadRangeGetCtx<'_>) -> Result<(), MeowError> {
///         // Reuse default behavior or customize as needed.
///         StandardRangeDownload.merge_range_get_headers(ctx)
///     }
/// }
/// ```
pub trait BreakpointDownload: Send + Sync {
    /// Returns known remote total size and skips the HEAD prepare request when
    /// present.
    ///
    /// This is useful for presigned URL downloads where a GET URL cannot be
    /// reused as HEAD, or where the application server already returned object
    /// metadata together with the presigned range URL.
    fn total_size_hint(&self, _task: &TransferTask) -> Option<u64> {
        None
    }

    /// Returns full URL for HEAD request.
    ///
    /// Default implementation returns [`TransferTask::url`].
    ///
    /// # Panics
    ///
    /// Implementations should avoid panicking and prefer returning recoverable
    /// errors from later merge/parse methods.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use rusty_cat::api::{BreakpointDownload, StandardRangeDownload, TransferTask};
    ///
    /// fn head_url_for(task: &TransferTask) -> String {
    ///     BreakpointDownload::head_url(&StandardRangeDownload, task)
    /// }
    /// ```
    fn head_url(&self, task: &TransferTask) -> String {
        task.url().to_string()
    }

    /// Returns full URL for range GET requests.
    ///
    /// Default implementation returns [`TransferTask::url`]. Presigned
    /// protocols can override this when HEAD and GET use different URLs.
    fn range_url(&self, task: &TransferTask) -> String {
        task.url().to_string()
    }

    /// Merges protocol-specific headers before sending HEAD request.
    ///
    /// Default implementation is no-op.
    ///
    /// # Errors
    ///
    /// Return [`MeowError`] when required HEAD headers cannot be generated
    /// (for example, signing failure or invalid header values).
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use rusty_cat::api::DownloadHeadCtx;
    ///
    /// fn inspect_head_ctx(ctx: &DownloadHeadCtx<'_>) {
    ///     let _ = ctx.task.file_name();
    ///     let _ = ctx.base.len();
    /// }
    /// ```
    fn merge_head_headers(&self, _ctx: DownloadHeadCtx<'_>) -> Result<(), MeowError> {
        Ok(())
    }

    /// Merges protocol-specific headers before range GET request.
    ///
    /// Default implementation sets:
    /// - `Range: <range_value>`
    /// - `Accept: <task.range_accept or application/octet-stream>`
    ///
    /// # Errors
    ///
    /// Return [`MeowError`] when protocol-specific range headers cannot be
    /// generated.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use rusty_cat::api::DownloadRangeGetCtx;
    ///
    /// fn inspect_range_ctx(ctx: &DownloadRangeGetCtx<'_>) {
    ///     let _ = (ctx.range_value, ctx.task.url());
    /// }
    /// ```
    fn merge_range_get_headers(&self, ctx: DownloadRangeGetCtx<'_>) -> Result<(), MeowError> {
        let _ = self;
        crate::http_breakpoint::insert_header(ctx.base, "Range", ctx.range_value);
        let accept = ctx
            .task
            .breakpoint_download_http()
            .map(|c| c.range_accept.as_str())
            .unwrap_or(crate::http_breakpoint::DEFAULT_RANGE_ACCEPT);
        crate::http_breakpoint::insert_header(ctx.base, "Accept", accept);
        Ok(())
    }

    /// Parses total resource size from successful HEAD response headers.
    ///
    /// Default implementation requires valid `Content-Length > 0`.
    ///
    /// # Errors
    ///
    /// Returns `MissingOrInvalidContentLengthFromHead` when total size cannot
    /// be parsed from response headers.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH};
    /// use rusty_cat::api::{BreakpointDownload, StandardRangeDownload};
    ///
    /// let mut headers = HeaderMap::new();
    /// headers.insert(CONTENT_LENGTH, HeaderValue::from_static("1024"));
    /// let total = StandardRangeDownload.total_size_from_head(&headers)?;
    /// assert_eq!(total, 1024);
    /// # Ok::<(), rusty_cat::api::MeowError>(())
    /// ```
    fn total_size_from_head(&self, headers: &HeaderMap) -> Result<u64, MeowError> {
        headers
            .get(reqwest::header::CONTENT_LENGTH)
            .and_then(|v| v.to_str().ok())
            .and_then(|s| s.parse::<u64>().ok())
            .filter(|&n| n > 0)
            .ok_or_else(|| {
                MeowError::from_code_str(
                    InnerErrorCode::MissingOrInvalidContentLengthFromHead,
                    "missing or invalid content-length from HEAD",
                )
            })
    }
}