Skip to main content

fresh/input/
command_registry.rs

1//! Dynamic command registry for plugins and extensions
2//!
3//! This module allows plugins to register custom commands dynamically
4//! while maintaining the built-in command set.
5
6use crate::input::commands::{get_all_commands, Command, CommandSource, Suggestion};
7use crate::input::fuzzy::fuzzy_match;
8use crate::input::keybindings::Action;
9use crate::input::keybindings::KeyContext;
10use std::sync::{Arc, RwLock};
11
12/// Registry for managing editor commands
13///
14/// Supports both built-in commands and dynamically registered plugin commands.
15/// Thread-safe for use across multiple threads (e.g., from async tasks).
16pub struct CommandRegistry {
17    /// Built-in commands (loaded once at startup)
18    builtin_commands: Vec<Command>,
19
20    /// Plugin-registered commands (dynamically added/removed)
21    plugin_commands: Arc<RwLock<Vec<Command>>>,
22
23    /// Command usage history (most recent first)
24    /// Used to sort command palette suggestions by recency
25    command_history: Vec<String>,
26}
27
28impl CommandRegistry {
29    /// Maximum number of commands to keep in history
30    const MAX_HISTORY_SIZE: usize = 50;
31
32    /// Create a new command registry with built-in commands
33    pub fn new() -> Self {
34        Self {
35            builtin_commands: get_all_commands(),
36            plugin_commands: Arc::new(RwLock::new(Vec::new())),
37            command_history: Vec::new(),
38        }
39    }
40
41    /// Refresh built-in commands (e.g. after locale change)
42    pub fn refresh_builtin_commands(&mut self) {
43        self.builtin_commands = get_all_commands();
44    }
45
46    /// Record that a command was used (for history/sorting)
47    ///
48    /// This moves the command to the front of the history list.
49    /// Recently used commands appear first in suggestions.
50    pub fn record_usage(&mut self, command_name: &str) {
51        // Remove existing entry if present
52        self.command_history.retain(|name| name != command_name);
53
54        // Add to front (most recent)
55        self.command_history.insert(0, command_name.to_string());
56
57        // Trim to max size
58        if self.command_history.len() > Self::MAX_HISTORY_SIZE {
59            self.command_history.truncate(Self::MAX_HISTORY_SIZE);
60        }
61    }
62
63    /// Get the position of a command in history (0 = most recent)
64    /// Returns None if command is not in history
65    fn history_position(&self, command_name: &str) -> Option<usize> {
66        self.command_history
67            .iter()
68            .position(|name| name == command_name)
69    }
70
71    /// Register a new command (typically from a plugin)
72    ///
73    /// If a command with the same name already exists, it will be replaced.
74    /// This allows plugins to override built-in commands.
75    /// Note: For plugin commands, prefer `try_register` which enforces
76    /// first-writer-wins semantics. This method is kept for internal use
77    /// and hot-reload scenarios.
78    pub fn register(&self, command: Command) {
79        tracing::debug!(
80            "CommandRegistry::register: name='{}', action={:?}",
81            command.name,
82            command.action
83        );
84        let mut commands = self.plugin_commands.write().unwrap();
85
86        // Remove existing command with same name
87        commands.retain(|c| c.name != command.name);
88
89        // Add new command
90        commands.push(command);
91        tracing::debug!(
92            "CommandRegistry::register: plugin_commands now has {} items",
93            commands.len()
94        );
95    }
96
97    /// Try to register a command, failing if a command with the same name
98    /// is already registered by a different plugin (first-writer-wins).
99    ///
100    /// Returns `Ok(())` on success, or `Err` with the name of the existing
101    /// plugin that owns the command.
102    pub fn try_register(&self, command: Command) -> Result<(), (String, CommandSource)> {
103        let mut commands = self.plugin_commands.write().unwrap();
104
105        if let Some(existing) = commands.iter().find(|c| c.name == command.name) {
106            // Allow same plugin to re-register (hot-reload)
107            if existing.source == command.source {
108                commands.retain(|c| c.name != command.name);
109                commands.push(command);
110                return Ok(());
111            }
112            return Err((existing.name.clone(), existing.source.clone()));
113        }
114
115        commands.push(command);
116        Ok(())
117    }
118
119    /// Unregister a command by name
120    pub fn unregister(&self, name: &str) {
121        let mut commands = self.plugin_commands.write().unwrap();
122        commands.retain(|c| c.name != name);
123    }
124
125    /// Unregister all commands whose name starts with a prefix
126    pub fn unregister_by_prefix(&self, prefix: &str) {
127        let mut commands = self.plugin_commands.write().unwrap();
128        commands.retain(|c| !c.name.starts_with(prefix));
129    }
130
131    /// Unregister all commands registered by a specific plugin
132    pub fn unregister_by_plugin(&self, plugin_name: &str) {
133        let mut commands = self.plugin_commands.write().unwrap();
134        let before = commands.len();
135        commands.retain(|c| {
136            if let super::commands::CommandSource::Plugin(ref name) = c.source {
137                name != plugin_name
138            } else {
139                true
140            }
141        });
142        let removed = before - commands.len();
143        if removed > 0 {
144            tracing::debug!(
145                "Unregistered {} commands from plugin '{}'",
146                removed,
147                plugin_name
148            );
149        }
150    }
151
152    /// Get all commands (built-in + plugin)
153    pub fn get_all(&self) -> Vec<Command> {
154        let mut all_commands = self.builtin_commands.clone();
155
156        let plugin_commands = self.plugin_commands.read().unwrap();
157        let plugin_count = plugin_commands.len();
158
159        // Debug: check if vi_mode_toggle is in plugin commands
160        let target_action =
161            crate::input::keybindings::Action::PluginAction("vi_mode_toggle".to_string());
162        let has_target = plugin_commands.iter().any(|c| c.action == target_action);
163        if has_target {
164            tracing::debug!("get_all: vi_mode_toggle found via comparison!");
165        } else if plugin_count > 0 {
166            tracing::debug!(
167                "get_all: {} plugin commands but vi_mode_toggle NOT found",
168                plugin_count
169            );
170        }
171
172        all_commands.extend(plugin_commands.iter().cloned());
173
174        tracing::trace!(
175            "CommandRegistry::get_all: {} builtin + {} plugin = {} total",
176            self.builtin_commands.len(),
177            plugin_count,
178            all_commands.len()
179        );
180        all_commands
181    }
182
183    /// Filter commands by fuzzy matching query with context awareness
184    ///
185    /// When query is empty, commands are sorted by recency (most recently used first).
186    /// When query is not empty, commands are sorted by match quality (fzf-style scoring)
187    /// with recency as tiebreaker for equal scores.
188    /// Disabled commands always appear after enabled ones.
189    ///
190    /// `has_lsp_config` indicates whether the active buffer's language has an LSP server
191    /// configured. When false, LSP start/restart/toggle commands are disabled.
192    #[allow(clippy::too_many_arguments)]
193    pub fn filter(
194        &self,
195        query: &str,
196        current_context: KeyContext,
197        keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
198        selection_active: bool,
199        active_custom_contexts: &std::collections::HashSet<String>,
200        active_buffer_mode: Option<&str>,
201        has_lsp_config: bool,
202    ) -> Vec<Suggestion> {
203        let commands = self.get_all();
204
205        // Helper function to check if command should be visible (custom context check)
206        // Commands with unmet custom contexts are completely hidden, not just disabled
207        // A custom context is satisfied if:
208        // 1. It's in the global active_custom_contexts set, OR
209        // 2. It matches the focused buffer's mode (for buffer-scoped commands)
210        let is_visible = |cmd: &Command| -> bool {
211            cmd.custom_contexts.is_empty()
212                || cmd.custom_contexts.iter().all(|ctx| {
213                    active_custom_contexts.contains(ctx)
214                        || active_buffer_mode.is_some_and(|mode| mode == ctx)
215                })
216        };
217
218        // Helper function to check if command is available in current context
219        let is_available = |cmd: &Command| -> bool {
220            // Global commands are always available
221            if cmd.contexts.contains(&KeyContext::Global) {
222                return true;
223            }
224
225            // Check built-in contexts
226            cmd.contexts.is_empty() || cmd.contexts.contains(&current_context)
227        };
228
229        // Helper to create a suggestion from a command
230        let current_context_ref = &current_context;
231        let make_suggestion =
232            |cmd: &Command, score: i32, localized_name: String, localized_desc: String| {
233                let mut available = is_available(cmd);
234                if cmd.action == Action::FindInSelection && !selection_active {
235                    available = false;
236                }
237                // Disable LSP start/restart/toggle commands when no LSP is configured
238                if !has_lsp_config
239                    && matches!(cmd.action, Action::LspRestart | Action::LspToggleForBuffer)
240                {
241                    available = false;
242                }
243                let keybinding = keybinding_resolver
244                    .get_keybinding_for_action(&cmd.action, current_context_ref.clone());
245                let history_pos = self.history_position(&cmd.name);
246
247                let suggestion = Suggestion::with_source(
248                    localized_name,
249                    Some(localized_desc),
250                    !available,
251                    keybinding,
252                    Some(cmd.source.clone()),
253                );
254                (suggestion, history_pos, score)
255            };
256
257        // Match by name or description
258        // Commands with unmet custom contexts are completely hidden
259        // match_kind: 0 = name match, 1 = description match
260        let mut suggestions: Vec<(Suggestion, Option<usize>, i32, u8)> = commands
261            .iter()
262            .filter(|cmd| is_visible(cmd))
263            .filter_map(|cmd| {
264                let localized_name = cmd.get_localized_name();
265                let name_result = fuzzy_match(query, &localized_name);
266                if name_result.matched {
267                    let localized_desc = cmd.get_localized_description();
268                    let (suggestion, hist, score) =
269                        make_suggestion(cmd, name_result.score, localized_name, localized_desc);
270                    Some((suggestion, hist, score, 0))
271                } else if !query.is_empty() {
272                    let localized_desc = cmd.get_localized_description();
273                    let desc_result = fuzzy_match(query, &localized_desc);
274                    if desc_result.matched {
275                        let (suggestion, hist, score) =
276                            make_suggestion(cmd, desc_result.score, localized_name, localized_desc);
277                        Some((suggestion, hist, score, 1))
278                    } else {
279                        None
280                    }
281                } else {
282                    None
283                }
284            })
285            .collect();
286
287        // Sort by:
288        // 1. Disabled status (enabled first)
289        // 2. Match kind (name matches before description matches) - only when query is not empty
290        // 3. Fuzzy match score (higher is better) - only when query is not empty
291        // 4. History position (recent first, then never-used alphabetically)
292        let has_query = !query.is_empty();
293        suggestions.sort_by(
294            |(a, a_hist, a_score, a_kind), (b, b_hist, b_score, b_kind)| {
295                // First sort by disabled status
296                match a.disabled.cmp(&b.disabled) {
297                    std::cmp::Ordering::Equal => {}
298                    other => return other,
299                }
300
301                if has_query {
302                    // Name matches before description matches
303                    match a_kind.cmp(b_kind) {
304                        std::cmp::Ordering::Equal => {}
305                        other => return other,
306                    }
307
308                    // Within the same kind, sort by fuzzy score (higher is better)
309                    match b_score.cmp(a_score) {
310                        std::cmp::Ordering::Equal => {}
311                        other => return other,
312                    }
313                }
314
315                // Then sort by history position (lower = more recent = better)
316                match (a_hist, b_hist) {
317                    (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
318                    (Some(_), None) => std::cmp::Ordering::Less, // In history beats not in history
319                    (None, Some(_)) => std::cmp::Ordering::Greater,
320                    (None, None) => a.text.cmp(&b.text), // Alphabetical for never-used commands
321                }
322            },
323        );
324
325        // Extract just the suggestions
326        suggestions.into_iter().map(|(s, _, _, _)| s).collect()
327    }
328
329    /// Get count of registered plugin commands
330    pub fn plugin_command_count(&self) -> usize {
331        self.plugin_commands.read().unwrap().len()
332    }
333
334    /// Get count of total commands (built-in + plugin)
335    pub fn total_command_count(&self) -> usize {
336        self.builtin_commands.len() + self.plugin_command_count()
337    }
338
339    /// Find a command by exact name match
340    pub fn find_by_name(&self, name: &str) -> Option<Command> {
341        // Check plugin commands first (they can override built-in)
342        {
343            let plugin_commands = self.plugin_commands.read().unwrap();
344            if let Some(cmd) = plugin_commands.iter().find(|c| c.name == name) {
345                return Some(cmd.clone());
346            }
347        }
348
349        // Then check built-in commands
350        self.builtin_commands
351            .iter()
352            .find(|c| c.name == name)
353            .cloned()
354    }
355}
356
357impl Default for CommandRegistry {
358    fn default() -> Self {
359        Self::new()
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::input::commands::CommandSource;
367    use crate::input::keybindings::Action;
368
369    #[test]
370    fn test_command_registry_creation() {
371        let registry = CommandRegistry::new();
372        assert!(registry.total_command_count() > 0); // Has built-in commands
373        assert_eq!(registry.plugin_command_count(), 0); // No plugin commands yet
374    }
375
376    #[test]
377    fn test_register_command() {
378        let registry = CommandRegistry::new();
379
380        let custom_command = Command {
381            name: "Test Command".to_string(),
382            description: "A test command".to_string(),
383            action: Action::None,
384            contexts: vec![],
385            custom_contexts: vec![],
386            source: CommandSource::Builtin,
387        };
388
389        registry.register(custom_command.clone());
390        assert_eq!(registry.plugin_command_count(), 1);
391
392        let found = registry.find_by_name("Test Command");
393        assert!(found.is_some());
394        assert_eq!(found.unwrap().description, "A test command");
395    }
396
397    #[test]
398    fn test_unregister_command() {
399        let registry = CommandRegistry::new();
400
401        let custom_command = Command {
402            name: "Test Command".to_string(),
403            description: "A test command".to_string(),
404            action: Action::None,
405            contexts: vec![],
406            custom_contexts: vec![],
407            source: CommandSource::Builtin,
408        };
409
410        registry.register(custom_command);
411        assert_eq!(registry.plugin_command_count(), 1);
412
413        registry.unregister("Test Command");
414        assert_eq!(registry.plugin_command_count(), 0);
415    }
416
417    #[test]
418    fn test_register_replaces_existing() {
419        let registry = CommandRegistry::new();
420
421        let command1 = Command {
422            name: "Test Command".to_string(),
423            description: "First version".to_string(),
424            action: Action::None,
425            contexts: vec![],
426            custom_contexts: vec![],
427            source: CommandSource::Builtin,
428        };
429
430        let command2 = Command {
431            name: "Test Command".to_string(),
432            description: "Second version".to_string(),
433            action: Action::None,
434            contexts: vec![],
435            custom_contexts: vec![],
436            source: CommandSource::Builtin,
437        };
438
439        registry.register(command1);
440        assert_eq!(registry.plugin_command_count(), 1);
441
442        registry.register(command2);
443        assert_eq!(registry.plugin_command_count(), 1); // Still just one
444
445        let found = registry.find_by_name("Test Command").unwrap();
446        assert_eq!(found.description, "Second version");
447    }
448
449    #[test]
450    fn test_unregister_by_prefix() {
451        let registry = CommandRegistry::new();
452
453        registry.register(Command {
454            name: "Plugin A: Command 1".to_string(),
455            description: "".to_string(),
456            action: Action::None,
457            contexts: vec![],
458            custom_contexts: vec![],
459            source: CommandSource::Builtin,
460        });
461
462        registry.register(Command {
463            name: "Plugin A: Command 2".to_string(),
464            description: "".to_string(),
465            action: Action::None,
466            contexts: vec![],
467            custom_contexts: vec![],
468            source: CommandSource::Builtin,
469        });
470
471        registry.register(Command {
472            name: "Plugin B: Command".to_string(),
473            description: "".to_string(),
474            action: Action::None,
475            contexts: vec![],
476            custom_contexts: vec![],
477            source: CommandSource::Builtin,
478        });
479
480        assert_eq!(registry.plugin_command_count(), 3);
481
482        registry.unregister_by_prefix("Plugin A:");
483        assert_eq!(registry.plugin_command_count(), 1);
484
485        let remaining = registry.find_by_name("Plugin B: Command");
486        assert!(remaining.is_some());
487    }
488
489    #[test]
490    fn test_filter_commands() {
491        use crate::config::Config;
492        use crate::input::keybindings::KeybindingResolver;
493
494        let registry = CommandRegistry::new();
495        let config = Config::default();
496        let keybindings = KeybindingResolver::new(&config);
497
498        registry.register(Command {
499            name: "Test Save".to_string(),
500            description: "Test save command".to_string(),
501            action: Action::None,
502            contexts: vec![KeyContext::Normal],
503            custom_contexts: vec![],
504            source: CommandSource::Builtin,
505        });
506
507        let empty_contexts = std::collections::HashSet::new();
508        let results = registry.filter(
509            "save",
510            KeyContext::Normal,
511            &keybindings,
512            false,
513            &empty_contexts,
514            None,
515            true,
516        );
517        assert!(results.len() >= 2); // At least "Save File" + "Test Save"
518
519        // Check that both built-in and custom commands appear
520        let names: Vec<String> = results.iter().map(|s| s.text.clone()).collect();
521        assert!(names.iter().any(|n| n.contains("Save")));
522    }
523
524    #[test]
525    fn test_context_filtering() {
526        use crate::config::Config;
527        use crate::input::keybindings::KeybindingResolver;
528
529        let registry = CommandRegistry::new();
530        let config = Config::default();
531        let keybindings = KeybindingResolver::new(&config);
532
533        registry.register(Command {
534            name: "Normal Only".to_string(),
535            description: "Available only in normal context".to_string(),
536            action: Action::None,
537            contexts: vec![KeyContext::Normal],
538            custom_contexts: vec![],
539            source: CommandSource::Builtin,
540        });
541
542        registry.register(Command {
543            name: "Popup Only".to_string(),
544            description: "Available only in popup context".to_string(),
545            action: Action::None,
546            contexts: vec![KeyContext::Popup],
547            custom_contexts: vec![],
548            source: CommandSource::Builtin,
549        });
550
551        // In normal context, "Popup Only" should be disabled
552        let empty_contexts = std::collections::HashSet::new();
553        let results = registry.filter(
554            "",
555            KeyContext::Normal,
556            &keybindings,
557            false,
558            &empty_contexts,
559            None,
560            true,
561        );
562        let popup_only = results.iter().find(|s| s.text == "Popup Only");
563        assert!(popup_only.is_some());
564        assert!(popup_only.unwrap().disabled);
565
566        // In popup context, "Normal Only" should be disabled
567        let results = registry.filter(
568            "",
569            KeyContext::Popup,
570            &keybindings,
571            false,
572            &empty_contexts,
573            None,
574            true,
575        );
576        let normal_only = results.iter().find(|s| s.text == "Normal Only");
577        assert!(normal_only.is_some());
578        assert!(normal_only.unwrap().disabled);
579    }
580
581    #[test]
582    fn test_get_all_merges_commands() {
583        let registry = CommandRegistry::new();
584        let initial_count = registry.total_command_count();
585
586        registry.register(Command {
587            name: "Custom 1".to_string(),
588            description: "".to_string(),
589            action: Action::None,
590            contexts: vec![],
591            custom_contexts: vec![],
592            source: CommandSource::Builtin,
593        });
594
595        registry.register(Command {
596            name: "Custom 2".to_string(),
597            description: "".to_string(),
598            action: Action::None,
599            contexts: vec![],
600            custom_contexts: vec![],
601            source: CommandSource::Builtin,
602        });
603
604        let all = registry.get_all();
605        assert_eq!(all.len(), initial_count + 2);
606    }
607
608    #[test]
609    fn test_plugin_command_overrides_builtin() {
610        let registry = CommandRegistry::new();
611
612        // Check a built-in command exists
613        let builtin = registry.find_by_name("Save File");
614        assert!(builtin.is_some());
615        let original_desc = builtin.unwrap().description;
616
617        // Override it with a plugin command
618        registry.register(Command {
619            name: "Save File".to_string(),
620            description: "Custom save implementation".to_string(),
621            action: Action::None,
622            contexts: vec![],
623            custom_contexts: vec![],
624            source: CommandSource::Builtin,
625        });
626
627        // Should now find the custom version
628        let custom = registry.find_by_name("Save File").unwrap();
629        assert_eq!(custom.description, "Custom save implementation");
630        assert_ne!(custom.description, original_desc);
631    }
632
633    #[test]
634    fn test_record_usage() {
635        let mut registry = CommandRegistry::new();
636
637        registry.record_usage("Save File");
638        assert_eq!(registry.history_position("Save File"), Some(0));
639
640        registry.record_usage("Open File");
641        assert_eq!(registry.history_position("Open File"), Some(0));
642        assert_eq!(registry.history_position("Save File"), Some(1));
643
644        // Using Save File again should move it to front
645        registry.record_usage("Save File");
646        assert_eq!(registry.history_position("Save File"), Some(0));
647        assert_eq!(registry.history_position("Open File"), Some(1));
648    }
649
650    #[test]
651    fn test_history_sorting() {
652        use crate::config::Config;
653        use crate::input::keybindings::KeybindingResolver;
654
655        let mut registry = CommandRegistry::new();
656        let config = Config::default();
657        let keybindings = KeybindingResolver::new(&config);
658
659        // Record some commands
660        registry.record_usage("Quit");
661        registry.record_usage("Save File");
662        registry.record_usage("Open File");
663
664        // Filter with empty query should return history-sorted results
665        let empty_contexts = std::collections::HashSet::new();
666        let results = registry.filter(
667            "",
668            KeyContext::Normal,
669            &keybindings,
670            false,
671            &empty_contexts,
672            None,
673            true,
674        );
675
676        // Find positions of our test commands in results
677        let open_pos = results.iter().position(|s| s.text == "Open File").unwrap();
678        let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
679        let quit_pos = results.iter().position(|s| s.text == "Quit").unwrap();
680
681        // Most recently used should be first
682        assert!(
683            open_pos < save_pos,
684            "Open File should come before Save File"
685        );
686        assert!(save_pos < quit_pos, "Save File should come before Quit");
687    }
688
689    #[test]
690    fn test_history_max_size() {
691        let mut registry = CommandRegistry::new();
692
693        // Add more than MAX_HISTORY_SIZE commands
694        for i in 0..60 {
695            registry.record_usage(&format!("Command {}", i));
696        }
697
698        // Should be trimmed to MAX_HISTORY_SIZE
699        assert_eq!(
700            registry.command_history.len(),
701            CommandRegistry::MAX_HISTORY_SIZE
702        );
703
704        // Most recent should still be at front
705        assert_eq!(registry.history_position("Command 59"), Some(0));
706
707        // Oldest should be trimmed
708        assert_eq!(registry.history_position("Command 0"), None);
709    }
710
711    #[test]
712    fn test_unused_commands_alphabetical() {
713        use crate::config::Config;
714        use crate::input::keybindings::KeybindingResolver;
715
716        let mut registry = CommandRegistry::new();
717        let config = Config::default();
718        let keybindings = KeybindingResolver::new(&config);
719
720        // Register some custom commands (never used)
721        registry.register(Command {
722            name: "Zebra Command".to_string(),
723            description: "".to_string(),
724            action: Action::None,
725            contexts: vec![],
726            custom_contexts: vec![],
727            source: CommandSource::Builtin,
728        });
729
730        registry.register(Command {
731            name: "Alpha Command".to_string(),
732            description: "".to_string(),
733            action: Action::None,
734            contexts: vec![],
735            custom_contexts: vec![],
736            source: CommandSource::Builtin,
737        });
738
739        // Use one built-in command
740        registry.record_usage("Save File");
741
742        let empty_contexts = std::collections::HashSet::new();
743        let results = registry.filter(
744            "",
745            KeyContext::Normal,
746            &keybindings,
747            false,
748            &empty_contexts,
749            None,
750            true,
751        );
752
753        let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
754        let alpha_pos = results
755            .iter()
756            .position(|s| s.text == "Alpha Command")
757            .unwrap();
758        let zebra_pos = results
759            .iter()
760            .position(|s| s.text == "Zebra Command")
761            .unwrap();
762
763        // Used command should be first
764        assert!(
765            save_pos < alpha_pos,
766            "Save File should come before Alpha Command"
767        );
768        // Unused commands should be alphabetical
769        assert!(
770            alpha_pos < zebra_pos,
771            "Alpha Command should come before Zebra Command"
772        );
773    }
774
775    #[test]
776    fn test_required_commands_exist() {
777        // This test ensures that all required command palette entries exist.
778        // If this test fails, it means a command was removed or renamed.
779        crate::i18n::set_locale("en");
780        let registry = CommandRegistry::new();
781
782        let required_commands = [
783            // LSP commands
784            ("Show Completions", Action::LspCompletion),
785            ("Go to Definition", Action::LspGotoDefinition),
786            ("Show Hover Info", Action::LspHover),
787            ("Find References", Action::LspReferences),
788            // Help commands
789            ("Show Manual", Action::ShowHelp),
790            ("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
791            // Scroll commands
792            ("Scroll Up", Action::ScrollUp),
793            ("Scroll Down", Action::ScrollDown),
794            ("Scroll Tabs Left", Action::ScrollTabsLeft),
795            ("Scroll Tabs Right", Action::ScrollTabsRight),
796            // Navigation commands
797            ("Smart Home", Action::SmartHome),
798            // Delete commands
799            ("Delete Word Backward", Action::DeleteWordBackward),
800            ("Delete Word Forward", Action::DeleteWordForward),
801            ("Delete to End of Line", Action::DeleteToLineEnd),
802        ];
803
804        for (name, expected_action) in required_commands {
805            let cmd = registry.find_by_name(name);
806            assert!(
807                cmd.is_some(),
808                "Command '{}' should exist in command palette",
809                name
810            );
811            assert_eq!(
812                cmd.unwrap().action,
813                expected_action,
814                "Command '{}' should have action {:?}",
815                name,
816                expected_action
817            );
818        }
819    }
820
821    #[test]
822    fn test_try_register_first_writer_wins() {
823        let registry = CommandRegistry::new();
824
825        let cmd_a = Command {
826            name: "My Command".to_string(),
827            description: "From plugin A".to_string(),
828            action: Action::None,
829            contexts: vec![],
830            custom_contexts: vec![],
831            source: CommandSource::Plugin("plugin-a".to_string()),
832        };
833
834        let cmd_b = Command {
835            name: "My Command".to_string(),
836            description: "From plugin B".to_string(),
837            action: Action::None,
838            contexts: vec![],
839            custom_contexts: vec![],
840            source: CommandSource::Plugin("plugin-b".to_string()),
841        };
842
843        // First registration succeeds
844        assert!(registry.try_register(cmd_a).is_ok());
845        assert_eq!(registry.plugin_command_count(), 1);
846
847        // Second registration by different plugin fails
848        let result = registry.try_register(cmd_b);
849        assert!(result.is_err());
850        assert_eq!(registry.plugin_command_count(), 1);
851
852        // Original plugin's command is still there
853        let found = registry.find_by_name("My Command").unwrap();
854        assert_eq!(found.description, "From plugin A");
855    }
856
857    #[test]
858    fn test_try_register_same_plugin_allowed() {
859        let registry = CommandRegistry::new();
860
861        let cmd1 = Command {
862            name: "My Command".to_string(),
863            description: "Version 1".to_string(),
864            action: Action::None,
865            contexts: vec![],
866            custom_contexts: vec![],
867            source: CommandSource::Plugin("plugin-a".to_string()),
868        };
869
870        let cmd2 = Command {
871            name: "My Command".to_string(),
872            description: "Version 2".to_string(),
873            action: Action::None,
874            contexts: vec![],
875            custom_contexts: vec![],
876            source: CommandSource::Plugin("plugin-a".to_string()),
877        };
878
879        assert!(registry.try_register(cmd1).is_ok());
880        // Same plugin re-registering is allowed (hot-reload)
881        assert!(registry.try_register(cmd2).is_ok());
882        assert_eq!(registry.plugin_command_count(), 1);
883
884        let found = registry.find_by_name("My Command").unwrap();
885        assert_eq!(found.description, "Version 2");
886    }
887
888    #[test]
889    fn test_try_register_after_unregister() {
890        let registry = CommandRegistry::new();
891
892        let cmd_a = Command {
893            name: "My Command".to_string(),
894            description: "From plugin A".to_string(),
895            action: Action::None,
896            contexts: vec![],
897            custom_contexts: vec![],
898            source: CommandSource::Plugin("plugin-a".to_string()),
899        };
900
901        let cmd_b = Command {
902            name: "My Command".to_string(),
903            description: "From plugin B".to_string(),
904            action: Action::None,
905            contexts: vec![],
906            custom_contexts: vec![],
907            source: CommandSource::Plugin("plugin-b".to_string()),
908        };
909
910        // Plugin A registers
911        assert!(registry.try_register(cmd_a).is_ok());
912
913        // Unregister clears the slot
914        registry.unregister("My Command");
915        assert_eq!(registry.plugin_command_count(), 0);
916
917        // Now plugin B can register
918        assert!(registry.try_register(cmd_b).is_ok());
919        let found = registry.find_by_name("My Command").unwrap();
920        assert_eq!(found.description, "From plugin B");
921    }
922}