rs-histver 0.4.2

Library for querying Rust historical release versions (stable/beta/nightly)
Documentation
use anyhow::{Context, Result};
use std::sync::Arc;
use std::sync::OnceLock;
use tokio::sync::Semaphore;

use crate::constants::STATIC_DIST_BASE_URL;
use crate::domain::RustRelease;
use crate::options::NetworkConfig;

/// Cached regex for extracting date from version string.
static DATE_RE: OnceLock<regex_lite::Regex> = OnceLock::new();

fn date_regex() -> &'static regex_lite::Regex {
    DATE_RE.get_or_init(|| {
        regex_lite::Regex::new(r"(\d{4}-\d{2}-\d{2})")
            .expect("DATE_RE regex pattern is statically valid")
    })
}

/// Build an HTTP client
pub(super) fn build_client(network: &NetworkConfig) -> Result<reqwest::Client> {
    reqwest::Client::builder()
        .user_agent(&network.user_agent)
        .timeout(std::time::Duration::from_secs(network.timeout))
        .build()
        .context("Failed to build HTTP client")
}

/// Build an HTTP client with fallback to a default one on error
pub(super) fn build_client_fallback(network: &NetworkConfig) -> reqwest::Client {
    build_client(network).unwrap_or_else(|_| {
        reqwest::Client::builder()
            .user_agent(&network.user_agent)
            .build()
            .expect("reqwest default client builder should never fail")
    })
}

/// Concurrently probe channel TOML for the recent N days (including today).
///
/// Returns `Ok(Vec)` with all discovered releases sorted by date descending.
/// Returns `Err` only when every single probe request fails — otherwise partial
/// results are returned with a warning printed for failed tasks.
pub(super) async fn probe_channel_history(
    network: &NetworkConfig,
    channel: &str,
    days: u32,
) -> Result<Vec<RustRelease>> {
    let client = Arc::new(build_client_fallback(network));
    let semaphore = Arc::new(Semaphore::new(network.max_concurrency));
    let today = chrono::Local::now().date_naive();

    let mut handles = Vec::with_capacity(days as usize);

    for i in 0..days {
        let date = today - chrono::Duration::days(i64::from(i));
        let date_str = date.format("%Y-%m-%d").to_string();
        let client = client.clone();
        let sem = semaphore.clone();
        let channel = channel.to_string();

        handles.push(tokio::spawn(async move {
            let _permit = sem
                .acquire()
                .await
                .expect("Semaphore is never closed during probe");
            let url = format!("{STATIC_DIST_BASE_URL}/{date_str}/channel-rust-{channel}.toml");
            match client.get(&url).send().await {
                Ok(resp) if resp.status().is_success() => match resp.text().await {
                    Ok(text) => parse_channel_toml_version(&text, &channel, &date_str),
                    Err(_) => None,
                },
                _ => None,
            }
        }));
    }

    let total = handles.len();
    let mut results = Vec::new();
    let mut task_errors = 0u32;
    for handle in handles {
        match handle.await {
            Ok(Some(r)) => results.push(r),
            Ok(None) => {}
            Err(_) => task_errors += 1,
        }
    }

    if task_errors > 0 {
        eprintln!(
            "Warning: {}/{} probe tasks panicked or were cancelled. \
             Results may be incomplete.",
            task_errors, total
        );
    }

    if results.is_empty() && task_errors > 0 && task_errors as usize == total {
        anyhow::bail!(
            "All {total} probe requests for '{channel}' channel failed — check network connectivity"
        );
    }

    results.sort_by(|a, b| b.date.cmp(&a.date));
    Ok(results)
}

/// Parse the version field from [pkg.rust] section in a channel TOML
fn parse_channel_toml_version(
    text: &str,
    channel: &str,
    fallback_date: &str,
) -> Option<RustRelease> {
    let marker = "[pkg.rust]";
    let pos = text.find(marker)?;
    let after = &text[pos + marker.len()..];

    let version_prefix = r#"version = ""#;
    let vpos = after.find(version_prefix)?;
    let rest = &after[vpos + version_prefix.len()..];
    let end = rest.find('"')?;
    let version_str = &rest[..end];

    let date = date_regex()
        .captures(version_str)
        .map_or_else(|| fallback_date.to_string(), |c| c[1].to_string());

    Some(RustRelease {
        version: version_str.to_string(),
        date,
        channel: channel.to_string(),
    })
}