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::new(localized_name)
248                    .with_description(localized_desc)
249                    .set_disabled(!available)
250                    .with_keybinding(keybinding)
251                    .with_source(Some(cmd.source.clone()));
252                (suggestion, history_pos, score)
253            };
254
255        // Match by name or description
256        // Commands with unmet custom contexts are completely hidden
257        // match_kind: 0 = name match, 1 = description match
258        let mut suggestions: Vec<(Suggestion, Option<usize>, i32, u8)> = commands
259            .iter()
260            .filter(|cmd| is_visible(cmd))
261            .filter_map(|cmd| {
262                let localized_name = cmd.get_localized_name();
263                let name_result = fuzzy_match(query, &localized_name);
264                if name_result.matched {
265                    let localized_desc = cmd.get_localized_description();
266                    let (suggestion, hist, score) =
267                        make_suggestion(cmd, name_result.score, localized_name, localized_desc);
268                    Some((suggestion, hist, score, 0))
269                } else if !query.is_empty() {
270                    let localized_desc = cmd.get_localized_description();
271                    let desc_result = fuzzy_match(query, &localized_desc);
272                    if desc_result.matched {
273                        let (suggestion, hist, score) =
274                            make_suggestion(cmd, desc_result.score, localized_name, localized_desc);
275                        Some((suggestion, hist, score, 1))
276                    } else {
277                        None
278                    }
279                } else {
280                    None
281                }
282            })
283            .collect();
284
285        // Sort by:
286        // 1. Disabled status (enabled first)
287        // 2. Match kind (name matches before description matches) - only when query is not empty
288        // 3. Fuzzy match score (higher is better) - only when query is not empty
289        // 4. History position (recent first, then never-used alphabetically)
290        let has_query = !query.is_empty();
291        suggestions.sort_by(
292            |(a, a_hist, a_score, a_kind), (b, b_hist, b_score, b_kind)| {
293                // First sort by disabled status
294                match a.disabled.cmp(&b.disabled) {
295                    std::cmp::Ordering::Equal => {}
296                    other => return other,
297                }
298
299                if has_query {
300                    // Name matches before description matches
301                    match a_kind.cmp(b_kind) {
302                        std::cmp::Ordering::Equal => {}
303                        other => return other,
304                    }
305
306                    // Within the same kind, sort by fuzzy score (higher is better)
307                    match b_score.cmp(a_score) {
308                        std::cmp::Ordering::Equal => {}
309                        other => return other,
310                    }
311                }
312
313                // Then sort by history position (lower = more recent = better)
314                match (a_hist, b_hist) {
315                    (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos),
316                    (Some(_), None) => std::cmp::Ordering::Less, // In history beats not in history
317                    (None, Some(_)) => std::cmp::Ordering::Greater,
318                    (None, None) => a.text.cmp(&b.text), // Alphabetical for never-used commands
319                }
320            },
321        );
322
323        // Extract just the suggestions
324        suggestions.into_iter().map(|(s, _, _, _)| s).collect()
325    }
326
327    /// Get count of registered plugin commands
328    pub fn plugin_command_count(&self) -> usize {
329        self.plugin_commands.read().unwrap().len()
330    }
331
332    /// Get count of total commands (built-in + plugin)
333    pub fn total_command_count(&self) -> usize {
334        self.builtin_commands.len() + self.plugin_command_count()
335    }
336
337    /// Find a command by exact name match
338    pub fn find_by_name(&self, name: &str) -> Option<Command> {
339        // Check plugin commands first (they can override built-in)
340        {
341            let plugin_commands = self.plugin_commands.read().unwrap();
342            if let Some(cmd) = plugin_commands.iter().find(|c| c.name == name) {
343                return Some(cmd.clone());
344            }
345        }
346
347        // Then check built-in commands
348        self.builtin_commands
349            .iter()
350            .find(|c| c.name == name)
351            .cloned()
352    }
353}
354
355impl Default for CommandRegistry {
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::input::commands::CommandSource;
365    use crate::input::keybindings::Action;
366
367    #[test]
368    fn test_command_registry_creation() {
369        let registry = CommandRegistry::new();
370        assert!(registry.total_command_count() > 0); // Has built-in commands
371        assert_eq!(registry.plugin_command_count(), 0); // No plugin commands yet
372    }
373
374    #[test]
375    fn test_register_command() {
376        let registry = CommandRegistry::new();
377
378        let custom_command = Command {
379            name: "Test Command".to_string(),
380            description: "A test command".to_string(),
381            action: Action::None,
382            contexts: vec![],
383            custom_contexts: vec![],
384            source: CommandSource::Builtin,
385        };
386
387        registry.register(custom_command.clone());
388        assert_eq!(registry.plugin_command_count(), 1);
389
390        let found = registry.find_by_name("Test Command");
391        assert!(found.is_some());
392        assert_eq!(found.unwrap().description, "A test command");
393    }
394
395    #[test]
396    fn test_unregister_command() {
397        let registry = CommandRegistry::new();
398
399        let custom_command = Command {
400            name: "Test Command".to_string(),
401            description: "A test command".to_string(),
402            action: Action::None,
403            contexts: vec![],
404            custom_contexts: vec![],
405            source: CommandSource::Builtin,
406        };
407
408        registry.register(custom_command);
409        assert_eq!(registry.plugin_command_count(), 1);
410
411        registry.unregister("Test Command");
412        assert_eq!(registry.plugin_command_count(), 0);
413    }
414
415    #[test]
416    fn test_register_replaces_existing() {
417        let registry = CommandRegistry::new();
418
419        let command1 = Command {
420            name: "Test Command".to_string(),
421            description: "First version".to_string(),
422            action: Action::None,
423            contexts: vec![],
424            custom_contexts: vec![],
425            source: CommandSource::Builtin,
426        };
427
428        let command2 = Command {
429            name: "Test Command".to_string(),
430            description: "Second version".to_string(),
431            action: Action::None,
432            contexts: vec![],
433            custom_contexts: vec![],
434            source: CommandSource::Builtin,
435        };
436
437        registry.register(command1);
438        assert_eq!(registry.plugin_command_count(), 1);
439
440        registry.register(command2);
441        assert_eq!(registry.plugin_command_count(), 1); // Still just one
442
443        let found = registry.find_by_name("Test Command").unwrap();
444        assert_eq!(found.description, "Second version");
445    }
446
447    #[test]
448    fn test_unregister_by_prefix() {
449        let registry = CommandRegistry::new();
450
451        registry.register(Command {
452            name: "Plugin A: Command 1".to_string(),
453            description: "".to_string(),
454            action: Action::None,
455            contexts: vec![],
456            custom_contexts: vec![],
457            source: CommandSource::Builtin,
458        });
459
460        registry.register(Command {
461            name: "Plugin A: Command 2".to_string(),
462            description: "".to_string(),
463            action: Action::None,
464            contexts: vec![],
465            custom_contexts: vec![],
466            source: CommandSource::Builtin,
467        });
468
469        registry.register(Command {
470            name: "Plugin B: Command".to_string(),
471            description: "".to_string(),
472            action: Action::None,
473            contexts: vec![],
474            custom_contexts: vec![],
475            source: CommandSource::Builtin,
476        });
477
478        assert_eq!(registry.plugin_command_count(), 3);
479
480        registry.unregister_by_prefix("Plugin A:");
481        assert_eq!(registry.plugin_command_count(), 1);
482
483        let remaining = registry.find_by_name("Plugin B: Command");
484        assert!(remaining.is_some());
485    }
486
487    #[test]
488    fn test_filter_commands() {
489        use crate::config::Config;
490        use crate::input::keybindings::KeybindingResolver;
491
492        let registry = CommandRegistry::new();
493        let config = Config::default();
494        let keybindings = KeybindingResolver::new(&config);
495
496        registry.register(Command {
497            name: "Test Save".to_string(),
498            description: "Test save command".to_string(),
499            action: Action::None,
500            contexts: vec![KeyContext::Normal],
501            custom_contexts: vec![],
502            source: CommandSource::Builtin,
503        });
504
505        let empty_contexts = std::collections::HashSet::new();
506        let results = registry.filter(
507            "save",
508            KeyContext::Normal,
509            &keybindings,
510            false,
511            &empty_contexts,
512            None,
513            true,
514        );
515        assert!(results.len() >= 2); // At least "Save File" + "Test Save"
516
517        // Check that both built-in and custom commands appear
518        let names: Vec<String> = results.iter().map(|s| s.text.clone()).collect();
519        assert!(names.iter().any(|n| n.contains("Save")));
520    }
521
522    #[test]
523    fn test_context_filtering() {
524        use crate::config::Config;
525        use crate::input::keybindings::KeybindingResolver;
526
527        let registry = CommandRegistry::new();
528        let config = Config::default();
529        let keybindings = KeybindingResolver::new(&config);
530
531        registry.register(Command {
532            name: "Normal Only".to_string(),
533            description: "Available only in normal context".to_string(),
534            action: Action::None,
535            contexts: vec![KeyContext::Normal],
536            custom_contexts: vec![],
537            source: CommandSource::Builtin,
538        });
539
540        registry.register(Command {
541            name: "Popup Only".to_string(),
542            description: "Available only in popup context".to_string(),
543            action: Action::None,
544            contexts: vec![KeyContext::Popup],
545            custom_contexts: vec![],
546            source: CommandSource::Builtin,
547        });
548
549        // In normal context, "Popup Only" should be disabled
550        let empty_contexts = std::collections::HashSet::new();
551        let results = registry.filter(
552            "",
553            KeyContext::Normal,
554            &keybindings,
555            false,
556            &empty_contexts,
557            None,
558            true,
559        );
560        let popup_only = results.iter().find(|s| s.text == "Popup Only");
561        assert!(popup_only.is_some());
562        assert!(popup_only.unwrap().disabled);
563
564        // In popup context, "Normal Only" should be disabled
565        let results = registry.filter(
566            "",
567            KeyContext::Popup,
568            &keybindings,
569            false,
570            &empty_contexts,
571            None,
572            true,
573        );
574        let normal_only = results.iter().find(|s| s.text == "Normal Only");
575        assert!(normal_only.is_some());
576        assert!(normal_only.unwrap().disabled);
577    }
578
579    #[test]
580    fn test_get_all_merges_commands() {
581        let registry = CommandRegistry::new();
582        let initial_count = registry.total_command_count();
583
584        registry.register(Command {
585            name: "Custom 1".to_string(),
586            description: "".to_string(),
587            action: Action::None,
588            contexts: vec![],
589            custom_contexts: vec![],
590            source: CommandSource::Builtin,
591        });
592
593        registry.register(Command {
594            name: "Custom 2".to_string(),
595            description: "".to_string(),
596            action: Action::None,
597            contexts: vec![],
598            custom_contexts: vec![],
599            source: CommandSource::Builtin,
600        });
601
602        let all = registry.get_all();
603        assert_eq!(all.len(), initial_count + 2);
604    }
605
606    #[test]
607    fn test_plugin_command_overrides_builtin() {
608        let registry = CommandRegistry::new();
609
610        // Check a built-in command exists
611        let builtin = registry.find_by_name("Save File");
612        assert!(builtin.is_some());
613        let original_desc = builtin.unwrap().description;
614
615        // Override it with a plugin command
616        registry.register(Command {
617            name: "Save File".to_string(),
618            description: "Custom save implementation".to_string(),
619            action: Action::None,
620            contexts: vec![],
621            custom_contexts: vec![],
622            source: CommandSource::Builtin,
623        });
624
625        // Should now find the custom version
626        let custom = registry.find_by_name("Save File").unwrap();
627        assert_eq!(custom.description, "Custom save implementation");
628        assert_ne!(custom.description, original_desc);
629    }
630
631    #[test]
632    fn test_record_usage() {
633        let mut registry = CommandRegistry::new();
634
635        registry.record_usage("Save File");
636        assert_eq!(registry.history_position("Save File"), Some(0));
637
638        registry.record_usage("Open File");
639        assert_eq!(registry.history_position("Open File"), Some(0));
640        assert_eq!(registry.history_position("Save File"), Some(1));
641
642        // Using Save File again should move it to front
643        registry.record_usage("Save File");
644        assert_eq!(registry.history_position("Save File"), Some(0));
645        assert_eq!(registry.history_position("Open File"), Some(1));
646    }
647
648    #[test]
649    fn test_history_sorting() {
650        use crate::config::Config;
651        use crate::input::keybindings::KeybindingResolver;
652
653        let mut registry = CommandRegistry::new();
654        let config = Config::default();
655        let keybindings = KeybindingResolver::new(&config);
656
657        // Record some commands
658        registry.record_usage("Quit");
659        registry.record_usage("Save File");
660        registry.record_usage("Open File");
661
662        // Filter with empty query should return history-sorted results
663        let empty_contexts = std::collections::HashSet::new();
664        let results = registry.filter(
665            "",
666            KeyContext::Normal,
667            &keybindings,
668            false,
669            &empty_contexts,
670            None,
671            true,
672        );
673
674        // Find positions of our test commands in results
675        let open_pos = results.iter().position(|s| s.text == "Open File").unwrap();
676        let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
677        let quit_pos = results.iter().position(|s| s.text == "Quit").unwrap();
678
679        // Most recently used should be first
680        assert!(
681            open_pos < save_pos,
682            "Open File should come before Save File"
683        );
684        assert!(save_pos < quit_pos, "Save File should come before Quit");
685    }
686
687    #[test]
688    fn test_history_max_size() {
689        let mut registry = CommandRegistry::new();
690
691        // Add more than MAX_HISTORY_SIZE commands
692        for i in 0..60 {
693            registry.record_usage(&format!("Command {}", i));
694        }
695
696        // Should be trimmed to MAX_HISTORY_SIZE
697        assert_eq!(
698            registry.command_history.len(),
699            CommandRegistry::MAX_HISTORY_SIZE
700        );
701
702        // Most recent should still be at front
703        assert_eq!(registry.history_position("Command 59"), Some(0));
704
705        // Oldest should be trimmed
706        assert_eq!(registry.history_position("Command 0"), None);
707    }
708
709    #[test]
710    fn test_unused_commands_alphabetical() {
711        use crate::config::Config;
712        use crate::input::keybindings::KeybindingResolver;
713
714        let mut registry = CommandRegistry::new();
715        let config = Config::default();
716        let keybindings = KeybindingResolver::new(&config);
717
718        // Register some custom commands (never used)
719        registry.register(Command {
720            name: "Zebra Command".to_string(),
721            description: "".to_string(),
722            action: Action::None,
723            contexts: vec![],
724            custom_contexts: vec![],
725            source: CommandSource::Builtin,
726        });
727
728        registry.register(Command {
729            name: "Alpha Command".to_string(),
730            description: "".to_string(),
731            action: Action::None,
732            contexts: vec![],
733            custom_contexts: vec![],
734            source: CommandSource::Builtin,
735        });
736
737        // Use one built-in command
738        registry.record_usage("Save File");
739
740        let empty_contexts = std::collections::HashSet::new();
741        let results = registry.filter(
742            "",
743            KeyContext::Normal,
744            &keybindings,
745            false,
746            &empty_contexts,
747            None,
748            true,
749        );
750
751        let save_pos = results.iter().position(|s| s.text == "Save File").unwrap();
752        let alpha_pos = results
753            .iter()
754            .position(|s| s.text == "Alpha Command")
755            .unwrap();
756        let zebra_pos = results
757            .iter()
758            .position(|s| s.text == "Zebra Command")
759            .unwrap();
760
761        // Used command should be first
762        assert!(
763            save_pos < alpha_pos,
764            "Save File should come before Alpha Command"
765        );
766        // Unused commands should be alphabetical
767        assert!(
768            alpha_pos < zebra_pos,
769            "Alpha Command should come before Zebra Command"
770        );
771    }
772
773    #[test]
774    fn test_required_commands_exist() {
775        // This test ensures that all required command palette entries exist.
776        // If this test fails, it means a command was removed or renamed.
777        crate::i18n::set_locale("en");
778        let registry = CommandRegistry::new();
779
780        let required_commands = [
781            // LSP commands
782            ("Show Completions", Action::LspCompletion),
783            ("Go to Definition", Action::LspGotoDefinition),
784            ("Show Hover Info", Action::LspHover),
785            ("Find References", Action::LspReferences),
786            // Help commands
787            ("Show Manual", Action::ShowHelp),
788            ("Show Keyboard Shortcuts", Action::ShowKeyboardShortcuts),
789            // Scroll commands
790            ("Scroll Up", Action::ScrollUp),
791            ("Scroll Down", Action::ScrollDown),
792            ("Scroll Tabs Left", Action::ScrollTabsLeft),
793            ("Scroll Tabs Right", Action::ScrollTabsRight),
794            // Navigation commands
795            ("Smart Home", Action::SmartHome),
796            // Delete commands
797            ("Delete Word Backward", Action::DeleteWordBackward),
798            ("Delete Word Forward", Action::DeleteWordForward),
799            ("Delete to End of Line", Action::DeleteToLineEnd),
800        ];
801
802        for (name, expected_action) in required_commands {
803            let cmd = registry.find_by_name(name);
804            assert!(
805                cmd.is_some(),
806                "Command '{}' should exist in command palette",
807                name
808            );
809            assert_eq!(
810                cmd.unwrap().action,
811                expected_action,
812                "Command '{}' should have action {:?}",
813                name,
814                expected_action
815            );
816        }
817    }
818
819    #[test]
820    fn test_try_register_first_writer_wins() {
821        let registry = CommandRegistry::new();
822
823        let cmd_a = Command {
824            name: "My Command".to_string(),
825            description: "From plugin A".to_string(),
826            action: Action::None,
827            contexts: vec![],
828            custom_contexts: vec![],
829            source: CommandSource::Plugin("plugin-a".to_string()),
830        };
831
832        let cmd_b = Command {
833            name: "My Command".to_string(),
834            description: "From plugin B".to_string(),
835            action: Action::None,
836            contexts: vec![],
837            custom_contexts: vec![],
838            source: CommandSource::Plugin("plugin-b".to_string()),
839        };
840
841        // First registration succeeds
842        assert!(registry.try_register(cmd_a).is_ok());
843        assert_eq!(registry.plugin_command_count(), 1);
844
845        // Second registration by different plugin fails
846        let result = registry.try_register(cmd_b);
847        assert!(result.is_err());
848        assert_eq!(registry.plugin_command_count(), 1);
849
850        // Original plugin's command is still there
851        let found = registry.find_by_name("My Command").unwrap();
852        assert_eq!(found.description, "From plugin A");
853    }
854
855    #[test]
856    fn test_try_register_same_plugin_allowed() {
857        let registry = CommandRegistry::new();
858
859        let cmd1 = Command {
860            name: "My Command".to_string(),
861            description: "Version 1".to_string(),
862            action: Action::None,
863            contexts: vec![],
864            custom_contexts: vec![],
865            source: CommandSource::Plugin("plugin-a".to_string()),
866        };
867
868        let cmd2 = Command {
869            name: "My Command".to_string(),
870            description: "Version 2".to_string(),
871            action: Action::None,
872            contexts: vec![],
873            custom_contexts: vec![],
874            source: CommandSource::Plugin("plugin-a".to_string()),
875        };
876
877        assert!(registry.try_register(cmd1).is_ok());
878        // Same plugin re-registering is allowed (hot-reload)
879        assert!(registry.try_register(cmd2).is_ok());
880        assert_eq!(registry.plugin_command_count(), 1);
881
882        let found = registry.find_by_name("My Command").unwrap();
883        assert_eq!(found.description, "Version 2");
884    }
885
886    #[test]
887    fn test_try_register_after_unregister() {
888        let registry = CommandRegistry::new();
889
890        let cmd_a = Command {
891            name: "My Command".to_string(),
892            description: "From plugin A".to_string(),
893            action: Action::None,
894            contexts: vec![],
895            custom_contexts: vec![],
896            source: CommandSource::Plugin("plugin-a".to_string()),
897        };
898
899        let cmd_b = Command {
900            name: "My Command".to_string(),
901            description: "From plugin B".to_string(),
902            action: Action::None,
903            contexts: vec![],
904            custom_contexts: vec![],
905            source: CommandSource::Plugin("plugin-b".to_string()),
906        };
907
908        // Plugin A registers
909        assert!(registry.try_register(cmd_a).is_ok());
910
911        // Unregister clears the slot
912        registry.unregister("My Command");
913        assert_eq!(registry.plugin_command_count(), 0);
914
915        // Now plugin B can register
916        assert!(registry.try_register(cmd_b).is_ok());
917        let found = registry.find_by_name("My Command").unwrap();
918        assert_eq!(found.description, "From plugin B");
919    }
920}