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;
9use super::workspace_config::{WorkspaceConfig, WorkspaceEntry};
10
11/// Current config version. Bump this when adding a new migration step.
12pub const CURRENT_CONFIG_VERSION: u32 = 6;
13
14/// Runs all necessary migrations on `settings`, mutating it in place.
15/// Returns `true` if any migration was applied (caller should persist).
16pub struct ConfigMigration;
17
18impl ConfigMigration {
19    /// Apply all pending migrations to bring `settings` up to
20    /// `CURRENT_CONFIG_VERSION`. Returns `true` if any migration ran.
21    pub fn run(settings: &mut AppSettings) -> Result<bool, SettingsError> {
22        let mut migrated = false;
23
24        // v1 → v2: workspace_dir → workspace_config
25        if settings.workspace_dir.is_some() {
26            Self::migrate_workspace_dir(settings)?;
27            migrated = true;
28        }
29
30        // Validate current_workspace points to an existing entry.
31        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        // v2 → v3: move per-workspace SQLite cache + extract last_paths history.
46        if settings.config_version < 3 {
47            Self::migrate_to_v3(settings)?;
48            migrated = true;
49        }
50
51        // v3 → v4: the leader gateway takes Ctrl-G; FollowLink moves to
52        // Ctrl-N (plus the hardcoded Ctrl+Enter on kitty-protocol terminals).
53        if settings.config_version < 4 {
54            Self::migrate_to_v4(settings);
55            migrated = true;
56        }
57
58        // v4 → v5: Ctrl-P becomes the command palette; settings move to
59        // Ctrl+Shift+P.
60        if settings.config_version < 5 {
61            Self::migrate_to_v5(settings);
62            migrated = true;
63        }
64
65        // v5 → v6: settings move from Ctrl+Shift+P (kitty chord-prefix
66        // collision) to Ctrl+,.
67        if settings.config_version < 6 {
68            Self::migrate_to_v6(settings);
69            migrated = true;
70        }
71
72        // Future migrations go here, gated on config_version:
73        // if settings.config_version < 7 { ... migrated = true; }
74
75        if migrated {
76            settings.config_version = CURRENT_CONFIG_VERSION;
77        }
78
79        Ok(migrated)
80    }
81
82    /// v5 → v6: settings move from Ctrl+Shift+P to Ctrl+, — Ctrl+Shift+P is
83    /// kitty's default hints-kitten chord prefix, which holds the screen
84    /// mid-chord and made the binding look broken there. Only applies when
85    /// the binding is still at the v5 default.
86    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    /// v4 → v5: swap the palette onto Ctrl-P and settings onto Ctrl+Shift+P —
108    /// only for bindings still at their previous defaults; customised ones
109    /// are left untouched.
110    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    /// v3 → v4: move Ctrl-G from FollowLink to the new Leader gateway —
136    /// but only when the user still had the old default (FollowLink bound
137    /// to exactly Ctrl-G); customised bindings are left untouched, and the
138    /// leader is then inserted only if Ctrl-G is free.
139    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            // Old default: hand Ctrl-G to the leader, FollowLink → Ctrl-N.
155            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        // (If the user had customised FollowLink, the leader simply stays
160        // unbound until `merge_missing_default_bindings` finds Ctrl-G free
161        // or the user binds it explicitly.)
162    }
163
164    /// v2 → v3: move `<workspace>/kimun.sqlite` to
165    /// `<cache_dir>/<workspace>.kimuncache` and extract per-workspace
166    /// `last_paths` to `<history_dir>/<workspace>.txt`. Then clear the
167    /// in-memory `last_paths` so the next save does not re-write them.
168    ///
169    /// Pre-flight: validates every workspace name; aborts with a single
170    /// error listing every bad name. Idempotent: skips any step whose
171    /// destination already exists.
172    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                        // EXDEV: source and destination on different filesystems —
241                        // rename(2) cannot cross mount points; fall back to copy + unlink.
242                        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    /// Migrate the legacy `workspace_dir` field into `workspace_config`.
276    ///
277    /// Two sub-cases:
278    /// 1. No `workspace_config` exists — full migration: create one with a
279    ///    "default" workspace from the legacy fields.
280    /// 2. `workspace_config` already exists — the legacy field is orphaned
281    ///    (e.g. from a partial earlier migration). Add it as "default" if no
282    ///    workspace already points to the same path.
283    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            // Full Phase 1 → Phase 2 migration.
290            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            // Theme stays as the top-level field — no duplication.
305        } else if let Some(ref mut wc) = settings.workspace_config {
306            // Phase 2 config exists but legacy workspace_dir was still present.
307            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    /// Find a unique workspace name starting from `base`. If `base` is taken,
340    /// tries `base-2`, `base-3`, etc.
341    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 // EXDEV on Linux
359}
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        // Pre-existing Phase 2 config with a different workspace.
408        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"); // unchanged
423    }
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        // Pre-existing config already has a workspace at the same path.
431        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); // not duplicated
441        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        // Old default: FollowLink bound to exactly Ctrl-G.
477        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        // v6 chains after v5: settings end on Ctrl+, (kitty collision).
539        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        // Customised binding untouched; the leader is not force-bound.
590        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}