release-dep 0.1.0

A Rust library for fetching and downloading release binaries from multiple Git repositories (GitHub and Gitee) with semver version matching.
Documentation
//! # Release Dependency Manager
//!
//! 这个库提供了从多个 Git 仓库(Gitee, GitHub)获取指定包的 release 版本的功能。
//! 支持按照 semver 版本语义进行匹配,并自动下载匹配的二进制文件。
#![cfg(not(target_os = "none"))]

use std::{path::PathBuf, time::Duration};

use anyhow::{bail, Result};
use reqwest::Client;
pub use semver::Version;
use semver::VersionReq;
use tokio::{select, time::sleep};

mod gitee;
mod github;

use gitee::gitee_get_release;
use github::github_get_release;
use tokio_util::sync::CancellationToken;

/// 表示一个 release 依赖项
#[derive(Debug, Clone)]
pub struct ReleaseDep {
    /// 包名
    pub name: String,
    /// 版本号
    pub version: Version,
    /// 二进制文件路径
    pub binary: PathBuf,
}

/// 配置结构体,用于指定要获取的包信息
#[derive(Debug, Clone)]
pub struct Config<'a> {
    /// 包名
    pub package: &'a str,
    /// 期望的版本号(semver 格式)
    pub version: &'a str,
    /// 仓库 URL 列表,按优先级排序
    pub repo: &'a [&'static str],
    /// 下载文件夹地址,None 表示使用系统临时目录
    pub download_dir: Option<&'a str>,
    /// 超时时间(秒),None 表示使用默认的 30 秒
    pub timeout: Option<Duration>,
}

/// 获取指定配置的 release 依赖项
///
/// 这个函数会并行地从多个仓库中搜索匹配的 release,
/// 返回第一个找到的兼容版本。
pub fn get_release(config: Config<'_>) -> Result<ReleaseDep> {
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .expect("Failed to create Tokio runtime");
    runtime.block_on(_get_release(config))
}

pub struct OneConfig {
    pub package: String,
    pub version: VersionReq,
    pub url_info: UrlInfo,
    pub download_dir: Option<PathBuf>,
}

async fn _get_release(config: Config<'_>) -> Result<ReleaseDep> {
    let package = config.package.to_string();
    let version = VersionReq::parse(config.version).unwrap();
    let download_dir = config.download_dir.map(PathBuf::from);

    let (tx, mut rx) = tokio::sync::mpsc::channel(1);
    let cancel = CancellationToken::new();

    for &repo in config.repo {
        let url_info = match parse_url(repo) {
            Ok(v) => v,
            Err(e) => bail!("url {repo} parse error: {e}"),
        };
        println!("Parsed URL: {url_info:?}");

        let one_config = OneConfig {
            package: package.clone(),
            version: version.clone(),
            url_info: url_info.clone(),
            download_dir: download_dir.clone(),
        };

        match url_info.provider {
            RepoProvider::Gitee => {
                tokio::spawn({
                    let tx = tx.clone();
                    let cancel = cancel.clone();
                    async move {
                        let result = select! {
                            _ = cancel.cancelled() => {
                                println!("Gitee cancelled");
                                return;
                            }
                            result = gitee_get_release(one_config) => result,
                        };
                        match result {
                            Ok(v) => {
                                let _ = tx.try_send(v);
                            }
                            Err(e) => {
                                eprintln!("Gitee fetch task failed: {e}");
                            }
                        }
                    }
                });
            }
            RepoProvider::Github => {
                tokio::spawn({
                    let tx = tx.clone();
                    let cancel = cancel.clone();
                    async move {
                        let result = select! {
                            _ = cancel.cancelled() => {
                                println!("Github cancelled");
                                return;
                            }
                            result = github_get_release(one_config) => result,
                        };
                        match result {
                            Ok(v) => {
                                let _ = tx.try_send(v);
                            }
                            Err(e) => {
                                eprintln!("Gitee fetch task failed: {e}");
                            }
                        }
                    }
                });
            }
        }
    }

    let release = if let Some(d) = config.timeout {
        select! {
            result = rx.recv() => {
                match result {
                    Some(release) => release,
                    None => bail!("No release found"),
                }
            }
            _ = sleep(d) => {
                bail!("Operation timed out after {:?}", d);
            }
        }
    } else {
        match rx.recv().await {
            Some(release) => release,
            None => bail!("No release found"),
        }
    };

    Ok(release)
}

/// 下载二进制文件的辅助函数
pub async fn download_binary(
    client: &Client,
    url: &str,
    filename: &str,
    download_dir: Option<&str>,
    provider: RepoProvider,
) -> Result<PathBuf> {
    println!("Downloading binary from: {url}");

    let response = client.get(url).send().await?;

    if !response.status().is_success() {
        bail!("Failed to download binary: {}", response.status());
    }

    // 选择下载目录
    let base_dir = if let Some(download_dir) = download_dir {
        PathBuf::from(download_dir)
    } else {
        std::env::temp_dir()
    };

    // 根据 provider 创建子文件夹
    let provider_dir = match provider {
        RepoProvider::Gitee => "gitee",
        RepoProvider::Github => "github",
    };

    let dir = base_dir.join(provider_dir);

    // 确保目录存在
    std::fs::create_dir_all(&dir)?;

    let binary_path = dir.join(filename);

    let bytes = response.bytes().await?;
    std::fs::write(&binary_path, bytes)?;

    // 返回绝对路径
    let absolute_path = binary_path.canonicalize()?;

    println!("Binary downloaded to: {absolute_path:?}");
    Ok(absolute_path)
}

#[derive(Debug, Clone, Copy)]
pub enum RepoProvider {
    Gitee,
    Github,
}

#[derive(Debug, Clone)]
pub struct UrlInfo {
    pub provider: RepoProvider,
    pub repo_base: String,
    pub owner: String,
    pub repo: String,
}

/// 从仓库URL中提取owner和repo名称
fn parse_url(repo_url: &str) -> Result<UrlInfo> {
    // 移除协议前缀和可能的.git后缀
    let url = repo_url
        .strip_prefix("https://")
        .or_else(|| repo_url.strip_prefix("http://"))
        .unwrap_or(repo_url)
        .strip_suffix(".git")
        .unwrap_or(
            repo_url
                .strip_prefix("https://")
                .or_else(|| repo_url.strip_prefix("http://"))
                .unwrap_or(repo_url),
        );

    // 分割路径,格式应该是: domain.com/owner/repo
    let parts: Vec<&str> = url.split('/').collect();
    if parts.len() < 3 {
        bail!("Invalid repository URL: {repo_url}");
    }
    let repo_base = parts[0].to_string();
    let owner = parts[1].to_string();
    let repo = parts[2].to_string();

    let provider = match repo_base.as_str() {
        "gitee.com" => RepoProvider::Gitee,
        "github.com" => RepoProvider::Github,
        _ => bail!("Unsupported repository provider: {repo_base}"),
    };

    Ok(UrlInfo {
        provider,
        repo_base,
        owner,
        repo,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_url() {
        let gitee_url = "https://gitee.com/zr233/somehal";
        let github_url = "https://github.com/rcore-os/somehal";

        let gitee_info = parse_url(gitee_url).unwrap();
        assert!(matches!(gitee_info.provider, RepoProvider::Gitee));
        assert_eq!(gitee_info.owner, "zr233");
        assert_eq!(gitee_info.repo, "somehal");

        let github_info = parse_url(github_url).unwrap();
        assert!(matches!(github_info.provider, RepoProvider::Github));
        assert_eq!(github_info.owner, "rcore-os");
        assert_eq!(github_info.repo, "somehal");
    }
}