diff --git a/README.md b/README.md index f1492fe..092608f 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Cmirror 是一个基于 Rust 编写的跨平台命令行工具,旨在解决国 | 工具 | 状态 | 配置文件路径 | 备注 | | :--- | :--- | :--- | :--- | | **pip** (Python) | ✅ 支持 | `~/.pip/pip.conf` (Linux/Mac) | 支持 venv 及全局配置 | +| **uv** (Python) | ✅ 支持 | `uv.toml` | 优先项目级配置,其次全局 | +| **conda** (Python) | ✅ 支持 | `~/.condarc` | 自动配置 channels | | **npm** (Node.js) | ✅ 支持 | `~/.npmrc` | | | **docker** | ✅ 支持 | `/etc/docker/daemon.json` | 需 sudo 权限 | | **apt** (Ubuntu/Debian) | ✅ 支持 | `/etc/apt/sources.list` | 智能替换域名,需 sudo | @@ -203,6 +205,10 @@ cmirror restore pip * [ ] 支持 yum/dnf (CentOS/Fedora) * [ ] TUI 交互式界面 (Dialoguer) -## 🤝 贡献 +## 🤝 反馈与共建 -欢迎提交 Issue 和 Pull Request!让我们一起改善国内的开发体验。 +> 🚧 **温馨提示**:受限于开发环境与测试设备,本项目在部分系统或特定配置下的表现可能尚未经过完全覆盖的严格测试。 + +如果您在使用过程中遇到任何“水土不服”或意外 Bug,请不吝前往 [Issues](https://github.com/ox01024/cmirror/issues) 提交反馈。您的每一次报错、建议或 PR,都是让 `cmirror` 变得更稳健的重要动力! + +开源不易,感谢每一位开发者的包容与支持。❤️ diff --git a/assets/mirrors.json b/assets/mirrors.json index 62d9072..da332b0 100644 --- a/assets/mirrors.json +++ b/assets/mirrors.json @@ -48,5 +48,19 @@ { "name": "Tuna", "url": "http://mirrors.tuna.tsinghua.edu.cn/debian/" }, { "name": "USTC", "url": "http://mirrors.ustc.edu.cn/debian/" }, { "name": "Official", "url": "http://deb.debian.org/debian/" } + ], + "uv": [ + { "name": "Aliyun", "url": "https://mirrors.aliyun.com/pypi/simple/" }, + { "name": "Tuna", "url": "https://pypi.tuna.tsinghua.edu.cn/simple" }, + { "name": "Tencent", "url": "https://mirrors.cloud.tencent.com/pypi/simple" }, + { "name": "USTC", "url": "https://mirrors.ustc.edu.cn/pypi/simple" }, + { "name": "Douban", "url": "https://pypi.doubanio.com/simple" }, + { "name": "Official", "url": "https://pypi.org/simple" } + ], + "conda": [ + { "name": "Tuna", "url": "https://mirrors.tuna.tsinghua.edu.cn/anaconda" }, + { "name": "USTC", "url": "https://mirrors.ustc.edu.cn/anaconda" }, + { "name": "Aliyun", "url": "https://mirrors.aliyun.com/anaconda" }, + { "name": "SJTU", "url": "https://mirror.sjtu.edu.cn/anaconda" } ] } diff --git a/src/sources/conda.rs b/src/sources/conda.rs new file mode 100644 index 0000000..66a0468 --- /dev/null +++ b/src/sources/conda.rs @@ -0,0 +1,213 @@ +use crate::config; +use crate::error::Result; +use crate::traits::SourceManager; +use crate::types::Mirror; +use crate::utils; +use async_trait::async_trait; +use directories::BaseDirs; +use regex::Regex; +use std::path::PathBuf; +use tokio::fs; + +pub struct CondaManager { + custom_path: Option, +} + +impl CondaManager { + pub fn new() -> Self { + Self { custom_path: None } + } + + #[cfg(test)] + pub fn with_path(path: PathBuf) -> Self { + Self { + custom_path: Some(path), + } + } +} + +#[async_trait] +impl SourceManager for CondaManager { + fn name(&self) -> &'static str { + "conda" + } + + fn requires_sudo(&self) -> bool { + false + } + + fn list_candidates(&self) -> Vec { + config::get_candidates("conda") + } + + fn config_path(&self) -> PathBuf { + if let Some(ref path) = self.custom_path { + return path.clone(); + } + + // Standard: ~/.condarc + BaseDirs::new() + .map(|dirs| dirs.home_dir().join(".condarc")) + .unwrap_or_else(|| PathBuf::from(".").join(".condarc")) + } + + async fn current_url(&self) -> Result> { + let path = self.config_path(); + if !fs::try_exists(&path).await.unwrap_or(false) { + return Ok(None); + } + + let content = fs::read_to_string(&path).await?; + + // Look for the first url in default_channels + // Pattern: default_channels: + // - (url) + let re = Regex::new(r"(?m)^default_channels:\s*\n\s*-\s*(.+)$")?; + + if let Some(caps) = re.captures(&content) { + return Ok(Some(caps[1].trim().to_string())); + } + + // Fallback: check channels (if user uses simple config) + let re_channels = Regex::new(r"(?m)^channels:\s*\n\s*-\s*(.+)$")?; + if let Some(caps) = re_channels.captures(&content) { + let val = caps[1].trim(); + if val != "defaults" { + return Ok(Some(val.to_string())); + } + } + + Ok(None) + } + + async fn set_source(&self, mirror: &Mirror) -> Result<()> { + let path = self.config_path(); + + // 1. Ensure directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + + // 2. Read existing + let content = if fs::try_exists(&path).await.unwrap_or(false) { + fs::read_to_string(&path).await? + } else { + String::new() + }; + + // 3. Backup + if !content.is_empty() { + utils::backup_file(&path).await?; + } + + // 4. Construct new content + // Strategy: + // We assume the mirror.url is the base URL (e.g., https://mirrors.tuna.tsinghua.edu.cn/anaconda) + // We will configure default_channels and custom_channels. + + let base_url = mirror.url.trim_end_matches('/'); + + let new_config_block = format!( + "show_channel_urls: true\ndefault_channels:\n - {}/pkgs/main\n - {}/pkgs/r\n - {}/pkgs/msys2\ncustom_channels:\n conda-forge: {}/cloud\n msys2: {}/cloud\n bioconda: {}/cloud\n menpo: {}/cloud\n pytorch: {}/cloud\n pytorch-lts: {}/cloud\n simpleitk: {}/cloud", + base_url, base_url, base_url, base_url, base_url, base_url, base_url, base_url, base_url, base_url + ); + + // Remove existing default_channels and custom_channels blocks + // This is a naive regex removal. It assumes blocks end at the next top-level key (start of line) + + let mut new_content = content.clone(); + + // Remove default_channels block + let re_default = Regex::new(r"(?m)^default_channels:(\s*\n\s+-.*)*\n?")?; + new_content = re_default.replace(&new_content, "").to_string(); + + // Remove custom_channels block + let re_custom = Regex::new(r"(?m)^custom_channels:(\s*\n\s+.*)*\n?")?; + new_content = re_custom.replace(&new_content, "").to_string(); + + // Remove show_channel_urls line + let re_show = Regex::new(r"(?m)^show_channel_urls:.*\n?")?; + new_content = re_show.replace(&new_content, "").to_string(); + + // Append new config + // Ensure we have a newline separator if file not empty + let prefix = if new_content.trim().is_empty() { + "" + } else { + "\n" + }; + new_content = format!("{}{}{}\n", new_content.trim(), prefix, new_config_block); + + // 5. Write + fs::write(&path, new_content).await?; + + Ok(()) + } + + async fn restore(&self) -> Result<()> { + utils::restore_latest_backup(&self.config_path()).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_conda_flow() -> Result<()> { + let dir = tempdir()?; + let config_path = dir.path().join(".condarc"); + let manager = CondaManager::with_path(config_path.clone()); + + // 1. Initial state + assert!(manager.current_url().await?.is_none()); + + // 2. Set source + let mirror = Mirror { + name: "TestConda".to_string(), + url: "https://mirrors.tuna.tsinghua.edu.cn/anaconda".to_string(), + }; + manager.set_source(&mirror).await?; + + // 3. Check current (should match first default channel) + let current = manager.current_url().await?; + assert_eq!( + current, + Some("https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main".to_string()) + ); + + // Check content + let content = fs::read_to_string(&config_path).await?; + assert!(content.contains("default_channels:")); + assert!(content.contains(" - https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main")); + assert!(content.contains("custom_channels:")); + assert!( + content.contains("conda-forge: https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud") + ); + + // 4. Set another + let mirror2 = Mirror { + name: "TestConda2".to_string(), + url: "https://mirrors.ustc.edu.cn/anaconda".to_string(), + }; + // Sleep for backup timestamp + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + manager.set_source(&mirror2).await?; + assert_eq!( + manager.current_url().await?, + Some("https://mirrors.ustc.edu.cn/anaconda/pkgs/main".to_string()) + ); + + // 5. Restore + manager.restore().await?; + // Should be back to mirror 1 + assert_eq!( + manager.current_url().await?, + Some("https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main".to_string()) + ); + + Ok(()) + } +} diff --git a/src/sources/mod.rs b/src/sources/mod.rs index b3180ac..a2194bd 100644 --- a/src/sources/mod.rs +++ b/src/sources/mod.rs @@ -1,15 +1,19 @@ pub mod apt; pub mod brew; pub mod cargo; +pub mod conda; pub mod docker; pub mod go; pub mod npm; pub mod pip; +pub mod uv; use crate::error::{MirrorError, Result}; use crate::traits::SourceManager; -pub const SUPPORTED_TOOLS: &[&str] = &["pip", "npm", "docker", "go", "cargo", "brew", "apt"]; +pub const SUPPORTED_TOOLS: &[&str] = &[ + "pip", "npm", "docker", "go", "cargo", "brew", "apt", "uv", "conda", +]; pub fn get_manager(name: &str) -> Result> { match name.to_lowercase().as_str() { @@ -20,6 +24,8 @@ pub fn get_manager(name: &str) -> Result> { "cargo" => Ok(Box::new(cargo::CargoManager::new())), "brew" => Ok(Box::new(brew::BrewManager::new())), "apt" => Ok(Box::new(apt::AptManager::new())), + "uv" => Ok(Box::new(uv::UvManager::new())), + "conda" => Ok(Box::new(conda::CondaManager::new())), _ => Err(MirrorError::UnknownTool(format!( "Unsupported tool: '{}'. Available: {}", name, diff --git a/src/sources/uv.rs b/src/sources/uv.rs new file mode 100644 index 0000000..ea550ae --- /dev/null +++ b/src/sources/uv.rs @@ -0,0 +1,219 @@ +use crate::config; +use crate::error::{MirrorError, Result}; +use crate::traits::SourceManager; +use crate::types::Mirror; +use crate::utils; +use async_trait::async_trait; +use directories::BaseDirs; +use std::path::PathBuf; +use tokio::fs; + +pub struct UvManager { + custom_path: Option, +} + +impl UvManager { + pub fn new() -> Self { + Self { custom_path: None } + } + + #[cfg(test)] + pub fn with_path(path: PathBuf) -> Self { + Self { + custom_path: Some(path), + } + } +} + +#[async_trait] +impl SourceManager for UvManager { + fn name(&self) -> &'static str { + "uv" + } + + fn requires_sudo(&self) -> bool { + false + } + + fn list_candidates(&self) -> Vec { + config::get_candidates("uv") + } + + fn config_path(&self) -> PathBuf { + if let Some(ref path) = self.custom_path { + return path.clone(); + } + + // 1. Check current directory (Project level) + let local_path = PathBuf::from("uv.toml"); + if local_path.exists() { + return local_path; + } + + // 2. Global path + // Linux: ~/.config/uv/uv.toml + // macOS: ~/Library/Application Support/uv/uv.toml + // Windows: %APPDATA%\uv\uv.toml + BaseDirs::new() + .map(|dirs| dirs.config_dir().join("uv").join("uv.toml")) + .unwrap_or_else(|| PathBuf::from(".").join("uv.toml")) + } + + async fn current_url(&self) -> Result> { + let path = self.config_path(); + if !fs::try_exists(&path).await.unwrap_or(false) { + return Ok(None); + } + + let content = fs::read_to_string(&path).await?; + let config: toml::Value = + toml::from_str(&content).unwrap_or(toml::Value::Table(toml::map::Map::new())); + + // Look for [[index]] with default = true + if let Some(indices) = config.get("index").and_then(|v| v.as_array()) { + for index in indices { + if index + .get("default") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + if let Some(url) = index.get("url").and_then(|v| v.as_str()) { + return Ok(Some(url.to_string())); + } + } + } + } + + Ok(None) + } + + async fn set_source(&self, mirror: &Mirror) -> Result<()> { + let path = self.config_path(); + + // 1. Ensure directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + + // 2. Read existing TOML or create empty + let content = if fs::try_exists(&path).await.unwrap_or(false) { + fs::read_to_string(&path).await? + } else { + String::new() + }; + + // 3. Backup + if !content.is_empty() { + utils::backup_file(&path).await?; + } + + // 4. Update TOML + let mut config: toml::Value = + toml::from_str(&content).unwrap_or(toml::Value::Table(toml::map::Map::new())); + + let root = config + .as_table_mut() + .ok_or(MirrorError::Custom("Invalid uv.toml format".to_string()))?; + + // Handle [[index]] + // If "index" key doesn't exist, create it as array + if !root.contains_key("index") { + root.insert("index".to_string(), toml::Value::Array(Vec::new())); + } + + let indices = + root.get_mut("index") + .and_then(|v| v.as_array_mut()) + .ok_or(MirrorError::Custom( + "Invalid 'index' in uv.toml (expected array)".to_string(), + ))?; + + // Check if there is already a default index + let mut found_default = false; + for index in indices.iter_mut() { + if let Some(table) = index.as_table_mut() { + if table + .get("default") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + // Update existing default + table.insert("url".to_string(), toml::Value::String(mirror.url.clone())); + // Ensure name matches if we want (optional, but keeping existing name is good) + // If no name, maybe set it to "pypi"? uv docs say name is optional. + found_default = true; + break; + } + } + } + + if !found_default { + // Append new default index + let mut new_index = toml::map::Map::new(); + new_index.insert("name".to_string(), toml::Value::String("pypi".to_string())); + new_index.insert("url".to_string(), toml::Value::String(mirror.url.clone())); + new_index.insert("default".to_string(), toml::Value::Boolean(true)); + indices.push(toml::Value::Table(new_index)); + } + + // 5. Write back + let new_content = toml::to_string_pretty(&config)?; + fs::write(&path, new_content).await?; + + Ok(()) + } + + async fn restore(&self) -> Result<()> { + utils::restore_latest_backup(&self.config_path()).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn test_uv_flow() -> Result<()> { + let dir = tempdir()?; + let config_path = dir.path().join("uv.toml"); + let manager = UvManager::with_path(config_path.clone()); + + // 1. Initial state + assert!(manager.current_url().await?.is_none()); + + // 2. Set source + let mirror = Mirror { + name: "TestUV".to_string(), + url: "https://test.pypi.org/simple".to_string(), + }; + manager.set_source(&mirror).await?; + + // 3. Check current + assert_eq!(manager.current_url().await?, Some(mirror.url.clone())); + + // Check TOML structure + let content = fs::read_to_string(&config_path).await?; + assert!(content.contains("[[index]]")); + assert!(content.contains("default = true")); + assert!(content.contains(&format!("url = \"{}\"", mirror.url))); + + // 4. Set another + let mirror2 = Mirror { + name: "TestUV2".to_string(), + url: "https://test2.pypi.org/simple".to_string(), + }; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + manager.set_source(&mirror2).await?; + assert_eq!(manager.current_url().await?, Some(mirror2.url.clone())); + + let content2 = fs::read_to_string(&config_path).await?; + assert!(content2.contains(&format!("url = \"{}\"", mirror2.url))); + + // 5. Restore + manager.restore().await?; + assert_eq!(manager.current_url().await?, Some(mirror.url)); + + Ok(()) + } +}