use crate::architecture::Architecture;
use crate::constants::{backup, config, docker, updates, version};
use crate::version::Version; use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use toml;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppConfig {
pub versions: VersionConfig,
pub docker: DockerConfig,
pub backup: BackupConfig,
pub cache: CacheConfig,
pub updates: UpdatesConfig,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct VersionConfig {
pub docker_service: String,
#[serde(default)]
pub patch_version: String,
#[serde(default)]
pub local_patch_level: u32,
#[serde(default)]
pub full_version_with_patches: String,
#[serde(default)]
pub last_full_upgrade: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub last_patch_upgrade: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub applied_patches: Vec<AppliedPatch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppliedPatch {
pub version: String,
pub level: u32,
pub applied_at: chrono::DateTime<chrono::Utc>,
}
pub type Versions = VersionConfig;
impl VersionConfig {
pub fn new() -> Self {
let docker_service = version::version_info::DEFAULT_DOCKER_SERVICE_VERSION.to_string();
let full_version = format!("{docker_service}.0");
Self {
docker_service: docker_service.clone(),
patch_version: "0.0.0".to_string(),
local_patch_level: 0,
full_version_with_patches: full_version,
last_full_upgrade: None,
last_patch_upgrade: None,
applied_patches: Vec::new(),
}
}
pub fn update_full_version(&mut self, new_version: String) {
self.docker_service = new_version.clone();
self.local_patch_level = 0; self.full_version_with_patches = format!("{new_version}.0");
self.last_full_upgrade = Some(chrono::Utc::now());
self.applied_patches.clear();
tracing::info!("Full version updated: {} -> {}", self.docker_service, new_version);
}
pub fn apply_patch(&mut self, patch_version: String) {
self.patch_version = patch_version.clone();
self.local_patch_level += 1;
self.full_version_with_patches =
format!("{}.{}", self.docker_service, self.local_patch_level);
self.last_patch_upgrade = Some(chrono::Utc::now());
self.applied_patches.push(AppliedPatch {
version: patch_version.clone(),
level: self.local_patch_level,
applied_at: chrono::Utc::now(),
});
tracing::info!(
"Patch applied: {} (level: {})",
patch_version,
self.local_patch_level
);
}
pub fn get_current_version(&self) -> Result<Version> {
if !self.full_version_with_patches.is_empty() {
self.full_version_with_patches.parse::<Version>()
} else {
format!("{}.0", self.docker_service).parse::<Version>()
}
}
pub fn needs_migration(&self) -> bool {
self.full_version_with_patches.is_empty()
|| (self.local_patch_level == 0 && !self.applied_patches.is_empty())
}
pub fn migrate(&mut self) -> Result<()> {
if self.full_version_with_patches.is_empty() {
self.full_version_with_patches =
format!("{}.{}", self.docker_service, self.local_patch_level);
tracing::info!(
"Configuration migration: building full version number {}",
self.full_version_with_patches
);
}
self.validate()?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
if self.docker_service.is_empty() {
return Err(anyhow::anyhow!("docker_service cannot be empty"));
}
if !self.full_version_with_patches.is_empty() {
let _version = self
.full_version_with_patches
.parse::<Version>()
.map_err(|e| anyhow::anyhow!(format!("Invalid full version number format: {e}")))?;
}
if self.applied_patches.len() != self.local_patch_level as usize {
tracing::warn!(
"Patch level inconsistent with history: level={}, history_count={}",
self.local_patch_level,
self.applied_patches.len()
);
}
Ok(())
}
pub fn get_patch_summary(&self) -> String {
if self.applied_patches.is_empty() {
format!("版本: {} (无补丁)", self.docker_service)
} else {
format!(
"版本: {} (已应用{}个补丁,当前级别: {})",
self.docker_service,
self.applied_patches.len(),
self.local_patch_level
)
}
}
pub fn rollback_last_patch(&mut self) -> Result<Option<AppliedPatch>> {
if let Some(last_patch) = self.applied_patches.pop() {
if self.local_patch_level > 0 {
self.local_patch_level -= 1;
}
self.full_version_with_patches =
format!("{}.{}", self.docker_service, self.local_patch_level);
if let Some(prev_patch) = self.applied_patches.last() {
self.patch_version = prev_patch.version.clone();
} else {
self.patch_version = "0.0.0".to_string();
}
tracing::info!(
"Rolled back patch: {} (level: {})",
last_patch.version,
last_patch.level
);
Ok(Some(last_patch))
} else {
Ok(None)
}
}
}
impl Default for VersionConfig {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DockerConfig {
#[serde(default = "default_compose_file_path")]
pub compose_file: String,
#[serde(default = "default_env_file_path")]
pub env_file: String,
}
fn default_env_file_path() -> String {
docker::get_env_file_path_str()
}
fn default_compose_file_path() -> String {
docker::get_compose_file_path_str()
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BackupConfig {
pub storage_dir: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CacheConfig {
pub cache_dir: String,
pub download_dir: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UpdatesConfig {
pub check_frequency: String,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
versions: VersionConfig::new(),
docker: DockerConfig {
compose_file: docker::get_compose_file_path_str(),
env_file: docker::get_env_file_path_str(),
},
backup: BackupConfig {
storage_dir: backup::get_default_storage_dir()
.to_string_lossy()
.to_string(),
},
cache: CacheConfig {
cache_dir: config::get_default_cache_dir()
.to_string_lossy()
.to_string(),
download_dir: config::get_default_download_dir()
.to_string_lossy()
.to_string(),
},
updates: UpdatesConfig {
check_frequency: updates::DEFAULT_CHECK_FREQUENCY.to_string(),
},
}
}
}
impl AppConfig {
pub fn get_docker_versions(&self) -> String {
self.versions.docker_service.clone()
}
pub fn write_docker_versions(&mut self, docker_service: String) {
self.versions.docker_service = docker_service;
}
pub fn find_and_load_config() -> Result<Self> {
let config_files = ["config.toml", "/app/config.toml"];
for config_file in &config_files {
if Path::new(config_file).exists() {
tracing::info!("Found configuration file: {}", config_file);
return Self::load_from_file(config_file);
}
}
tracing::warn!("Configuration file not found, creating default config: config.toml");
let default_config = Self::default();
default_config.save_to_file("config.toml")?;
Ok(default_config)
}
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(&path)?;
let config: AppConfig = toml::from_str(&content)?;
Ok(config)
}
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let content = self.to_toml_with_comments();
fs::write(&path, content)?;
Ok(())
}
fn to_toml_with_comments(&self) -> String {
const TEMPLATE: &str = include_str!("../templates/config.toml.template");
let compose_file = self.docker.compose_file.replace('\\', "/");
let backup_storage_dir = self.backup.storage_dir.replace('\\', "/");
let cache_dir = self.cache.cache_dir.replace('\\', "/");
let download_dir = self.cache.download_dir.replace('\\', "/");
TEMPLATE
.replace("{docker_service_version}", &self.get_docker_versions())
.replace("{compose_file}", &compose_file)
.replace("{backup_storage_dir}", &backup_storage_dir)
.replace("{cache_dir}", &cache_dir)
.replace("{download_dir}", &download_dir)
.replace("{check_frequency}", &self.updates.check_frequency)
}
pub fn ensure_cache_dirs(&self) -> Result<()> {
fs::create_dir_all(&self.cache.cache_dir)?;
fs::create_dir_all(&self.cache.download_dir)?;
Ok(())
}
pub fn get_download_dir(&self) -> PathBuf {
PathBuf::from(&self.cache.download_dir)
}
pub fn get_version_download_dir(&self, version: &str, download_type: &str) -> PathBuf {
PathBuf::from(&self.cache.download_dir)
.join(version)
.join(download_type)
}
pub fn get_version_download_file_path(
&self,
version: &str,
download_type: &str,
filename: Option<&str>,
) -> Result<PathBuf> {
let download_dir = self.get_version_download_dir(version, download_type);
let path = match filename {
Some(filename) => download_dir.join(filename),
None => {
match find_archive_file(&download_dir) {
Some(found) => download_dir.join(found),
None => {
return Err(anyhow::anyhow!(
"Archive file not found, directory: {}, supported formats: .zip, .tar.gz",
download_dir.display()
));
}
}
}
};
if !path.exists() {
return Err(anyhow::anyhow!("Archive file does not exist: {}", path.display()));
}
Ok(path)
}
pub fn ensure_version_download_dir(
&self,
version: &str,
download_type: &str,
) -> Result<PathBuf> {
let dir = self.get_version_download_dir(version, download_type);
fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn get_backup_dir(&self) -> PathBuf {
PathBuf::from(&self.backup.storage_dir)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::TempDir;
#[test]
fn test_version_config_new() {
let config = VersionConfig::new();
assert!(!config.docker_service.is_empty());
assert_eq!(config.patch_version, "0.0.0");
assert_eq!(config.local_patch_level, 0);
assert!(config.full_version_with_patches.ends_with(".0"));
assert!(config.applied_patches.is_empty());
assert!(config.last_full_upgrade.is_none());
assert!(config.last_patch_upgrade.is_none());
}
#[test]
fn test_update_full_version() {
let mut config = VersionConfig::new();
config.apply_patch("0.0.1".to_string());
assert_eq!(config.local_patch_level, 1);
config.update_full_version("0.0.14".to_string());
assert_eq!(config.docker_service, "0.0.14");
assert_eq!(config.local_patch_level, 0);
assert_eq!(config.full_version_with_patches, "0.0.14.0");
assert!(config.applied_patches.is_empty());
assert!(config.last_full_upgrade.is_some());
}
#[test]
fn test_apply_patch() {
let mut config = VersionConfig::new();
let initial_service_version = config.docker_service.clone();
config.apply_patch("patch-0.0.1".to_string());
assert_eq!(config.patch_version, "patch-0.0.1");
assert_eq!(config.local_patch_level, 1);
assert_eq!(
config.full_version_with_patches,
format!("{initial_service_version}.1")
);
assert_eq!(config.applied_patches.len(), 1);
assert!(config.last_patch_upgrade.is_some());
config.apply_patch("patch-0.0.2".to_string());
assert_eq!(config.patch_version, "patch-0.0.2");
assert_eq!(config.local_patch_level, 2);
assert_eq!(
config.full_version_with_patches,
format!("{initial_service_version}.2")
);
assert_eq!(config.applied_patches.len(), 2);
assert_eq!(config.applied_patches[0].version, "patch-0.0.1");
assert_eq!(config.applied_patches[0].level, 1);
assert_eq!(config.applied_patches[1].version, "patch-0.0.2");
assert_eq!(config.applied_patches[1].level, 2);
}
#[test]
fn test_get_current_version() {
let mut config = VersionConfig::new();
let version = config.get_current_version().unwrap();
assert_eq!(version.build, 0);
config.apply_patch("patch-0.0.1".to_string());
let version = config.get_current_version().unwrap();
assert_eq!(version.build, 1);
}
#[test]
fn test_backward_compatibility() {
let old_config = VersionConfig {
docker_service: "0.0.13".to_string(),
patch_version: String::new(),
local_patch_level: 0,
full_version_with_patches: String::new(),
last_full_upgrade: None,
last_patch_upgrade: None,
applied_patches: Vec::new(),
};
assert!(old_config.needs_migration());
let version = old_config.get_current_version().unwrap();
assert_eq!(version.to_string(), "0.0.13.0");
}
#[test]
fn test_migration() {
let mut config = VersionConfig {
docker_service: "0.0.13".to_string(),
patch_version: String::new(),
local_patch_level: 2,
full_version_with_patches: String::new(),
last_full_upgrade: None,
last_patch_upgrade: None,
applied_patches: Vec::new(),
};
assert!(config.needs_migration());
config.migrate().unwrap();
assert!(!config.needs_migration());
assert_eq!(config.full_version_with_patches, "0.0.13.2");
}
#[test]
fn test_validation() {
let mut config = VersionConfig::new();
assert!(config.validate().is_ok());
config.docker_service = String::new();
assert!(config.validate().is_err());
config.docker_service = "0.0.13".to_string();
config.full_version_with_patches = "invalid.version".to_string();
assert!(config.validate().is_err());
}
#[test]
fn test_rollback_last_patch() {
let mut config = VersionConfig::new();
assert!(config.rollback_last_patch().unwrap().is_none());
config.apply_patch("patch-1".to_string());
config.apply_patch("patch-2".to_string());
assert_eq!(config.local_patch_level, 2);
assert_eq!(config.applied_patches.len(), 2);
let rolled_back = config.rollback_last_patch().unwrap();
assert!(rolled_back.is_some());
assert_eq!(rolled_back.unwrap().version, "patch-2");
assert_eq!(config.local_patch_level, 1);
assert_eq!(config.applied_patches.len(), 1);
assert_eq!(config.patch_version, "patch-1");
let rolled_back = config.rollback_last_patch().unwrap();
assert!(rolled_back.is_some());
assert_eq!(rolled_back.unwrap().version, "patch-1");
assert_eq!(config.local_patch_level, 0);
assert_eq!(config.applied_patches.len(), 0);
assert_eq!(config.patch_version, "0.0.0");
}
#[test]
fn test_patch_summary() {
let mut config = VersionConfig::new();
let summary = config.get_patch_summary();
assert!(summary.contains("无补丁"));
config.apply_patch("patch-1".to_string());
config.apply_patch("patch-2".to_string());
let summary = config.get_patch_summary();
assert!(summary.contains("已应用2个补丁"));
assert!(summary.contains("当前级别: 2"));
}
#[test]
fn test_serde_compatibility() {
let mut config = VersionConfig::new();
config.apply_patch("test-patch".to_string());
let serialized = toml::to_string(&config).unwrap();
let deserialized: VersionConfig = toml::from_str(&serialized).unwrap();
assert_eq!(config.docker_service, deserialized.docker_service);
assert_eq!(config.patch_version, deserialized.patch_version);
assert_eq!(config.local_patch_level, deserialized.local_patch_level);
assert_eq!(
config.full_version_with_patches,
deserialized.full_version_with_patches
);
assert_eq!(
config.applied_patches.len(),
deserialized.applied_patches.len()
);
}
#[test]
fn test_task_1_3_acceptance_criteria() {
let mut config = VersionConfig::new();
assert!(!config.docker_service.is_empty());
assert!(config.patch_version.is_empty() || config.patch_version == "0.0.0");
config.update_full_version("0.0.14".to_string());
assert_eq!(config.full_version_with_patches, "0.0.14.0");
config.apply_patch("0.0.1".to_string());
assert_eq!(config.full_version_with_patches, "0.0.14.1");
let version = config.get_current_version().unwrap();
assert_eq!(version.to_string(), "0.0.14.1");
let old_config = VersionConfig {
docker_service: "0.0.13".to_string(),
patch_version: String::new(),
local_patch_level: 0,
full_version_with_patches: String::new(),
last_full_upgrade: None,
last_patch_upgrade: None,
applied_patches: Vec::new(),
};
assert!(old_config.needs_migration());
println!("✅ Task 1.3: 配置文件结构扩展 - 验收标准测试通过");
println!(" - ✅ VersionConfig结构体扩展完成");
println!(" - ✅ update_full_version方法正常工作");
println!(" - ✅ apply_patch方法正常工作");
println!(" - ✅ get_current_version方法正常工作");
println!(" - ✅ 配置迁移逻辑(向后兼容)正常工作");
}
#[test]
fn test_find_archive_file() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
File::create(dir.join("docker-aarch64.zip")).unwrap();
File::create(dir.join("docker-x86_64.tar.gz")).unwrap();
File::create(dir.join("other.txt")).unwrap();
File::create(dir.join("archive.zip")).unwrap();
let result = find_archive_file(dir);
assert_eq!(result, Some("docker-aarch64.zip".to_string()));
}
#[test]
fn test_find_archive_file_no_match() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
File::create(dir.join("archive.zip")).unwrap();
File::create(dir.join("other.txt")).unwrap();
let result = find_archive_file(dir);
assert!(result.is_none());
}
#[test]
fn test_find_archive_file_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
let result = find_archive_file(dir);
assert!(result.is_none());
}
#[test]
fn test_find_archive_file_tar_gz() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
File::create(dir.join("docker-aarch64.tar.gz")).unwrap();
let result = find_archive_file(dir);
assert_eq!(result, Some("docker-aarch64.tar.gz".to_string()));
}
}
fn find_archive_file(dir: &Path) -> Option<String> {
let entries = fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = path.file_name()?.to_str()?;
if !file_name.starts_with("docker-") {
continue;
}
if file_name.ends_with(".zip") || file_name.ends_with(".tar.gz") {
return Some(file_name.to_string());
}
}
None
}