use super::channel::{fetch_latest, Channel};
use super::download::Downloader;
use crate::core::{get_home_dir, validate_version_name, ErrorCode, MihomoError, Result};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering as CmpOrdering;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::fs;
static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionInfo {
pub version: String,
pub path: PathBuf,
pub is_default: bool,
}
pub struct VersionManager {
install_dir: PathBuf,
config_file: PathBuf,
}
impl VersionManager {
pub fn new() -> Result<Self> {
let home = get_home_dir()?;
Self::with_home(home)
}
pub fn with_home(home: PathBuf) -> Result<Self> {
let install_dir = home.join("versions");
let config_file = home.join("config.toml");
Ok(Self {
install_dir,
config_file,
})
}
pub async fn install(&self, version: &str) -> Result<()> {
validate_version_name(version).map_err(|_| {
MihomoError::version_with_code(
ErrorCode::InvalidVersion,
format!("Invalid version '{}'", version),
)
})?;
fs::create_dir_all(&self.install_dir).await?;
let version_dir = self.install_dir.join(version);
if version_dir.exists() {
return Err(MihomoError::version(format!(
"Version {} is already installed",
version
)));
}
let binary_name = if cfg!(windows) {
"mihomo.exe"
} else {
"mihomo"
};
let temp_path = self.temp_download_path(version, binary_name);
let downloader = Downloader::new();
if let Err(err) = downloader.download_version(version, &temp_path).await {
let _ = fs::remove_file(&temp_path).await;
return Err(err);
}
fs::create_dir_all(&version_dir).await?;
let binary_path = version_dir.join(binary_name);
if let Err(err) = fs::rename(&temp_path, &binary_path).await {
if err.kind() == std::io::ErrorKind::CrossesDevices {
if let Err(copy_err) = fs::copy(&temp_path, &binary_path).await {
let _ = fs::remove_file(&temp_path).await;
return Err(copy_err.into());
}
let _ = fs::remove_file(&temp_path).await;
} else {
let _ = fs::remove_file(&temp_path).await;
return Err(err.into());
}
}
Ok(())
}
fn temp_download_path(&self, version: &str, binary_name: &str) -> PathBuf {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let seq = TEMP_FILE_COUNTER.fetch_add(1, AtomicOrdering::Relaxed);
self.install_dir.join(format!(
".mihomo-{}-{}-{}-{}-{}.downloading",
version,
std::process::id(),
ts,
seq,
binary_name
))
}
pub async fn install_channel(&self, channel: Channel) -> Result<String> {
let info = fetch_latest(channel).await?;
self.install(&info.version).await?;
Ok(info.version)
}
pub async fn list_installed(&self) -> Result<Vec<VersionInfo>> {
if !self.install_dir.exists() {
return Ok(vec![]);
}
let mut versions = vec![];
let default_version = self.get_default().await.ok();
let mut entries = fs::read_dir(&self.install_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await?.is_dir() {
let version = entry.file_name().to_string_lossy().to_string();
let is_default = default_version.as_ref() == Some(&version);
versions.push(VersionInfo {
version,
path: entry.path(),
is_default,
});
}
}
versions.sort_by(|a, b| {
match (
Self::parse_semver(&a.version),
Self::parse_semver(&b.version),
) {
(Some(av), Some(bv)) => bv.cmp(&av),
(Some(_), None) => CmpOrdering::Less,
(None, Some(_)) => CmpOrdering::Greater,
(None, None) => b.version.cmp(&a.version),
}
});
Ok(versions)
}
fn parse_semver(raw: &str) -> Option<Version> {
Version::parse(raw.trim_start_matches('v')).ok()
}
pub async fn set_default(&self, version: &str) -> Result<()> {
validate_version_name(version).map_err(|_| {
MihomoError::version_with_code(
ErrorCode::InvalidVersion,
format!("Invalid version '{}'", version),
)
})?;
let version_dir = self.install_dir.join(version);
if !version_dir.exists() {
return Err(MihomoError::NotFound(format!(
"Version {} is not installed",
version
)));
}
if let Some(parent) = self.config_file.parent() {
fs::create_dir_all(parent).await?;
}
let mut config = if self.config_file.exists() {
let content = fs::read_to_string(&self.config_file).await?;
toml::from_str::<toml::Value>(&content)
.map_err(|e| MihomoError::config(format!("Invalid config: {}", e)))?
} else {
toml::Value::Table(toml::map::Map::new())
};
if let toml::Value::Table(ref mut table) = config {
let default_table = table
.entry("default".to_string())
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
if let toml::Value::Table(ref mut default) = default_table {
default.insert(
"version".to_string(),
toml::Value::String(version.to_string()),
);
}
}
let content = toml::to_string(&config)
.map_err(|e| MihomoError::config(format!("Failed to serialize config: {}", e)))?;
fs::write(&self.config_file, content).await?;
Ok(())
}
pub async fn get_default(&self) -> Result<String> {
if !self.config_file.exists() {
return Err(MihomoError::NotFound("No default version set".to_string()));
}
let content = fs::read_to_string(&self.config_file).await?;
let config: toml::Value = toml::from_str(&content)
.map_err(|e| MihomoError::config(format!("Invalid config: {}", e)))?;
config
.get("default")
.and_then(|d| d.get("version"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| MihomoError::config("No default version in config"))
}
pub async fn get_binary_path(&self, version: Option<&str>) -> Result<PathBuf> {
let version = if let Some(v) = version {
validate_version_name(v).map_err(|_| {
MihomoError::version_with_code(
ErrorCode::InvalidVersion,
format!("Invalid version '{}'", v),
)
})?;
v.to_string()
} else {
let default = self.get_default().await?;
validate_version_name(&default).map_err(|_| {
MihomoError::version_with_code(
ErrorCode::InvalidVersion,
format!("Invalid version '{}'", default),
)
})?;
default
};
let binary_name = if cfg!(windows) {
"mihomo.exe"
} else {
"mihomo"
};
let path = self.install_dir.join(&version).join(binary_name);
if !path.exists() {
return Err(MihomoError::NotFound(format!(
"Binary not found for version {}",
version
)));
}
Ok(path)
}
pub async fn uninstall(&self, version: &str) -> Result<()> {
validate_version_name(version).map_err(|_| {
MihomoError::version_with_code(
ErrorCode::InvalidVersion,
format!("Invalid version '{}'", version),
)
})?;
let version_dir = self.install_dir.join(version);
if !version_dir.exists() {
return Err(MihomoError::NotFound(format!(
"Version {} is not installed",
version
)));
}
let default_version = match self.get_default().await {
Ok(v) => Some(v),
Err(MihomoError::NotFound(_)) => None,
Err(err) => return Err(err),
};
if default_version.as_ref() == Some(&version.to_string()) {
return Err(MihomoError::version("Cannot uninstall the default version"));
}
fs::remove_dir_all(version_dir).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::VersionManager;
use std::path::Path;
use std::path::PathBuf;
use tempfile::tempdir;
use tokio::fs;
#[test]
fn test_temp_download_path_is_unique() {
let vm = VersionManager::with_home(PathBuf::from("/tmp/mihomo-rs-test-home"))
.expect("version manager should be created");
let path1 = vm.temp_download_path("v1.0.0", "mihomo");
let path2 = vm.temp_download_path("v1.0.0", "mihomo");
assert_ne!(path1, path2);
}
#[test]
fn test_version_manager_new_smoke() {
let manager = VersionManager::new().expect("version manager should be constructible");
let _ = manager.install_dir.clone();
}
#[test]
fn test_parse_semver_supports_v_prefix_and_plain_values() {
assert!(VersionManager::parse_semver("v1.2.3").is_some());
assert!(VersionManager::parse_semver("1.2.3").is_some());
}
#[test]
fn test_parse_semver_rejects_invalid_values() {
assert!(VersionManager::parse_semver("snapshot").is_none());
assert!(VersionManager::parse_semver("v1").is_none());
}
#[tokio::test]
async fn test_list_installed_uses_semver_order() {
let temp = tempdir().expect("create temp dir");
let vm = VersionManager::with_home(temp.path().to_path_buf())
.expect("version manager should be created");
fs::create_dir_all(vm.install_dir.join("v1.9.0"))
.await
.expect("create v1.9.0");
fs::create_dir_all(vm.install_dir.join("v1.10.0"))
.await
.expect("create v1.10.0");
fs::create_dir_all(vm.install_dir.join("v1.2.0"))
.await
.expect("create v1.2.0");
let names: Vec<String> = vm
.list_installed()
.await
.expect("list installed")
.into_iter()
.map(|v| v.version)
.collect();
assert_eq!(
names,
vec![
"v1.10.0".to_string(),
"v1.9.0".to_string(),
"v1.2.0".to_string(),
]
);
}
#[tokio::test]
async fn test_list_installed_returns_empty_when_directory_missing() {
let temp = tempdir().expect("create temp dir");
let vm = VersionManager::with_home(temp.path().to_path_buf())
.expect("version manager should be created");
let listed = vm.list_installed().await.expect("list installed");
assert!(listed.is_empty());
}
#[tokio::test]
async fn test_list_installed_ignores_non_directory_entries() {
let temp = tempdir().expect("create temp dir");
let vm = VersionManager::with_home(temp.path().to_path_buf())
.expect("version manager should be created");
fs::create_dir_all(&vm.install_dir)
.await
.expect("create install dir");
fs::write(vm.install_dir.join("README.txt"), b"not a version dir")
.await
.expect("write marker file");
fs::create_dir_all(vm.install_dir.join("v1.0.0"))
.await
.expect("create version dir");
let listed = vm.list_installed().await.expect("list installed");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].version, "v1.0.0");
}
#[tokio::test]
async fn test_install_returns_error_when_version_already_installed() {
let temp = tempdir().expect("create temp dir");
let vm = VersionManager::with_home(temp.path().to_path_buf())
.expect("version manager should be created");
let existing = vm.install_dir.join("v9.9.9");
fs::create_dir_all(&existing)
.await
.expect("create existing version directory");
let err = vm
.install("v9.9.9")
.await
.expect_err("existing version should fail install");
assert!(err.to_string().contains("already installed"));
assert!(Path::new(&existing).exists());
}
#[tokio::test]
async fn test_set_get_default_and_binary_path_roundtrip() {
let temp = tempdir().expect("create temp dir");
let vm = VersionManager::with_home(temp.path().to_path_buf())
.expect("version manager should be created");
let version_dir = vm.install_dir.join("v1.2.3");
fs::create_dir_all(&version_dir)
.await
.expect("create version directory");
let binary_name = if cfg!(windows) {
"mihomo.exe"
} else {
"mihomo"
};
let binary_path = version_dir.join(binary_name);
fs::write(&binary_path, b"fake-binary")
.await
.expect("write fake binary");
vm.set_default("v1.2.3").await.expect("set default version");
let default_version = vm.get_default().await.expect("get default version");
assert_eq!(default_version, "v1.2.3");
let resolved = vm.get_binary_path(None).await.expect("resolve binary path");
assert_eq!(resolved, binary_path);
}
#[tokio::test]
async fn test_uninstall_removes_non_default_version() {
let temp = tempdir().expect("create temp dir");
let vm = VersionManager::with_home(temp.path().to_path_buf())
.expect("version manager should be created");
let keep = vm.install_dir.join("v1.0.0");
let remove = vm.install_dir.join("v1.1.0");
fs::create_dir_all(&keep)
.await
.expect("create keep version");
fs::create_dir_all(&remove)
.await
.expect("create remove version");
vm.set_default("v1.0.0").await.expect("set default version");
vm.uninstall("v1.1.0")
.await
.expect("uninstall non-default version");
assert!(keep.exists());
assert!(!remove.exists());
}
}