fetchr-http 0.1.1

HTTP transport layer, range probes, and header detection for Fetchr.
Documentation
use std::time::Duration;
use reqwest::header::{HeaderMap, RANGE};
use reqwest::{Client, StatusCode};
use url::Url;

use crate::error::{HttpError, Result};
use crate::metadata::RemoteMetadata;

#[derive(Clone, Debug)]
pub struct HttpClientConfig {
    pub user_agent: String,
    pub connect_timeout: Duration,
    pub timeout: Duration,
}

impl Default for HttpClientConfig {
    fn default() -> Self {
        Self {
            user_agent: format!("Fetchr/{}", env!("CARGO_PKG_VERSION")),
            connect_timeout: Duration::from_secs(10),
            timeout: Duration::from_secs(30),
        }
    }
}

#[derive(Clone, Debug)]
pub struct HttpClient {
    inner: Client,
}

impl HttpClient {
    pub fn new(config: HttpClientConfig) -> Result<Self> {
        let client = Client::builder()
            .user_agent(config.user_agent)
            .connect_timeout(config.connect_timeout)
            .timeout(config.timeout)
            .build()?;

        Ok(Self { inner: client })
    }

    /// Inspects the remote URL via HEAD or range GET request to extract headers & metadata.
    pub async fn probe_metadata(&self, url: &str) -> Result<RemoteMetadata> {
        let parsed_url = Url::parse(url).map_err(|_| HttpError::InvalidUrl(url.to_string()))?;

        // 1. Try HEAD request first
        let head_res = self.inner.head(parsed_url.clone()).send().await;
        if let Ok(res) = head_res {
            if res.status().is_success() {
                let final_url = res.url().to_string();
                let headers = res.headers();
                return Ok(RemoteMetadata::from_headers(final_url, headers));
            }
        }

        // 2. Fallback: Probe using Range: bytes=0-0 GET request if HEAD is rejected/unsupported
        let get_res = self
            .inner
            .get(parsed_url)
            .header(RANGE, "bytes=0-0")
            .send()
            .await?;

        if !get_res.status().is_success() && get_res.status() != StatusCode::PARTIAL_CONTENT {
            return Err(HttpError::HttpStatus {
                status: get_res.status().as_u16(),
                message: get_res.status().canonical_reason().unwrap_or("Unknown").to_string(),
            });
        }

        let final_url = get_res.url().to_string();
        let headers = get_res.headers();
        let mut metadata = RemoteMetadata::from_headers(final_url, headers);

        // If status was 206 Partial Content, Accept-Ranges is true
        if get_res.status() == StatusCode::PARTIAL_CONTENT {
            metadata.accept_ranges = true;
            // Parse real Content-Length from Content-Range header if main Content-Length was 1
            if let Some(content_range) = headers.get(reqwest::header::CONTENT_RANGE) {
                if let Ok(range_str) = content_range.to_str() {
                    // Example: bytes 0-0/10485760
                    if let Some(total_str) = range_str.split('/').nth(1) {
                        if let Ok(total_len) = total_str.parse::<u64>() {
                            metadata.content_length = Some(total_len);
                        }
                    }
                }
            }
        }

        Ok(metadata)
    }

    /// Initiates a streaming HTTP GET request, optionally with a Range header.
    pub async fn fetch_stream(
        &self,
        url: &str,
        start_byte: u64,
        end_byte: Option<u64>,
    ) -> Result<(StatusCode, HeaderMap, reqwest::Response)> {
        let parsed_url = Url::parse(url).map_err(|_| HttpError::InvalidUrl(url.to_string()))?;
        let mut req = self.inner.get(parsed_url);

        if start_byte > 0 || end_byte.is_some() {
            let range_val = match end_byte {
                Some(end) => format!("bytes={}-{}", start_byte, end),
                None => format!("bytes={}-", start_byte),
            };
            req = req.header(RANGE, range_val);
        }

        let res = req.send().await?;
        let status = res.status();
        let headers = res.headers().clone();

        if !status.is_success() && status != StatusCode::PARTIAL_CONTENT {
            return Err(HttpError::HttpStatus {
                status: status.as_u16(),
                message: status.canonical_reason().unwrap_or("Unknown").to_string(),
            });
        }

        Ok((status, headers, res))
    }
}

impl Default for HttpClient {
    fn default() -> Self {
        Self::new(HttpClientConfig::default()).expect("Failed to build default HttpClient")
    }
}