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