1use crate::constants::{backup, config, docker, updates, version};
2use crate::version::Version; use anyhow::Result;
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::{Path, PathBuf};
7use toml;
8
9#[derive(Debug, Serialize, Deserialize, Clone)]
11pub struct AppConfig {
12 pub versions: VersionConfig,
13 pub docker: DockerConfig,
14 pub backup: BackupConfig,
15 pub cache: CacheConfig,
16 pub updates: UpdatesConfig,
17}
18
19#[derive(Debug, Serialize, Deserialize, Clone)]
21pub struct VersionConfig {
22 pub docker_service: String,
24
25 #[serde(default)]
27 pub patch_version: String,
28
29 #[serde(default)]
31 pub local_patch_level: u32,
32
33 #[serde(default)]
35 pub full_version_with_patches: String,
36
37 #[serde(default)]
39 pub last_full_upgrade: Option<chrono::DateTime<chrono::Utc>>,
40
41 #[serde(default)]
43 pub last_patch_upgrade: Option<chrono::DateTime<chrono::Utc>>,
44
45 #[serde(default)]
47 pub applied_patches: Vec<AppliedPatch>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AppliedPatch {
53 pub version: String,
54 pub level: u32,
55 pub applied_at: chrono::DateTime<chrono::Utc>,
56}
57
58pub type Versions = VersionConfig;
60
61impl VersionConfig {
62 pub fn new() -> Self {
64 let docker_service = version::version_info::DEFAULT_DOCKER_SERVICE_VERSION.to_string();
65 let full_version = format!("{docker_service}.0");
66
67 Self {
68 docker_service: docker_service.clone(),
69 patch_version: "0.0.0".to_string(),
70 local_patch_level: 0,
71 full_version_with_patches: full_version,
72 last_full_upgrade: None,
73 last_patch_upgrade: None,
74 applied_patches: Vec::new(),
75 }
76 }
77
78 pub fn update_full_version(&mut self, new_version: String) {
80 self.docker_service = new_version.clone();
81 self.local_patch_level = 0; self.full_version_with_patches = format!("{new_version}.0");
83 self.last_full_upgrade = Some(chrono::Utc::now());
84 self.applied_patches.clear(); tracing::info!(
87 "Full version updated: {} -> {}",
88 self.docker_service,
89 new_version
90 );
91 }
92
93 pub fn apply_patch(&mut self, patch_version: String) {
95 self.patch_version = patch_version.clone();
96 self.local_patch_level += 1;
97 self.full_version_with_patches =
98 format!("{}.{}", self.docker_service, self.local_patch_level);
99 self.last_patch_upgrade = Some(chrono::Utc::now());
100
101 self.applied_patches.push(AppliedPatch {
103 version: patch_version.clone(),
104 level: self.local_patch_level,
105 applied_at: chrono::Utc::now(),
106 });
107
108 tracing::info!(
109 "Patch applied: {} (level: {})",
110 patch_version,
111 self.local_patch_level
112 );
113 }
114
115 pub fn get_current_version(&self) -> Result<Version> {
117 if !self.full_version_with_patches.is_empty() {
118 self.full_version_with_patches.parse::<Version>()
119 } else {
120 format!("{}.0", self.docker_service).parse::<Version>()
122 }
123 }
124
125 pub fn needs_migration(&self) -> bool {
127 self.full_version_with_patches.is_empty()
128 || (self.local_patch_level == 0 && !self.applied_patches.is_empty())
129 }
130
131 pub fn migrate(&mut self) -> Result<()> {
133 if self.full_version_with_patches.is_empty() {
134 self.full_version_with_patches =
136 format!("{}.{}", self.docker_service, self.local_patch_level);
137 tracing::info!(
138 "Configuration migration: building full version number {}",
139 self.full_version_with_patches
140 );
141 }
142
143 self.validate()?;
145
146 Ok(())
147 }
148
149 pub fn validate(&self) -> Result<()> {
151 if self.docker_service.is_empty() {
153 return Err(anyhow::anyhow!("docker_service cannot be empty"));
154 }
155
156 if !self.full_version_with_patches.is_empty() {
158 let _version = self
159 .full_version_with_patches
160 .parse::<Version>()
161 .map_err(|e| anyhow::anyhow!(format!("Invalid full version number format: {e}")))?;
162 }
163
164 if self.applied_patches.len() != self.local_patch_level as usize {
166 tracing::warn!(
167 "Patch level inconsistent with history: level={}, history_count={}",
168 self.local_patch_level,
169 self.applied_patches.len()
170 );
171 }
172
173 Ok(())
174 }
175
176 pub fn get_patch_summary(&self) -> String {
178 if self.applied_patches.is_empty() {
179 format!("版本: {} (无补丁)", self.docker_service)
180 } else {
181 format!(
182 "版本: {} (已应用{}个补丁,当前级别: {})",
183 self.docker_service,
184 self.applied_patches.len(),
185 self.local_patch_level
186 )
187 }
188 }
189
190 pub fn rollback_last_patch(&mut self) -> Result<Option<AppliedPatch>> {
192 if let Some(last_patch) = self.applied_patches.pop() {
193 if self.local_patch_level > 0 {
194 self.local_patch_level -= 1;
195 }
196
197 self.full_version_with_patches =
198 format!("{}.{}", self.docker_service, self.local_patch_level);
199
200 if let Some(prev_patch) = self.applied_patches.last() {
202 self.patch_version = prev_patch.version.clone();
203 } else {
204 self.patch_version = "0.0.0".to_string();
205 }
206
207 tracing::info!(
208 "Rolled back patch: {} (level: {})",
209 last_patch.version,
210 last_patch.level
211 );
212 Ok(Some(last_patch))
213 } else {
214 Ok(None)
215 }
216 }
217}
218
219impl Default for VersionConfig {
220 fn default() -> Self {
221 Self::new()
222 }
223}
224
225#[derive(Debug, Serialize, Deserialize, Clone)]
227pub struct DockerConfig {
228 #[serde(default = "default_compose_file_path")]
229 pub compose_file: String,
230 #[serde(default = "default_env_file_path")]
231 pub env_file: String,
232}
233fn default_env_file_path() -> String {
235 docker::get_env_file_path_str()
236}
237
238fn default_compose_file_path() -> String {
239 docker::get_compose_file_path_str()
240}
241
242#[derive(Debug, Serialize, Deserialize, Clone)]
244pub struct BackupConfig {
245 pub storage_dir: String,
246}
247
248#[derive(Debug, Serialize, Deserialize, Clone)]
250pub struct CacheConfig {
251 pub cache_dir: String,
252 pub download_dir: String,
253}
254
255#[derive(Debug, Serialize, Deserialize, Clone)]
257pub struct UpdatesConfig {
258 pub check_frequency: String,
259}
260
261impl Default for AppConfig {
262 fn default() -> Self {
263 Self {
264 versions: VersionConfig::new(),
265 docker: DockerConfig {
266 compose_file: docker::get_compose_file_path_str(),
267 env_file: docker::get_env_file_path_str(),
268 },
269 backup: BackupConfig {
270 storage_dir: backup::get_default_storage_dir()
271 .to_string_lossy()
272 .to_string(),
273 },
274 cache: CacheConfig {
275 cache_dir: config::get_default_cache_dir()
276 .to_string_lossy()
277 .to_string(),
278 download_dir: config::get_default_download_dir()
279 .to_string_lossy()
280 .to_string(),
281 },
282 updates: UpdatesConfig {
283 check_frequency: updates::DEFAULT_CHECK_FREQUENCY.to_string(),
284 },
285 }
286 }
287}
288
289impl AppConfig {
290 pub fn get_docker_versions(&self) -> String {
292 self.versions.docker_service.clone()
293 }
294
295 pub fn write_docker_versions(&mut self, docker_service: String) {
297 self.versions.docker_service = docker_service;
298 }
299
300 pub fn find_and_load_config() -> Result<Self> {
303 let config_files = ["config.toml", "/app/config.toml"];
304
305 for config_file in &config_files {
306 if Path::new(config_file).exists() {
307 tracing::info!("Found configuration file: {}", config_file);
308 return Self::load_from_file(config_file);
309 }
310 }
311
312 tracing::warn!("Configuration file not found, creating default config: config.toml");
314 let default_config = Self::default();
315 default_config.save_to_file("config.toml")?;
316 Ok(default_config)
317 }
318
319 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
321 let content = fs::read_to_string(&path)?;
322 let config: AppConfig = toml::from_str(&content)?;
323
324 Ok(config)
325 }
326
327 pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
329 let content = self.to_toml_with_comments();
330 fs::write(&path, content)?;
331 Ok(())
332 }
333
334 fn to_toml_with_comments(&self) -> String {
336 const TEMPLATE: &str = include_str!("../templates/config.toml.template");
337
338 let compose_file = self.docker.compose_file.replace('\\', "/");
340 let backup_storage_dir = self.backup.storage_dir.replace('\\', "/");
341 let cache_dir = self.cache.cache_dir.replace('\\', "/");
342 let download_dir = self.cache.download_dir.replace('\\', "/");
343
344 TEMPLATE
345 .replace("{docker_service_version}", &self.get_docker_versions())
346 .replace("{compose_file}", &compose_file)
347 .replace("{backup_storage_dir}", &backup_storage_dir)
348 .replace("{cache_dir}", &cache_dir)
349 .replace("{download_dir}", &download_dir)
350 .replace("{check_frequency}", &self.updates.check_frequency)
351 }
352
353 pub fn ensure_cache_dirs(&self) -> Result<()> {
355 fs::create_dir_all(&self.cache.cache_dir)?;
356 fs::create_dir_all(&self.cache.download_dir)?;
357 Ok(())
358 }
359
360 pub fn get_download_dir(&self) -> PathBuf {
362 PathBuf::from(&self.cache.download_dir)
363 }
364
365 pub fn get_version_download_dir(&self, version: &str, download_type: &str) -> PathBuf {
367 PathBuf::from(&self.cache.download_dir)
368 .join(version)
369 .join(download_type)
370 }
371
372 pub fn get_version_download_file_path(
376 &self,
377 version: &str,
378 download_type: &str,
379 filename: Option<&str>,
380 ) -> Result<PathBuf> {
381 let download_dir = self.get_version_download_dir(version, download_type);
382
383 let path = match filename {
384 Some(filename) => download_dir.join(filename),
385 None => {
386 match find_archive_file(&download_dir) {
388 Some(found) => download_dir.join(found),
389 None => {
390 return Err(anyhow::anyhow!(
392 "Archive file not found, directory: {}, supported formats: .zip, .tar.gz",
393 download_dir.display()
394 ));
395 }
396 }
397 }
398 };
399
400 if !path.exists() {
402 return Err(anyhow::anyhow!(
403 "Archive file does not exist: {}",
404 path.display()
405 ));
406 }
407
408 Ok(path)
409 }
410
411 pub fn ensure_version_download_dir(
413 &self,
414 version: &str,
415 download_type: &str,
416 ) -> Result<PathBuf> {
417 let dir = self.get_version_download_dir(version, download_type);
418 fs::create_dir_all(&dir)?;
419 Ok(dir)
420 }
421
422 pub fn get_backup_dir(&self) -> PathBuf {
424 PathBuf::from(&self.backup.storage_dir)
425 }
426}
427
428fn find_archive_file(dir: &Path) -> Option<String> {
435 let entries = fs::read_dir(dir).ok()?;
436
437 for entry in entries.flatten() {
438 let path = entry.path();
439 if !path.is_file() {
440 continue;
441 }
442
443 let file_name = path.file_name()?.to_str()?;
444
445 if !file_name.starts_with("docker-") {
447 continue;
448 }
449
450 if file_name.ends_with(".zip") || file_name.ends_with(".tar.gz") {
452 return Some(file_name.to_string());
453 }
454 }
455
456 None
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use std::fs::File;
463 use tempfile::TempDir;
464
465 #[test]
466 fn test_version_config_new() {
467 let config = VersionConfig::new();
468
469 assert!(!config.docker_service.is_empty());
470 assert_eq!(config.patch_version, "0.0.0");
471 assert_eq!(config.local_patch_level, 0);
472 assert!(config.full_version_with_patches.ends_with(".0"));
473 assert!(config.applied_patches.is_empty());
474 assert!(config.last_full_upgrade.is_none());
475 assert!(config.last_patch_upgrade.is_none());
476 }
477
478 #[test]
479 fn test_update_full_version() {
480 let mut config = VersionConfig::new();
481 config.apply_patch("0.0.1".to_string());
482 assert_eq!(config.local_patch_level, 1);
483
484 config.update_full_version("0.0.14".to_string());
486
487 assert_eq!(config.docker_service, "0.0.14");
488 assert_eq!(config.local_patch_level, 0);
489 assert_eq!(config.full_version_with_patches, "0.0.14.0");
490 assert!(config.applied_patches.is_empty());
491 assert!(config.last_full_upgrade.is_some());
492 }
493
494 #[test]
495 fn test_apply_patch() {
496 let mut config = VersionConfig::new();
497 let initial_service_version = config.docker_service.clone();
498
499 config.apply_patch("patch-0.0.1".to_string());
501
502 assert_eq!(config.patch_version, "patch-0.0.1");
503 assert_eq!(config.local_patch_level, 1);
504 assert_eq!(
505 config.full_version_with_patches,
506 format!("{initial_service_version}.1")
507 );
508 assert_eq!(config.applied_patches.len(), 1);
509 assert!(config.last_patch_upgrade.is_some());
510
511 config.apply_patch("patch-0.0.2".to_string());
513
514 assert_eq!(config.patch_version, "patch-0.0.2");
515 assert_eq!(config.local_patch_level, 2);
516 assert_eq!(
517 config.full_version_with_patches,
518 format!("{initial_service_version}.2")
519 );
520 assert_eq!(config.applied_patches.len(), 2);
521
522 assert_eq!(config.applied_patches[0].version, "patch-0.0.1");
524 assert_eq!(config.applied_patches[0].level, 1);
525 assert_eq!(config.applied_patches[1].version, "patch-0.0.2");
526 assert_eq!(config.applied_patches[1].level, 2);
527 }
528
529 #[test]
530 fn test_get_current_version() {
531 let mut config = VersionConfig::new();
532
533 let version = config.get_current_version().unwrap();
535 assert_eq!(version.build, 0);
536
537 config.apply_patch("patch-0.0.1".to_string());
539 let version = config.get_current_version().unwrap();
540 assert_eq!(version.build, 1);
541 }
542
543 #[test]
544 fn test_backward_compatibility() {
545 let old_config = VersionConfig {
547 docker_service: "0.0.13".to_string(),
548 patch_version: String::new(),
549 local_patch_level: 0,
550 full_version_with_patches: String::new(),
551 last_full_upgrade: None,
552 last_patch_upgrade: None,
553 applied_patches: Vec::new(),
554 };
555
556 assert!(old_config.needs_migration());
557
558 let version = old_config.get_current_version().unwrap();
559 assert_eq!(version.to_string(), "0.0.13.0");
560 }
561
562 #[test]
563 fn test_migration() {
564 let mut config = VersionConfig {
565 docker_service: "0.0.13".to_string(),
566 patch_version: String::new(),
567 local_patch_level: 2,
568 full_version_with_patches: String::new(),
569 last_full_upgrade: None,
570 last_patch_upgrade: None,
571 applied_patches: Vec::new(),
572 };
573
574 assert!(config.needs_migration());
575
576 config.migrate().unwrap();
577
578 assert!(!config.needs_migration());
579 assert_eq!(config.full_version_with_patches, "0.0.13.2");
580 }
581
582 #[test]
583 fn test_validation() {
584 let mut config = VersionConfig::new();
585
586 assert!(config.validate().is_ok());
588
589 config.docker_service = String::new();
591 assert!(config.validate().is_err());
592
593 config.docker_service = "0.0.13".to_string();
595 config.full_version_with_patches = "invalid.version".to_string();
596 assert!(config.validate().is_err());
597 }
598
599 #[test]
600 fn test_rollback_last_patch() {
601 let mut config = VersionConfig::new();
602
603 assert!(config.rollback_last_patch().unwrap().is_none());
605
606 config.apply_patch("patch-1".to_string());
608 config.apply_patch("patch-2".to_string());
609 assert_eq!(config.local_patch_level, 2);
610 assert_eq!(config.applied_patches.len(), 2);
611
612 let rolled_back = config.rollback_last_patch().unwrap();
614 assert!(rolled_back.is_some());
615 assert_eq!(rolled_back.unwrap().version, "patch-2");
616 assert_eq!(config.local_patch_level, 1);
617 assert_eq!(config.applied_patches.len(), 1);
618 assert_eq!(config.patch_version, "patch-1");
619
620 let rolled_back = config.rollback_last_patch().unwrap();
622 assert!(rolled_back.is_some());
623 assert_eq!(rolled_back.unwrap().version, "patch-1");
624 assert_eq!(config.local_patch_level, 0);
625 assert_eq!(config.applied_patches.len(), 0);
626 assert_eq!(config.patch_version, "0.0.0");
627 }
628
629 #[test]
630 fn test_patch_summary() {
631 let mut config = VersionConfig::new();
632
633 let summary = config.get_patch_summary();
635 assert!(summary.contains("无补丁"));
636
637 config.apply_patch("patch-1".to_string());
639 config.apply_patch("patch-2".to_string());
640 let summary = config.get_patch_summary();
641 assert!(summary.contains("已应用2个补丁"));
642 assert!(summary.contains("当前级别: 2"));
643 }
644
645 #[test]
646 fn test_serde_compatibility() {
647 let mut config = VersionConfig::new();
649 config.apply_patch("test-patch".to_string());
650
651 let serialized = toml::to_string(&config).unwrap();
653
654 let deserialized: VersionConfig = toml::from_str(&serialized).unwrap();
656
657 assert_eq!(config.docker_service, deserialized.docker_service);
658 assert_eq!(config.patch_version, deserialized.patch_version);
659 assert_eq!(config.local_patch_level, deserialized.local_patch_level);
660 assert_eq!(
661 config.full_version_with_patches,
662 deserialized.full_version_with_patches
663 );
664 assert_eq!(
665 config.applied_patches.len(),
666 deserialized.applied_patches.len()
667 );
668 }
669
670 #[test]
672 fn test_task_1_3_acceptance_criteria() {
673 let mut config = VersionConfig::new();
675 assert!(!config.docker_service.is_empty());
676 assert!(config.patch_version.is_empty() || config.patch_version == "0.0.0");
677
678 config.update_full_version("0.0.14".to_string());
680 assert_eq!(config.full_version_with_patches, "0.0.14.0");
681
682 config.apply_patch("0.0.1".to_string());
684 assert_eq!(config.full_version_with_patches, "0.0.14.1");
685
686 let version = config.get_current_version().unwrap();
688 assert_eq!(version.to_string(), "0.0.14.1");
689
690 let old_config = VersionConfig {
692 docker_service: "0.0.13".to_string(),
693 patch_version: String::new(),
694 local_patch_level: 0,
695 full_version_with_patches: String::new(),
696 last_full_upgrade: None,
697 last_patch_upgrade: None,
698 applied_patches: Vec::new(),
699 };
700 assert!(old_config.needs_migration());
701
702 println!("✅ Task 1.3: 配置文件结构扩展 - 验收标准测试通过");
703 println!(" - ✅ VersionConfig结构体扩展完成");
704 println!(" - ✅ update_full_version方法正常工作");
705 println!(" - ✅ apply_patch方法正常工作");
706 println!(" - ✅ get_current_version方法正常工作");
707 println!(" - ✅ 配置迁移逻辑(向后兼容)正常工作");
708 }
709
710 #[test]
712 fn test_find_archive_file() {
713 let temp_dir = TempDir::new().unwrap();
714 let dir = temp_dir.path();
715
716 File::create(dir.join("docker-aarch64.zip")).unwrap();
718 File::create(dir.join("docker-x86_64.tar.gz")).unwrap();
719 File::create(dir.join("other.txt")).unwrap();
720 File::create(dir.join("archive.zip")).unwrap(); let result = find_archive_file(dir);
724 assert_eq!(result, Some("docker-aarch64.zip".to_string()));
725 }
726
727 #[test]
728 fn test_find_archive_file_no_match() {
729 let temp_dir = TempDir::new().unwrap();
730 let dir = temp_dir.path();
731
732 File::create(dir.join("archive.zip")).unwrap();
734 File::create(dir.join("other.txt")).unwrap();
735
736 let result = find_archive_file(dir);
738 assert!(result.is_none());
739 }
740
741 #[test]
742 fn test_find_archive_file_empty_dir() {
743 let temp_dir = TempDir::new().unwrap();
744 let dir = temp_dir.path();
745
746 let result = find_archive_file(dir);
748 assert!(result.is_none());
749 }
750
751 #[test]
752 fn test_find_archive_file_tar_gz() {
753 let temp_dir = TempDir::new().unwrap();
754 let dir = temp_dir.path();
755
756 File::create(dir.join("docker-aarch64.tar.gz")).unwrap();
758
759 let result = find_archive_file(dir);
760 assert_eq!(result, Some("docker-aarch64.tar.gz".to_string()));
761 }
762}