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 = 6;
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        // Future migrations go here, gated on config_version:
65        // if settings.config_version < 7 { ... migrated = true; }
66
67        if migrated {
68            settings.config_version = CURRENT_CONFIG_VERSION;
69        }
70
71        Ok(migrated)
72    }
73
74    /// v5 → v6: settings move from Ctrl+Shift+P to Ctrl+, — Ctrl+Shift+P is
75    /// kitty's default hints-kitten chord prefix, which holds the screen
76    /// mid-chord and made the binding look broken there. Only applies when
77    /// the binding is still at the v5 default.
78    fn migrate_to_v6(settings: &mut AppSettings) {
79        use crate::keys::KeyBindings;
80        use crate::keys::action_shortcuts::ActionShortcuts;
81        use crate::keys::key_combo::KeyCombo;
82        use crate::keys::key_strike::KeyStrike;
83
84        let ctrl = crate::keys::key_combo::KeyModifiers::new().and_ctrl();
85        let ctrl_shift_p = KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyP);
86        let ctrl_comma = KeyCombo::new(ctrl, KeyStrike::Comma);
87
88        let mut map = settings.key_bindings.to_hashmap();
89        let at_old_default = map
90            .get(&ActionShortcuts::OpenPreferences)
91            .is_some_and(|v| v.as_slice() == [ctrl_shift_p]);
92        let comma_free = !map.values().flatten().any(|c| *c == ctrl_comma);
93        if at_old_default && comma_free {
94            map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_comma]);
95        }
96        settings.key_bindings = KeyBindings::from_hashmap(map);
97    }
98
99    /// v4 → v5: swap the palette onto Ctrl-P and settings onto Ctrl+Shift+P —
100    /// only for bindings still at their previous defaults; customised ones
101    /// are left untouched.
102    fn migrate_to_v5(settings: &mut AppSettings) {
103        use crate::keys::KeyBindings;
104        use crate::keys::action_shortcuts::ActionShortcuts;
105        use crate::keys::key_combo::KeyCombo;
106        use crate::keys::key_strike::KeyStrike;
107
108        let ctrl = crate::keys::key_combo::KeyModifiers::new().and_ctrl();
109        let ctrl_shift = ctrl.and_shift();
110        let ctrl_p = KeyCombo::new(ctrl, KeyStrike::KeyP);
111        let ctrl_shift_p = KeyCombo::new(ctrl_shift, KeyStrike::KeyP);
112
113        let mut map = settings.key_bindings.to_hashmap();
114        let settings_is_old_default = map
115            .get(&ActionShortcuts::OpenPreferences)
116            .is_some_and(|v| v.as_slice() == [ctrl_p]);
117        let palette_unset_or_old_default = map
118            .get(&ActionShortcuts::OpenCommandPalette)
119            .is_none_or(|v| v.is_empty() || v.as_slice() == [ctrl_shift_p]);
120        if settings_is_old_default && palette_unset_or_old_default {
121            map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_shift_p]);
122            map.insert(ActionShortcuts::OpenCommandPalette, vec![ctrl_p]);
123        }
124        settings.key_bindings = KeyBindings::from_hashmap(map);
125    }
126
127    /// v3 → v4: move Ctrl-G from FollowLink to the new Leader gateway —
128    /// but only when the user still had the old default (FollowLink bound
129    /// to exactly Ctrl-G); customised bindings are left untouched, and the
130    /// leader is then inserted only if Ctrl-G is free.
131    fn migrate_to_v4(settings: &mut AppSettings) {
132        use crate::keys::KeyBindings;
133        use crate::keys::action_shortcuts::ActionShortcuts;
134        use crate::keys::key_combo::KeyCombo;
135        use crate::keys::key_strike::KeyStrike;
136
137        let ctrl = crate::keys::key_combo::KeyModifiers::new().and_ctrl();
138        let ctrl_g = KeyCombo::new(ctrl, KeyStrike::KeyG);
139        let ctrl_n = KeyCombo::new(ctrl, KeyStrike::KeyN);
140
141        let mut map = settings.key_bindings.to_hashmap();
142        let follow_is_old_default = map
143            .get(&ActionShortcuts::FollowLink)
144            .is_some_and(|v| v.as_slice() == [ctrl_g]);
145        if follow_is_old_default {
146            // Old default: hand Ctrl-G to the leader, FollowLink → Ctrl-N.
147            map.insert(ActionShortcuts::FollowLink, vec![ctrl_n]);
148            map.entry(ActionShortcuts::Leader).or_default().push(ctrl_g);
149        }
150        settings.key_bindings = KeyBindings::from_hashmap(map);
151        // (If the user had customised FollowLink, the leader simply stays
152        // unbound until `merge_missing_default_bindings` finds Ctrl-G free
153        // or the user binds it explicitly.)
154    }
155}
156
157#[cfg(test)]
158#[allow(clippy::field_reassign_with_default)]
159mod tests {
160    use super::*;
161    use crate::settings::workspace_config::WorkspaceConfig;
162
163    #[test]
164    fn a_config_already_at_the_current_version_is_left_alone() {
165        let mut settings = AppSettings::default();
166        settings.config_version = CURRENT_CONFIG_VERSION;
167        settings.workspace_config = Some(WorkspaceConfig::new_empty());
168
169        let migrated = ConfigMigration::run(&mut settings).unwrap();
170        assert!(!migrated);
171    }
172
173    #[test]
174    fn v4_moves_ctrl_g_from_followlink_to_leader() {
175        use crate::keys::KeyBindings;
176        use crate::keys::action_shortcuts::ActionShortcuts;
177        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
178        use crate::keys::key_strike::KeyStrike;
179
180        let ctrl = KeyModifiers::new().and_ctrl();
181        let ctrl_g = KeyCombo::new(ctrl, KeyStrike::KeyG);
182        let ctrl_n = KeyCombo::new(ctrl, KeyStrike::KeyN);
183
184        // Old default: FollowLink bound to exactly Ctrl-G.
185        let mut settings = AppSettings::default();
186        let mut map = std::collections::HashMap::new();
187        map.insert(ActionShortcuts::FollowLink, vec![ctrl_g]);
188        settings.key_bindings = KeyBindings::from_hashmap(map);
189        settings.config_version = 3;
190
191        assert!(ConfigMigration::run(&mut settings).unwrap());
192        let map = settings.key_bindings.to_hashmap();
193        assert_eq!(map.get(&ActionShortcuts::Leader), Some(&vec![ctrl_g]));
194        assert_eq!(map.get(&ActionShortcuts::FollowLink), Some(&vec![ctrl_n]));
195        assert_eq!(settings.config_version, CURRENT_CONFIG_VERSION);
196    }
197
198    #[test]
199    fn v6_moves_settings_to_ctrl_comma() {
200        use crate::keys::KeyBindings;
201        use crate::keys::action_shortcuts::ActionShortcuts;
202        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
203        use crate::keys::key_strike::KeyStrike;
204
205        let ctrl = KeyModifiers::new().and_ctrl();
206        let ctrl_shift_p = KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyP);
207        let ctrl_comma = KeyCombo::new(ctrl, KeyStrike::Comma);
208
209        let mut settings = AppSettings::default();
210        let mut map = std::collections::HashMap::new();
211        map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_shift_p]);
212        settings.key_bindings = KeyBindings::from_hashmap(map);
213        settings.config_version = 5;
214
215        assert!(ConfigMigration::run(&mut settings).unwrap());
216        let map = settings.key_bindings.to_hashmap();
217        assert_eq!(
218            map.get(&ActionShortcuts::OpenPreferences),
219            Some(&vec![ctrl_comma])
220        );
221    }
222
223    #[test]
224    fn v5_swaps_palette_onto_ctrl_p() {
225        use crate::keys::KeyBindings;
226        use crate::keys::action_shortcuts::ActionShortcuts;
227        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
228        use crate::keys::key_strike::KeyStrike;
229
230        let ctrl = KeyModifiers::new().and_ctrl();
231        let ctrl_p = KeyCombo::new(ctrl, KeyStrike::KeyP);
232        let ctrl_shift_p = KeyCombo::new(ctrl.and_shift(), KeyStrike::KeyP);
233
234        let mut settings = AppSettings::default();
235        let mut map = std::collections::HashMap::new();
236        map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_p]);
237        settings.key_bindings = KeyBindings::from_hashmap(map);
238        settings.config_version = 4;
239
240        assert!(ConfigMigration::run(&mut settings).unwrap());
241        let map = settings.key_bindings.to_hashmap();
242        assert_eq!(
243            map.get(&ActionShortcuts::OpenCommandPalette),
244            Some(&vec![ctrl_p])
245        );
246        // v6 chains after v5: settings end on Ctrl+, (kitty collision).
247        let ctrl_comma = KeyCombo::new(ctrl, KeyStrike::Comma);
248        assert_eq!(
249            map.get(&ActionShortcuts::OpenPreferences),
250            Some(&vec![ctrl_comma])
251        );
252        let _ = ctrl_shift_p;
253    }
254
255    #[test]
256    fn v5_leaves_customised_settings_binding_alone() {
257        use crate::keys::KeyBindings;
258        use crate::keys::action_shortcuts::ActionShortcuts;
259        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
260        use crate::keys::key_strike::KeyStrike;
261
262        let ctrl = KeyModifiers::new().and_ctrl();
263        let ctrl_x = KeyCombo::new(ctrl, KeyStrike::KeyX);
264
265        let mut settings = AppSettings::default();
266        let mut map = std::collections::HashMap::new();
267        map.insert(ActionShortcuts::OpenPreferences, vec![ctrl_x]);
268        settings.key_bindings = KeyBindings::from_hashmap(map);
269        settings.config_version = 4;
270
271        ConfigMigration::run(&mut settings).unwrap();
272        let map = settings.key_bindings.to_hashmap();
273        assert_eq!(
274            map.get(&ActionShortcuts::OpenPreferences),
275            Some(&vec![ctrl_x])
276        );
277    }
278
279    #[test]
280    fn v4_leaves_customised_followlink_alone() {
281        use crate::keys::KeyBindings;
282        use crate::keys::action_shortcuts::ActionShortcuts;
283        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
284        use crate::keys::key_strike::KeyStrike;
285
286        let ctrl = KeyModifiers::new().and_ctrl();
287        let ctrl_x = KeyCombo::new(ctrl, KeyStrike::KeyX);
288
289        let mut settings = AppSettings::default();
290        let mut map = std::collections::HashMap::new();
291        map.insert(ActionShortcuts::FollowLink, vec![ctrl_x]);
292        settings.key_bindings = KeyBindings::from_hashmap(map);
293        settings.config_version = 3;
294
295        ConfigMigration::run(&mut settings).unwrap();
296        let map = settings.key_bindings.to_hashmap();
297        // Customised binding untouched; the leader is not force-bound.
298        assert_eq!(map.get(&ActionShortcuts::FollowLink), Some(&vec![ctrl_x]));
299        assert!(
300            map.get(&ActionShortcuts::Leader)
301                .is_none_or(|v| v.is_empty())
302        );
303    }
304}