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