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