1use crossterm::event::{KeyCode, KeyModifiers};
7use std::collections::HashSet;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
12pub struct KeyCombo {
13 pub code: KeyCode,
14 pub modifiers: KeyModifiers,
15}
16
17#[derive(Debug, Clone)]
19pub enum KeybindAction {
20 SlashCommand(String),
22 LoadSkill(String),
24 InjectPrompt(String),
26 RunScript { script: String, plugin_dir: PathBuf },
28 Disabled,
30}
31
32#[derive(Debug, Clone, PartialEq)]
34pub enum KeybindSource {
35 Core,
36 User,
37 Plugin(String),
38}
39
40#[derive(Debug, Clone)]
42pub struct Keybind {
43 pub key: KeyCombo,
44 pub action: KeybindAction,
45 pub description: String,
46 pub source: KeybindSource,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct KeybindCollision {
53 pub losing_plugin: String,
55 pub key: String,
57 pub winning_owner: String,
60 pub reason: String,
64}
65
66#[derive(Debug, Clone)]
68pub struct KeybindRegistry {
69 binds: Vec<Keybind>,
70 reserved: HashSet<KeyCombo>,
71 collisions: Vec<KeybindCollision>,
72}
73
74impl Default for KeybindRegistry {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80impl KeybindRegistry {
81 pub fn new() -> Self {
82 let mut registry = Self {
83 binds: Vec::new(),
84 reserved: HashSet::new(),
85 collisions: Vec::new(),
86 };
87 registry.register_core();
88 registry
89 }
90
91 pub fn collisions(&self) -> &[KeybindCollision] {
93 &self.collisions
94 }
95
96 pub fn clear_collisions(&mut self) {
98 self.collisions.clear();
99 }
100
101 fn register_core(&mut self) {
103 let core_keys = vec![
104 (KeyCode::Char('c'), KeyModifiers::CONTROL, "Quit"),
105 (KeyCode::Esc, KeyModifiers::NONE, "Abort stream"),
106 (KeyCode::Enter, KeyModifiers::NONE, "Submit"),
107 (KeyCode::Enter, KeyModifiers::SHIFT, "Newline"),
108 (KeyCode::Tab, KeyModifiers::NONE, "Autocomplete"),
109 (KeyCode::Char('a'), KeyModifiers::CONTROL, "Cursor start"),
110 (KeyCode::Char('e'), KeyModifiers::CONTROL, "Cursor end"),
111 (KeyCode::Char('u'), KeyModifiers::CONTROL, "Clear input"),
112 (KeyCode::Char('w'), KeyModifiers::CONTROL, "Delete word"),
113 (KeyCode::Char('o'), KeyModifiers::CONTROL, "Toggle output"),
114 (KeyCode::Left, KeyModifiers::ALT, "Jump word left"),
115 (KeyCode::Right, KeyModifiers::ALT, "Jump word right"),
116 (KeyCode::Up, KeyModifiers::SHIFT, "Scroll up"),
117 (KeyCode::Down, KeyModifiers::SHIFT, "Scroll down"),
118 (KeyCode::Up, KeyModifiers::NONE, "History up"),
119 (KeyCode::Down, KeyModifiers::NONE, "History down"),
120 (KeyCode::Left, KeyModifiers::NONE, "Cursor left"),
121 (KeyCode::Right, KeyModifiers::NONE, "Cursor right"),
122 (KeyCode::Backspace, KeyModifiers::NONE, "Backspace"),
123 (KeyCode::Backspace, KeyModifiers::ALT, "Delete word"),
124 (KeyCode::Home, KeyModifiers::NONE, "Cursor start"),
125 (KeyCode::End, KeyModifiers::NONE, "Cursor end"),
126 ];
127 for (code, modifiers, desc) in core_keys {
128 let combo = KeyCombo { code, modifiers };
129 self.reserved.insert(combo.clone());
130 self.binds.push(Keybind {
131 key: combo,
132 action: KeybindAction::Disabled, description: desc.to_string(),
134 source: KeybindSource::Core,
135 });
136 }
137 }
138
139 pub fn register_plugin(
141 &mut self,
142 plugin_name: &str,
143 keybinds: &[ManifestKeybind],
144 plugin_dir: &std::path::Path,
145 ) {
146 for kb in keybinds {
147 let combo = match parse_key(&kb.key) {
148 Ok(c) => c,
149 Err(e) => {
150 tracing::warn!(
151 "plugin '{}': invalid keybind '{}': {}",
152 plugin_name,
153 kb.key,
154 e
155 );
156 self.collisions.push(KeybindCollision {
157 losing_plugin: plugin_name.to_string(),
158 key: kb.key.clone(),
159 winning_owner: "n/a".to_string(),
160 reason: format!("invalid notation: {}", e),
161 });
162 continue;
163 }
164 };
165
166 if self.reserved.contains(&combo) {
168 tracing::warn!(
169 "plugin '{}': keybind '{}' conflicts with core — skipped",
170 plugin_name,
171 kb.key
172 );
173 self.collisions.push(KeybindCollision {
174 losing_plugin: plugin_name.to_string(),
175 key: kb.key.clone(),
176 winning_owner: "core".to_string(),
177 reason: "conflicts with core".to_string(),
178 });
179 continue;
180 }
181
182 if let Some(existing) = self
184 .binds
185 .iter()
186 .find(|b| b.key == combo && b.source != KeybindSource::Core)
187 {
188 let winning_owner = match &existing.source {
189 KeybindSource::Plugin(name) => name.clone(),
190 KeybindSource::User => "user".to_string(),
191 KeybindSource::Core => "core".to_string(),
192 };
193 tracing::warn!(
194 "plugin '{}': keybind '{}' already registered — skipped",
195 plugin_name,
196 kb.key
197 );
198 self.collisions.push(KeybindCollision {
199 losing_plugin: plugin_name.to_string(),
200 key: kb.key.clone(),
201 winning_owner,
202 reason: "already registered".to_string(),
203 });
204 continue;
205 }
206
207 let action = match kb.action.as_str() {
208 "slash_command" => {
209 KeybindAction::SlashCommand(kb.command.clone().unwrap_or_default())
210 }
211 "load_skill" => KeybindAction::LoadSkill(kb.skill.clone().unwrap_or_default()),
212 "inject_prompt" => {
213 KeybindAction::InjectPrompt(kb.prompt.clone().unwrap_or_default())
214 }
215 "run_script" => KeybindAction::RunScript {
216 script: kb.script.clone().unwrap_or_default(),
217 plugin_dir: plugin_dir.to_path_buf(),
218 },
219 other => {
220 tracing::warn!(
221 "plugin '{}': unknown keybind action '{}'",
222 plugin_name,
223 other
224 );
225 continue;
226 }
227 };
228
229 self.binds.push(Keybind {
230 key: combo,
231 action,
232 description: kb.description.clone().unwrap_or_default(),
233 source: KeybindSource::Plugin(plugin_name.to_string()),
234 });
235 }
236 }
237
238 pub fn register_user(&mut self, config_keybinds: &std::collections::HashMap<String, String>) {
240 for (key_str, value) in config_keybinds {
241 let combo = match parse_key(key_str) {
242 Ok(c) => c,
243 Err(e) => {
244 tracing::warn!("config: invalid keybind '{}': {}", key_str, e);
245 continue;
246 }
247 };
248
249 if self.reserved.contains(&combo) {
251 tracing::warn!("config: keybind '{}' is a core bind — skipped", key_str);
252 continue;
253 }
254
255 self.binds
257 .retain(|b| b.key != combo || b.source == KeybindSource::Core);
258
259 let action = if value == "disabled" {
260 KeybindAction::Disabled
261 } else if let Some(stripped) = value.strip_prefix('/') {
262 let cmd = stripped.to_string();
263 KeybindAction::SlashCommand(cmd)
264 } else {
265 KeybindAction::InjectPrompt(value.clone())
266 };
267
268 self.binds.push(Keybind {
269 key: combo,
270 action,
271 description: format!("User: {}", value),
272 source: KeybindSource::User,
273 });
274 }
275 }
276
277 pub fn set_slash_command_key(
284 &mut self,
285 slash_command: &str,
286 new_key: &str,
287 ) -> Result<(), String> {
288 let combo = parse_key(new_key)?;
289 if self.reserved.contains(&combo) {
290 return Err(format!("'{}' is reserved by core — cannot rebind", new_key));
291 }
292 self.binds.retain(|b| {
294 if b.source == KeybindSource::Core {
295 return true;
296 }
297 !matches!(&b.action, KeybindAction::SlashCommand(c) if c == slash_command)
298 });
299 self.binds
302 .retain(|b| b.key != combo || b.source == KeybindSource::Core);
303 self.binds.push(Keybind {
304 key: combo,
305 action: KeybindAction::SlashCommand(slash_command.to_string()),
306 description: format!("User: /{}", slash_command),
307 source: KeybindSource::User,
308 });
309 Ok(())
310 }
311
312 pub fn match_key(&self, code: KeyCode, modifiers: KeyModifiers) -> Option<&Keybind> {
315 let combo = KeyCombo { code, modifiers };
316
317 if self.reserved.contains(&combo) {
319 return None;
320 }
321
322 self.binds
323 .iter()
324 .find(|b| b.key == combo && !matches!(b.source, KeybindSource::Core))
325 }
326
327 pub fn all(&self) -> &[Keybind] {
329 &self.binds
330 }
331
332 pub fn custom_binds(&self) -> Vec<&Keybind> {
334 self.binds
335 .iter()
336 .filter(|b| !matches!(b.source, KeybindSource::Core))
337 .collect()
338 }
339}
340
341#[derive(Debug, Clone, serde::Deserialize)]
343pub struct ManifestKeybind {
344 pub key: String,
345 #[serde(default)]
346 pub action: String,
347 #[serde(default)]
348 pub command: Option<String>,
349 #[serde(default)]
350 pub skill: Option<String>,
351 #[serde(default)]
352 pub prompt: Option<String>,
353 #[serde(default)]
354 pub script: Option<String>,
355 #[serde(default)]
356 pub description: Option<String>,
357}
358
359pub fn parse_key(notation: &str) -> Result<KeyCombo, String> {
372 let notation = notation.trim();
373 if notation.is_empty() {
374 return Err("empty key notation".to_string());
375 }
376
377 let parts: Vec<&str> = notation.split('-').collect();
378 let mut modifiers = KeyModifiers::empty();
379
380 for part in &parts[..parts.len().saturating_sub(1)] {
382 match *part {
383 "C" => modifiers |= KeyModifiers::CONTROL,
384 "S" => modifiers |= KeyModifiers::SHIFT,
385 "A" => modifiers |= KeyModifiers::ALT,
386 other => {
387 return Err(format!(
388 "unknown modifier: '{}' (expected C, S, or A)",
389 other
390 ))
391 }
392 }
393 }
394
395 let key_str = parts.last().ok_or("missing key")?;
396 let code = match *key_str {
397 k if k.len() == 1 => {
398 let ch = k.chars().next().unwrap();
399 KeyCode::Char(ch.to_ascii_lowercase())
400 }
401 k if k.starts_with('F') && k.len() <= 3 => {
402 let n: u8 = k[1..]
403 .parse()
404 .map_err(|_| format!("invalid F-key: '{}'", k))?;
405 if !(1..=12).contains(&n) {
406 return Err(format!("F-key out of range: F{} (expected F1–F12)", n));
407 }
408 KeyCode::F(n)
409 }
410 "Space" => KeyCode::Char(' '),
411 "Tab" => KeyCode::Tab,
412 "Enter" => KeyCode::Enter,
413 "Esc" => KeyCode::Esc,
414 "Backspace" | "BS" => KeyCode::Backspace,
415 "Delete" | "Del" => KeyCode::Delete,
416 "Home" => KeyCode::Home,
417 "End" => KeyCode::End,
418 "PageUp" | "PgUp" => KeyCode::PageUp,
419 "PageDown" | "PgDn" => KeyCode::PageDown,
420 "Up" => KeyCode::Up,
421 "Down" => KeyCode::Down,
422 "Left" => KeyCode::Left,
423 "Right" => KeyCode::Right,
424 other => return Err(format!("unknown key: '{}'", other)),
425 };
426
427 Ok(KeyCombo { code, modifiers })
428}
429
430pub fn format_key(combo: &KeyCombo) -> String {
432 let mut parts = Vec::new();
433 if combo.modifiers.contains(KeyModifiers::CONTROL) {
434 parts.push("Ctrl");
435 }
436 if combo.modifiers.contains(KeyModifiers::ALT) {
437 parts.push("Alt");
438 }
439 if combo.modifiers.contains(KeyModifiers::SHIFT) {
440 parts.push("Shift");
441 }
442
443 let key = match combo.code {
444 KeyCode::Char(' ') => "Space".to_string(),
445 KeyCode::Char(c) => c.to_uppercase().to_string(),
446 KeyCode::F(n) => format!("F{}", n),
447 KeyCode::Tab => "Tab".to_string(),
448 KeyCode::Enter => "Enter".to_string(),
449 KeyCode::Esc => "Esc".to_string(),
450 KeyCode::Backspace => "Backspace".to_string(),
451 KeyCode::Delete => "Delete".to_string(),
452 KeyCode::Home => "Home".to_string(),
453 KeyCode::End => "End".to_string(),
454 KeyCode::PageUp => "PageUp".to_string(),
455 KeyCode::PageDown => "PageDown".to_string(),
456 KeyCode::Up => "↑".to_string(),
457 KeyCode::Down => "↓".to_string(),
458 KeyCode::Left => "←".to_string(),
459 KeyCode::Right => "→".to_string(),
460 _ => "?".to_string(),
461 };
462 parts.push(&key);
463 parts.join("+")
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
473 fn parse_single_char() {
474 let k = parse_key("s").unwrap();
475 assert_eq!(k.code, KeyCode::Char('s'));
476 assert_eq!(k.modifiers, KeyModifiers::NONE);
477 }
478
479 #[test]
480 fn parse_ctrl_char() {
481 let k = parse_key("C-s").unwrap();
482 assert_eq!(k.code, KeyCode::Char('s'));
483 assert_eq!(k.modifiers, KeyModifiers::CONTROL);
484 }
485
486 #[test]
487 fn parse_ctrl_shift() {
488 let k = parse_key("C-S-s").unwrap();
489 assert_eq!(k.code, KeyCode::Char('s'));
490 assert_eq!(k.modifiers, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
491 }
492
493 #[test]
494 fn parse_alt() {
495 let k = parse_key("A-p").unwrap();
496 assert_eq!(k.code, KeyCode::Char('p'));
497 assert_eq!(k.modifiers, KeyModifiers::ALT);
498 }
499
500 #[test]
501 fn parse_ctrl_alt() {
502 let k = parse_key("C-A-x").unwrap();
503 assert_eq!(k.code, KeyCode::Char('x'));
504 assert_eq!(k.modifiers, KeyModifiers::CONTROL | KeyModifiers::ALT);
505 }
506
507 #[test]
508 fn parse_f_keys() {
509 let k = parse_key("F5").unwrap();
510 assert_eq!(k.code, KeyCode::F(5));
511 assert_eq!(k.modifiers, KeyModifiers::NONE);
512
513 let k = parse_key("C-F12").unwrap();
514 assert_eq!(k.code, KeyCode::F(12));
515 assert_eq!(k.modifiers, KeyModifiers::CONTROL);
516 }
517
518 #[test]
519 fn parse_special_keys() {
520 assert_eq!(parse_key("Space").unwrap().code, KeyCode::Char(' '));
521 assert_eq!(parse_key("Tab").unwrap().code, KeyCode::Tab);
522 assert_eq!(parse_key("Enter").unwrap().code, KeyCode::Enter);
523 assert_eq!(parse_key("Esc").unwrap().code, KeyCode::Esc);
524 assert_eq!(parse_key("Backspace").unwrap().code, KeyCode::Backspace);
525 assert_eq!(parse_key("Home").unwrap().code, KeyCode::Home);
526 assert_eq!(parse_key("End").unwrap().code, KeyCode::End);
527 }
528
529 #[test]
530 fn parse_ctrl_space() {
531 let k = parse_key("C-Space").unwrap();
532 assert_eq!(k.code, KeyCode::Char(' '));
533 assert_eq!(k.modifiers, KeyModifiers::CONTROL);
534 }
535
536 #[test]
537 fn parse_uppercase_normalized_to_lower() {
538 let k = parse_key("C-S").unwrap();
539 assert_eq!(k.code, KeyCode::Char('s'));
540 }
541
542 #[test]
543 fn parse_empty_errors() {
544 assert!(parse_key("").is_err());
545 assert!(parse_key(" ").is_err());
546 }
547
548 #[test]
549 fn parse_unknown_modifier_errors() {
550 assert!(parse_key("X-s").is_err());
551 }
552
553 #[test]
554 fn parse_unknown_key_errors() {
555 assert!(parse_key("C-FooBar").is_err());
556 }
557
558 #[test]
559 fn parse_f_key_out_of_range() {
560 assert!(parse_key("F0").is_err());
561 assert!(parse_key("F13").is_err());
562 }
563
564 #[test]
567 fn format_ctrl_shift_s() {
568 let k = KeyCombo {
569 code: KeyCode::Char('s'),
570 modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
571 };
572 assert_eq!(format_key(&k), "Ctrl+Shift+S");
573 }
574
575 #[test]
576 fn format_f5() {
577 let k = KeyCombo {
578 code: KeyCode::F(5),
579 modifiers: KeyModifiers::NONE,
580 };
581 assert_eq!(format_key(&k), "F5");
582 }
583
584 #[test]
585 fn format_alt_space() {
586 let k = KeyCombo {
587 code: KeyCode::Char(' '),
588 modifiers: KeyModifiers::ALT,
589 };
590 assert_eq!(format_key(&k), "Alt+Space");
591 }
592
593 #[test]
596 fn core_binds_are_reserved() {
597 let reg = KeybindRegistry::new();
598 assert!(reg
600 .match_key(KeyCode::Char('c'), KeyModifiers::CONTROL)
601 .is_none());
602 }
603
604 #[test]
605 fn plugin_bind_matches() {
606 let mut reg = KeybindRegistry::new();
607 reg.register_plugin(
608 "test",
609 &[ManifestKeybind {
610 key: "C-S-s".to_string(),
611 action: "slash_command".to_string(),
612 command: Some("scholar".to_string()),
613 skill: None,
614 prompt: None,
615 script: None,
616 description: Some("Search papers".to_string()),
617 }],
618 std::path::Path::new("/tmp"),
619 );
620
621 let result = reg.match_key(
622 KeyCode::Char('s'),
623 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
624 );
625 assert!(result.is_some());
626 assert_eq!(result.unwrap().description, "Search papers");
627 }
628
629 #[test]
630 fn plugin_cannot_override_core() {
631 let mut reg = KeybindRegistry::new();
632 reg.register_plugin(
633 "evil",
634 &[ManifestKeybind {
635 key: "C-c".to_string(),
636 action: "inject_prompt".to_string(),
637 command: None,
638 skill: None,
639 prompt: Some("hacked".to_string()),
640 script: None,
641 description: Some("evil".to_string()),
642 }],
643 std::path::Path::new("/tmp"),
644 );
645
646 assert!(reg
648 .match_key(KeyCode::Char('c'), KeyModifiers::CONTROL)
649 .is_none());
650 }
651
652 #[test]
653 fn user_overrides_plugin() {
654 let mut reg = KeybindRegistry::new();
655 reg.register_plugin(
656 "test",
657 &[ManifestKeybind {
658 key: "F5".to_string(),
659 action: "slash_command".to_string(),
660 command: Some("scholar".to_string()),
661 skill: None,
662 prompt: None,
663 script: None,
664 description: Some("Scholar".to_string()),
665 }],
666 std::path::Path::new("/tmp"),
667 );
668
669 let mut overrides = std::collections::HashMap::new();
670 overrides.insert("F5".to_string(), "/compact".to_string());
671 reg.register_user(&overrides);
672
673 let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
674 assert!(result.is_some());
675 assert_eq!(result.unwrap().description, "User: /compact");
676 }
677
678 #[test]
679 fn user_can_disable_bind() {
680 let mut reg = KeybindRegistry::new();
681 reg.register_plugin(
682 "test",
683 &[ManifestKeybind {
684 key: "F5".to_string(),
685 action: "slash_command".to_string(),
686 command: Some("scholar".to_string()),
687 skill: None,
688 prompt: None,
689 script: None,
690 description: Some("Scholar".to_string()),
691 }],
692 std::path::Path::new("/tmp"),
693 );
694
695 let mut overrides = std::collections::HashMap::new();
696 overrides.insert("F5".to_string(), "disabled".to_string());
697 reg.register_user(&overrides);
698
699 let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
700 assert!(result.is_some());
701 assert!(matches!(result.unwrap().action, KeybindAction::Disabled));
702 }
703
704 #[test]
705 fn duplicate_plugin_binds_first_wins() {
706 let mut reg = KeybindRegistry::new();
707 reg.register_plugin(
708 "first",
709 &[ManifestKeybind {
710 key: "F5".to_string(),
711 action: "slash_command".to_string(),
712 command: Some("first".to_string()),
713 skill: None,
714 prompt: None,
715 script: None,
716 description: Some("First".to_string()),
717 }],
718 std::path::Path::new("/tmp"),
719 );
720
721 reg.register_plugin(
722 "second",
723 &[ManifestKeybind {
724 key: "F5".to_string(),
725 action: "slash_command".to_string(),
726 command: Some("second".to_string()),
727 skill: None,
728 prompt: None,
729 script: None,
730 description: Some("Second".to_string()),
731 }],
732 std::path::Path::new("/tmp"),
733 );
734
735 let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
736 assert!(result.is_some());
737 assert_eq!(result.unwrap().description, "First");
738 }
739
740 #[test]
741 fn custom_binds_excludes_core() {
742 let reg = KeybindRegistry::new();
743 let custom = reg.custom_binds();
744 assert!(custom.is_empty()); }
746
747 #[test]
748 fn set_slash_command_key_replaces_existing_sidecar_toggle() {
749 let mut reg = KeybindRegistry::new();
750 let mut overrides = std::collections::HashMap::new();
751 overrides.insert("F8".to_string(), "/sidecar toggle".to_string());
752 reg.register_user(&overrides);
753 let f8 = parse_key("F8").unwrap();
754 assert!(reg.match_key(f8.code, f8.modifiers).is_some());
755
756 reg.set_slash_command_key("sidecar toggle", "C-G").unwrap();
758
759 assert!(reg.match_key(f8.code, f8.modifiers).is_none());
761 let cg = parse_key("C-G").unwrap();
763 let bind = reg
764 .match_key(cg.code, cg.modifiers)
765 .expect("C-G bind missing");
766 assert!(matches!(&bind.action, KeybindAction::SlashCommand(c) if c == "sidecar toggle"));
767 }
768
769 #[test]
770 fn set_slash_command_key_rejects_core_chord() {
771 let mut reg = KeybindRegistry::new();
772 let err = reg
774 .set_slash_command_key("sidecar toggle", "Esc")
775 .unwrap_err();
776 assert!(
777 err.contains("reserved"),
778 "expected reserved error, got: {err}"
779 );
780 }
781
782 fn mk_kb(key: &str, cmd: &str) -> ManifestKeybind {
785 ManifestKeybind {
786 key: key.to_string(),
787 action: "slash_command".to_string(),
788 command: Some(cmd.to_string()),
789 skill: None,
790 prompt: None,
791 script: None,
792 description: Some(cmd.to_string()),
793 }
794 }
795
796 #[test]
797 fn register_plugin_records_core_collision() {
798 let mut reg = KeybindRegistry::new();
799 reg.register_plugin(
801 "evil",
802 &[mk_kb("C-c", "hack")],
803 std::path::Path::new("/tmp"),
804 );
805 assert_eq!(reg.collisions().len(), 1);
806 let c = ®.collisions()[0];
807 assert_eq!(c.losing_plugin, "evil");
808 assert_eq!(c.winning_owner, "core");
809 assert_eq!(c.reason, "conflicts with core");
810 assert_eq!(c.key, "C-c");
811 }
812
813 #[test]
814 fn register_plugin_records_plugin_vs_plugin_collision() {
815 let mut reg = KeybindRegistry::new();
816 reg.register_plugin(
818 "A",
819 &[mk_kb("C-Space", "alpha")],
820 std::path::Path::new("/tmp"),
821 );
822 reg.register_plugin(
823 "B",
824 &[mk_kb("C-Space", "beta")],
825 std::path::Path::new("/tmp"),
826 );
827 assert_eq!(reg.collisions().len(), 1);
828 let c = ®.collisions()[0];
829 assert_eq!(c.losing_plugin, "B");
830 assert_eq!(c.winning_owner, "A");
831 assert_eq!(c.reason, "already registered");
832 }
833
834 #[test]
835 fn register_plugin_records_invalid_key_notation() {
836 let mut reg = KeybindRegistry::new();
837 reg.register_plugin(
838 "weird",
839 &[mk_kb("this is not a key", "noop")],
840 std::path::Path::new("/tmp"),
841 );
842 assert_eq!(reg.collisions().len(), 1);
843 let c = ®.collisions()[0];
844 assert_eq!(c.losing_plugin, "weird");
845 assert_eq!(c.winning_owner, "n/a");
846 assert!(
847 c.reason.starts_with("invalid notation"),
848 "reason should start with 'invalid notation', got: {}",
849 c.reason
850 );
851 }
852
853 #[test]
854 fn collisions_is_empty_when_no_conflicts() {
855 let mut reg = KeybindRegistry::new();
856 reg.register_plugin("solo", &[mk_kb("F7", "solo")], std::path::Path::new("/tmp"));
857 assert!(reg.collisions().is_empty());
858 }
859
860 #[test]
861 fn multiple_collisions_are_all_recorded() {
862 let mut reg = KeybindRegistry::new();
863 reg.register_plugin(
864 "A",
865 &[mk_kb("C-Space", "alpha")],
866 std::path::Path::new("/tmp"),
867 );
868 reg.register_plugin(
870 "B",
871 &[mk_kb("C-Space", "beta")],
872 std::path::Path::new("/tmp"),
873 );
874 reg.register_plugin(
875 "C",
876 &[mk_kb("C-Space", "gamma")],
877 std::path::Path::new("/tmp"),
878 );
879 assert_eq!(reg.collisions().len(), 2);
880 assert_eq!(reg.collisions()[0].losing_plugin, "B");
881 assert_eq!(reg.collisions()[1].losing_plugin, "C");
882 for c in reg.collisions() {
883 assert_eq!(c.winning_owner, "A");
884 assert_eq!(c.reason, "already registered");
885 }
886 }
887}