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 = 3;
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        // v2 → v3: move per-workspace SQLite cache + extract last_paths history.
47        if settings.config_version < 3 {
48            Self::migrate_to_v3(settings)?;
49            migrated = true;
50        }
51
52        // Future migrations go here, gated on config_version:
53        // if settings.config_version < 4 { ... migrated = true; }
54
55        if migrated {
56            settings.config_version = CURRENT_CONFIG_VERSION;
57        }
58
59        Ok(migrated)
60    }
61
62    /// v2 → v3: move `<workspace>/kimun.sqlite` to
63    /// `<cache_dir>/<workspace>.kimuncache` and extract per-workspace
64    /// `last_paths` to `<history_dir>/<workspace>.txt`. Then clear the
65    /// in-memory `last_paths` so the next save does not re-write them.
66    ///
67    /// Pre-flight: validates every workspace name; aborts with a single
68    /// error listing every bad name. Idempotent: skips any step whose
69    /// destination already exists.
70    fn migrate_to_v3(settings: &mut AppSettings) -> eyre::Result<()> {
71        let Some(ref wc) = settings.workspace_config else {
72            return Ok(());
73        };
74
75        let mut invalid = Vec::new();
76        for name in wc.workspaces.keys() {
77            if let Err(e) = kimun_core::nfs::filename::validate_filename(name) {
78                invalid.push(format!("{e}"));
79            }
80        }
81        if !invalid.is_empty() {
82            return Err(eyre::eyre!(
83                "Cannot migrate to v3: invalid workspace names:\n  - {}",
84                invalid.join("\n  - ")
85            ));
86        }
87
88        if let Some(ref cfg_path) = settings.config_file {
89            let bak_path = cfg_path.with_extension("toml.bak.v2");
90            if !bak_path.exists() {
91                std::fs::copy(cfg_path, &bak_path).map_err(|e| {
92                    eyre::eyre!("failed to back up config to {:?}: {}", bak_path, e)
93                })?;
94                tracing::info!("backed up v2 config to {:?}", bak_path);
95            }
96        }
97
98        let cache_dir = settings
99            .cache_dir_resolved()
100            .map(|p| p.to_path_buf())
101            .unwrap_or_else(|| settings.cache_dir.clone());
102        let history_dir = settings
103            .history_dir_resolved()
104            .map(|p| p.to_path_buf())
105            .unwrap_or_else(|| settings.history_dir.clone());
106
107        let work: Vec<(String, std::path::PathBuf, Vec<String>)> = wc
108            .workspaces
109            .iter()
110            .map(|(name, entry)| {
111                (
112                    name.clone(),
113                    entry.effective_path().clone(),
114                    entry.last_paths.clone(),
115                )
116            })
117            .collect();
118
119        for (name, ws_path, last_paths) in work {
120            let old_db = ws_path.join("kimun.sqlite");
121            let new_db = cache_dir.join(format!("{name}.kimuncache"));
122            if old_db.exists() {
123                if new_db.exists() {
124                    tracing::warn!(
125                        "destination cache {:?} already exists, leaving old DB at {:?}",
126                        new_db,
127                        old_db
128                    );
129                } else {
130                    std::fs::create_dir_all(&cache_dir).map_err(|e| {
131                        eyre::eyre!("failed to create cache dir {:?}: {}", cache_dir, e)
132                    })?;
133                    if let Err(rename_err) = std::fs::rename(&old_db, &new_db) {
134                        // EXDEV: source and destination on different filesystems —
135                        // rename(2) cannot cross mount points; fall back to copy + unlink.
136                        if rename_err.raw_os_error() == Some(libc_exdev_code()) {
137                            std::fs::copy(&old_db, &new_db)?;
138                            std::fs::remove_file(&old_db)?;
139                        } else {
140                            return Err(eyre::eyre!(
141                                "failed to move {:?} -> {:?}: {}",
142                                old_db,
143                                new_db,
144                                rename_err
145                            ));
146                        }
147                    }
148                    tracing::info!("migrated {:?} -> {:?}", old_db, new_db);
149                }
150            }
151
152            if !last_paths.is_empty() {
153                let hist_path = history_dir.join(format!("{name}.txt"));
154                if !hist_path.exists() {
155                    std::fs::create_dir_all(&history_dir)?;
156                    let body = last_paths.join("\n") + "\n";
157                    std::fs::write(&hist_path, body)?;
158                }
159            }
160        }
161
162        if let Some(ref mut wc) = settings.workspace_config {
163            for entry in wc.workspaces.values_mut() {
164                entry.last_paths.clear();
165            }
166        }
167
168        Ok(())
169    }
170
171    /// Migrate the legacy `workspace_dir` field into `workspace_config`.
172    ///
173    /// Two sub-cases:
174    /// 1. No `workspace_config` exists — full migration: create one with a
175    ///    "default" workspace from the legacy fields.
176    /// 2. `workspace_config` already exists — the legacy field is orphaned
177    ///    (e.g. from a partial earlier migration). Add it as "default" if no
178    ///    workspace already points to the same path.
179    fn migrate_workspace_dir(settings: &mut AppSettings) -> eyre::Result<()> {
180        let Some(workspace_dir) = settings.workspace_dir.take() else {
181            return Ok(());
182        };
183
184        if settings.workspace_config.is_none() {
185            // Full Phase 1 → Phase 2 migration.
186            if !workspace_dir.exists() {
187                return Err(eyre::eyre!(
188                    "Cannot migrate: workspace directory {} no longer exists",
189                    workspace_dir.display()
190                ));
191            }
192            tracing::info!("Migrating Phase 1 config to Phase 2 format");
193            let last_paths: Vec<String> =
194                settings.last_paths.iter().map(|p| p.to_string()).collect();
195
196            settings.workspace_config = Some(WorkspaceConfig::from_phase1_migration(
197                workspace_dir,
198                last_paths,
199            ));
200            // Theme stays as the top-level field — no duplication.
201        } else if let Some(ref mut wc) = settings.workspace_config {
202            // Phase 2 config exists but legacy workspace_dir was still present.
203            let already_exists = wc
204                .workspaces
205                .values()
206                .any(|e| *e.effective_path() == workspace_dir);
207            if !already_exists && !workspace_dir.exists() {
208                tracing::warn!(
209                    "Dropping orphaned workspace_dir {:?} (directory no longer exists)",
210                    workspace_dir
211                );
212            } else if !already_exists && workspace_dir.exists() {
213                tracing::info!(
214                    "Migrating orphaned workspace_dir into workspace_config as 'default'"
215                );
216                let name = Self::unique_workspace_name(wc, "default");
217                let last_paths: Vec<String> =
218                    settings.last_paths.iter().map(|p| p.to_string()).collect();
219                let entry = WorkspaceEntry {
220                    path: workspace_dir,
221                    last_paths,
222                    created: chrono::Utc::now(),
223                    quick_note_path: None,
224                    inbox_path: None,
225                    resolved_path: None,
226                };
227                wc.workspaces.insert(name, entry);
228            }
229        }
230
231        settings.last_paths.clear();
232        Ok(())
233    }
234
235    /// Find a unique workspace name starting from `base`. If `base` is taken,
236    /// tries `base-2`, `base-3`, etc.
237    fn unique_workspace_name(wc: &WorkspaceConfig, base: &str) -> String {
238        if !wc.workspaces.contains_key(base) {
239            return base.to_string();
240        }
241        let mut n = 2;
242        loop {
243            let candidate = format!("{}-{}", base, n);
244            if !wc.workspaces.contains_key(&candidate) {
245                return candidate;
246            }
247            n += 1;
248        }
249    }
250}
251
252#[cfg(unix)]
253fn libc_exdev_code() -> i32 {
254    18 // EXDEV on Linux
255}
256#[cfg(not(unix))]
257fn libc_exdev_code() -> i32 {
258    -1
259}
260
261#[cfg(test)]
262#[allow(clippy::field_reassign_with_default)]
263mod tests {
264    use super::*;
265    use std::path::PathBuf;
266
267    fn settings_with_workspace_dir(path: &str) -> AppSettings {
268        let mut s = AppSettings::default();
269        s.workspace_dir = Some(PathBuf::from(path));
270        s.theme = "gruvbox_dark".to_string();
271        s
272    }
273
274    #[test]
275    fn full_phase1_migration_creates_default_workspace() {
276        let dir = tempfile::TempDir::new().unwrap();
277        let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
278
279        let migrated = ConfigMigration::run(&mut settings).unwrap();
280
281        assert!(migrated);
282        assert!(settings.workspace_dir.is_none());
283        assert!(settings.last_paths.is_empty());
284        assert_eq!(settings.config_version, CURRENT_CONFIG_VERSION);
285        let wc = settings.workspace_config.as_ref().unwrap();
286        assert!(wc.workspaces.contains_key("default"));
287        assert_eq!(wc.global.current_workspace, "default");
288    }
289
290    #[test]
291    fn full_phase1_migration_fails_for_missing_dir() {
292        let mut settings = settings_with_workspace_dir("/nonexistent/path/that/does/not/exist");
293        let result = ConfigMigration::run(&mut settings);
294        assert!(result.is_err());
295        assert!(result.unwrap_err().to_string().contains("Cannot migrate"));
296    }
297
298    #[test]
299    fn orphaned_workspace_dir_migrated_into_existing_config() {
300        let dir = tempfile::TempDir::new().unwrap();
301        let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
302
303        // Pre-existing Phase 2 config with a different workspace.
304        let other_dir = tempfile::TempDir::new().unwrap();
305        let mut wc = WorkspaceConfig::new_empty();
306        wc.add_workspace("production".to_string(), other_dir.path().to_path_buf())
307            .unwrap();
308        wc.global.current_workspace = "production".to_string();
309        settings.workspace_config = Some(wc);
310
311        let migrated = ConfigMigration::run(&mut settings).unwrap();
312
313        assert!(migrated);
314        assert!(settings.workspace_dir.is_none());
315        let wc = settings.workspace_config.as_ref().unwrap();
316        assert!(wc.workspaces.contains_key("default"));
317        assert!(wc.workspaces.contains_key("production"));
318        assert_eq!(wc.global.current_workspace, "production"); // unchanged
319    }
320
321    #[test]
322    fn orphaned_workspace_dir_skipped_if_same_path_exists() {
323        let dir = tempfile::TempDir::new().unwrap();
324        let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
325
326        // Pre-existing config already has a workspace at the same path.
327        let mut wc = WorkspaceConfig::new_empty();
328        wc.add_workspace("existing".to_string(), dir.path().to_path_buf())
329            .unwrap();
330        wc.global.current_workspace = "existing".to_string();
331        settings.workspace_config = Some(wc);
332
333        ConfigMigration::run(&mut settings).unwrap();
334
335        let wc = settings.workspace_config.as_ref().unwrap();
336        assert_eq!(wc.workspaces.len(), 1); // not duplicated
337        assert!(wc.workspaces.contains_key("existing"));
338    }
339
340    #[test]
341    fn unique_name_avoids_collision() {
342        let mut wc = WorkspaceConfig::new_empty();
343        let dir = tempfile::TempDir::new().unwrap();
344        wc.add_workspace("default".to_string(), dir.path().to_path_buf())
345            .unwrap();
346
347        let name = ConfigMigration::unique_workspace_name(&wc, "default");
348        assert_eq!(name, "default-2");
349    }
350
351    #[test]
352    fn no_migration_when_no_legacy_fields() {
353        let mut settings = AppSettings::default();
354        settings.config_version = CURRENT_CONFIG_VERSION;
355        settings.workspace_config = Some(WorkspaceConfig::new_empty());
356
357        let migrated = ConfigMigration::run(&mut settings).unwrap();
358        assert!(!migrated);
359    }
360}