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