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