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 { Self::new() }
76}
77
78impl KeybindRegistry {
79 pub fn new() -> Self {
80 let mut registry = Self {
81 binds: Vec::new(),
82 reserved: HashSet::new(),
83 collisions: Vec::new(),
84 };
85 registry.register_core();
86 registry
87 }
88
89 pub fn collisions(&self) -> &[KeybindCollision] {
91 &self.collisions
92 }
93
94 pub fn clear_collisions(&mut self) {
96 self.collisions.clear();
97 }
98
99 fn register_core(&mut self) {
101 let core_keys = vec![
102 (KeyCode::Char('c'), KeyModifiers::CONTROL, "Quit"),
103 (KeyCode::Esc, KeyModifiers::NONE, "Abort stream"),
104 (KeyCode::Enter, KeyModifiers::NONE, "Submit"),
105 (KeyCode::Enter, KeyModifiers::SHIFT, "Newline"),
106 (KeyCode::Tab, KeyModifiers::NONE, "Autocomplete"),
107 (KeyCode::Char('a'), KeyModifiers::CONTROL, "Cursor start"),
108 (KeyCode::Char('e'), KeyModifiers::CONTROL, "Cursor end"),
109 (KeyCode::Char('u'), KeyModifiers::CONTROL, "Clear input"),
110 (KeyCode::Char('w'), KeyModifiers::CONTROL, "Delete word"),
111 (KeyCode::Char('o'), KeyModifiers::CONTROL, "Toggle output"),
112 (KeyCode::Left, KeyModifiers::ALT, "Jump word left"),
113 (KeyCode::Right, KeyModifiers::ALT, "Jump word right"),
114 (KeyCode::Up, KeyModifiers::SHIFT, "Scroll up"),
115 (KeyCode::Down, KeyModifiers::SHIFT, "Scroll down"),
116 (KeyCode::Up, KeyModifiers::NONE, "History up"),
117 (KeyCode::Down, KeyModifiers::NONE, "History down"),
118 (KeyCode::Left, KeyModifiers::NONE, "Cursor left"),
119 (KeyCode::Right, KeyModifiers::NONE, "Cursor right"),
120 (KeyCode::Backspace, KeyModifiers::NONE, "Backspace"),
121 (KeyCode::Backspace, KeyModifiers::ALT, "Delete word"),
122 (KeyCode::Home, KeyModifiers::NONE, "Cursor start"),
123 (KeyCode::End, KeyModifiers::NONE, "Cursor end"),
124 ];
125 for (code, modifiers, desc) in core_keys {
126 let combo = KeyCombo { code, modifiers };
127 self.reserved.insert(combo.clone());
128 self.binds.push(Keybind {
129 key: combo,
130 action: KeybindAction::Disabled, description: desc.to_string(),
132 source: KeybindSource::Core,
133 });
134 }
135 }
136
137 pub fn register_plugin(&mut self, plugin_name: &str, keybinds: &[ManifestKeybind], plugin_dir: &std::path::Path) {
139 for kb in keybinds {
140 let combo = match parse_key(&kb.key) {
141 Ok(c) => c,
142 Err(e) => {
143 tracing::warn!("plugin '{}': invalid keybind '{}': {}", plugin_name, kb.key, e);
144 self.collisions.push(KeybindCollision {
145 losing_plugin: plugin_name.to_string(),
146 key: kb.key.clone(),
147 winning_owner: "n/a".to_string(),
148 reason: format!("invalid notation: {}", e),
149 });
150 continue;
151 }
152 };
153
154 if self.reserved.contains(&combo) {
156 tracing::warn!("plugin '{}': keybind '{}' conflicts with core — skipped", plugin_name, kb.key);
157 self.collisions.push(KeybindCollision {
158 losing_plugin: plugin_name.to_string(),
159 key: kb.key.clone(),
160 winning_owner: "core".to_string(),
161 reason: "conflicts with core".to_string(),
162 });
163 continue;
164 }
165
166 if let Some(existing) = self
168 .binds
169 .iter()
170 .find(|b| b.key == combo && b.source != KeybindSource::Core)
171 {
172 let winning_owner = match &existing.source {
173 KeybindSource::Plugin(name) => name.clone(),
174 KeybindSource::User => "user".to_string(),
175 KeybindSource::Core => "core".to_string(),
176 };
177 tracing::warn!("plugin '{}': keybind '{}' already registered — skipped", plugin_name, kb.key);
178 self.collisions.push(KeybindCollision {
179 losing_plugin: plugin_name.to_string(),
180 key: kb.key.clone(),
181 winning_owner,
182 reason: "already registered".to_string(),
183 });
184 continue;
185 }
186
187 let action = match kb.action.as_str() {
188 "slash_command" => {
189 KeybindAction::SlashCommand(kb.command.clone().unwrap_or_default())
190 }
191 "load_skill" => {
192 KeybindAction::LoadSkill(kb.skill.clone().unwrap_or_default())
193 }
194 "inject_prompt" => {
195 KeybindAction::InjectPrompt(kb.prompt.clone().unwrap_or_default())
196 }
197 "run_script" => KeybindAction::RunScript {
198 script: kb.script.clone().unwrap_or_default(),
199 plugin_dir: plugin_dir.to_path_buf(),
200 },
201 other => {
202 tracing::warn!("plugin '{}': unknown keybind action '{}'", plugin_name, other);
203 continue;
204 }
205 };
206
207 self.binds.push(Keybind {
208 key: combo,
209 action,
210 description: kb.description.clone().unwrap_or_default(),
211 source: KeybindSource::Plugin(plugin_name.to_string()),
212 });
213 }
214 }
215
216 pub fn register_user(&mut self, config_keybinds: &std::collections::HashMap<String, String>) {
218 for (key_str, value) in config_keybinds {
219 let combo = match parse_key(key_str) {
220 Ok(c) => c,
221 Err(e) => {
222 tracing::warn!("config: invalid keybind '{}': {}", key_str, e);
223 continue;
224 }
225 };
226
227 if self.reserved.contains(&combo) {
229 tracing::warn!("config: keybind '{}' is a core bind — skipped", key_str);
230 continue;
231 }
232
233 self.binds.retain(|b| b.key != combo || b.source == KeybindSource::Core);
235
236 let action = if value == "disabled" {
237 KeybindAction::Disabled
238 } else if let Some(stripped) = value.strip_prefix('/') {
239 let cmd = stripped.to_string();
240 KeybindAction::SlashCommand(cmd)
241 } else {
242 KeybindAction::InjectPrompt(value.clone())
243 };
244
245 self.binds.push(Keybind {
246 key: combo,
247 action,
248 description: format!("User: {}", value),
249 source: KeybindSource::User,
250 });
251 }
252 }
253
254 pub fn set_slash_command_key(&mut self, slash_command: &str, new_key: &str) -> Result<(), String> {
261 let combo = parse_key(new_key)?;
262 if self.reserved.contains(&combo) {
263 return Err(format!("'{}' is reserved by core — cannot rebind", new_key));
264 }
265 self.binds.retain(|b| {
267 if b.source == KeybindSource::Core { return true; }
268 !matches!(&b.action, KeybindAction::SlashCommand(c) if c == slash_command)
269 });
270 self.binds.retain(|b| b.key != combo || b.source == KeybindSource::Core);
273 self.binds.push(Keybind {
274 key: combo,
275 action: KeybindAction::SlashCommand(slash_command.to_string()),
276 description: format!("User: /{}", slash_command),
277 source: KeybindSource::User,
278 });
279 Ok(())
280 }
281
282 pub fn match_key(&self, code: KeyCode, modifiers: KeyModifiers) -> Option<&Keybind> {
285 let combo = KeyCombo { code, modifiers };
286
287 if self.reserved.contains(&combo) {
289 return None;
290 }
291
292 self.binds.iter().find(|b| b.key == combo && !matches!(b.source, KeybindSource::Core))
293 }
294
295 pub fn all(&self) -> &[Keybind] {
297 &self.binds
298 }
299
300 pub fn custom_binds(&self) -> Vec<&Keybind> {
302 self.binds.iter().filter(|b| !matches!(b.source, KeybindSource::Core)).collect()
303 }
304}
305
306#[derive(Debug, Clone, serde::Deserialize)]
308pub struct ManifestKeybind {
309 pub key: String,
310 #[serde(default)]
311 pub action: String,
312 #[serde(default)]
313 pub command: Option<String>,
314 #[serde(default)]
315 pub skill: Option<String>,
316 #[serde(default)]
317 pub prompt: Option<String>,
318 #[serde(default)]
319 pub script: Option<String>,
320 #[serde(default)]
321 pub description: Option<String>,
322}
323
324pub fn parse_key(notation: &str) -> Result<KeyCombo, String> {
337 let notation = notation.trim();
338 if notation.is_empty() {
339 return Err("empty key notation".to_string());
340 }
341
342 let parts: Vec<&str> = notation.split('-').collect();
343 let mut modifiers = KeyModifiers::empty();
344
345 for part in &parts[..parts.len().saturating_sub(1)] {
347 match *part {
348 "C" => modifiers |= KeyModifiers::CONTROL,
349 "S" => modifiers |= KeyModifiers::SHIFT,
350 "A" => modifiers |= KeyModifiers::ALT,
351 other => return Err(format!("unknown modifier: '{}' (expected C, S, or A)", other)),
352 }
353 }
354
355 let key_str = parts.last().ok_or("missing key")?;
356 let code = match *key_str {
357 k if k.len() == 1 => {
358 let ch = k.chars().next().unwrap();
359 KeyCode::Char(ch.to_ascii_lowercase())
360 }
361 k if k.starts_with('F') && k.len() <= 3 => {
362 let n: u8 = k[1..].parse().map_err(|_| format!("invalid F-key: '{}'", k))?;
363 if !(1..=12).contains(&n) {
364 return Err(format!("F-key out of range: F{} (expected F1–F12)", n));
365 }
366 KeyCode::F(n)
367 }
368 "Space" => KeyCode::Char(' '),
369 "Tab" => KeyCode::Tab,
370 "Enter" => KeyCode::Enter,
371 "Esc" => KeyCode::Esc,
372 "Backspace" | "BS" => KeyCode::Backspace,
373 "Delete" | "Del" => KeyCode::Delete,
374 "Home" => KeyCode::Home,
375 "End" => KeyCode::End,
376 "PageUp" | "PgUp" => KeyCode::PageUp,
377 "PageDown" | "PgDn" => KeyCode::PageDown,
378 "Up" => KeyCode::Up,
379 "Down" => KeyCode::Down,
380 "Left" => KeyCode::Left,
381 "Right" => KeyCode::Right,
382 other => return Err(format!("unknown key: '{}'" , other)),
383 };
384
385 Ok(KeyCombo { code, modifiers })
386}
387
388pub fn format_key(combo: &KeyCombo) -> String {
390 let mut parts = Vec::new();
391 if combo.modifiers.contains(KeyModifiers::CONTROL) {
392 parts.push("Ctrl");
393 }
394 if combo.modifiers.contains(KeyModifiers::ALT) {
395 parts.push("Alt");
396 }
397 if combo.modifiers.contains(KeyModifiers::SHIFT) {
398 parts.push("Shift");
399 }
400
401 let key = match combo.code {
402 KeyCode::Char(' ') => "Space".to_string(),
403 KeyCode::Char(c) => c.to_uppercase().to_string(),
404 KeyCode::F(n) => format!("F{}", n),
405 KeyCode::Tab => "Tab".to_string(),
406 KeyCode::Enter => "Enter".to_string(),
407 KeyCode::Esc => "Esc".to_string(),
408 KeyCode::Backspace => "Backspace".to_string(),
409 KeyCode::Delete => "Delete".to_string(),
410 KeyCode::Home => "Home".to_string(),
411 KeyCode::End => "End".to_string(),
412 KeyCode::PageUp => "PageUp".to_string(),
413 KeyCode::PageDown => "PageDown".to_string(),
414 KeyCode::Up => "↑".to_string(),
415 KeyCode::Down => "↓".to_string(),
416 KeyCode::Left => "←".to_string(),
417 KeyCode::Right => "→".to_string(),
418 _ => "?".to_string(),
419 };
420 parts.push(&key);
421 parts.join("+")
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
431 fn parse_single_char() {
432 let k = parse_key("s").unwrap();
433 assert_eq!(k.code, KeyCode::Char('s'));
434 assert_eq!(k.modifiers, KeyModifiers::NONE);
435 }
436
437 #[test]
438 fn parse_ctrl_char() {
439 let k = parse_key("C-s").unwrap();
440 assert_eq!(k.code, KeyCode::Char('s'));
441 assert_eq!(k.modifiers, KeyModifiers::CONTROL);
442 }
443
444 #[test]
445 fn parse_ctrl_shift() {
446 let k = parse_key("C-S-s").unwrap();
447 assert_eq!(k.code, KeyCode::Char('s'));
448 assert_eq!(k.modifiers, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
449 }
450
451 #[test]
452 fn parse_alt() {
453 let k = parse_key("A-p").unwrap();
454 assert_eq!(k.code, KeyCode::Char('p'));
455 assert_eq!(k.modifiers, KeyModifiers::ALT);
456 }
457
458 #[test]
459 fn parse_ctrl_alt() {
460 let k = parse_key("C-A-x").unwrap();
461 assert_eq!(k.code, KeyCode::Char('x'));
462 assert_eq!(k.modifiers, KeyModifiers::CONTROL | KeyModifiers::ALT);
463 }
464
465 #[test]
466 fn parse_f_keys() {
467 let k = parse_key("F5").unwrap();
468 assert_eq!(k.code, KeyCode::F(5));
469 assert_eq!(k.modifiers, KeyModifiers::NONE);
470
471 let k = parse_key("C-F12").unwrap();
472 assert_eq!(k.code, KeyCode::F(12));
473 assert_eq!(k.modifiers, KeyModifiers::CONTROL);
474 }
475
476 #[test]
477 fn parse_special_keys() {
478 assert_eq!(parse_key("Space").unwrap().code, KeyCode::Char(' '));
479 assert_eq!(parse_key("Tab").unwrap().code, KeyCode::Tab);
480 assert_eq!(parse_key("Enter").unwrap().code, KeyCode::Enter);
481 assert_eq!(parse_key("Esc").unwrap().code, KeyCode::Esc);
482 assert_eq!(parse_key("Backspace").unwrap().code, KeyCode::Backspace);
483 assert_eq!(parse_key("Home").unwrap().code, KeyCode::Home);
484 assert_eq!(parse_key("End").unwrap().code, KeyCode::End);
485 }
486
487 #[test]
488 fn parse_ctrl_space() {
489 let k = parse_key("C-Space").unwrap();
490 assert_eq!(k.code, KeyCode::Char(' '));
491 assert_eq!(k.modifiers, KeyModifiers::CONTROL);
492 }
493
494 #[test]
495 fn parse_uppercase_normalized_to_lower() {
496 let k = parse_key("C-S").unwrap();
497 assert_eq!(k.code, KeyCode::Char('s'));
498 }
499
500 #[test]
501 fn parse_empty_errors() {
502 assert!(parse_key("").is_err());
503 assert!(parse_key(" ").is_err());
504 }
505
506 #[test]
507 fn parse_unknown_modifier_errors() {
508 assert!(parse_key("X-s").is_err());
509 }
510
511 #[test]
512 fn parse_unknown_key_errors() {
513 assert!(parse_key("C-FooBar").is_err());
514 }
515
516 #[test]
517 fn parse_f_key_out_of_range() {
518 assert!(parse_key("F0").is_err());
519 assert!(parse_key("F13").is_err());
520 }
521
522 #[test]
525 fn format_ctrl_shift_s() {
526 let k = KeyCombo {
527 code: KeyCode::Char('s'),
528 modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
529 };
530 assert_eq!(format_key(&k), "Ctrl+Shift+S");
531 }
532
533 #[test]
534 fn format_f5() {
535 let k = KeyCombo {
536 code: KeyCode::F(5),
537 modifiers: KeyModifiers::NONE,
538 };
539 assert_eq!(format_key(&k), "F5");
540 }
541
542 #[test]
543 fn format_alt_space() {
544 let k = KeyCombo {
545 code: KeyCode::Char(' '),
546 modifiers: KeyModifiers::ALT,
547 };
548 assert_eq!(format_key(&k), "Alt+Space");
549 }
550
551 #[test]
554 fn core_binds_are_reserved() {
555 let reg = KeybindRegistry::new();
556 assert!(reg.match_key(KeyCode::Char('c'), KeyModifiers::CONTROL).is_none());
558 }
559
560 #[test]
561 fn plugin_bind_matches() {
562 let mut reg = KeybindRegistry::new();
563 reg.register_plugin("test", &[ManifestKeybind {
564 key: "C-S-s".to_string(),
565 action: "slash_command".to_string(),
566 command: Some("scholar".to_string()),
567 skill: None, prompt: None, script: None,
568 description: Some("Search papers".to_string()),
569 }], std::path::Path::new("/tmp"));
570
571 let result = reg.match_key(KeyCode::Char('s'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
572 assert!(result.is_some());
573 assert_eq!(result.unwrap().description, "Search papers");
574 }
575
576 #[test]
577 fn plugin_cannot_override_core() {
578 let mut reg = KeybindRegistry::new();
579 reg.register_plugin("evil", &[ManifestKeybind {
580 key: "C-c".to_string(),
581 action: "inject_prompt".to_string(),
582 command: None, skill: None,
583 prompt: Some("hacked".to_string()),
584 script: None,
585 description: Some("evil".to_string()),
586 }], std::path::Path::new("/tmp"));
587
588 assert!(reg.match_key(KeyCode::Char('c'), KeyModifiers::CONTROL).is_none());
590 }
591
592 #[test]
593 fn user_overrides_plugin() {
594 let mut reg = KeybindRegistry::new();
595 reg.register_plugin("test", &[ManifestKeybind {
596 key: "F5".to_string(),
597 action: "slash_command".to_string(),
598 command: Some("scholar".to_string()),
599 skill: None, prompt: None, script: None,
600 description: Some("Scholar".to_string()),
601 }], std::path::Path::new("/tmp"));
602
603 let mut overrides = std::collections::HashMap::new();
604 overrides.insert("F5".to_string(), "/compact".to_string());
605 reg.register_user(&overrides);
606
607 let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
608 assert!(result.is_some());
609 assert_eq!(result.unwrap().description, "User: /compact");
610 }
611
612 #[test]
613 fn user_can_disable_bind() {
614 let mut reg = KeybindRegistry::new();
615 reg.register_plugin("test", &[ManifestKeybind {
616 key: "F5".to_string(),
617 action: "slash_command".to_string(),
618 command: Some("scholar".to_string()),
619 skill: None, prompt: None, script: None,
620 description: Some("Scholar".to_string()),
621 }], std::path::Path::new("/tmp"));
622
623 let mut overrides = std::collections::HashMap::new();
624 overrides.insert("F5".to_string(), "disabled".to_string());
625 reg.register_user(&overrides);
626
627 let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
628 assert!(result.is_some());
629 assert!(matches!(result.unwrap().action, KeybindAction::Disabled));
630 }
631
632 #[test]
633 fn duplicate_plugin_binds_first_wins() {
634 let mut reg = KeybindRegistry::new();
635 reg.register_plugin("first", &[ManifestKeybind {
636 key: "F5".to_string(),
637 action: "slash_command".to_string(),
638 command: Some("first".to_string()),
639 skill: None, prompt: None, script: None,
640 description: Some("First".to_string()),
641 }], std::path::Path::new("/tmp"));
642
643 reg.register_plugin("second", &[ManifestKeybind {
644 key: "F5".to_string(),
645 action: "slash_command".to_string(),
646 command: Some("second".to_string()),
647 skill: None, prompt: None, script: None,
648 description: Some("Second".to_string()),
649 }], std::path::Path::new("/tmp"));
650
651 let result = reg.match_key(KeyCode::F(5), KeyModifiers::NONE);
652 assert!(result.is_some());
653 assert_eq!(result.unwrap().description, "First");
654 }
655
656 #[test]
657 fn custom_binds_excludes_core() {
658 let reg = KeybindRegistry::new();
659 let custom = reg.custom_binds();
660 assert!(custom.is_empty()); }
662
663 #[test]
664 fn set_slash_command_key_replaces_existing_sidecar_toggle() {
665 let mut reg = KeybindRegistry::new();
666 let mut overrides = std::collections::HashMap::new();
667 overrides.insert("F8".to_string(), "/sidecar toggle".to_string());
668 reg.register_user(&overrides);
669 let f8 = parse_key("F8").unwrap();
670 assert!(reg.match_key(f8.code, f8.modifiers).is_some());
671
672 reg.set_slash_command_key("sidecar toggle", "C-G").unwrap();
674
675 assert!(reg.match_key(f8.code, f8.modifiers).is_none());
677 let cg = parse_key("C-G").unwrap();
679 let bind = reg.match_key(cg.code, cg.modifiers).expect("C-G bind missing");
680 assert!(matches!(&bind.action, KeybindAction::SlashCommand(c) if c == "sidecar toggle"));
681 }
682
683 #[test]
684 fn set_slash_command_key_rejects_core_chord() {
685 let mut reg = KeybindRegistry::new();
686 let err = reg.set_slash_command_key("sidecar toggle", "Esc").unwrap_err();
688 assert!(err.contains("reserved"), "expected reserved error, got: {err}");
689 }
690
691 fn mk_kb(key: &str, cmd: &str) -> ManifestKeybind {
694 ManifestKeybind {
695 key: key.to_string(),
696 action: "slash_command".to_string(),
697 command: Some(cmd.to_string()),
698 skill: None,
699 prompt: None,
700 script: None,
701 description: Some(cmd.to_string()),
702 }
703 }
704
705 #[test]
706 fn register_plugin_records_core_collision() {
707 let mut reg = KeybindRegistry::new();
708 reg.register_plugin("evil", &[mk_kb("C-c", "hack")], std::path::Path::new("/tmp"));
710 assert_eq!(reg.collisions().len(), 1);
711 let c = ®.collisions()[0];
712 assert_eq!(c.losing_plugin, "evil");
713 assert_eq!(c.winning_owner, "core");
714 assert_eq!(c.reason, "conflicts with core");
715 assert_eq!(c.key, "C-c");
716 }
717
718 #[test]
719 fn register_plugin_records_plugin_vs_plugin_collision() {
720 let mut reg = KeybindRegistry::new();
721 reg.register_plugin("A", &[mk_kb("C-Space", "alpha")], std::path::Path::new("/tmp"));
723 reg.register_plugin("B", &[mk_kb("C-Space", "beta")], std::path::Path::new("/tmp"));
724 assert_eq!(reg.collisions().len(), 1);
725 let c = ®.collisions()[0];
726 assert_eq!(c.losing_plugin, "B");
727 assert_eq!(c.winning_owner, "A");
728 assert_eq!(c.reason, "already registered");
729 }
730
731 #[test]
732 fn register_plugin_records_invalid_key_notation() {
733 let mut reg = KeybindRegistry::new();
734 reg.register_plugin(
735 "weird",
736 &[mk_kb("this is not a key", "noop")],
737 std::path::Path::new("/tmp"),
738 );
739 assert_eq!(reg.collisions().len(), 1);
740 let c = ®.collisions()[0];
741 assert_eq!(c.losing_plugin, "weird");
742 assert_eq!(c.winning_owner, "n/a");
743 assert!(
744 c.reason.starts_with("invalid notation"),
745 "reason should start with 'invalid notation', got: {}",
746 c.reason
747 );
748 }
749
750 #[test]
751 fn collisions_is_empty_when_no_conflicts() {
752 let mut reg = KeybindRegistry::new();
753 reg.register_plugin("solo", &[mk_kb("F7", "solo")], std::path::Path::new("/tmp"));
754 assert!(reg.collisions().is_empty());
755 }
756
757 #[test]
758 fn multiple_collisions_are_all_recorded() {
759 let mut reg = KeybindRegistry::new();
760 reg.register_plugin("A", &[mk_kb("C-Space", "alpha")], std::path::Path::new("/tmp"));
761 reg.register_plugin("B", &[mk_kb("C-Space", "beta")], std::path::Path::new("/tmp"));
763 reg.register_plugin("C", &[mk_kb("C-Space", "gamma")], std::path::Path::new("/tmp"));
764 assert_eq!(reg.collisions().len(), 2);
765 assert_eq!(reg.collisions()[0].losing_plugin, "B");
766 assert_eq!(reg.collisions()[1].losing_plugin, "C");
767 for c in reg.collisions() {
768 assert_eq!(c.winning_owner, "A");
769 assert_eq!(c.reason, "already registered");
770 }
771 }
772}