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 rooted at `root_dir`.
75    ///
76    /// # Arguments
77    ///
78    /// * `root_dir` - The directory that contains the `Settings/` subdirectory
79    ///   and the legacy `Settings.toml` file. Pass
80    ///   [`Device::data_dir()`](crate::device::Device::data_dir) so that
81    ///   versioned settings are stored on the SD card when one is present.
82    /// * `current_version` - The current application version (from
83    ///   `get_current_version()`)
84    ///
85    /// The build UUID is automatically obtained from the compile-time `BUILD_UUID`
86    /// environment variable set by the core crate's build script.
87    ///
88    /// # Example
89    ///
90    /// ```no_run
91    /// use cadmus_core::device::CURRENT_DEVICE;
92    /// use cadmus_core::settings::versioned::SettingsManager;
93    /// use cadmus_core::version::get_current_version;
94    ///
95    /// let manager = SettingsManager::new(CURRENT_DEVICE.data_dir(), get_current_version());
96    /// ```
97    pub fn new(root_dir: PathBuf, current_version: GitVersion) -> Self {
98        let settings_dir = root_dir.join(SETTINGS_DIR);
99        let manifest_path = settings_dir.join(MANIFEST_FILE);
100
101        SettingsManager {
102            settings_dir,
103            manifest_path,
104            current_version,
105            build_uuid: env!("BUILD_UUID").to_string(),
106            root_dir,
107        }
108    }
109
110    /// Loads settings from the versioned storage, migrating if necessary.
111    ///
112    /// This function is designed to be maximally resilient:
113    /// 1. Creates Settings directory if it doesn't exist
114    /// 2. Attempts to migrate legacy Settings.toml if it exists (non-fatal if it fails)
115    /// 3. Reads the manifest to find the appropriate settings file
116    /// 4. Loads and deserializes the settings
117    ///
118    /// The manifest is searched for an entry matching the current version.
119    /// If no exact match exists, the entry with the most recent UUID is used.
120    /// If the manifest is empty, default settings are returned.
121    ///
122    /// # Returns
123    ///
124    /// Returns `Settings` in all cases:
125    /// - Loaded from versioned file if available
126    /// - Loaded from most recent version if exact match not found
127    /// - Default settings if no versions exist or all file reads fail
128    ///
129    /// Never fails - returns defaults as ultimate fallback.
130    ///
131    /// # Diagnostic Output
132    ///
133    /// This function uses `println!` and `eprintln!` for diagnostic messages
134    /// instead of `tracing::*` macros because logging/tracing is not yet
135    /// configured at this point in the app startup sequence. Tracing is
136    /// configured *after* settings are loaded, so using tracing macros here
137    /// would result in messages being silently dropped or not properly routed.
138    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret(level = tracing::Level::TRACE)))]
139    pub fn load(&self) -> Settings {
140        if let Err(e) = fs::create_dir_all(&self.settings_dir) {
141            eprintln!("failed to create settings directory: {}; using defaults", e);
142            return Settings::default();
143        }
144
145        self.migrate_legacy_settings();
146
147        let manifest = match self.read_manifest() {
148            Ok(m) => m,
149            Err(e) => {
150                eprintln!("failed to read manifest: {}; using defaults", e);
151                return Settings::default();
152            }
153        };
154
155        let matched_entry = manifest
156            .entries
157            .iter()
158            .find(|e| e.version == self.current_version)
159            .cloned()
160            .or_else(|| {
161                let mut entries: Vec<_> = manifest.entries.clone();
162                entries.sort_by(|a, b| b.uuid.cmp(&a.uuid));
163                entries.first().cloned()
164            });
165
166        match matched_entry {
167            Some(entry) => {
168                println!(
169                    "Loading settings from version {} (file: {})",
170                    entry.version, entry.file
171                );
172                let file_path = self.settings_dir.join(&entry.file);
173                match crate::helpers::load_toml::<Settings, _>(&file_path) {
174                    Ok(mut settings) => {
175                        if settings.sanitize() {
176                            eprintln!(
177                                "some settings value were invalid, they have been cleaned up"
178                            );
179                        }
180
181                        settings
182                    }
183                    Err(e) => {
184                        eprintln!(
185                            "failed to load settings file {}: {}; using defaults",
186                            file_path.display(),
187                            e
188                        );
189                        Settings::default()
190                    }
191                }
192            }
193            None => {
194                println!(
195                    "No existing settings found for version {}, using defaults",
196                    self.current_version
197                );
198                Settings::default()
199            }
200        }
201    }
202
203    /// Saves settings to a versioned file and updates the manifest.
204    ///
205    /// This function:
206    /// 1. Creates a new `Settings-<version>.toml` file
207    /// 2. Updates the manifest with new entry
208    /// 3. Removes old files exceeding retention limit
209    ///
210    /// # Arguments
211    ///
212    /// * `settings` - The settings to save
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if:
217    /// - Settings file cannot be written
218    /// - Manifest cannot be updated
219    /// - Old files cannot be removed
220    #[cfg_attr(
221        feature = "tracing", tracing::instrument(
222            skip(self, settings),
223            fields(
224                version = %self.current_version,
225                settings_dir = %self.settings_dir.display(),
226                build_uuid = %self.build_uuid
227            ),
228            ret(level = tracing::Level::TRACE)
229        )
230    )]
231    pub fn save(&self, settings: &Settings) -> Result<(), Error> {
232        tracing::debug!(settings_dir = %self.settings_dir.display(), "creating settings directory");
233        fs::create_dir_all(&self.settings_dir).context("failed to create settings directory")?;
234
235        let filename = format!("Settings-{}.toml", self.current_version);
236        let file_path = self.settings_dir.join(&filename);
237
238        tracing::debug!(file_path = %file_path.display(), "saving settings to file");
239        crate::helpers::save_toml(settings, &file_path).context("failed to save settings file")?;
240
241        let file_size = file_path.metadata().ok().map(|m| m.len());
242
243        tracing::info!(
244            version = %self.current_version,
245            file = %filename,
246            file_path = %file_path.display(),
247            file_size = ?file_size,
248            "Saved versioned settings"
249        );
250
251        self.update_manifest_and_cleanup(&filename, settings)?;
252
253        Ok(())
254    }
255
256    /// Migrates legacy Settings.toml from the root directory to the new versioned format.
257    ///
258    /// This method is automatically called during `load()` to handle upgrades from the
259    /// old settings system. If a legacy `Settings.toml` file exists in the application's
260    /// root directory, it is:
261    ///
262    /// 1. Loaded with all existing settings preserved
263    /// 2. Saved to the new versioned location: `Settings/Settings-v<version>.toml`
264    /// 3. Registered in the manifest as a historical entry
265    /// 4. Deleted to prevent accidental duplication
266    ///
267    /// # Behavior
268    ///
269    /// This method is fully non-fatal:
270    /// - If the legacy file doesn't exist, returns silently (success)
271    /// - If the legacy file can't be read, logs a warning and returns (failure is acceptable)
272    /// - If any write operation fails (save, manifest update, deletion), logs a warning
273    ///   but continues - the important part is reading the legacy settings, not cleanup
274    ///
275    /// Never propagates errors because migration is opportunistic, not required for
276    /// the app to function.
277    ///
278    /// # Diagnostic Output
279    ///
280    /// This function uses `println!` and `eprintln!` for diagnostic messages
281    /// instead of `tracing::*` macros because logging/tracing is not yet
282    /// configured during the settings loading phase (called from `load()`).
283    /// Tracing is initialized *after* settings are fully loaded, so any
284    /// tracing calls here would be silently discarded.
285    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret(level = tracing::Level::TRACE)))]
286    fn migrate_legacy_settings(&self) {
287        let legacy_path = self.root_dir.join(LEGACY_SETTINGS_FILE);
288
289        if !legacy_path.exists() {
290            return;
291        }
292
293        println!(
294            "Migrating legacy settings from {} to versioned format",
295            legacy_path.display()
296        );
297
298        let settings = match crate::helpers::load_toml::<Settings, _>(&legacy_path) {
299            Ok(s) => s,
300            Err(e) => {
301                eprintln!(
302                    "failed to load legacy settings file {}: {}; skipping migration",
303                    legacy_path.display(),
304                    e
305                );
306                return;
307            }
308        };
309
310        let filename = format!("Settings-{}.toml", self.current_version);
311        let file_path = self.settings_dir.join(&filename);
312
313        if let Err(e) = crate::helpers::save_toml(&settings, &file_path) {
314            eprintln!(
315                "Failed to save migrated settings file {}: {}; continuing with legacy",
316                file_path.display(),
317                e
318            );
319            return;
320        }
321
322        let mut manifest = self.read_manifest().unwrap_or_default();
323
324        let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
325
326        manifest
327            .entries
328            .retain(|e| e.version != self.current_version);
329
330        let new_entry = SettingsEntry {
331            version: self.current_version.clone(),
332            uuid: self.build_uuid.clone(),
333            file: filename,
334            saved_at: Some(now),
335        };
336
337        manifest.entries.push(new_entry);
338
339        if let Err(e) = self.write_manifest(&manifest) {
340            eprintln!(
341                "Failed to update manifest after migration: {}; continuing",
342                e
343            );
344        }
345
346        if let Err(e) = fs::remove_file(&legacy_path) {
347            eprintln!(
348                "Failed to delete legacy {} after migration: {}; continuing",
349                legacy_path.display(),
350                e
351            );
352        }
353
354        println!(
355            "Successfully migrated legacy settings to version {} (file: {})",
356            self.current_version,
357            file_path.display()
358        );
359    }
360
361    /// Reads the settings manifest from disk.
362    ///
363    /// The manifest file (`.cadmus-index.toml`) tracks all known settings versions
364    /// and their metadata. This method loads the current manifest or returns a default
365    /// empty manifest if the file doesn't exist.
366    ///
367    /// # Returns
368    ///
369    /// `Ok(SettingsManifest)` containing:
370    /// - All known settings file entries in order
371    /// - Version information and timestamps for each entry
372    ///
373    /// `Err` if the manifest file exists but cannot be read or parsed.
374    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret(level = tracing::Level::TRACE)))]
375    fn read_manifest(&self) -> Result<SettingsManifest, Error> {
376        if self.manifest_path.exists() {
377            crate::helpers::load_toml::<SettingsManifest, _>(&self.manifest_path)
378                .context("failed to read settings manifest")
379        } else {
380            Ok(SettingsManifest::default())
381        }
382    }
383
384    /// Writes the settings manifest to disk.
385    ///
386    /// Persists the manifest file (`.cadmus-index.toml`) with all settings version
387    /// entries and their metadata. This is called after any changes to the manifest
388    /// (migration, version updates, cleanup).
389    ///
390    /// # Arguments
391    ///
392    /// * `manifest` - The manifest to write to disk
393    ///
394    /// # Returns
395    ///
396    /// `Ok(())` if the manifest was successfully written.
397    ///
398    /// `Err` if the manifest file cannot be written or serialized.
399    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, manifest), ret(level = tracing::Level::TRACE)))]
400    fn write_manifest(&self, manifest: &SettingsManifest) -> Result<(), Error> {
401        crate::helpers::save_toml(manifest, &self.manifest_path)
402            .context("failed to write settings manifest")
403    }
404
405    /// Updates the manifest with a new settings version entry and cleans up old files.
406    ///
407    /// This is called during `save()` when settings are persisted to a new versioned file.
408    /// Combines manifest update and cleanup into a single I/O pass to minimize filesystem
409    /// operations on embedded devices with slow storage.
410    ///
411    /// The process:
412    /// 1. Reads the current manifest (single read)
413    /// 2. Creates a new entry for the current version with timestamp
414    /// 3. Removes any existing entry for the same version (deduplication)
415    /// 4. Appends the new entry
416    /// 5. Partitions entries to protect current version from cleanup
417    /// 6. Removes old files exceeding retention limit (only from other versions)
418    /// 7. Writes the updated manifest once (single write)
419    ///
420    /// # Data Integrity
421    ///
422    /// The current version's entry is **never removed**, regardless of its UUID.
423    /// This prevents silent data loss during version downgrades where an older build
424    /// UUID would sort to the front and be considered "oldest" for cleanup purposes.
425    ///
426    /// # Arguments
427    ///
428    /// * `filename` - The filename of the new settings file (relative to Settings directory)
429    /// * `settings` - The settings containing the `settings_retention` configuration
430    ///
431    /// # Returns
432    ///
433    /// `Ok(())` if the manifest was successfully updated and written.
434    ///
435    /// `Err` if reading, updating, or writing the manifest fails.
436    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, settings), fields(filename = filename), ret(level = tracing::Level::TRACE)))]
437    fn update_manifest_and_cleanup(
438        &self,
439        filename: &str,
440        settings: &Settings,
441    ) -> Result<(), Error> {
442        let mut manifest = self.read_manifest()?;
443        let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
444
445        manifest
446            .entries
447            .retain(|e| e.version != self.current_version);
448
449        let new_entry = SettingsEntry {
450            version: self.current_version.clone(),
451            uuid: self.build_uuid.clone(),
452            file: filename.to_string(),
453            saved_at: Some(now),
454        };
455
456        manifest.entries.push(new_entry);
457
458        let retention = settings.settings_retention;
459
460        if retention > 0 && manifest.entries.len() > retention {
461            let (current, mut others): (Vec<_>, Vec<_>) = manifest
462                .entries
463                .drain(..)
464                .partition(|e| e.version == self.current_version);
465
466            others.sort_by(|a, b| a.uuid.cmp(&b.uuid));
467
468            let max_others = retention.saturating_sub(current.len());
469            let entries_to_remove = others.len().saturating_sub(max_others);
470            let candidates: Vec<_> = others.drain(..entries_to_remove).collect();
471
472            for entry in candidates {
473                let file_path = self.settings_dir.join(&entry.file);
474
475                if file_path.exists() {
476                    if let Err(e) = fs::remove_file(&file_path) {
477                        tracing::warn!(
478                            version = %entry.version,
479                            file = %entry.file,
480                            error = %e,
481                            "Failed to remove old settings file, will retry on next cleanup"
482                        );
483                        others.push(entry);
484                    } else {
485                        tracing::debug!(
486                            version = %entry.version,
487                            file = %entry.file,
488                            "Removed old settings file"
489                        );
490                    }
491                }
492            }
493
494            manifest.entries = others;
495            manifest.entries.extend(current);
496        }
497
498        self.write_manifest(&manifest)
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use tempfile::TempDir;
506
507    impl SettingsManager {
508        fn clone_with_version(&self, version: GitVersion) -> Self {
509            SettingsManager {
510                settings_dir: self.settings_dir.clone(),
511                manifest_path: self.manifest_path.clone(),
512                current_version: version,
513                build_uuid: self.build_uuid.clone(),
514                root_dir: self.root_dir.clone(),
515            }
516        }
517    }
518
519    fn create_test_manager(temp_dir: &TempDir) -> SettingsManager {
520        let root_dir = temp_dir.path().to_path_buf();
521        let settings_dir = root_dir.join(SETTINGS_DIR);
522        let manifest_path = settings_dir.join(MANIFEST_FILE);
523
524        SettingsManager {
525            settings_dir,
526            manifest_path,
527            current_version: "v0.1.0".parse::<GitVersion>().unwrap(),
528            build_uuid: "018e1234567890abcdef".to_string(),
529            root_dir,
530        }
531    }
532
533    fn create_test_manager_with_root(temp_dir: &TempDir) -> (SettingsManager, PathBuf) {
534        let manager = create_test_manager(temp_dir);
535        (manager.clone(), manager.root_dir.clone())
536    }
537
538    #[test]
539    fn test_creates_settings_directory() {
540        let temp_dir = TempDir::new().unwrap();
541        let manager = create_test_manager(&temp_dir);
542
543        let settings = manager.load();
544        assert!(manager.settings_dir.exists());
545        assert_eq!(settings.selected_library, 0);
546    }
547
548    #[test]
549    fn test_manifest_is_created_on_save() {
550        let temp_dir = TempDir::new().unwrap();
551        let manager = create_test_manager(&temp_dir);
552
553        let settings = Settings::default();
554        manager.save(&settings).unwrap();
555
556        assert!(manager.manifest_path.exists());
557
558        let manifest = manager.read_manifest().unwrap();
559        assert_eq!(manifest.entries.len(), 1);
560        assert_eq!(manifest.entries[0].version.to_string(), "v0.1.0");
561    }
562
563    #[test]
564    fn test_settings_file_is_created() {
565        let temp_dir = TempDir::new().unwrap();
566        let manager = create_test_manager(&temp_dir);
567
568        let settings = Settings::default();
569        manager.save(&settings).unwrap();
570
571        let expected_file = manager.settings_dir.join("Settings-v0.1.0.toml");
572        assert!(expected_file.exists());
573    }
574
575    #[test]
576    fn test_load_existing_file_same_version() {
577        let temp_dir = TempDir::new().unwrap();
578        let manager = create_test_manager(&temp_dir);
579
580        let settings = manager.load();
581        assert_eq!(settings.selected_library, 0, "Should load defaults");
582
583        let manifest = manager.read_manifest().unwrap();
584        assert!(
585            manifest.entries.is_empty(),
586            "Manifest should be empty with no legacy file"
587        );
588    }
589
590    #[test]
591    fn test_legacy_migration_preserves_manifest_history() {
592        let temp_dir = TempDir::new().unwrap();
593        let (mut manager, root) = create_test_manager_with_root(&temp_dir);
594
595        let legacy_settings = Settings {
596            selected_library: 1,
597            ..Settings::default()
598        };
599        let legacy_path = root.join(LEGACY_SETTINGS_FILE);
600        crate::helpers::save_toml(&legacy_settings, &legacy_path).unwrap();
601
602        let settings = manager.load();
603
604        manager.current_version = "v0.1.1".parse::<GitVersion>().unwrap();
605        manager.save(&settings).unwrap();
606
607        let loaded = manager.load();
608        assert_eq!(
609            loaded.selected_library, 1,
610            "Second load should still work correctly"
611        );
612    }
613
614    #[test]
615    fn test_same_version_multiple_saves_updates_entry() {
616        let temp_dir = TempDir::new().unwrap();
617        let manager = create_test_manager(&temp_dir);
618
619        let mut settings = Settings {
620            selected_library: 1,
621            ..Settings::default()
622        };
623
624        manager.save(&settings).unwrap();
625
626        let file_path = manager
627            .settings_dir
628            .join(format!("Settings-{}.toml", manager.current_version));
629
630        assert!(
631            file_path.exists(),
632            "Settings file should exist after first save"
633        );
634
635        let manifest = manager.read_manifest().unwrap();
636        assert_eq!(
637            manifest.entries.len(),
638            1,
639            "Manifest should have 1 entry after first save"
640        );
641        assert_eq!(manifest.entries[0].version.to_string(), "v0.1.0");
642
643        settings.selected_library = 2;
644        manager.save(&settings).unwrap();
645
646        assert!(
647            file_path.exists(),
648            "Settings file should still exist after second save with same version"
649        );
650
651        let manifest = manager.read_manifest().unwrap();
652        assert_eq!(
653            manifest.entries.len(),
654            1,
655            "Manifest should still have 1 entry (same version replaces previous)"
656        );
657        assert_eq!(manifest.entries[0].version.to_string(), "v0.1.0");
658
659        // Verify the settings were updated by loading
660        let loaded = manager.load();
661        assert_eq!(
662            loaded.selected_library, 2,
663            "Settings should reflect the second save"
664        );
665    }
666
667    #[test]
668    fn test_load_falls_back_to_most_recent_by_uuid() {
669        let temp_dir = TempDir::new().unwrap();
670        let root_dir = temp_dir.path().to_path_buf();
671        let settings_dir = root_dir.join(SETTINGS_DIR);
672        let manifest_path = settings_dir.join(MANIFEST_FILE);
673
674        // Create manager for v0.1.0 with an older UUID (smaller timestamp)
675        let manager_v1 = SettingsManager {
676            settings_dir: settings_dir.clone(),
677            manifest_path: manifest_path.clone(),
678            current_version: "v0.1.0".parse::<GitVersion>().unwrap(),
679            build_uuid: "018e0000000000000000".to_string(), // Older UUID
680            root_dir: root_dir.clone(),
681        };
682
683        let settings_v1 = Settings {
684            selected_library: 1,
685            ..Settings::default()
686        };
687        manager_v1.save(&settings_v1).unwrap();
688
689        // Create manager for v0.2.0 with a newer UUID (larger timestamp)
690        let manager_v2 = SettingsManager {
691            settings_dir: settings_dir.clone(),
692            manifest_path: manifest_path.clone(),
693            current_version: "v0.2.0".parse::<GitVersion>().unwrap(),
694            build_uuid: "018effffffffffffffff".to_string(), // Newer UUID
695            root_dir: root_dir.clone(),
696        };
697
698        let settings_v2 = Settings {
699            selected_library: 2,
700            ..Settings::default()
701        };
702        manager_v2.save(&settings_v2).unwrap();
703
704        // Create manager for v0.3.0 with a different UUID (no settings saved)
705        // This should fall back to the most recent settings by UUID (v0.2.0)
706        let manager_v3 = SettingsManager {
707            settings_dir,
708            manifest_path,
709            current_version: "v0.3.0".parse::<GitVersion>().unwrap(),
710            build_uuid: "018eaaaaaaaaaaaaaaaa".to_string(), // Different UUID
711            root_dir,
712        };
713
714        let loaded = manager_v3.load();
715
716        assert_eq!(
717            loaded.selected_library, 2,
718            "v0.3.0 should load settings from v0.2.0 (most recent by UUID)"
719        );
720    }
721
722    #[test]
723    fn test_load_uses_exact_version_match_when_available() {
724        let temp_dir = TempDir::new().unwrap();
725        let manager = create_test_manager(&temp_dir);
726
727        // Create settings for v0.1.0 with selected_library = 1
728        let settings_v1 = Settings {
729            selected_library: 1,
730            ..Settings::default()
731        };
732        manager.save(&settings_v1).unwrap();
733
734        // Create a new manager simulating v0.2.0 with a newer UUID and different settings
735        let manager_v2 = manager.clone_with_version("v0.2.0".parse::<GitVersion>().unwrap());
736        let settings_v2 = Settings {
737            selected_library: 2,
738            ..Settings::default()
739        };
740        manager_v2.save(&settings_v2).unwrap();
741
742        // Load as v0.1.0 - should find exact match and use v0.1.0 settings
743        let manager_v1_reload = manager.clone_with_version("v0.1.0".parse::<GitVersion>().unwrap());
744        let loaded = manager_v1_reload.load();
745
746        assert_eq!(
747            loaded.selected_library, 1,
748            "v0.1.0 should load its own settings (exact match), not v0.2.0"
749        );
750    }
751
752    #[test]
753    fn test_migration_succeeds_even_if_legacy_deletion_fails() {
754        let temp_dir = TempDir::new().unwrap();
755        let (manager, root) = create_test_manager_with_root(&temp_dir);
756
757        let legacy_settings = Settings {
758            selected_library: 5,
759            ..Settings::default()
760        };
761        let legacy_path = root.join(LEGACY_SETTINGS_FILE);
762        crate::helpers::save_toml(&legacy_settings, &legacy_path).unwrap();
763
764        assert!(legacy_path.exists(), "Legacy settings file should exist");
765
766        let loaded = manager.load();
767
768        assert_eq!(
769            loaded.selected_library, 5,
770            "Migration should succeed and load settings even if deletion fails"
771        );
772
773        let versioned_file = manager.settings_dir.join("Settings-v0.1.0.toml");
774        assert!(
775            versioned_file.exists(),
776            "Versioned settings file should be created"
777        );
778
779        let manifest = manager.read_manifest().unwrap();
780        assert_eq!(
781            manifest.entries.len(),
782            1,
783            "Manifest should have migrated entry"
784        );
785        assert_eq!(manifest.entries[0].version.to_string(), "v0.1.0");
786    }
787
788    #[test]
789    fn test_load_returns_defaults_on_all_failures() {
790        let temp_dir = TempDir::new().unwrap();
791        let manager = create_test_manager(&temp_dir);
792
793        let loaded = manager.load();
794
795        assert_eq!(loaded.selected_library, 0, "Should return defaults");
796        assert_eq!(
797            loaded.keyboard_layout, "English",
798            "Should return default keyboard layout"
799        );
800    }
801
802    #[test]
803    fn test_migration_deduplicated_on_retry() {
804        let temp_dir = TempDir::new().unwrap();
805        let (manager, root) = create_test_manager_with_root(&temp_dir);
806
807        let legacy_settings = Settings {
808            selected_library: 3,
809            ..Settings::default()
810        };
811        let legacy_path = root.join(LEGACY_SETTINGS_FILE);
812        crate::helpers::save_toml(&legacy_settings, &legacy_path).unwrap();
813
814        let loaded1 = manager.load();
815        assert_eq!(
816            loaded1.selected_library, 3,
817            "First load should get legacy settings"
818        );
819
820        let manifest = manager.read_manifest().unwrap();
821        let first_entry_count = manifest.entries.len();
822
823        let loaded2 = manager.load();
824        assert_eq!(
825            loaded2.selected_library, 3,
826            "Second load should still get correct settings"
827        );
828
829        let manifest = manager.read_manifest().unwrap();
830        assert_eq!(
831            manifest.entries.len(),
832            first_entry_count,
833            "Manifest should not have duplicates on retry"
834        );
835    }
836
837    #[test]
838    fn test_save_and_load_roundtrip() {
839        let temp_dir = TempDir::new().unwrap();
840        let manager = create_test_manager(&temp_dir);
841
842        let settings = Settings {
843            selected_library: 5,
844            inverted: true,
845            ..Settings::default()
846        };
847
848        manager.save(&settings).unwrap();
849
850        let loaded = manager.load();
851        assert_eq!(
852            loaded.selected_library, 5,
853            "Should save and load selected_library"
854        );
855        assert!(loaded.inverted, "Should save and load inverted");
856    }
857
858    #[test]
859    fn test_load_sanitizes_unsupported_calendar_intermissions() {
860        let temp_dir = TempDir::new().unwrap();
861        let manager = create_test_manager(&temp_dir);
862        let mut settings = Settings::default();
863
864        settings.intermissions[crate::settings::IntermKind::PowerOff] =
865            crate::settings::IntermissionDisplay::Calendar;
866        settings.intermissions[crate::settings::IntermKind::Share] =
867            crate::settings::IntermissionDisplay::Calendar;
868
869        manager.save(&settings).unwrap();
870
871        let loaded = manager.load();
872
873        assert_eq!(
874            loaded.intermissions[crate::settings::IntermKind::PowerOff],
875            crate::settings::IntermissionDisplay::Logo
876        );
877        assert_eq!(
878            loaded.intermissions[crate::settings::IntermKind::Share],
879            crate::settings::IntermissionDisplay::Logo
880        );
881    }
882
883    #[test]
884    fn test_retention_cleanup_removes_oldest_by_uuid() {
885        let temp_dir = TempDir::new().unwrap();
886        let (manager, root) = create_test_manager_with_root(&temp_dir);
887
888        let settings = Settings {
889            settings_retention: 2,
890            ..Settings::default()
891        };
892
893        let managers = [
894            ("v0.1.0", "018e0000000000000000"),
895            ("v0.1.1", "018e5555555555555555"),
896            ("v0.1.2", "018effffffffffffffff"),
897        ];
898
899        for (version, uuid) in managers {
900            let mgr = SettingsManager {
901                settings_dir: manager.settings_dir.clone(),
902                manifest_path: manager.manifest_path.clone(),
903                current_version: version.parse::<GitVersion>().unwrap(),
904                build_uuid: uuid.to_string(),
905                root_dir: root.clone(),
906            };
907
908            mgr.save(&settings).unwrap();
909        }
910
911        let manifest_path = manager.manifest_path.clone();
912        let manifest_content = std::fs::read_to_string(&manifest_path).unwrap();
913
914        assert!(
915            manifest_content.contains("v0.1.1"),
916            "Oldest entry (v0.1.0) should be removed, v0.1.1 should remain"
917        );
918        assert!(
919            manifest_content.contains("v0.1.2"),
920            "Newest entry (v0.1.2) should be kept"
921        );
922        assert!(
923            !manifest_content.contains("018e0000000000000000"),
924            "Settings file for oldest UUID should be deleted"
925        );
926    }
927
928    #[test]
929    fn test_retention_cleanup_protects_current_version_during_downgrade() {
930        let temp_dir = TempDir::new().unwrap();
931        let (manager, root) = create_test_manager_with_root(&temp_dir);
932
933        let settings = Settings {
934            settings_retention: 2,
935            ..Settings::default()
936        };
937
938        let managers = [
939            ("v0.2.0", "018f0000000000000000"),
940            ("v0.3.0", "018f1111111111111111"),
941        ];
942
943        for (version, uuid) in managers {
944            let mgr = SettingsManager {
945                settings_dir: manager.settings_dir.clone(),
946                manifest_path: manager.manifest_path.clone(),
947                current_version: version.parse::<GitVersion>().unwrap(),
948                build_uuid: uuid.to_string(),
949                root_dir: root.clone(),
950            };
951
952            mgr.save(&settings).unwrap();
953        }
954
955        let downgrade_mgr = SettingsManager {
956            settings_dir: manager.settings_dir.clone(),
957            manifest_path: manager.manifest_path.clone(),
958            current_version: "v0.1.0".parse::<GitVersion>().unwrap(),
959            build_uuid: "018e0000000000000000".to_string(),
960            root_dir: root.clone(),
961        };
962
963        downgrade_mgr.save(&settings).unwrap();
964
965        let manifest = downgrade_mgr.read_manifest().unwrap();
966
967        assert_eq!(
968            manifest.entries.len(),
969            2,
970            "Manifest should have 2 entries (retention=2, oldest v0.2.0 should be removed)"
971        );
972
973        let current_entry = manifest
974            .entries
975            .iter()
976            .find(|e| e.version.to_string() == "v0.1.0")
977            .expect("Current version v0.1.0 must be in manifest");
978
979        assert_eq!(current_entry.uuid, "018e0000000000000000");
980
981        let remaining_versions: Vec<String> = manifest
982            .entries
983            .iter()
984            .map(|e| e.version.to_string())
985            .collect();
986
987        assert!(
988            remaining_versions.contains(&"v0.1.0".to_string()),
989            "Current version v0.1.0 must be protected from deletion"
990        );
991        assert!(
992            remaining_versions.contains(&"v0.3.0".to_string()),
993            "Newer version v0.3.0 should be kept (less old than v0.2.0)"
994        );
995        assert!(
996            !remaining_versions.contains(&"v0.2.0".to_string()),
997            "Oldest non-current version v0.2.0 should be removed"
998        );
999
1000        let v010_file = downgrade_mgr.settings_dir.join("Settings-v0.1.0.toml");
1001        assert!(
1002            v010_file.exists(),
1003            "Current version file Settings-v0.1.0.toml must not be deleted"
1004        );
1005
1006        let v020_file = downgrade_mgr.settings_dir.join("Settings-v0.2.0.toml");
1007        assert!(
1008            !v020_file.exists(),
1009            "Oldest version file Settings-v0.2.0.toml should be deleted"
1010        );
1011    }
1012
1013    #[test]
1014    fn test_retention_cleanup_continues_on_file_removal_failure() {
1015        let temp_dir = TempDir::new().unwrap();
1016        let (manager, root) = create_test_manager_with_root(&temp_dir);
1017
1018        let settings = Settings {
1019            settings_retention: 2,
1020            ..Settings::default()
1021        };
1022
1023        let managers = [
1024            ("v0.1.0", "018e0000000000000000"),
1025            ("v0.1.1", "018e5555555555555555"),
1026            ("v0.1.2", "018effffffffffffffff"),
1027        ];
1028
1029        for (version, uuid) in managers {
1030            let mgr = SettingsManager {
1031                settings_dir: manager.settings_dir.clone(),
1032                manifest_path: manager.manifest_path.clone(),
1033                current_version: version.parse::<GitVersion>().unwrap(),
1034                build_uuid: uuid.to_string(),
1035                root_dir: root.clone(),
1036            };
1037
1038            mgr.save(&settings).unwrap();
1039        }
1040
1041        let manifest = manager.read_manifest().unwrap();
1042
1043        assert_eq!(
1044            manifest.entries.len(),
1045            2,
1046            "Manifest should have 2 entries (retention=2)"
1047        );
1048
1049        let versions: Vec<String> = manifest
1050            .entries
1051            .iter()
1052            .map(|e| e.version.to_string())
1053            .collect();
1054
1055        assert!(
1056            versions.contains(&"v0.1.1".to_string()),
1057            "Entry for v0.1.1 should be in manifest"
1058        );
1059        assert!(
1060            versions.contains(&"v0.1.2".to_string()),
1061            "Entry for v0.1.2 should be in manifest"
1062        );
1063        assert!(
1064            !versions.contains(&"v0.1.0".to_string()),
1065            "Entry for v0.1.0 should be removed"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_save_succeeds_even_if_cleanup_cant_remove_files() {
1071        let temp_dir = TempDir::new().unwrap();
1072        let (manager, root) = create_test_manager_with_root(&temp_dir);
1073
1074        let settings = Settings {
1075            settings_retention: 1,
1076            ..Settings::default()
1077        };
1078
1079        let v1_mgr = SettingsManager {
1080            settings_dir: manager.settings_dir.clone(),
1081            manifest_path: manager.manifest_path.clone(),
1082            current_version: "v0.1.0".parse::<GitVersion>().unwrap(),
1083            build_uuid: "018e0000000000000000".to_string(),
1084            root_dir: root.clone(),
1085        };
1086
1087        v1_mgr.save(&settings).unwrap();
1088
1089        let v2_mgr = SettingsManager {
1090            settings_dir: manager.settings_dir.clone(),
1091            manifest_path: manager.manifest_path.clone(),
1092            current_version: "v0.1.1".parse::<GitVersion>().unwrap(),
1093            build_uuid: "018e5555555555555555".to_string(),
1094            root_dir: root.clone(),
1095        };
1096
1097        v2_mgr.save(&settings).unwrap();
1098
1099        let manifest = v2_mgr.read_manifest().unwrap();
1100        assert_eq!(
1101            manifest.entries.len(),
1102            1,
1103            "Should keep only 1 entry with retention=1"
1104        );
1105        assert_eq!(
1106            manifest.entries[0].version.to_string(),
1107            "v0.1.1",
1108            "Should keep the current (newest) version"
1109        );
1110
1111        let v3_mgr = SettingsManager {
1112            settings_dir: manager.settings_dir.clone(),
1113            manifest_path: manager.manifest_path.clone(),
1114            current_version: "v0.1.2".parse::<GitVersion>().unwrap(),
1115            build_uuid: "018effffffffffffffff".to_string(),
1116            root_dir: root.clone(),
1117        };
1118
1119        let save_result = v3_mgr.save(&settings);
1120
1121        assert!(
1122            save_result.is_ok(),
1123            "save() should succeed even if file removal fails"
1124        );
1125
1126        let manifest_final = v3_mgr.read_manifest().unwrap();
1127        assert_eq!(
1128            manifest_final.entries.len(),
1129            1,
1130            "Manifest should be updated and written"
1131        );
1132        assert_eq!(
1133            manifest_final.entries[0].version.to_string(),
1134            "v0.1.2",
1135            "Current version should be in manifest"
1136        );
1137    }
1138}