cargo-fresh 0.12.0

A Rust tool for checking and updating globally installed Cargo packages with interactive mode and smart prerelease detection
Documentation
//! 自实现的二进制下载器——替代 cargo binstall 子进程。
//!
//! 单元拆分:
//! - `events`:  ProgressEvent / DownloaderError 类型 (无逻辑)
//! - `resolve`: 候选 URL 推导 (纯函数)
//! - `fetch`:   HTTP 流式下载 + sha256
//! - `archive`: tar.gz / zip 解压
//! - `install`: atomic rename + .crates2.json 写

pub mod archive;
pub mod events;
pub mod fetch;
pub mod github_api;
pub mod install;
pub mod probe;
pub mod resolve;
pub mod token;

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;

use crate::downloader::events::{DownloaderError, ProgressEvent, UnsupportedReason};
use crate::downloader::resolve::CandidateUrl;

/// 调度器传给 downloader 的输入。
pub struct InstallSpec {
    pub name: String,
    pub version: String,
    pub repo_url: Option<String>,
    /// binary 名候选——`.crates2.json` 的 bins[] 直接搬过来。
    /// 包名 != binary 名时 (ripgrep -> rg) 必须填, 否则解压找不到文件。
    /// 空 Vec → fallback 到 `name` 自身。
    pub bins: Vec<String>,
}

pub struct InstallOutcome {
    pub name: String,
    pub old_version: Option<String>,
    pub new_version: String,
}

/// 在 download_and_install 之前先调一次 GitHub Releases API,如果命中
/// 直接返回单元素的候选列表;fetch::fetch 拿到只跑 1 个 HEAD 就胜出,
/// 跳过 360 候选盲探。
///
/// 返回 None 的两种情况调用方都走 fallback:
/// - repo_url 不在 github.com 上 (e.g. gitlab.com 自托管)
/// - API 任一形态失败 (RateLimited / Network / Parse)
async fn try_api_winning_url(
    client: &reqwest::Client,
    spec: &InstallSpec,
    repo_url: &str,
    targets: &[String],
    name_candidates: &[String],
) -> Option<CandidateUrl> {
    let (owner, repo) = github_api::parse_owner_repo(repo_url)?;
    let token = token::discover_token();
    let expected = resolve::expected_filenames(name_candidates, &spec.version, targets);
    let pkg = name_candidates.first()?;
    let tags = [
        format!("v{}", spec.version),
        spec.version.clone(),
        format!("{pkg}-v{}", spec.version),
        format!("{pkg}-{}", spec.version),
        format!("{pkg}/v{}", spec.version),
        format!("{pkg}/{}", spec.version),
    ];
    for tag in &tags {
        match github_api::fetch_release_assets(
            client,
            "https://api.github.com",
            &owner,
            &repo,
            tag,
            token,
        )
        .await
        {
            Ok(assets) => {
                if let Some(asset) = github_api::match_winning_asset(&assets, &expected) {
                    let archive_fmt = if asset.name.ends_with(".zip") {
                        resolve::ArchiveFmt::Zip
                    } else if asset.name.ends_with(".tar.gz") || asset.name.ends_with(".tgz") {
                        resolve::ArchiveFmt::TarGz
                    } else {
                        resolve::ArchiveFmt::Bin
                    };
                    return Some(CandidateUrl {
                        url: asset.browser_download_url.clone(),
                        archive_fmt,
                    });
                }
                // 200 but no asset matched — try next tag
            }
            Err(github_api::GithubApiError::NotFound) => continue,
            Err(_) => return None, // rate limited / network / parse — let caller fallback
        }
    }
    None
}

/// 主入口——把 (spec, events_tx, cancel) 串成完整流水线。
pub async fn download_and_install(
    client: &reqwest::Client,
    spec: InstallSpec,
    old_version: Option<String>,
    events: UnboundedSender<ProgressEvent>,
    cancel: Arc<AtomicBool>,
) -> Result<InstallOutcome, DownloaderError> {
    let _ = events.send(ProgressEvent::Resolving {
        name: spec.name.clone(),
    });
    if cancel.load(Ordering::SeqCst) {
        return Err(DownloaderError::Cancelled);
    }

    let targets = resolve::current_targets();
    if targets.is_empty() {
        return Err(DownloaderError::Unsupported(UnsupportedReason::UnsupportedPlatform));
    }

    let repo_url = spec
        .repo_url
        .as_deref()
        .ok_or(DownloaderError::Unsupported(UnsupportedReason::NoMetadataAndNoConvention))?;

    // {name} 候选: package 名先 (canonical, 多数包文件名沿用), 再加 binary 名
    // (覆盖 tauri-cli 这种情况: 包名 tauri-cli, 二进制 cargo-tauri,
    //  release 文件名是 `cargo-tauri-aarch64-apple-darwin.zip`)
    let mut name_candidates: Vec<String> = vec![spec.name.clone()];
    for b in &spec.bins {
        if !name_candidates.contains(b) {
            name_candidates.push(b.clone());
        }
    }
    // API-first: 1 GitHub API request -> single-URL candidate list -> fetch
    // does 1 HEAD + stream GET. Fallback to full 360-URL candidate enumeration
    // when API is unreachable / rate-limited / repo isn't on github.com.
    let candidates =
        match try_api_winning_url(client, &spec, repo_url, &targets, &name_candidates).await {
            Some(winner) => vec![winner],
            None => resolve::candidate_urls(&name_candidates, &spec.version, repo_url, &targets)?,
        };

    let fetched = fetch::fetch(client, &spec.name, &candidates, &events, cancel.clone()).await?;

    if cancel.load(Ordering::SeqCst) {
        return Err(DownloaderError::Cancelled);
    }

    let _ = events.send(ProgressEvent::Extracting {
        name: spec.name.clone(),
    });

    // archive_fmt 从 fetched URL 的扩展名复算——更鲁棒
    let fmt = if fetched.winning_url.ends_with(".zip") {
        resolve::ArchiveFmt::Zip
    } else {
        resolve::ArchiveFmt::TarGz
    };

    // 包名 != binary 名时 (ripgrep -> rg), spec.bins 来自 .crates2.json;
    // 空时 fallback 到 spec.name (单 binary 普通包路径)
    let mut bin_candidates: Vec<String> = spec.bins.clone();
    if bin_candidates.is_empty() {
        bin_candidates.push(spec.name.clone());
    }
    let extracted = archive::extract(&fetched.archive_path, fmt, &bin_candidates)?;

    if cancel.load(Ordering::SeqCst) {
        return Err(DownloaderError::Cancelled);
    }

    let _ = events.send(ProgressEvent::Installing {
        name: spec.name.clone(),
    });

    // 实际找到的 binary 名 (可能是 "rg" 而非 "ripgrep") —— install_binary 用这个
    // 当作目标文件名, .crates*.json 写入器会通过 bins[] 找到对应包条目
    let _installed_path = install::install_binary(
        &extracted.binary_path,
        &extracted.binary_name,
        &spec.version,
    )?;

    let _ = events.send(ProgressEvent::Done {
        name: spec.name.clone(),
        version: spec.version.clone(),
    });

    Ok(InstallOutcome {
        name: spec.name,
        old_version,
        new_version: spec.version,
    })
}