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