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 super::AppSettings;
8use super::SettingsError;
9
10/// Current config version. Bump this when adding a new migration step.
11///
12/// Migrations below v3 have been removed: they upgraded the pre-`workspace_config`
13/// layout (`workspace_dir` + top-level `last_paths`) and moved the index out of
14/// the vault, and every installation has long since passed through them. The
15/// oldest config this build understands is a v3 one.
16pub const CURRENT_CONFIG_VERSION: u32 = 7;
17
18/// Runs all necessary migrations on `settings`, mutating it in place.
19/// Returns `true` if any migration was applied (caller should persist).
20pub struct ConfigMigration;
21
22impl ConfigMigration {
23    /// Apply all pending migrations to bring `settings` up to
24    /// `CURRENT_CONFIG_VERSION`. Returns `true` if any migration ran.
25    pub fn run(settings: &mut AppSettings) -> Result<bool, SettingsError> {
26        let mut migrated = false;
27
28        // Validate current_workspace points to an existing entry.
29        if let Some(ref mut wc) = settings.workspace_config
30            && !wc.global.current_workspace.is_empty()
31            && !wc.workspaces.contains_key(&wc.global.current_workspace)
32        {
33            let first = wc.workspaces.keys().next().cloned().unwrap_or_default();
34            tracing::warn!(
35                "current_workspace '{}' does not exist, resetting to '{}'",
36                wc.global.current_workspace,
37                first
38            );
39            wc.global.current_workspace = first;
40            migrated = true;
41        }
42
43        // v3 → v4: the leader gateway takes Ctrl-G; FollowLink moves to
44        // Ctrl-N (plus the hardcoded Ctrl+Enter on kitty-protocol terminals).
45        if settings.config_version < 4 {
46            Self::migrate_to_v4(settings);
47            migrated = true;
48        }
49
50        // v4 → v5: Ctrl-P becomes the command palette; settings move to
51        // Ctrl+Shift+P.
52        if settings.config_version < 5 {
53            Self::migrate_to_v5(settings);
54            migrated = true;
55        }
56
57        // v5 → v6: settings move from Ctrl+Shift+P (kitty chord-prefix
58        // collision) to Ctrl+,.
59        if settings.config_version < 6 {
60            Self::migrate_to_v6(settings);
61            migrated = true;
62        }
63
64        // v6 → v7: the formatting chords retire to the leader's `+text` group.
65        if settings.config_version < 7 {
66            Self::migrate_to_v7(settings);
67            migrated = true;
68        }
69
70        // Future migrations go here, gated on config_version:
71        // if settings.config_version < 8 { ... migrated = true; }
72
73        if migrated {
74            settings.config_version = CURRENT_CONFIG_VERSION;
75        }
76
77        Ok(migrated)
78    }
79
80    /// v5 → v6: settings move from Ctrl+Shift+P to Ctrl+, — Ctrl+Shift+P is
81    /// kitty's default hints-kitten chord prefix, which holds the screen
82    /// mid-chord and made the binding look broken there. Only applies when
83    /// the binding is still at the v5 default.
84    fn migrate_to_v6(settings: &mut AppSettings) {
85        use crate::keys::KeyBindings;
86        use crate::keys::action_shortcuts::ActionShortcuts;
87        use crate::keys::key_combo::KeyCombo;
88        use crate::keys::key_strike::KeyStrike;
89
90        let ctrl = crate::keys::key_combo::KeyModifiers::new().and_ctrl();
91        let ctrl_shift_p = KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyP);
92        let ctrl_comma = KeyCombo::new(ctrl, KeyStrike::Comma);
93
94        let mut map = settings.key_bindings.to_hashmap();
95        let at_old_default =
96            still_at_default(&map, &ActionShortcuts::OpenPreferences, ctrl_shift_p);
97        let comma_free = !map.values().flatten().any(|c| *c == ctrl_comma);
98        if at_old_default && comma_free {
99            map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_comma]);
100        }
101        settings.key_bindings = KeyBindings::from_hashmap(map);
102    }
103
104    /// v6 → v7: drop the retired formatting chords, which moved to the
105    /// leader's `+text` group (`<leader> t b` / `t i` / `t s`).
106    ///
107    /// Needed because `key_bindings` is serialized: every config written
108    /// before this release carries the old defaults — `TextEditor-Bold =
109    /// ["ctrl&B"]` and friends — and `merge_missing_default_bindings` only
110    /// ever *adds*. Without this step the change reaches new installs only.
111    /// Existing ones would keep the chords *and* gain the leader group, which
112    /// is more inconsistent than before it, and two of those chords
113    /// (`Ctrl+I`, `Ctrl+Shift+L`) cannot arrive on a legacy terminal at all —
114    /// so the startup warning would fire about bindings kimün itself wrote.
115    ///
116    /// Each entry goes only if it still holds exactly the combo that was its
117    /// default; an edited one is left alone.
118    fn migrate_to_v7(settings: &mut AppSettings) {
119        use crate::keys::KeyBindings;
120        use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
121        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
122        use crate::keys::key_strike::KeyStrike;
123
124        let ctrl = KeyModifiers::new().and_ctrl();
125        let retired = [
126            (TextAction::Bold, KeyCombo::new(ctrl, KeyStrike::KeyB)),
127            (TextAction::Italic, KeyCombo::new(ctrl, KeyStrike::KeyI)),
128            (TextAction::Underline, KeyCombo::new(ctrl, KeyStrike::KeyU)),
129            (
130                TextAction::Strikethrough,
131                KeyCombo::new(ctrl, KeyStrike::KeyS),
132            ),
133            (
134                TextAction::Image,
135                KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyL),
136            ),
137            // `Link → Ctrl+L` and `ToggleHeader → Ctrl+T` were defaults on
138            // paper only: overwritten inside `default_keybindings` before it
139            // was ever serialized, so no v6 file carries them. A file that
140            // does was edited by hand, and is left alone like any other edit.
141        ];
142
143        let mut map = settings.key_bindings.to_hashmap();
144        for (action, old_default) in retired {
145            let action = ActionShortcuts::Text(action);
146            if still_at_default(&map, &action, old_default) {
147                map.remove(&action);
148            }
149        }
150        settings.key_bindings = KeyBindings::from_hashmap(map);
151    }
152
153    /// v4 → v5: swap the palette onto Ctrl-P and settings onto Ctrl+Shift+P —
154    /// only for bindings still at their previous defaults; customised ones
155    /// are left untouched.
156    fn migrate_to_v5(settings: &mut AppSettings) {
157        use crate::keys::KeyBindings;
158        use crate::keys::action_shortcuts::ActionShortcuts;
159        use crate::keys::key_combo::KeyCombo;
160        use crate::keys::key_strike::KeyStrike;
161
162        let ctrl = crate::keys::key_combo::KeyModifiers::new().and_ctrl();
163        let ctrl_shift = ctrl.and_shift();
164        let ctrl_p = KeyCombo::new(ctrl, KeyStrike::KeyP);
165        let ctrl_shift_p = KeyCombo::new(ctrl_shift, KeyStrike::KeyP);
166
167        let mut map = settings.key_bindings.to_hashmap();
168        let settings_is_old_default =
169            still_at_default(&map, &ActionShortcuts::OpenPreferences, ctrl_p);
170        let palette_unset_or_old_default = map
171            .get(&ActionShortcuts::OpenCommandPalette)
172            .is_none_or(|v| v.is_empty() || v.as_slice() == [ctrl_shift_p]);
173        if settings_is_old_default && palette_unset_or_old_default {
174            map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_shift_p]);
175            map.insert(ActionShortcuts::OpenCommandPalette, vec![ctrl_p]);
176        }
177        settings.key_bindings = KeyBindings::from_hashmap(map);
178    }
179
180    /// v3 → v4: move Ctrl-G from FollowLink to the new Leader gateway —
181    /// but only when the user still had the old default (FollowLink bound
182    /// to exactly Ctrl-G); customised bindings are left untouched, and the
183    /// leader is then inserted only if Ctrl-G is free.
184    fn migrate_to_v4(settings: &mut AppSettings) {
185        use crate::keys::KeyBindings;
186        use crate::keys::action_shortcuts::ActionShortcuts;
187        use crate::keys::key_combo::KeyCombo;
188        use crate::keys::key_strike::KeyStrike;
189
190        let ctrl = crate::keys::key_combo::KeyModifiers::new().and_ctrl();
191        let ctrl_g = KeyCombo::new(ctrl, KeyStrike::KeyG);
192        let ctrl_n = KeyCombo::new(ctrl, KeyStrike::KeyN);
193
194        let mut map = settings.key_bindings.to_hashmap();
195        let follow_is_old_default = still_at_default(&map, &ActionShortcuts::FollowLink, ctrl_g);
196        if follow_is_old_default {
197            // Old default: hand Ctrl-G to the leader, FollowLink → Ctrl-N.
198            map.insert(ActionShortcuts::FollowLink, vec![ctrl_n]);
199            map.entry(ActionShortcuts::Leader).or_default().push(ctrl_g);
200        }
201        settings.key_bindings = KeyBindings::from_hashmap(map);
202        // (If the user had customised FollowLink, the leader simply stays
203        // unbound until `merge_missing_default_bindings` finds Ctrl-G free
204        // or the user binds it explicitly.)
205    }
206}
207
208/// Whether `action` is bound to exactly `default` and nothing else — the
209/// "the user never touched this" test every keymap migration makes before it
210/// moves a binding. Someone who typed the old default by hand is
211/// indistinguishable from someone who inherited it; that is the accepted
212/// cost of serializing defaults, and re-adding the line is how they get it
213/// back.
214fn still_at_default(
215    map: &std::collections::HashMap<
216        crate::keys::action_shortcuts::ActionShortcuts,
217        Vec<crate::keys::key_combo::KeyCombo>,
218    >,
219    action: &crate::keys::action_shortcuts::ActionShortcuts,
220    default: crate::keys::key_combo::KeyCombo,
221) -> bool {
222    map.get(action).is_some_and(|v| v.as_slice() == [default])
223}
224
225#[cfg(test)]
226#[allow(clippy::field_reassign_with_default)]
227mod tests {
228    use super::*;
229    use crate::settings::workspace_config::WorkspaceConfig;
230
231    #[test]
232    fn a_config_already_at_the_current_version_is_left_alone() {
233        let mut settings = AppSettings::default();
234        settings.config_version = CURRENT_CONFIG_VERSION;
235        settings.workspace_config = Some(WorkspaceConfig::new_empty());
236
237        let migrated = ConfigMigration::run(&mut settings).unwrap();
238        assert!(!migrated);
239    }
240
241    #[test]
242    fn v4_moves_ctrl_g_from_followlink_to_leader() {
243        use crate::keys::KeyBindings;
244        use crate::keys::action_shortcuts::ActionShortcuts;
245        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
246        use crate::keys::key_strike::KeyStrike;
247
248        let ctrl = KeyModifiers::new().and_ctrl();
249        let ctrl_g = KeyCombo::new(ctrl, KeyStrike::KeyG);
250        let ctrl_n = KeyCombo::new(ctrl, KeyStrike::KeyN);
251
252        // Old default: FollowLink bound to exactly Ctrl-G.
253        let mut settings = AppSettings::default();
254        let mut map = std::collections::HashMap::new();
255        map.insert(ActionShortcuts::FollowLink, vec![ctrl_g]);
256        settings.key_bindings = KeyBindings::from_hashmap(map);
257        settings.config_version = 3;
258
259        assert!(ConfigMigration::run(&mut settings).unwrap());
260        let map = settings.key_bindings.to_hashmap();
261        assert_eq!(map.get(&ActionShortcuts::Leader), Some(&vec![ctrl_g]));
262        assert_eq!(map.get(&ActionShortcuts::FollowLink), Some(&vec![ctrl_n]));
263        assert_eq!(settings.config_version, CURRENT_CONFIG_VERSION);
264    }
265
266    /// The retired formatting chords leave existing configs, so the leader
267    /// really is the only route — for upgraders as well as new installs.
268    #[test]
269    fn v7_retires_the_formatting_chords() {
270        use crate::keys::KeyBindings;
271        use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
272        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
273        use crate::keys::key_strike::KeyStrike;
274
275        let ctrl = KeyModifiers::new().and_ctrl();
276        let mut settings = AppSettings::default();
277        let mut map = std::collections::HashMap::new();
278        // Exactly what a pre-v7 config.toml carries.
279        for (action, key) in [
280            (TextAction::Bold, KeyStrike::KeyB),
281            (TextAction::Italic, KeyStrike::KeyI),
282            (TextAction::Underline, KeyStrike::KeyU),
283            (TextAction::Strikethrough, KeyStrike::KeyS),
284        ] {
285            map.insert(
286                ActionShortcuts::Text(action),
287                vec![KeyCombo::new(ctrl, key)],
288            );
289        }
290        map.insert(
291            ActionShortcuts::Text(TextAction::Image),
292            vec![KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyL)],
293        );
294        // A chord the user chose themselves, which must survive.
295        let ctrl_y = KeyCombo::new(ctrl, KeyStrike::KeyY);
296        map.insert(ActionShortcuts::Text(TextAction::Header(1)), vec![ctrl_y]);
297        settings.key_bindings = KeyBindings::from_hashmap(map);
298        settings.config_version = 6;
299
300        assert!(ConfigMigration::run(&mut settings).unwrap());
301        let map = settings.key_bindings.to_hashmap();
302        for action in [
303            TextAction::Bold,
304            TextAction::Italic,
305            TextAction::Underline,
306            TextAction::Strikethrough,
307            TextAction::Image,
308        ] {
309            let name = format!("{action:?}");
310            assert!(
311                !map.contains_key(&ActionShortcuts::Text(action)),
312                "{name} should have retired to the leader"
313            );
314        }
315        assert_eq!(
316            map.get(&ActionShortcuts::Text(TextAction::Header(1))),
317            Some(&vec![ctrl_y]),
318            "a chord the user chose is not ours to retire"
319        );
320    }
321
322    /// An edited formatting chord is the user's, not an inherited default, so
323    /// the migration leaves it where it is.
324    #[test]
325    fn v7_leaves_a_customised_formatting_chord_alone() {
326        use crate::keys::KeyBindings;
327        use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
328        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
329        use crate::keys::key_strike::KeyStrike;
330
331        let ctrl = KeyModifiers::new().and_ctrl();
332        let moved = KeyCombo::new(ctrl.and_alt(), KeyStrike::KeyB);
333        let mut settings = AppSettings::default();
334        settings.key_bindings = KeyBindings::from_hashmap(std::collections::HashMap::from([(
335            ActionShortcuts::Text(TextAction::Bold),
336            vec![moved],
337        )]));
338        settings.config_version = 6;
339
340        ConfigMigration::run(&mut settings).unwrap();
341        assert_eq!(
342            settings
343                .key_bindings
344                .to_hashmap()
345                .get(&ActionShortcuts::Text(TextAction::Bold)),
346            Some(&vec![moved])
347        );
348    }
349
350    /// `Link → Ctrl+L` and `ToggleHeader → Ctrl+T` were never written to a
351    /// config by kimün, so a v6 file that carries one was typed by hand —
352    /// and a hand-typed line is the user's to keep.
353    #[test]
354    fn v7_keeps_a_hand_written_link_or_header_chord() {
355        use crate::keys::KeyBindings;
356        use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
357        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
358        use crate::keys::key_strike::KeyStrike;
359
360        let ctrl = KeyModifiers::new().and_ctrl();
361        let link = KeyCombo::new(ctrl, KeyStrike::KeyL);
362        let header = KeyCombo::new(ctrl, KeyStrike::KeyT);
363        let mut settings = AppSettings::default();
364        settings.key_bindings = KeyBindings::from_hashmap(std::collections::HashMap::from([
365            (ActionShortcuts::Text(TextAction::Link), vec![link]),
366            (
367                ActionShortcuts::Text(TextAction::ToggleHeader),
368                vec![header],
369            ),
370        ]));
371        settings.config_version = 6;
372
373        ConfigMigration::run(&mut settings).unwrap();
374        let map = settings.key_bindings.to_hashmap();
375        assert_eq!(
376            map.get(&ActionShortcuts::Text(TextAction::Link)),
377            Some(&vec![link])
378        );
379        assert_eq!(
380            map.get(&ActionShortcuts::Text(TextAction::ToggleHeader)),
381            Some(&vec![header])
382        );
383    }
384
385    #[test]
386    fn v6_moves_settings_to_ctrl_comma() {
387        use crate::keys::KeyBindings;
388        use crate::keys::action_shortcuts::ActionShortcuts;
389        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
390        use crate::keys::key_strike::KeyStrike;
391
392        let ctrl = KeyModifiers::new().and_ctrl();
393        let ctrl_shift_p = KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyP);
394        let ctrl_comma = KeyCombo::new(ctrl, KeyStrike::Comma);
395
396        let mut settings = AppSettings::default();
397        let mut map = std::collections::HashMap::new();
398        map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_shift_p]);
399        settings.key_bindings = KeyBindings::from_hashmap(map);
400        settings.config_version = 5;
401
402        assert!(ConfigMigration::run(&mut settings).unwrap());
403        let map = settings.key_bindings.to_hashmap();
404        assert_eq!(
405            map.get(&ActionShortcuts::OpenPreferences),
406            Some(&vec![ctrl_comma])
407        );
408    }
409
410    #[test]
411    fn v5_swaps_palette_onto_ctrl_p() {
412        use crate::keys::KeyBindings;
413        use crate::keys::action_shortcuts::ActionShortcuts;
414        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
415        use crate::keys::key_strike::KeyStrike;
416
417        let ctrl = KeyModifiers::new().and_ctrl();
418        let ctrl_p = KeyCombo::new(ctrl, KeyStrike::KeyP);
419        let ctrl_shift_p = KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyP);
420
421        let mut settings = AppSettings::default();
422        let mut map = std::collections::HashMap::new();
423        map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_p]);
424        settings.key_bindings = KeyBindings::from_hashmap(map);
425        settings.config_version = 4;
426
427        assert!(ConfigMigration::run(&mut settings).unwrap());
428        let map = settings.key_bindings.to_hashmap();
429        assert_eq!(
430            map.get(&ActionShortcuts::OpenCommandPalette),
431            Some(&vec![ctrl_p])
432        );
433        // v6 chains after v5: settings end on Ctrl+, (kitty collision).
434        let ctrl_comma = KeyCombo::new(ctrl, KeyStrike::Comma);
435        assert_eq!(
436            map.get(&ActionShortcuts::OpenPreferences),
437            Some(&vec![ctrl_comma])
438        );
439        let _ = ctrl_shift_p;
440    }
441
442    #[test]
443    fn v5_leaves_customised_settings_binding_alone() {
444        use crate::keys::KeyBindings;
445        use crate::keys::action_shortcuts::ActionShortcuts;
446        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
447        use crate::keys::key_strike::KeyStrike;
448
449        let ctrl = KeyModifiers::new().and_ctrl();
450        let ctrl_x = KeyCombo::new(ctrl, KeyStrike::KeyX);
451
452        let mut settings = AppSettings::default();
453        let mut map = std::collections::HashMap::new();
454        map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_x]);
455        settings.key_bindings = KeyBindings::from_hashmap(map);
456        settings.config_version = 4;
457
458        ConfigMigration::run(&mut settings).unwrap();
459        let map = settings.key_bindings.to_hashmap();
460        assert_eq!(
461            map.get(&ActionShortcuts::OpenPreferences),
462            Some(&vec![ctrl_x])
463        );
464    }
465
466    #[test]
467    fn v4_leaves_customised_followlink_alone() {
468        use crate::keys::KeyBindings;
469        use crate::keys::action_shortcuts::ActionShortcuts;
470        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
471        use crate::keys::key_strike::KeyStrike;
472
473        let ctrl = KeyModifiers::new().and_ctrl();
474        let ctrl_x = KeyCombo::new(ctrl, KeyStrike::KeyX);
475
476        let mut settings = AppSettings::default();
477        let mut map = std::collections::HashMap::new();
478        map.insert(ActionShortcuts::FollowLink, vec![ctrl_x]);
479        settings.key_bindings = KeyBindings::from_hashmap(map);
480        settings.config_version = 3;
481
482        ConfigMigration::run(&mut settings).unwrap();
483        let map = settings.key_bindings.to_hashmap();
484        // Customised binding untouched; the leader is not force-bound.
485        assert_eq!(map.get(&ActionShortcuts::FollowLink), Some(&vec![ctrl_x]));
486        assert!(
487            map.get(&ActionShortcuts::Leader)
488                .is_none_or(|v| v.is_empty())
489        );
490    }
491}