Skip to main content

cadmus_core/settings/
versioned.rs

1//! Settings versioning and migration support.
2//!
3//! This module provides infrastructure for maintaining backward and forward
4//! compatibility across application versions. Settings are stored in a
5//! versioned directory structure with a manifest file tracking all versions.
6//!
7//! # Directory Structure
8//!
9//! ```text
10//! Settings/
11//! ├── .cadmus-index.toml        # Manifest file with version metadata
12//! ├── Settings-v0.1.2.toml      # Version-specific settings files
13//! ├── Settings-v0.1.3-5-gabc123.toml
14//! └── Settings-v0.2.0.toml
15//! ```
16//!
17//! # Migration Strategy
18//!
19//! When the application loads:
20//! 1. Check for legacy `Settings.toml` in the root directory
21//! 2. If it exists, migrate it to the versioned system and delete the old file
22//! 3. Read the manifest to find the most recent version
23//! 4. Load that version's settings file
24//! 5. If the current version differs, copy to new version file
25//!
26//! When the application saves:
27//! 1. Write to the current version file
28//! 2. Update manifest metadata
29//! 3. Remove old files exceeding retention limit
30use 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/// Metadata for a settings file version in the manifest.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct SettingsEntry {
44    /// The version (e.g., v0.1.2 or v0.1.3-5-gabc123).
45    pub version: GitVersion,
46    /// UUID v7 from the build that created this entry (timestamp-sortable).
47    pub uuid: String,
48    /// Path to the settings file (relative to Settings directory).
49    pub file: String,
50    /// When this settings file was last saved.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub saved_at: Option<String>,
53}
54
55/// Manifest file that tracks all settings versions.
56#[derive(Debug, Serialize, Deserialize, Default)]
57pub struct SettingsManifest {
58    /// All known settings versions, in order.
59    #[serde(default)]
60    pub entries: Vec<SettingsEntry>,
61}
62
63/// Manages versioned settings files and migrations.
64#[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    /// Creates a new settings manager.
75    ///
76    /// # Arguments
77    ///
78    /// * `current_version` - The current application version (from `get_current_version()`)
79    ///
80    /// The build UUID is automatically obtained from the compile-time `BUILD_UUID`
81    /// environment variable set by the core crate's build script.
82    ///
83    /// # Example
84    ///
85    /// ```no_run
86    /// use cadmus_core::settings::versioned::SettingsManager;
87    /// use cadmus_core::version::get_current_version;
88    ///
89    /// let manager = SettingsManager::new(get_current_version());
90    /// ```
91    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    /// Loads settings from the versioned storage, migrating if necessary.
106    ///
107    /// This function is designed to be maximally resilient:
108    /// 1. Creates Settings directory if it doesn't exist
109    /// 2. Attempts to migrate legacy Settings.toml if it exists (non-fatal if it fails)
110    /// 3. Reads the manifest to find the appropriate settings file
111    /// 4. Loads and deserializes the settings
112    ///
113    /// The manifest is searched for an entry matching the current version.
114    /// If no exact match exists, the entry with the most recent UUID is used.
115    /// If the manifest is empty, default settings are returned.
116    ///
117    /// # Returns
118    ///
119    /// Returns `Settings` in all cases:
120    /// - Loaded from versioned file if available
121    /// - Loaded from most recent version if exact match not found
122    /// - Default settings if no versions exist or all file reads fail
123    ///
124    /// Never fails - returns defaults as ultimate fallback.
125    ///
126    /// # Diagnostic Output
127    ///
128    /// This function uses `println!` and `eprintln!` for diagnostic messages
129    /// instead of `tracing::*` macros because logging/tracing is not yet
130    /// configured at this point in the app startup sequence. Tracing is
131    /// configured *after* settings are loaded, so using tracing macros here
132    /// would result in messages being silently dropped or not properly routed.
133    #[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    /// Saves settings to a versioned file and updates the manifest.
199    ///
200    /// This function:
201    /// 1. Creates a new `Settings-<version>.toml` file
202    /// 2. Updates the manifest with new entry
203    /// 3. Removes old files exceeding retention limit
204    ///
205    /// # Arguments
206    ///
207    /// * `settings` - The settings to save
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if:
212    /// - Settings file cannot be written
213    /// - Manifest cannot be updated
214    /// - Old files cannot be removed
215    #[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    /// Migrates legacy Settings.toml from the root directory to the new versioned format.
252    ///
253    /// This method is automatically called during `load()` to handle upgrades from the
254    /// old settings system. If a legacy `Settings.toml` file exists in the application's
255    /// root directory, it is:
256    ///
257    /// 1. Loaded with all existing settings preserved
258    /// 2. Saved to the new versioned location: `Settings/Settings-v<version>.toml`
259    /// 3. Registered in the manifest as a historical entry
260    /// 4. Deleted to prevent accidental duplication
261    ///
262    /// # Behavior
263    ///
264    /// This method is fully non-fatal:
265    /// - If the legacy file doesn't exist, returns silently (success)
266    /// - If the legacy file can't be read, logs a warning and returns (failure is acceptable)
267    /// - If any write operation fails (save, manifest update, deletion), logs a warning
268    ///   but continues - the important part is reading the legacy settings, not cleanup
269    ///
270    /// Never propagates errors because migration is opportunistic, not required for
271    /// the app to function.
272    ///
273    /// # Diagnostic Output
274    ///
275    /// This function uses `println!` and `eprintln!` for diagnostic messages
276    /// instead of `tracing::*` macros because logging/tracing is not yet
277    /// configured during the settings loading phase (called from `load()`).
278    /// Tracing is initialized *after* settings are fully loaded, so any
279    /// tracing calls here would be silently discarded.
280    #[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    /// Reads the settings manifest from disk.
357    ///
358    /// The manifest file (`.cadmus-index.toml`) tracks all known settings versions
359    /// and their metadata. This method loads the current manifest or returns a default
360    /// empty manifest if the file doesn't exist.
361    ///
362    /// # Returns
363    ///
364    /// `Ok(SettingsManifest)` containing:
365    /// - All known settings file entries in order
366    /// - Version information and timestamps for each entry
367    ///
368    /// `Err` if the manifest file exists but cannot be read or parsed.
369    #[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    /// Writes the settings manifest to disk.
380    ///
381    /// Persists the manifest file (`.cadmus-index.toml`) with all settings version
382    /// entries and their metadata. This is called after any changes to the manifest
383    /// (migration, version updates, cleanup).
384    ///
385    /// # Arguments
386    ///
387    /// * `manifest` - The manifest to write to disk
388    ///
389    /// # Returns
390    ///
391    /// `Ok(())` if the manifest was successfully written.
392    ///
393    /// `Err` if the manifest file cannot be written or serialized.
394    #[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    /// Updates the manifest with a new settings version entry and cleans up old files.
401    ///
402    /// This is called during `save()` when settings are persisted to a new versioned file.
403    /// Combines manifest update and cleanup into a single I/O pass to minimize filesystem
404    /// operations on embedded devices with slow storage.
405    ///
406    /// The process:
407    /// 1. Reads the current manifest (single read)
408    /// 2. Creates a new entry for the current version with timestamp
409    /// 3. Removes any existing entry for the same version (deduplication)
410    /// 4. Appends the new entry
411    /// 5. Partitions entries to protect current version from cleanup
412    /// 6. Removes old files exceeding retention limit (only from other versions)
413    /// 7. Writes the updated manifest once (single write)
414    ///
415    /// # Data Integrity
416    ///
417    /// The current version's entry is **never removed**, regardless of its UUID.
418    /// This prevents silent data loss during version downgrades where an older build
419    /// UUID would sort to the front and be considered "oldest" for cleanup purposes.
420    ///
421    /// # Arguments
422    ///
423    /// * `filename` - The filename of the new settings file (relative to Settings directory)
424    /// * `settings` - The settings containing the `settings_retention` configuration
425    ///
426    /// # Returns
427    ///
428    /// `Ok(())` if the manifest was successfully updated and written.
429    ///
430    /// `Err` if reading, updating, or writing the manifest fails.
431    #[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        // Verify the settings were updated by loading
655        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        // Create manager for v0.1.0 with an older UUID (smaller timestamp)
670        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(), // Older UUID
675            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        // Create manager for v0.2.0 with a newer UUID (larger timestamp)
685        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(), // Newer UUID
690            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        // Create manager for v0.3.0 with a different UUID (no settings saved)
700        // This should fall back to the most recent settings by UUID (v0.2.0)
701        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(), // Different UUID
706            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        // Create settings for v0.1.0 with selected_library = 1
723        let settings_v1 = Settings {
724            selected_library: 1,
725            ..Settings::default()
726        };
727        manager.save(&settings_v1).unwrap();
728
729        // Create a new manager simulating v0.2.0 with a newer UUID and different settings
730        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        // Load as v0.1.0 - should find exact match and use v0.1.0 settings
738        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}