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