1use super::AppSettings;
8use super::SettingsError;
9use super::workspace_config::{WorkspaceConfig, WorkspaceEntry};
10
11pub const CURRENT_CONFIG_VERSION: u32 = 6;
13
14pub struct ConfigMigration;
17
18impl ConfigMigration {
19 pub fn run(settings: &mut AppSettings) -> Result<bool, SettingsError> {
22 let mut migrated = false;
23
24 if settings.workspace_dir.is_some() {
26 Self::migrate_workspace_dir(settings)?;
27 migrated = true;
28 }
29
30 if let Some(ref mut wc) = settings.workspace_config
32 && !wc.global.current_workspace.is_empty()
33 && !wc.workspaces.contains_key(&wc.global.current_workspace)
34 {
35 let first = wc.workspaces.keys().next().cloned().unwrap_or_default();
36 tracing::warn!(
37 "current_workspace '{}' does not exist, resetting to '{}'",
38 wc.global.current_workspace,
39 first
40 );
41 wc.global.current_workspace = first;
42 migrated = true;
43 }
44
45 if settings.config_version < 3 {
47 Self::migrate_to_v3(settings)?;
48 migrated = true;
49 }
50
51 if settings.config_version < 4 {
54 Self::migrate_to_v4(settings);
55 migrated = true;
56 }
57
58 if settings.config_version < 5 {
61 Self::migrate_to_v5(settings);
62 migrated = true;
63 }
64
65 if settings.config_version < 6 {
68 Self::migrate_to_v6(settings);
69 migrated = true;
70 }
71
72 if migrated {
76 settings.config_version = CURRENT_CONFIG_VERSION;
77 }
78
79 Ok(migrated)
80 }
81
82 fn migrate_to_v6(settings: &mut AppSettings) {
87 use crate::keys::KeyBindings;
88 use crate::keys::action_shortcuts::ActionShortcuts;
89 use crate::keys::key_combo::KeyCombo;
90 use crate::keys::key_strike::KeyStrike;
91
92 let ctrl = crate::keys::key_combo::KeyModifiers::new().and_ctrl();
93 let ctrl_shift_p = KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyP);
94 let ctrl_comma = KeyCombo::new(ctrl, KeyStrike::Comma);
95
96 let mut map = settings.key_bindings.to_hashmap();
97 let at_old_default = map
98 .get(&ActionShortcuts::OpenPreferences)
99 .is_some_and(|v| v.as_slice() == [ctrl_shift_p]);
100 let comma_free = !map.values().flatten().any(|c| *c == ctrl_comma);
101 if at_old_default && comma_free {
102 map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_comma]);
103 }
104 settings.key_bindings = KeyBindings::from_hashmap(map);
105 }
106
107 fn migrate_to_v5(settings: &mut AppSettings) {
111 use crate::keys::KeyBindings;
112 use crate::keys::action_shortcuts::ActionShortcuts;
113 use crate::keys::key_combo::KeyCombo;
114 use crate::keys::key_strike::KeyStrike;
115
116 let ctrl = crate::keys::key_combo::KeyModifiers::new().and_ctrl();
117 let ctrl_shift = ctrl.and_shift();
118 let ctrl_p = KeyCombo::new(ctrl, KeyStrike::KeyP);
119 let ctrl_shift_p = KeyCombo::new(ctrl_shift, KeyStrike::KeyP);
120
121 let mut map = settings.key_bindings.to_hashmap();
122 let settings_is_old_default = map
123 .get(&ActionShortcuts::OpenPreferences)
124 .is_some_and(|v| v.as_slice() == [ctrl_p]);
125 let palette_unset_or_old_default = map
126 .get(&ActionShortcuts::OpenCommandPalette)
127 .is_none_or(|v| v.is_empty() || v.as_slice() == [ctrl_shift_p]);
128 if settings_is_old_default && palette_unset_or_old_default {
129 map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_shift_p]);
130 map.insert(ActionShortcuts::OpenCommandPalette, vec![ctrl_p]);
131 }
132 settings.key_bindings = KeyBindings::from_hashmap(map);
133 }
134
135 fn migrate_to_v4(settings: &mut AppSettings) {
140 use crate::keys::KeyBindings;
141 use crate::keys::action_shortcuts::ActionShortcuts;
142 use crate::keys::key_combo::KeyCombo;
143 use crate::keys::key_strike::KeyStrike;
144
145 let ctrl = crate::keys::key_combo::KeyModifiers::new().and_ctrl();
146 let ctrl_g = KeyCombo::new(ctrl, KeyStrike::KeyG);
147 let ctrl_n = KeyCombo::new(ctrl, KeyStrike::KeyN);
148
149 let mut map = settings.key_bindings.to_hashmap();
150 let follow_is_old_default = map
151 .get(&ActionShortcuts::FollowLink)
152 .is_some_and(|v| v.as_slice() == [ctrl_g]);
153 if follow_is_old_default {
154 map.insert(ActionShortcuts::FollowLink, vec![ctrl_n]);
156 map.entry(ActionShortcuts::Leader).or_default().push(ctrl_g);
157 }
158 settings.key_bindings = KeyBindings::from_hashmap(map);
159 }
163
164 fn migrate_to_v3(settings: &mut AppSettings) -> Result<(), SettingsError> {
173 let Some(ref wc) = settings.workspace_config else {
174 return Ok(());
175 };
176
177 let mut invalid = Vec::new();
178 for name in wc.workspaces.keys() {
179 if let Err(e) = kimun_core::nfs::filename::validate_filename(name) {
180 invalid.push(format!("{e}"));
181 }
182 }
183 if !invalid.is_empty() {
184 return Err(SettingsError::Migration(format!(
185 "Cannot migrate to v3: invalid workspace names:\n - {}",
186 invalid.join("\n - ")
187 )));
188 }
189
190 if let Some(ref cfg_path) = settings.config_file {
191 let bak_path = cfg_path.with_extension("toml.bak.v2");
192 if !bak_path.exists() {
193 std::fs::copy(cfg_path, &bak_path).map_err(|e| {
194 SettingsError::Migration(format!(
195 "failed to back up config to {bak_path:?}: {e}"
196 ))
197 })?;
198 tracing::info!("backed up v2 config to {:?}", bak_path);
199 }
200 }
201
202 let cache_dir = settings
203 .cache_dir_resolved()
204 .map(|p| p.to_path_buf())
205 .unwrap_or_else(|| settings.cache_dir.clone());
206 let history_dir = settings
207 .history_dir_resolved()
208 .map(|p| p.to_path_buf())
209 .unwrap_or_else(|| settings.history_dir.clone());
210
211 let work: Vec<(String, std::path::PathBuf, Vec<String>)> = wc
212 .workspaces
213 .iter()
214 .map(|(name, entry)| {
215 (
216 name.clone(),
217 entry.effective_path().clone(),
218 entry.last_paths.clone(),
219 )
220 })
221 .collect();
222
223 for (name, ws_path, last_paths) in work {
224 let old_db = ws_path.join("kimun.sqlite");
225 let new_db = cache_dir.join(format!("{name}.kimuncache"));
226 if old_db.exists() {
227 if new_db.exists() {
228 tracing::warn!(
229 "destination cache {:?} already exists, leaving old DB at {:?}",
230 new_db,
231 old_db
232 );
233 } else {
234 std::fs::create_dir_all(&cache_dir).map_err(|e| {
235 SettingsError::Migration(format!(
236 "failed to create cache dir {cache_dir:?}: {e}"
237 ))
238 })?;
239 if let Err(rename_err) = std::fs::rename(&old_db, &new_db) {
240 if rename_err.raw_os_error() == Some(libc_exdev_code()) {
243 std::fs::copy(&old_db, &new_db)?;
244 std::fs::remove_file(&old_db)?;
245 } else {
246 return Err(SettingsError::Migration(format!(
247 "failed to move {:?} -> {:?}: {}",
248 old_db, new_db, rename_err
249 )));
250 }
251 }
252 tracing::info!("migrated {:?} -> {:?}", old_db, new_db);
253 }
254 }
255
256 if !last_paths.is_empty() {
257 let hist_path = history_dir.join(format!("{name}.txt"));
258 if !hist_path.exists() {
259 std::fs::create_dir_all(&history_dir)?;
260 let body = last_paths.join("\n") + "\n";
261 std::fs::write(&hist_path, body)?;
262 }
263 }
264 }
265
266 if let Some(ref mut wc) = settings.workspace_config {
267 for entry in wc.workspaces.values_mut() {
268 entry.last_paths.clear();
269 }
270 }
271
272 Ok(())
273 }
274
275 fn migrate_workspace_dir(settings: &mut AppSettings) -> Result<(), SettingsError> {
284 let Some(workspace_dir) = settings.workspace_dir.take() else {
285 return Ok(());
286 };
287
288 if settings.workspace_config.is_none() {
289 if !workspace_dir.exists() {
291 return Err(SettingsError::Migration(format!(
292 "Cannot migrate: workspace directory {} no longer exists",
293 workspace_dir.display()
294 )));
295 }
296 tracing::info!("Migrating Phase 1 config to Phase 2 format");
297 let last_paths: Vec<String> =
298 settings.last_paths.iter().map(|p| p.to_string()).collect();
299
300 settings.workspace_config = Some(WorkspaceConfig::from_phase1_migration(
301 workspace_dir,
302 last_paths,
303 ));
304 } else if let Some(ref mut wc) = settings.workspace_config {
306 let already_exists = wc
308 .workspaces
309 .values()
310 .any(|e| *e.effective_path() == workspace_dir);
311 if !already_exists && !workspace_dir.exists() {
312 tracing::warn!(
313 "Dropping orphaned workspace_dir {:?} (directory no longer exists)",
314 workspace_dir
315 );
316 } else if !already_exists && workspace_dir.exists() {
317 tracing::info!(
318 "Migrating orphaned workspace_dir into workspace_config as 'default'"
319 );
320 let name = Self::unique_workspace_name(wc, "default");
321 let last_paths: Vec<String> =
322 settings.last_paths.iter().map(|p| p.to_string()).collect();
323 let entry = WorkspaceEntry {
324 path: workspace_dir,
325 last_paths,
326 created: chrono::Utc::now(),
327 quick_note_path: None,
328 inbox_path: None,
329 resolved_path: None,
330 };
331 wc.workspaces.insert(name, entry);
332 }
333 }
334
335 settings.last_paths.clear();
336 Ok(())
337 }
338
339 fn unique_workspace_name(wc: &WorkspaceConfig, base: &str) -> String {
342 if !wc.workspaces.contains_key(base) {
343 return base.to_string();
344 }
345 let mut n = 2;
346 loop {
347 let candidate = format!("{}-{}", base, n);
348 if !wc.workspaces.contains_key(&candidate) {
349 return candidate;
350 }
351 n += 1;
352 }
353 }
354}
355
356#[cfg(unix)]
357fn libc_exdev_code() -> i32 {
358 18 }
360#[cfg(not(unix))]
361fn libc_exdev_code() -> i32 {
362 -1
363}
364
365#[cfg(test)]
366#[allow(clippy::field_reassign_with_default)]
367mod tests {
368 use super::*;
369 use std::path::PathBuf;
370
371 fn settings_with_workspace_dir(path: &str) -> AppSettings {
372 let mut s = AppSettings::default();
373 s.workspace_dir = Some(PathBuf::from(path));
374 s.theme = "gruvbox_dark".to_string();
375 s
376 }
377
378 #[test]
379 fn full_phase1_migration_creates_default_workspace() {
380 let dir = tempfile::TempDir::new().unwrap();
381 let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
382
383 let migrated = ConfigMigration::run(&mut settings).unwrap();
384
385 assert!(migrated);
386 assert!(settings.workspace_dir.is_none());
387 assert!(settings.last_paths.is_empty());
388 assert_eq!(settings.config_version, CURRENT_CONFIG_VERSION);
389 let wc = settings.workspace_config.as_ref().unwrap();
390 assert!(wc.workspaces.contains_key("default"));
391 assert_eq!(wc.global.current_workspace, "default");
392 }
393
394 #[test]
395 fn full_phase1_migration_fails_for_missing_dir() {
396 let mut settings = settings_with_workspace_dir("/nonexistent/path/that/does/not/exist");
397 let result = ConfigMigration::run(&mut settings);
398 assert!(result.is_err());
399 assert!(result.unwrap_err().to_string().contains("Cannot migrate"));
400 }
401
402 #[test]
403 fn orphaned_workspace_dir_migrated_into_existing_config() {
404 let dir = tempfile::TempDir::new().unwrap();
405 let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
406
407 let other_dir = tempfile::TempDir::new().unwrap();
409 let mut wc = WorkspaceConfig::new_empty();
410 wc.add_workspace("production".to_string(), other_dir.path().to_path_buf())
411 .unwrap();
412 wc.global.current_workspace = "production".to_string();
413 settings.workspace_config = Some(wc);
414
415 let migrated = ConfigMigration::run(&mut settings).unwrap();
416
417 assert!(migrated);
418 assert!(settings.workspace_dir.is_none());
419 let wc = settings.workspace_config.as_ref().unwrap();
420 assert!(wc.workspaces.contains_key("default"));
421 assert!(wc.workspaces.contains_key("production"));
422 assert_eq!(wc.global.current_workspace, "production"); }
424
425 #[test]
426 fn orphaned_workspace_dir_skipped_if_same_path_exists() {
427 let dir = tempfile::TempDir::new().unwrap();
428 let mut settings = settings_with_workspace_dir(dir.path().to_str().unwrap());
429
430 let mut wc = WorkspaceConfig::new_empty();
432 wc.add_workspace("existing".to_string(), dir.path().to_path_buf())
433 .unwrap();
434 wc.global.current_workspace = "existing".to_string();
435 settings.workspace_config = Some(wc);
436
437 ConfigMigration::run(&mut settings).unwrap();
438
439 let wc = settings.workspace_config.as_ref().unwrap();
440 assert_eq!(wc.workspaces.len(), 1); assert!(wc.workspaces.contains_key("existing"));
442 }
443
444 #[test]
445 fn unique_name_avoids_collision() {
446 let mut wc = WorkspaceConfig::new_empty();
447 let dir = tempfile::TempDir::new().unwrap();
448 wc.add_workspace("default".to_string(), dir.path().to_path_buf())
449 .unwrap();
450
451 let name = ConfigMigration::unique_workspace_name(&wc, "default");
452 assert_eq!(name, "default-2");
453 }
454
455 #[test]
456 fn no_migration_when_no_legacy_fields() {
457 let mut settings = AppSettings::default();
458 settings.config_version = CURRENT_CONFIG_VERSION;
459 settings.workspace_config = Some(WorkspaceConfig::new_empty());
460
461 let migrated = ConfigMigration::run(&mut settings).unwrap();
462 assert!(!migrated);
463 }
464
465 #[test]
466 fn v4_moves_ctrl_g_from_followlink_to_leader() {
467 use crate::keys::KeyBindings;
468 use crate::keys::action_shortcuts::ActionShortcuts;
469 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
470 use crate::keys::key_strike::KeyStrike;
471
472 let ctrl = KeyModifiers::new().and_ctrl();
473 let ctrl_g = KeyCombo::new(ctrl, KeyStrike::KeyG);
474 let ctrl_n = KeyCombo::new(ctrl, KeyStrike::KeyN);
475
476 let mut settings = AppSettings::default();
478 let mut map = std::collections::HashMap::new();
479 map.insert(ActionShortcuts::FollowLink, vec![ctrl_g]);
480 settings.key_bindings = KeyBindings::from_hashmap(map);
481 settings.config_version = 3;
482
483 assert!(ConfigMigration::run(&mut settings).unwrap());
484 let map = settings.key_bindings.to_hashmap();
485 assert_eq!(map.get(&ActionShortcuts::Leader), Some(&vec![ctrl_g]));
486 assert_eq!(map.get(&ActionShortcuts::FollowLink), Some(&vec![ctrl_n]));
487 assert_eq!(settings.config_version, CURRENT_CONFIG_VERSION);
488 }
489
490 #[test]
491 fn v6_moves_settings_to_ctrl_comma() {
492 use crate::keys::KeyBindings;
493 use crate::keys::action_shortcuts::ActionShortcuts;
494 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
495 use crate::keys::key_strike::KeyStrike;
496
497 let ctrl = KeyModifiers::new().and_ctrl();
498 let ctrl_shift_p = KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyP);
499 let ctrl_comma = KeyCombo::new(ctrl, KeyStrike::Comma);
500
501 let mut settings = AppSettings::default();
502 let mut map = std::collections::HashMap::new();
503 map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_shift_p]);
504 settings.key_bindings = KeyBindings::from_hashmap(map);
505 settings.config_version = 5;
506
507 assert!(ConfigMigration::run(&mut settings).unwrap());
508 let map = settings.key_bindings.to_hashmap();
509 assert_eq!(
510 map.get(&ActionShortcuts::OpenPreferences),
511 Some(&vec![ctrl_comma])
512 );
513 }
514
515 #[test]
516 fn v5_swaps_palette_onto_ctrl_p() {
517 use crate::keys::KeyBindings;
518 use crate::keys::action_shortcuts::ActionShortcuts;
519 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
520 use crate::keys::key_strike::KeyStrike;
521
522 let ctrl = KeyModifiers::new().and_ctrl();
523 let ctrl_p = KeyCombo::new(ctrl, KeyStrike::KeyP);
524 let ctrl_shift_p = KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyP);
525
526 let mut settings = AppSettings::default();
527 let mut map = std::collections::HashMap::new();
528 map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_p]);
529 settings.key_bindings = KeyBindings::from_hashmap(map);
530 settings.config_version = 4;
531
532 assert!(ConfigMigration::run(&mut settings).unwrap());
533 let map = settings.key_bindings.to_hashmap();
534 assert_eq!(
535 map.get(&ActionShortcuts::OpenCommandPalette),
536 Some(&vec![ctrl_p])
537 );
538 let ctrl_comma = KeyCombo::new(ctrl, KeyStrike::Comma);
540 assert_eq!(
541 map.get(&ActionShortcuts::OpenPreferences),
542 Some(&vec![ctrl_comma])
543 );
544 let _ = ctrl_shift_p;
545 }
546
547 #[test]
548 fn v5_leaves_customised_settings_binding_alone() {
549 use crate::keys::KeyBindings;
550 use crate::keys::action_shortcuts::ActionShortcuts;
551 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
552 use crate::keys::key_strike::KeyStrike;
553
554 let ctrl = KeyModifiers::new().and_ctrl();
555 let ctrl_x = KeyCombo::new(ctrl, KeyStrike::KeyX);
556
557 let mut settings = AppSettings::default();
558 let mut map = std::collections::HashMap::new();
559 map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_x]);
560 settings.key_bindings = KeyBindings::from_hashmap(map);
561 settings.config_version = 4;
562
563 ConfigMigration::run(&mut settings).unwrap();
564 let map = settings.key_bindings.to_hashmap();
565 assert_eq!(
566 map.get(&ActionShortcuts::OpenPreferences),
567 Some(&vec![ctrl_x])
568 );
569 }
570
571 #[test]
572 fn v4_leaves_customised_followlink_alone() {
573 use crate::keys::KeyBindings;
574 use crate::keys::action_shortcuts::ActionShortcuts;
575 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
576 use crate::keys::key_strike::KeyStrike;
577
578 let ctrl = KeyModifiers::new().and_ctrl();
579 let ctrl_x = KeyCombo::new(ctrl, KeyStrike::KeyX);
580
581 let mut settings = AppSettings::default();
582 let mut map = std::collections::HashMap::new();
583 map.insert(ActionShortcuts::FollowLink, vec![ctrl_x]);
584 settings.key_bindings = KeyBindings::from_hashmap(map);
585 settings.config_version = 3;
586
587 ConfigMigration::run(&mut settings).unwrap();
588 let map = settings.key_bindings.to_hashmap();
589 assert_eq!(map.get(&ActionShortcuts::FollowLink), Some(&vec![ctrl_x]));
591 assert!(
592 map.get(&ActionShortcuts::Leader)
593 .is_none_or(|v| v.is_empty())
594 );
595 }
596}