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