Skip to main content

kimun_notes/settings/
config_migration.rs

1//! Config migration — upgrades settings from older versions to the current format.
2//!
3//! All migration logic lives here so there is a single place to manage
4//! version transitions. `ConfigMigration::run` is called once during
5//! `AppSettings::load_from_file` after deserialization.
6
7use color_eyre::eyre;
8
9use super::AppSettings;
10use super::workspace_config::{WorkspaceConfig, WorkspaceEntry};
11
12/// Current config version. Bump this when adding a new migration step.
13pub const CURRENT_CONFIG_VERSION: u32 = 2;
14
15/// Runs all necessary migrations on `settings`, mutating it in place.
16/// Returns `true` if any migration was applied (caller should persist).
17pub struct ConfigMigration;
18
19impl ConfigMigration {
20    /// Apply all pending migrations to bring `settings` up to
21    /// `CURRENT_CONFIG_VERSION`. Returns `true` if any migration ran.
22    pub fn run(settings: &mut AppSettings) -> eyre::Result<bool> {
23        let mut migrated = false;
24
25        // v1 → v2: workspace_dir → workspace_config
26        if settings.workspace_dir.is_some() {
27            Self::migrate_workspace_dir(settings)?;
28            migrated = true;
29        }
30
31        // Validate current_workspace points to an existing entry.
32        if let Some(ref mut wc) = settings.workspace_config
33            && !wc.global.current_workspace.is_empty()
34            && !wc.workspaces.contains_key(&wc.global.current_workspace)
35        {
36            let first = wc.workspaces.keys().next().cloned().unwrap_or_default();
37            tracing::warn!(
38                "current_workspace '{}' does not exist, resetting to '{}'",
39                wc.global.current_workspace,
40                first
41            );
42            wc.global.current_workspace = first;
43            migrated = true;
44        }
45
46        // Future migrations go here, gated on config_version:
47        // if settings.config_version < 3 { ... migrated = true; }
48
49        if migrated {
50            settings.config_version = CURRENT_CONFIG_VERSION;
51        }
52
53        Ok(migrated)
54    }
55
56    /// Migrate the legacy `workspace_dir` field into `workspace_config`.
57    ///
58    /// Two sub-cases:
59    /// 1. No `workspace_config` exists — full migration: create one with a
60    ///    "default" workspace from the legacy fields.
61    /// 2. `workspace_config` already exists — the legacy field is orphaned
62    ///    (e.g. from a partial earlier migration). Add it as "default" if no
63    ///    workspace already points to the same path.
64    fn migrate_workspace_dir(settings: &mut AppSettings) -> eyre::Result<()> {
65        let Some(workspace_dir) = settings.workspace_dir.take() else {
66            return Ok(());
67        };
68
69        if settings.workspace_config.is_none() {
70            // Full Phase 1 → Phase 2 migration.
71            if !workspace_dir.exists() {
72                return Err(eyre::eyre!(
73                    "Cannot migrate: workspace directory {} no longer exists",
74                    workspace_dir.display()
75                ));
76            }
77            tracing::info!("Migrating Phase 1 config to Phase 2 format");
78            let last_paths: Vec<String> =
79                settings.last_paths.iter().map(|p| p.to_string()).collect();
80
81            settings.workspace_config = Some(WorkspaceConfig::from_phase1_migration(
82                workspace_dir,
83                last_paths,
84            ));
85            // Theme stays as the top-level field — no duplication.
86        } else if let Some(ref mut wc) = settings.workspace_config {
87            // Phase 2 config exists but legacy workspace_dir was still present.
88            let already_exists = wc
89                .workspaces
90                .values()
91                .any(|e| *e.effective_path() == workspace_dir);
92            if !already_exists && !workspace_dir.exists() {
93                tracing::warn!(
94                    "Dropping orphaned workspace_dir {:?} (directory no longer exists)",
95                    workspace_dir
96                );
97            } else if !already_exists && workspace_dir.exists() {
98                tracing::info!(
99                    "Migrating orphaned workspace_dir into workspace_config as 'default'"
100                );
101                let name = Self::unique_workspace_name(wc, "default");
102                let last_paths: Vec<String> =
103                    settings.last_paths.iter().map(|p| p.to_string()).collect();
104                let entry = WorkspaceEntry {
105                    path: workspace_dir,
106                    last_paths,
107                    created: chrono::Utc::now(),
108                    quick_note_path: None,
109                    inbox_path: None,
110                    resolved_path: None,
111                };
112                wc.workspaces.insert(name, entry);
113            }
114        }
115
116        settings.last_paths.clear();
117        Ok(())
118    }
119
120    /// Find a unique workspace name starting from `base`. If `base` is taken,
121    /// tries `base-2`, `base-3`, etc.
122    fn unique_workspace_name(wc: &WorkspaceConfig, base: &str) -> String {
123        if !wc.workspaces.contains_key(base) {
124            return base.to_string();
125        }
126        let mut n = 2;
127        loop {
128            let candidate = format!("{}-{}", base, n);
129            if !wc.workspaces.contains_key(&candidate) {
130                return candidate;
131            }
132            n += 1;
133        }
134    }
135}
136
137#[cfg(test)]
138#[allow(clippy::field_reassign_with_default)]
139mod tests {
140    use super::*;
141    use std::path::PathBuf;
142
143    fn settings_with_workspace_dir(path: &str) -> AppSettings {
144        let mut s = AppSettings::default();
145        s.workspace_dir = Some(PathBuf::from(path));
146        s.theme = "gruvbox_dark".to_string();
147        s
148    }
149
150    #[test]
151    fn full_phase1_migration_creates_default_workspace() {
152        let dir = tempfile::TempDir::new().unwrap();
153        let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
154
155        let migrated = ConfigMigration::run(&mut settings).unwrap();
156
157        assert!(migrated);
158        assert!(settings.workspace_dir.is_none());
159        assert!(settings.last_paths.is_empty());
160        assert_eq!(settings.config_version, CURRENT_CONFIG_VERSION);
161        let wc = settings.workspace_config.as_ref().unwrap();
162        assert!(wc.workspaces.contains_key("default"));
163        assert_eq!(wc.global.current_workspace, "default");
164    }
165
166    #[test]
167    fn full_phase1_migration_fails_for_missing_dir() {
168        let mut settings = settings_with_workspace_dir("/nonexistent/path/that/does/not/exist");
169        let result = ConfigMigration::run(&mut settings);
170        assert!(result.is_err());
171        assert!(result.unwrap_err().to_string().contains("Cannot migrate"));
172    }
173
174    #[test]
175    fn orphaned_workspace_dir_migrated_into_existing_config() {
176        let dir = tempfile::TempDir::new().unwrap();
177        let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
178
179        // Pre-existing Phase 2 config with a different workspace.
180        let other_dir = tempfile::TempDir::new().unwrap();
181        let mut wc = WorkspaceConfig::new_empty();
182        wc.add_workspace("production".to_string(), other_dir.path().to_path_buf())
183            .unwrap();
184        wc.global.current_workspace = "production".to_string();
185        settings.workspace_config = Some(wc);
186
187        let migrated = ConfigMigration::run(&mut settings).unwrap();
188
189        assert!(migrated);
190        assert!(settings.workspace_dir.is_none());
191        let wc = settings.workspace_config.as_ref().unwrap();
192        assert!(wc.workspaces.contains_key("default"));
193        assert!(wc.workspaces.contains_key("production"));
194        assert_eq!(wc.global.current_workspace, "production"); // unchanged
195    }
196
197    #[test]
198    fn orphaned_workspace_dir_skipped_if_same_path_exists() {
199        let dir = tempfile::TempDir::new().unwrap();
200        let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
201
202        // Pre-existing config already has a workspace at the same path.
203        let mut wc = WorkspaceConfig::new_empty();
204        wc.add_workspace("existing".to_string(), dir.path().to_path_buf())
205            .unwrap();
206        wc.global.current_workspace = "existing".to_string();
207        settings.workspace_config = Some(wc);
208
209        ConfigMigration::run(&mut settings).unwrap();
210
211        let wc = settings.workspace_config.as_ref().unwrap();
212        assert_eq!(wc.workspaces.len(), 1); // not duplicated
213        assert!(wc.workspaces.contains_key("existing"));
214    }
215
216    #[test]
217    fn unique_name_avoids_collision() {
218        let mut wc = WorkspaceConfig::new_empty();
219        let dir = tempfile::TempDir::new().unwrap();
220        wc.add_workspace("default".to_string(), dir.path().to_path_buf())
221            .unwrap();
222
223        let name = ConfigMigration::unique_workspace_name(&wc, "default");
224        assert_eq!(name, "default-2");
225    }
226
227    #[test]
228    fn no_migration_when_no_legacy_fields() {
229        let mut settings = AppSettings::default();
230        settings.workspace_config = Some(WorkspaceConfig::new_empty());
231
232        let migrated = ConfigMigration::run(&mut settings).unwrap();
233        assert!(!migrated);
234    }
235}