use anyhow::{anyhow, Context, Result};
use clap::Parser;
use serde::Deserialize;
use std::env;
use std::path::PathBuf;
use std::time::Instant;
use tracing::{error, info, warn};
use nuwax_common::config::{GitHubConfig, OssConfig, SyncConfig};
use nuwax_common::downloader::{DownloadTask, FileDownloader};
use nuwax_common::github::GitHubClient;
use nuwax_common::oss::AsyncOssClient;
#[derive(Debug, Deserialize)]
struct ConfigOss {
endpoint: Option<String>,
bucket_name: Option<String>,
#[serde(default)]
cdn_domain: Option<String>,
#[serde(default)]
path_prefix: Option<String>,
#[serde(default)]
upload_timeout_seconds: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct AppConfig {
oss: Option<ConfigOss>,
github: Option<GitHubConfig>,
}
fn load_config_file(config_path: &str) -> Result<Option<AppConfig>> {
let config_file = PathBuf::from(config_path);
if !config_file.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&config_file)
.with_context(|| format!("读取配置文件失败: {}", config_path))?;
let config: AppConfig = toml::from_str(&content)
.with_context(|| format!("解析配置文件失败: {}", config_path))?;
Ok(Some(config))
}
#[derive(Parser, Debug)]
#[command(name = "nuwax-sync")]
#[command(about = "Sync GitHub Release assets to Alibaba Cloud OSS")]
#[command(version)]
struct Args {
#[arg(short = 'C', long, value_name = "FILE", default_value = "config.toml")]
config: String,
#[arg(short, long, value_name = "URL")]
repo: String,
#[arg(short, long, value_name = "TAG")]
tag: Option<String>,
#[arg(short, long, value_name = "PREFIX")]
prefix: Option<String>,
#[arg(long, value_name = "DIR", default_value = "./temp_downloads")]
temp_dir: String,
#[arg(short, long, value_name = "N", default_value = "3")]
concurrent: usize,
#[arg(long, value_name = "N", default_value = "3")]
retry: u32,
#[arg(long, default_value = "true")]
skip_existing: bool,
#[arg(long, default_value = "false")]
force: bool,
}
fn parse_github_url(url: &str) -> Result<(String, String)> {
let url = url.trim();
let url = url.strip_suffix(".git").unwrap_or(url);
if let Some(path_start) = url.find("github.com/") {
let path = &url[path_start + "github.com/".len()..];
let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if parts.len() >= 2 {
return Ok((parts[0].to_string(), parts[1].to_string()));
}
}
let parts: Vec<&str> = url.split('/').filter(|s| !s.is_empty()).collect();
if parts.len() == 2 {
return Ok((parts[0].to_string(), parts[1].to_string()));
}
Err(anyhow!(
"无法解析 GitHub URL: {}。支持的格式: https://github.com/owner/repo 或 owner/repo",
url
))
}
fn build_github_config(owner: String, repo: String) -> GitHubConfig {
GitHubConfig {
api_base_url: env::var("GITHUB_API_URL").unwrap_or_else(|_| "https://api.github.com".to_string()),
repo_owner: owner,
repo_name: repo,
access_token: env::var("GITHUB_TOKEN").unwrap_or_default(),
monitor_interval_minutes: 10,
request_timeout_seconds: 30,
}
}
fn build_oss_config(config: Option<&AppConfig>) -> Result<OssConfig> {
let oss_config_file = config.and_then(|c| c.oss.as_ref());
let access_key_id = env::var("OSS_ACCESS_KEY_ID")
.map_err(|_| anyhow!("环境变量 OSS_ACCESS_KEY_ID 未设置"))?;
let access_key_secret = env::var("OSS_ACCESS_KEY_SECRET")
.map_err(|_| anyhow!("环境变量 OSS_ACCESS_KEY_SECRET 未设置"))?;
let endpoint = env::var("OSS_ENDPOINT")
.ok()
.or_else(|| oss_config_file.and_then(|c| c.endpoint.clone()))
.ok_or_else(|| anyhow!("未找到 OSS_ENDPOINT,请设置环境变量或在配置文件中指定"))?;
let bucket_name = env::var("OSS_BUCKET_NAME")
.ok()
.or_else(|| oss_config_file.and_then(|c| c.bucket_name.clone()))
.ok_or_else(|| anyhow!("未找到 OSS_BUCKET_NAME,请设置环境变量或在配置文件中指定"))?;
let cdn_domain = env::var("OSS_CDN_DOMAIN")
.ok()
.or_else(|| oss_config_file.and_then(|c| c.cdn_domain.clone()))
.filter(|s| !s.is_empty());
let path_prefix = env::var("OSS_PATH_PREFIX")
.ok()
.or_else(|| oss_config_file.and_then(|c| c.path_prefix.clone()))
.unwrap_or_default();
let upload_timeout_seconds = env::var("OSS_UPLOAD_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
.or_else(|| oss_config_file.and_then(|c| c.upload_timeout_seconds))
.unwrap_or(3600);
Ok(OssConfig {
endpoint,
bucket_name,
access_key_id,
access_key_secret,
cdn_domain,
path_prefix,
upload_timeout_seconds,
})
}
fn build_sync_config(args: &Args) -> SyncConfig {
SyncConfig {
temp_download_dir: args.temp_dir.clone(),
concurrent_downloads: args.concurrent,
download_retry_count: args.retry,
auto_sync_enabled: false,
keep_versions_count: 10,
}
}
async fn run_sync(args: Args) -> Result<()> {
let start_time = Instant::now();
info!("🔍 解析 GitHub 仓库 URL: {}", args.repo);
let (owner, repo) = parse_github_url(&args.repo)?;
info!("✅ 解析成功: owner={}, repo={}", owner, repo);
info!("⚙️ 加载配置文件: {}...", args.config);
let config = load_config_file(&args.config)?;
if config.is_some() {
info!("✅ 配置文件加载成功");
} else {
info!("⚠️ 配置文件不存在: {},使用环境变量和默认值", args.config);
}
info!("⚙️ 构建客户端配置...");
let github_config = build_github_config(owner.clone(), repo.clone());
let oss_config = build_oss_config(config.as_ref())?;
let sync_config = build_sync_config(&args);
info!("✅ 配置加载完成");
info!(" GitHub: {}/{}", github_config.repo_owner, github_config.repo_name);
info!(" OSS: {}/{}", oss_config.endpoint, oss_config.bucket_name);
if let Some(cdn) = &oss_config.cdn_domain {
info!(" CDN: {}", cdn);
}
if !oss_config.path_prefix.is_empty() {
info!(" 路径前缀: {}", oss_config.path_prefix);
}
let github_client = GitHubClient::from_config(&github_config)
.context("初始化 GitHub 客户端失败")?;
let release = if let Some(tag) = &args.tag {
info!("📦 获取指定 Release: {}", tag);
github_client.get_release_by_tag(tag).await?
} else {
info!("📦 获取最新 Release...");
github_client.get_latest_release().await?
};
info!("✅ 找到 Release: {} ({})", release.name, release.tag_name);
info!(" 发布时间: {}", release.published_at.map(|d| d.to_string()).unwrap_or_default());
info!(" Assets 数量: {}", release.assets.len());
if release.assets.is_empty() {
warn!("⚠️ 该 Release 没有可下载的文件");
return Ok(());
}
let oss_prefix = args.prefix.unwrap_or_else(|| repo.clone());
let version_tag = release.tag_name.clone();
info!("☁️ OSS 上传路径: {}/{}/", oss_prefix, version_tag);
let downloader = FileDownloader::from_config(&sync_config)
.context("初始化文件下载器失败")?;
let oss_client = AsyncOssClient::from_config(&oss_config).await
.context("初始化 OSS 客户端失败")?;
let temp_dir = PathBuf::from(&sync_config.temp_download_dir);
std::fs::create_dir_all(&temp_dir)?;
let mut download_tasks = Vec::new();
let mut skipped_files = Vec::new();
for asset in &release.assets {
let remote_path = format!("{}/{}/{}", oss_prefix, version_tag, asset.name);
if !args.force {
match oss_client.file_exists(&remote_path).await {
Ok(true) => {
info!("⏭️ 文件已存在于 OSS,跳过: {}", asset.name);
skipped_files.push(asset.name.clone());
continue;
}
Ok(false) => {}
Err(e) => {
warn!("⚠️ 检查文件存在性失败 {}: {}", asset.name, e);
}
}
}
let expected_sha256 = asset.digest.as_ref().map(|d| {
if d.starts_with("sha256:") {
d.strip_prefix("sha256:").unwrap_or(d).to_string()
} else {
d.clone()
}
});
let task = DownloadTask::from_github_asset(asset, &temp_dir, expected_sha256);
download_tasks.push(task);
}
if download_tasks.is_empty() && !skipped_files.is_empty() {
info!("✅ 所有文件都已存在于 OSS,无需同步");
return Ok(());
}
info!("📥 需要下载 {} 个文件", download_tasks.len());
let download_results = if !download_tasks.is_empty() {
info!("🚀 开始下载文件...");
downloader.download_files(download_tasks).await?
} else {
Vec::new()
};
let mut failed_downloads = Vec::new();
let mut successful_downloads = Vec::new();
for result in &download_results {
if result.success {
successful_downloads.push(result);
info!("✅ 下载成功: {} ({} bytes)", result.task.filename, result.actual_size);
} else {
failed_downloads.push(result);
error!("❌ 下载失败: {} - {:?}", result.task.filename, result.error_message);
}
}
if !failed_downloads.is_empty() {
return Err(anyhow!(
"{} 个文件下载失败",
failed_downloads.len()
));
}
info!("☁️ 开始上传文件到 OSS...");
let mut uploaded_count = 0;
let mut failed_uploads = Vec::new();
for result in &successful_downloads {
let local_path = &result.task.local_path;
let filename = &result.task.filename;
let remote_path = format!("{}/{}/{}", oss_prefix, version_tag, filename);
let content_type = result.task.content_type.as_deref()
.unwrap_or("application/octet-stream");
info!("📤 上传: {} -> {}", filename, remote_path);
match oss_client.upload_file(local_path, &remote_path, content_type).await {
Ok(url) => {
info!("✅ 上传成功: {}", url);
uploaded_count += 1;
}
Err(e) => {
error!("❌ 上传失败 {}: {}", filename, e);
failed_uploads.push((filename.clone(), e.to_string()));
}
}
}
info!("🧹 清理临时文件...");
if let Err(e) = downloader.cleanup_temp_files().await {
warn!("⚠️ 清理临时文件失败: {}", e);
}
let duration = start_time.elapsed();
info!("");
info!("📊 同步完成统计:");
info!(" 总耗时: {:.2} 秒", duration.as_secs_f64());
info!(" Release: {} ({})", release.name, release.tag_name);
info!(" 跳过文件: {}", skipped_files.len());
info!(" 下载成功: {}", successful_downloads.len());
info!(" 上传成功: {}", uploaded_count);
if !failed_uploads.is_empty() {
error!(" 上传失败: {}", failed_uploads.len());
for (file, error) in &failed_uploads {
error!(" - {}: {}", file, error);
}
return Err(anyhow!("{} 个文件上传失败", failed_uploads.len()));
}
if !skipped_files.is_empty() {
info!(" 跳过的文件:");
for file in &skipped_files {
info!(" - {}", file);
}
}
info!("");
info!("🎉 同步完成! 文件已上传到: {}/{}/", oss_prefix, version_tag);
Ok(())
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
env::var("RUST_LOG")
.unwrap_or_else(|_| "info".to_string()),
)
.init();
let args = Args::parse();
info!("🚀 GitHub Release Sync Tool");
info!(" 仓库: {}", args.repo);
if let Some(tag) = &args.tag {
info!(" 版本: {}", tag);
} else {
info!(" 版本: latest");
}
info!("");
if let Err(e) = run_sync(args).await {
error!("❌ 同步失败: {}", e);
std::process::exit(1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_github_url() {
assert_eq!(
parse_github_url("https://github.com/owner/repo").unwrap(),
("owner".to_string(), "repo".to_string())
);
assert_eq!(
parse_github_url("https://github.com/owner/repo.git").unwrap(),
("owner".to_string(), "repo".to_string())
);
assert_eq!(
parse_github_url("github.com/owner/repo").unwrap(),
("owner".to_string(), "repo".to_string())
);
assert_eq!(
parse_github_url("owner/repo").unwrap(),
("owner".to_string(), "repo".to_string())
);
assert_eq!(
parse_github_url("https://github.com/owner/repo/").unwrap(),
("owner".to_string(), "repo".to_string())
);
assert!(parse_github_url("invalid").is_err());
assert!(parse_github_url("https://github.com/owner").is_err());
}
}