1use crate::settings::Settings;
31use crate::version::GitVersion;
32use anyhow::{Context, Error};
33use serde::{Deserialize, Serialize};
34use std::fs;
35use std::path::PathBuf;
36
37const SETTINGS_DIR: &str = "Settings";
38const MANIFEST_FILE: &str = ".cadmus-index.toml";
39const LEGACY_SETTINGS_FILE: &str = "Settings.toml";
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct SettingsEntry {
44 pub version: GitVersion,
46 pub uuid: String,
48 pub file: String,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub saved_at: Option<String>,
53}
54
55#[derive(Debug, Serialize, Deserialize, Default)]
57pub struct SettingsManifest {
58 #[serde(default)]
60 pub entries: Vec<SettingsEntry>,
61}
62
63#[derive(Clone)]
65pub struct SettingsManager {
66 settings_dir: PathBuf,
67 manifest_path: PathBuf,
68 current_version: GitVersion,
69 build_uuid: String,
70 root_dir: PathBuf,
71}
72
73impl SettingsManager {
74 pub fn new(root_dir: PathBuf, current_version: GitVersion) -> Self {
98 let settings_dir = root_dir.join(SETTINGS_DIR);
99 let manifest_path = settings_dir.join(MANIFEST_FILE);
100
101 SettingsManager {
102 settings_dir,
103 manifest_path,
104 current_version,
105 build_uuid: env!("BUILD_UUID").to_string(),
106 root_dir,
107 }
108 }
109
110 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret(level = tracing::Level::TRACE)))]
139 pub fn load(&self) -> Settings {
140 if let Err(e) = fs::create_dir_all(&self.settings_dir) {
141 eprintln!("failed to create settings directory: {}; using defaults", e);
142 return Settings::default();
143 }
144
145 self.migrate_legacy_settings();
146
147 let manifest = match self.read_manifest() {
148 Ok(m) => m,
149 Err(e) => {
150 eprintln!("failed to read manifest: {}; using defaults", e);
151 return Settings::default();
152 }
153 };
154
155 let matched_entry = manifest
156 .entries
157 .iter()
158 .find(|e| e.version == self.current_version)
159 .cloned()
160 .or_else(|| {
161 let mut entries: Vec<_> = manifest.entries.clone();
162 entries.sort_by(|a, b| b.uuid.cmp(&a.uuid));
163 entries.first().cloned()
164 });
165
166 match matched_entry {
167 Some(entry) => {
168 println!(
169 "Loading settings from version {} (file: {})",
170 entry.version, entry.file
171 );
172 let file_path = self.settings_dir.join(&entry.file);
173 match crate::helpers::load_toml::<Settings, _>(&file_path) {
174 Ok(mut settings) => {
175 if settings.sanitize() {
176 eprintln!(
177 "some settings value were invalid, they have been cleaned up"
178 );
179 }
180
181 settings
182 }
183 Err(e) => {
184 eprintln!(
185 "failed to load settings file {}: {}; using defaults",
186 file_path.display(),
187 e
188 );
189 Settings::default()
190 }
191 }
192 }
193 None => {
194 println!(
195 "No existing settings found for version {}, using defaults",
196 self.current_version
197 );
198 Settings::default()
199 }
200 }
201 }
202
203 #[cfg_attr(
221 feature = "tracing", tracing::instrument(
222 skip(self, settings),
223 fields(
224 version = %self.current_version,
225 settings_dir = %self.settings_dir.display(),
226 build_uuid = %self.build_uuid
227 ),
228 ret(level = tracing::Level::TRACE)
229 )
230 )]
231 pub fn save(&self, settings: &Settings) -> Result<(), Error> {
232 tracing::debug!(settings_dir = %self.settings_dir.display(), "creating settings directory");
233 fs::create_dir_all(&self.settings_dir).context("failed to create settings directory")?;
234
235 let filename = format!("Settings-{}.toml", self.current_version);
236 let file_path = self.settings_dir.join(&filename);
237
238 tracing::debug!(file_path = %file_path.display(), "saving settings to file");
239 crate::helpers::save_toml(settings, &file_path).context("failed to save settings file")?;
240
241 let file_size = file_path.metadata().ok().map(|m| m.len());
242
243 tracing::info!(
244 version = %self.current_version,
245 file = %filename,
246 file_path = %file_path.display(),
247 file_size = ?file_size,
248 "Saved versioned settings"
249 );
250
251 self.update_manifest_and_cleanup(&filename, settings)?;
252
253 Ok(())
254 }
255
256 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret(level = tracing::Level::TRACE)))]
286 fn migrate_legacy_settings(&self) {
287 let legacy_path = self.root_dir.join(LEGACY_SETTINGS_FILE);
288
289 if !legacy_path.exists() {
290 return;
291 }
292
293 println!(
294 "Migrating legacy settings from {} to versioned format",
295 legacy_path.display()
296 );
297
298 let settings = match crate::helpers::load_toml::<Settings, _>(&legacy_path) {
299 Ok(s) => s,
300 Err(e) => {
301 eprintln!(
302 "failed to load legacy settings file {}: {}; skipping migration",
303 legacy_path.display(),
304 e
305 );
306 return;
307 }
308 };
309
310 let filename = format!("Settings-{}.toml", self.current_version);
311 let file_path = self.settings_dir.join(&filename);
312
313 if let Err(e) = crate::helpers::save_toml(&settings, &file_path) {
314 eprintln!(
315 "Failed to save migrated settings file {}: {}; continuing with legacy",
316 file_path.display(),
317 e
318 );
319 return;
320 }
321
322 let mut manifest = self.read_manifest().unwrap_or_default();
323
324 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
325
326 manifest
327 .entries
328 .retain(|e| e.version != self.current_version);
329
330 let new_entry = SettingsEntry {
331 version: self.current_version.clone(),
332 uuid: self.build_uuid.clone(),
333 file: filename,
334 saved_at: Some(now),
335 };
336
337 manifest.entries.push(new_entry);
338
339 if let Err(e) = self.write_manifest(&manifest) {
340 eprintln!(
341 "Failed to update manifest after migration: {}; continuing",
342 e
343 );
344 }
345
346 if let Err(e) = fs::remove_file(&legacy_path) {
347 eprintln!(
348 "Failed to delete legacy {} after migration: {}; continuing",
349 legacy_path.display(),
350 e
351 );
352 }
353
354 println!(
355 "Successfully migrated legacy settings to version {} (file: {})",
356 self.current_version,
357 file_path.display()
358 );
359 }
360
361 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret(level = tracing::Level::TRACE)))]
375 fn read_manifest(&self) -> Result<SettingsManifest, Error> {
376 if self.manifest_path.exists() {
377 crate::helpers::load_toml::<SettingsManifest, _>(&self.manifest_path)
378 .context("failed to read settings manifest")
379 } else {
380 Ok(SettingsManifest::default())
381 }
382 }
383
384 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, manifest), ret(level = tracing::Level::TRACE)))]
400 fn write_manifest(&self, manifest: &SettingsManifest) -> Result<(), Error> {
401 crate::helpers::save_toml(manifest, &self.manifest_path)
402 .context("failed to write settings manifest")
403 }
404
405 #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, settings), fields(filename = filename), ret(level = tracing::Level::TRACE)))]
437 fn update_manifest_and_cleanup(
438 &self,
439 filename: &str,
440 settings: &Settings,
441 ) -> Result<(), Error> {
442 let mut manifest = self.read_manifest()?;
443 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
444
445 manifest
446 .entries
447 .retain(|e| e.version != self.current_version);
448
449 let new_entry = SettingsEntry {
450 version: self.current_version.clone(),
451 uuid: self.build_uuid.clone(),
452 file: filename.to_string(),
453 saved_at: Some(now),
454 };
455
456 manifest.entries.push(new_entry);
457
458 let retention = settings.settings_retention;
459
460 if retention > 0 && manifest.entries.len() > retention {
461 let (current, mut others): (Vec<_>, Vec<_>) = manifest
462 .entries
463 .drain(..)
464 .partition(|e| e.version == self.current_version);
465
466 others.sort_by(|a, b| a.uuid.cmp(&b.uuid));
467
468 let max_others = retention.saturating_sub(current.len());
469 let entries_to_remove = others.len().saturating_sub(max_others);
470 let candidates: Vec<_> = others.drain(..entries_to_remove).collect();
471
472 for entry in candidates {
473 let file_path = self.settings_dir.join(&entry.file);
474
475 if file_path.exists() {
476 if let Err(e) = fs::remove_file(&file_path) {
477 tracing::warn!(
478 version = %entry.version,
479 file = %entry.file,
480 error = %e,
481 "Failed to remove old settings file, will retry on next cleanup"
482 );
483 others.push(entry);
484 } else {
485 tracing::debug!(
486 version = %entry.version,
487 file = %entry.file,
488 "Removed old settings file"
489 );
490 }
491 }
492 }
493
494 manifest.entries = others;
495 manifest.entries.extend(current);
496 }
497
498 self.write_manifest(&manifest)
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use tempfile::TempDir;
506
507 impl SettingsManager {
508 fn clone_with_version(&self, version: GitVersion) -> Self {
509 SettingsManager {
510 settings_dir: self.settings_dir.clone(),
511 manifest_path: self.manifest_path.clone(),
512 current_version: version,
513 build_uuid: self.build_uuid.clone(),
514 root_dir: self.root_dir.clone(),
515 }
516 }
517 }
518
519 fn create_test_manager(temp_dir: &TempDir) -> SettingsManager {
520 let root_dir = temp_dir.path().to_path_buf();
521 let settings_dir = root_dir.join(SETTINGS_DIR);
522 let manifest_path = settings_dir.join(MANIFEST_FILE);
523
524 SettingsManager {
525 settings_dir,
526 manifest_path,
527 current_version: "v0.1.0".parse::<GitVersion>().unwrap(),
528 build_uuid: "018e1234567890abcdef".to_string(),
529 root_dir,
530 }
531 }
532
533 fn create_test_manager_with_root(temp_dir: &TempDir) -> (SettingsManager, PathBuf) {
534 let manager = create_test_manager(temp_dir);
535 (manager.clone(), manager.root_dir.clone())
536 }
537
538 #[test]
539 fn test_creates_settings_directory() {
540 let temp_dir = TempDir::new().unwrap();
541 let manager = create_test_manager(&temp_dir);
542
543 let settings = manager.load();
544 assert!(manager.settings_dir.exists());
545 assert_eq!(settings.selected_library, 0);
546 }
547
548 #[test]
549 fn test_manifest_is_created_on_save() {
550 let temp_dir = TempDir::new().unwrap();
551 let manager = create_test_manager(&temp_dir);
552
553 let settings = Settings::default();
554 manager.save(&settings).unwrap();
555
556 assert!(manager.manifest_path.exists());
557
558 let manifest = manager.read_manifest().unwrap();
559 assert_eq!(manifest.entries.len(), 1);
560 assert_eq!(manifest.entries[0].version.to_string(), "v0.1.0");
561 }
562
563 #[test]
564 fn test_settings_file_is_created() {
565 let temp_dir = TempDir::new().unwrap();
566 let manager = create_test_manager(&temp_dir);
567
568 let settings = Settings::default();
569 manager.save(&settings).unwrap();
570
571 let expected_file = manager.settings_dir.join("Settings-v0.1.0.toml");
572 assert!(expected_file.exists());
573 }
574
575 #[test]
576 fn test_load_existing_file_same_version() {
577 let temp_dir = TempDir::new().unwrap();
578 let manager = create_test_manager(&temp_dir);
579
580 let settings = manager.load();
581 assert_eq!(settings.selected_library, 0, "Should load defaults");
582
583 let manifest = manager.read_manifest().unwrap();
584 assert!(
585 manifest.entries.is_empty(),
586 "Manifest should be empty with no legacy file"
587 );
588 }
589
590 #[test]
591 fn test_legacy_migration_preserves_manifest_history() {
592 let temp_dir = TempDir::new().unwrap();
593 let (mut manager, root) = create_test_manager_with_root(&temp_dir);
594
595 let legacy_settings = Settings {
596 selected_library: 1,
597 ..Settings::default()
598 };
599 let legacy_path = root.join(LEGACY_SETTINGS_FILE);
600 crate::helpers::save_toml(&legacy_settings, &legacy_path).unwrap();
601
602 let settings = manager.load();
603
604 manager.current_version = "v0.1.1".parse::<GitVersion>().unwrap();
605 manager.save(&settings).unwrap();
606
607 let loaded = manager.load();
608 assert_eq!(
609 loaded.selected_library, 1,
610 "Second load should still work correctly"
611 );
612 }
613
614 #[test]
615 fn test_same_version_multiple_saves_updates_entry() {
616 let temp_dir = TempDir::new().unwrap();
617 let manager = create_test_manager(&temp_dir);
618
619 let mut settings = Settings {
620 selected_library: 1,
621 ..Settings::default()
622 };
623
624 manager.save(&settings).unwrap();
625
626 let file_path = manager
627 .settings_dir
628 .join(format!("Settings-{}.toml", manager.current_version));
629
630 assert!(
631 file_path.exists(),
632 "Settings file should exist after first save"
633 );
634
635 let manifest = manager.read_manifest().unwrap();
636 assert_eq!(
637 manifest.entries.len(),
638 1,
639 "Manifest should have 1 entry after first save"
640 );
641 assert_eq!(manifest.entries[0].version.to_string(), "v0.1.0");
642
643 settings.selected_library = 2;
644 manager.save(&settings).unwrap();
645
646 assert!(
647 file_path.exists(),
648 "Settings file should still exist after second save with same version"
649 );
650
651 let manifest = manager.read_manifest().unwrap();
652 assert_eq!(
653 manifest.entries.len(),
654 1,
655 "Manifest should still have 1 entry (same version replaces previous)"
656 );
657 assert_eq!(manifest.entries[0].version.to_string(), "v0.1.0");
658
659 let loaded = manager.load();
661 assert_eq!(
662 loaded.selected_library, 2,
663 "Settings should reflect the second save"
664 );
665 }
666
667 #[test]
668 fn test_load_falls_back_to_most_recent_by_uuid() {
669 let temp_dir = TempDir::new().unwrap();
670 let root_dir = temp_dir.path().to_path_buf();
671 let settings_dir = root_dir.join(SETTINGS_DIR);
672 let manifest_path = settings_dir.join(MANIFEST_FILE);
673
674 let manager_v1 = SettingsManager {
676 settings_dir: settings_dir.clone(),
677 manifest_path: manifest_path.clone(),
678 current_version: "v0.1.0".parse::<GitVersion>().unwrap(),
679 build_uuid: "018e0000000000000000".to_string(), root_dir: root_dir.clone(),
681 };
682
683 let settings_v1 = Settings {
684 selected_library: 1,
685 ..Settings::default()
686 };
687 manager_v1.save(&settings_v1).unwrap();
688
689 let manager_v2 = SettingsManager {
691 settings_dir: settings_dir.clone(),
692 manifest_path: manifest_path.clone(),
693 current_version: "v0.2.0".parse::<GitVersion>().unwrap(),
694 build_uuid: "018effffffffffffffff".to_string(), root_dir: root_dir.clone(),
696 };
697
698 let settings_v2 = Settings {
699 selected_library: 2,
700 ..Settings::default()
701 };
702 manager_v2.save(&settings_v2).unwrap();
703
704 let manager_v3 = SettingsManager {
707 settings_dir,
708 manifest_path,
709 current_version: "v0.3.0".parse::<GitVersion>().unwrap(),
710 build_uuid: "018eaaaaaaaaaaaaaaaa".to_string(), root_dir,
712 };
713
714 let loaded = manager_v3.load();
715
716 assert_eq!(
717 loaded.selected_library, 2,
718 "v0.3.0 should load settings from v0.2.0 (most recent by UUID)"
719 );
720 }
721
722 #[test]
723 fn test_load_uses_exact_version_match_when_available() {
724 let temp_dir = TempDir::new().unwrap();
725 let manager = create_test_manager(&temp_dir);
726
727 let settings_v1 = Settings {
729 selected_library: 1,
730 ..Settings::default()
731 };
732 manager.save(&settings_v1).unwrap();
733
734 let manager_v2 = manager.clone_with_version("v0.2.0".parse::<GitVersion>().unwrap());
736 let settings_v2 = Settings {
737 selected_library: 2,
738 ..Settings::default()
739 };
740 manager_v2.save(&settings_v2).unwrap();
741
742 let manager_v1_reload = manager.clone_with_version("v0.1.0".parse::<GitVersion>().unwrap());
744 let loaded = manager_v1_reload.load();
745
746 assert_eq!(
747 loaded.selected_library, 1,
748 "v0.1.0 should load its own settings (exact match), not v0.2.0"
749 );
750 }
751
752 #[test]
753 fn test_migration_succeeds_even_if_legacy_deletion_fails() {
754 let temp_dir = TempDir::new().unwrap();
755 let (manager, root) = create_test_manager_with_root(&temp_dir);
756
757 let legacy_settings = Settings {
758 selected_library: 5,
759 ..Settings::default()
760 };
761 let legacy_path = root.join(LEGACY_SETTINGS_FILE);
762 crate::helpers::save_toml(&legacy_settings, &legacy_path).unwrap();
763
764 assert!(legacy_path.exists(), "Legacy settings file should exist");
765
766 let loaded = manager.load();
767
768 assert_eq!(
769 loaded.selected_library, 5,
770 "Migration should succeed and load settings even if deletion fails"
771 );
772
773 let versioned_file = manager.settings_dir.join("Settings-v0.1.0.toml");
774 assert!(
775 versioned_file.exists(),
776 "Versioned settings file should be created"
777 );
778
779 let manifest = manager.read_manifest().unwrap();
780 assert_eq!(
781 manifest.entries.len(),
782 1,
783 "Manifest should have migrated entry"
784 );
785 assert_eq!(manifest.entries[0].version.to_string(), "v0.1.0");
786 }
787
788 #[test]
789 fn test_load_returns_defaults_on_all_failures() {
790 let temp_dir = TempDir::new().unwrap();
791 let manager = create_test_manager(&temp_dir);
792
793 let loaded = manager.load();
794
795 assert_eq!(loaded.selected_library, 0, "Should return defaults");
796 assert_eq!(
797 loaded.keyboard_layout, "English",
798 "Should return default keyboard layout"
799 );
800 }
801
802 #[test]
803 fn test_migration_deduplicated_on_retry() {
804 let temp_dir = TempDir::new().unwrap();
805 let (manager, root) = create_test_manager_with_root(&temp_dir);
806
807 let legacy_settings = Settings {
808 selected_library: 3,
809 ..Settings::default()
810 };
811 let legacy_path = root.join(LEGACY_SETTINGS_FILE);
812 crate::helpers::save_toml(&legacy_settings, &legacy_path).unwrap();
813
814 let loaded1 = manager.load();
815 assert_eq!(
816 loaded1.selected_library, 3,
817 "First load should get legacy settings"
818 );
819
820 let manifest = manager.read_manifest().unwrap();
821 let first_entry_count = manifest.entries.len();
822
823 let loaded2 = manager.load();
824 assert_eq!(
825 loaded2.selected_library, 3,
826 "Second load should still get correct settings"
827 );
828
829 let manifest = manager.read_manifest().unwrap();
830 assert_eq!(
831 manifest.entries.len(),
832 first_entry_count,
833 "Manifest should not have duplicates on retry"
834 );
835 }
836
837 #[test]
838 fn test_save_and_load_roundtrip() {
839 let temp_dir = TempDir::new().unwrap();
840 let manager = create_test_manager(&temp_dir);
841
842 let settings = Settings {
843 selected_library: 5,
844 inverted: true,
845 ..Settings::default()
846 };
847
848 manager.save(&settings).unwrap();
849
850 let loaded = manager.load();
851 assert_eq!(
852 loaded.selected_library, 5,
853 "Should save and load selected_library"
854 );
855 assert!(loaded.inverted, "Should save and load inverted");
856 }
857
858 #[test]
859 fn test_load_sanitizes_unsupported_calendar_intermissions() {
860 let temp_dir = TempDir::new().unwrap();
861 let manager = create_test_manager(&temp_dir);
862 let mut settings = Settings::default();
863
864 settings.intermissions[crate::settings::IntermKind::PowerOff] =
865 crate::settings::IntermissionDisplay::Calendar;
866 settings.intermissions[crate::settings::IntermKind::Share] =
867 crate::settings::IntermissionDisplay::Calendar;
868
869 manager.save(&settings).unwrap();
870
871 let loaded = manager.load();
872
873 assert_eq!(
874 loaded.intermissions[crate::settings::IntermKind::PowerOff],
875 crate::settings::IntermissionDisplay::Logo
876 );
877 assert_eq!(
878 loaded.intermissions[crate::settings::IntermKind::Share],
879 crate::settings::IntermissionDisplay::Logo
880 );
881 }
882
883 #[test]
884 fn test_retention_cleanup_removes_oldest_by_uuid() {
885 let temp_dir = TempDir::new().unwrap();
886 let (manager, root) = create_test_manager_with_root(&temp_dir);
887
888 let settings = Settings {
889 settings_retention: 2,
890 ..Settings::default()
891 };
892
893 let managers = [
894 ("v0.1.0", "018e0000000000000000"),
895 ("v0.1.1", "018e5555555555555555"),
896 ("v0.1.2", "018effffffffffffffff"),
897 ];
898
899 for (version, uuid) in managers {
900 let mgr = SettingsManager {
901 settings_dir: manager.settings_dir.clone(),
902 manifest_path: manager.manifest_path.clone(),
903 current_version: version.parse::<GitVersion>().unwrap(),
904 build_uuid: uuid.to_string(),
905 root_dir: root.clone(),
906 };
907
908 mgr.save(&settings).unwrap();
909 }
910
911 let manifest_path = manager.manifest_path.clone();
912 let manifest_content = std::fs::read_to_string(&manifest_path).unwrap();
913
914 assert!(
915 manifest_content.contains("v0.1.1"),
916 "Oldest entry (v0.1.0) should be removed, v0.1.1 should remain"
917 );
918 assert!(
919 manifest_content.contains("v0.1.2"),
920 "Newest entry (v0.1.2) should be kept"
921 );
922 assert!(
923 !manifest_content.contains("018e0000000000000000"),
924 "Settings file for oldest UUID should be deleted"
925 );
926 }
927
928 #[test]
929 fn test_retention_cleanup_protects_current_version_during_downgrade() {
930 let temp_dir = TempDir::new().unwrap();
931 let (manager, root) = create_test_manager_with_root(&temp_dir);
932
933 let settings = Settings {
934 settings_retention: 2,
935 ..Settings::default()
936 };
937
938 let managers = [
939 ("v0.2.0", "018f0000000000000000"),
940 ("v0.3.0", "018f1111111111111111"),
941 ];
942
943 for (version, uuid) in managers {
944 let mgr = SettingsManager {
945 settings_dir: manager.settings_dir.clone(),
946 manifest_path: manager.manifest_path.clone(),
947 current_version: version.parse::<GitVersion>().unwrap(),
948 build_uuid: uuid.to_string(),
949 root_dir: root.clone(),
950 };
951
952 mgr.save(&settings).unwrap();
953 }
954
955 let downgrade_mgr = SettingsManager {
956 settings_dir: manager.settings_dir.clone(),
957 manifest_path: manager.manifest_path.clone(),
958 current_version: "v0.1.0".parse::<GitVersion>().unwrap(),
959 build_uuid: "018e0000000000000000".to_string(),
960 root_dir: root.clone(),
961 };
962
963 downgrade_mgr.save(&settings).unwrap();
964
965 let manifest = downgrade_mgr.read_manifest().unwrap();
966
967 assert_eq!(
968 manifest.entries.len(),
969 2,
970 "Manifest should have 2 entries (retention=2, oldest v0.2.0 should be removed)"
971 );
972
973 let current_entry = manifest
974 .entries
975 .iter()
976 .find(|e| e.version.to_string() == "v0.1.0")
977 .expect("Current version v0.1.0 must be in manifest");
978
979 assert_eq!(current_entry.uuid, "018e0000000000000000");
980
981 let remaining_versions: Vec<String> = manifest
982 .entries
983 .iter()
984 .map(|e| e.version.to_string())
985 .collect();
986
987 assert!(
988 remaining_versions.contains(&"v0.1.0".to_string()),
989 "Current version v0.1.0 must be protected from deletion"
990 );
991 assert!(
992 remaining_versions.contains(&"v0.3.0".to_string()),
993 "Newer version v0.3.0 should be kept (less old than v0.2.0)"
994 );
995 assert!(
996 !remaining_versions.contains(&"v0.2.0".to_string()),
997 "Oldest non-current version v0.2.0 should be removed"
998 );
999
1000 let v010_file = downgrade_mgr.settings_dir.join("Settings-v0.1.0.toml");
1001 assert!(
1002 v010_file.exists(),
1003 "Current version file Settings-v0.1.0.toml must not be deleted"
1004 );
1005
1006 let v020_file = downgrade_mgr.settings_dir.join("Settings-v0.2.0.toml");
1007 assert!(
1008 !v020_file.exists(),
1009 "Oldest version file Settings-v0.2.0.toml should be deleted"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_retention_cleanup_continues_on_file_removal_failure() {
1015 let temp_dir = TempDir::new().unwrap();
1016 let (manager, root) = create_test_manager_with_root(&temp_dir);
1017
1018 let settings = Settings {
1019 settings_retention: 2,
1020 ..Settings::default()
1021 };
1022
1023 let managers = [
1024 ("v0.1.0", "018e0000000000000000"),
1025 ("v0.1.1", "018e5555555555555555"),
1026 ("v0.1.2", "018effffffffffffffff"),
1027 ];
1028
1029 for (version, uuid) in managers {
1030 let mgr = SettingsManager {
1031 settings_dir: manager.settings_dir.clone(),
1032 manifest_path: manager.manifest_path.clone(),
1033 current_version: version.parse::<GitVersion>().unwrap(),
1034 build_uuid: uuid.to_string(),
1035 root_dir: root.clone(),
1036 };
1037
1038 mgr.save(&settings).unwrap();
1039 }
1040
1041 let manifest = manager.read_manifest().unwrap();
1042
1043 assert_eq!(
1044 manifest.entries.len(),
1045 2,
1046 "Manifest should have 2 entries (retention=2)"
1047 );
1048
1049 let versions: Vec<String> = manifest
1050 .entries
1051 .iter()
1052 .map(|e| e.version.to_string())
1053 .collect();
1054
1055 assert!(
1056 versions.contains(&"v0.1.1".to_string()),
1057 "Entry for v0.1.1 should be in manifest"
1058 );
1059 assert!(
1060 versions.contains(&"v0.1.2".to_string()),
1061 "Entry for v0.1.2 should be in manifest"
1062 );
1063 assert!(
1064 !versions.contains(&"v0.1.0".to_string()),
1065 "Entry for v0.1.0 should be removed"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_save_succeeds_even_if_cleanup_cant_remove_files() {
1071 let temp_dir = TempDir::new().unwrap();
1072 let (manager, root) = create_test_manager_with_root(&temp_dir);
1073
1074 let settings = Settings {
1075 settings_retention: 1,
1076 ..Settings::default()
1077 };
1078
1079 let v1_mgr = SettingsManager {
1080 settings_dir: manager.settings_dir.clone(),
1081 manifest_path: manager.manifest_path.clone(),
1082 current_version: "v0.1.0".parse::<GitVersion>().unwrap(),
1083 build_uuid: "018e0000000000000000".to_string(),
1084 root_dir: root.clone(),
1085 };
1086
1087 v1_mgr.save(&settings).unwrap();
1088
1089 let v2_mgr = SettingsManager {
1090 settings_dir: manager.settings_dir.clone(),
1091 manifest_path: manager.manifest_path.clone(),
1092 current_version: "v0.1.1".parse::<GitVersion>().unwrap(),
1093 build_uuid: "018e5555555555555555".to_string(),
1094 root_dir: root.clone(),
1095 };
1096
1097 v2_mgr.save(&settings).unwrap();
1098
1099 let manifest = v2_mgr.read_manifest().unwrap();
1100 assert_eq!(
1101 manifest.entries.len(),
1102 1,
1103 "Should keep only 1 entry with retention=1"
1104 );
1105 assert_eq!(
1106 manifest.entries[0].version.to_string(),
1107 "v0.1.1",
1108 "Should keep the current (newest) version"
1109 );
1110
1111 let v3_mgr = SettingsManager {
1112 settings_dir: manager.settings_dir.clone(),
1113 manifest_path: manager.manifest_path.clone(),
1114 current_version: "v0.1.2".parse::<GitVersion>().unwrap(),
1115 build_uuid: "018effffffffffffffff".to_string(),
1116 root_dir: root.clone(),
1117 };
1118
1119 let save_result = v3_mgr.save(&settings);
1120
1121 assert!(
1122 save_result.is_ok(),
1123 "save() should succeed even if file removal fails"
1124 );
1125
1126 let manifest_final = v3_mgr.read_manifest().unwrap();
1127 assert_eq!(
1128 manifest_final.entries.len(),
1129 1,
1130 "Manifest should be updated and written"
1131 );
1132 assert_eq!(
1133 manifest_final.entries[0].version.to_string(),
1134 "v0.1.2",
1135 "Current version should be in manifest"
1136 );
1137 }
1138}