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