1#![allow(dead_code)] use std::{collections::HashMap, env, path::PathBuf};
4
5use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6use directories::ProjectDirs;
7use lazy_static::lazy_static;
8use ratatui::style::{Color, Modifier, Style};
9use serde::{Deserialize, de::Deserializer};
10use tracing::error;
11
12use crate::{action::Action, app::Mode};
13
14const CONFIG: &str = include_str!("../.config/config.json5");
15
16#[derive(Clone, Debug, Deserialize, Default)]
17pub struct AppConfig {
18 #[serde(default)]
19 pub data_dir: PathBuf,
20 #[serde(default)]
21 pub config_dir: PathBuf,
22}
23
24#[derive(Clone, Debug, Deserialize)]
27pub struct NodeConfig {
28 pub name: String,
30 pub url: String,
32 #[serde(default)]
35 pub token: Option<String>,
36 #[serde(default)]
39 pub default: bool,
40}
41
42impl NodeConfig {
43 pub fn resolved_token(&self) -> Option<String> {
46 let raw = self.token.as_deref()?;
47 if let Some(var) = raw.strip_prefix("@env:") {
48 env::var(var).ok()
49 } else {
50 Some(raw.to_string())
51 }
52 }
53}
54
55#[derive(Clone, Debug, Default, Deserialize)]
56pub struct Config {
57 #[serde(default, flatten)]
58 pub config: AppConfig,
59 #[serde(default = "default_nodes")]
60 pub nodes: Vec<NodeConfig>,
61 #[serde(default)]
62 pub keybindings: KeyBindings,
63 #[serde(default)]
64 pub styles: Styles,
65 #[serde(default)]
67 pub ui: UiConfig,
68 #[serde(default)]
72 pub bee: Option<BeeConfig>,
73 #[serde(default)]
78 pub metrics: MetricsConfig,
79 #[serde(default)]
84 pub economics: EconomicsConfig,
85 #[serde(default)]
88 pub alerts: AlertsConfig,
89 #[serde(default)]
94 pub durability: DurabilityConfig,
95 #[serde(default)]
100 pub pubsub: PubsubConfig,
101}
102
103#[derive(Clone, Debug, Deserialize)]
107pub struct BeeConfig {
108 pub bin: PathBuf,
113 pub config: PathBuf,
116 #[serde(default)]
120 pub logs: BeeLogsConfig,
121}
122
123#[derive(Clone, Debug, Deserialize)]
127pub struct BeeLogsConfig {
128 #[serde(default = "default_rotate_size_mb")]
133 pub rotate_size_mb: u64,
134 #[serde(default = "default_keep_files")]
138 pub keep_files: u32,
139}
140
141impl Default for BeeLogsConfig {
142 fn default() -> Self {
143 Self {
144 rotate_size_mb: default_rotate_size_mb(),
145 keep_files: default_keep_files(),
146 }
147 }
148}
149
150fn default_rotate_size_mb() -> u64 {
151 64
152}
153fn default_keep_files() -> u32 {
154 5
155}
156
157#[derive(Clone, Debug, Deserialize)]
161pub struct MetricsConfig {
162 #[serde(default)]
165 pub enabled: bool,
166 #[serde(default = "default_metrics_addr")]
169 pub addr: String,
170}
171
172impl Default for MetricsConfig {
173 fn default() -> Self {
174 Self {
175 enabled: false,
176 addr: default_metrics_addr(),
177 }
178 }
179}
180
181fn default_metrics_addr() -> String {
182 "127.0.0.1:9101".into()
183}
184
185#[derive(Clone, Debug, Default, Deserialize)]
189pub struct EconomicsConfig {
190 #[serde(default)]
195 pub gnosis_rpc_url: Option<String>,
196 #[serde(default)]
201 pub enable_market_tile: bool,
202}
203
204#[derive(Clone, Debug, Deserialize)]
208pub struct DurabilityConfig {
209 #[serde(default)]
216 pub swarmscan_check: bool,
217 #[serde(default = "default_swarmscan_url")]
223 pub swarmscan_url: String,
224}
225
226impl Default for DurabilityConfig {
227 fn default() -> Self {
228 Self {
229 swarmscan_check: false,
230 swarmscan_url: default_swarmscan_url(),
231 }
232 }
233}
234
235fn default_swarmscan_url() -> String {
236 "https://api.swarmscan.io/v1/chunks/{ref}".into()
237}
238
239#[derive(Clone, Debug, Deserialize)]
248pub struct PubsubConfig {
249 #[serde(default)]
256 pub history_file: Option<PathBuf>,
257 #[serde(default = "default_pubsub_rotate_size_mb")]
261 pub rotate_size_mb: u64,
262 #[serde(default = "default_pubsub_keep_files")]
266 pub keep_files: u32,
267}
268
269impl Default for PubsubConfig {
270 fn default() -> Self {
271 Self {
272 history_file: None,
273 rotate_size_mb: default_pubsub_rotate_size_mb(),
274 keep_files: default_pubsub_keep_files(),
275 }
276 }
277}
278
279fn default_pubsub_rotate_size_mb() -> u64 {
280 64
281}
282
283fn default_pubsub_keep_files() -> u32 {
284 5
285}
286
287#[derive(Clone, Debug, Deserialize)]
291pub struct AlertsConfig {
292 #[serde(default)]
295 pub webhook_url: Option<String>,
296 #[serde(default = "default_alerts_debounce_secs")]
300 pub debounce_secs: u64,
301}
302
303impl Default for AlertsConfig {
304 fn default() -> Self {
305 Self {
306 webhook_url: None,
307 debounce_secs: default_alerts_debounce_secs(),
308 }
309 }
310}
311
312fn default_alerts_debounce_secs() -> u64 {
313 crate::alerts::DEFAULT_DEBOUNCE_SECS
314}
315
316#[derive(Clone, Debug, Default, Deserialize)]
320pub struct UiConfig {
321 #[serde(default = "default_theme")]
324 pub theme: String,
325 #[serde(default)]
329 pub ascii_fallback: bool,
330 #[serde(default = "default_refresh")]
337 pub refresh: String,
338}
339
340fn default_theme() -> String {
341 "default".into()
342}
343
344fn default_refresh() -> String {
345 "default".into()
346}
347
348impl Config {
349 pub fn active_node(&self) -> Option<&NodeConfig> {
352 self.nodes
353 .iter()
354 .find(|n| n.default)
355 .or_else(|| self.nodes.first())
356 }
357}
358
359fn default_nodes() -> Vec<NodeConfig> {
362 vec![NodeConfig {
363 name: "local".to_string(),
364 url: "http://localhost:1633".to_string(),
365 token: None,
366 default: true,
367 }]
368}
369
370lazy_static! {
371 pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
372 pub static ref DATA_FOLDER: Option<PathBuf> =
373 env::var(format!("{}_DATA", PROJECT_NAME.clone()))
374 .ok()
375 .map(PathBuf::from);
376 pub static ref CONFIG_FOLDER: Option<PathBuf> =
377 env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
378 .ok()
379 .map(PathBuf::from);
380}
381
382impl Config {
383 pub fn new() -> color_eyre::Result<Self, config::ConfigError> {
384 let default_config: Config = json5::from_str(CONFIG).unwrap();
385 let data_dir = get_data_dir();
386 let config_dir = get_config_dir();
387 let mut builder = config::Config::builder()
388 .set_default("data_dir", data_dir.to_str().unwrap())?
389 .set_default("config_dir", config_dir.to_str().unwrap())?;
390
391 let config_files = [
392 ("config.json5", config::FileFormat::Json5),
393 ("config.json", config::FileFormat::Json),
394 ("config.yaml", config::FileFormat::Yaml),
395 ("config.toml", config::FileFormat::Toml),
396 ("config.ini", config::FileFormat::Ini),
397 ];
398 let mut found_config = false;
399 for (file, format) in &config_files {
400 let source = config::File::from(config_dir.join(file))
401 .format(*format)
402 .required(false);
403 builder = builder.add_source(source);
404 if config_dir.join(file).exists() {
405 found_config = true
406 }
407 }
408 if !found_config {
409 error!("No configuration file found. Application may not behave as expected");
410 }
411
412 let mut cfg: Self = builder.build()?.try_deserialize()?;
413
414 for (mode, default_bindings) in default_config.keybindings.0.iter() {
415 let user_bindings = cfg.keybindings.0.entry(*mode).or_default();
416 for (key, cmd) in default_bindings.iter() {
417 user_bindings
418 .entry(key.clone())
419 .or_insert_with(|| cmd.clone());
420 }
421 }
422 for (mode, default_styles) in default_config.styles.0.iter() {
423 let user_styles = cfg.styles.0.entry(*mode).or_default();
424 for (style_key, style) in default_styles.iter() {
425 user_styles.entry(style_key.clone()).or_insert(*style);
426 }
427 }
428
429 Ok(cfg)
430 }
431}
432
433pub fn get_data_dir() -> PathBuf {
434 if let Some(s) = DATA_FOLDER.clone() {
435 s
436 } else if let Some(proj_dirs) = project_directory() {
437 proj_dirs.data_local_dir().to_path_buf()
438 } else {
439 PathBuf::from(".").join(".data")
440 }
441}
442
443pub fn get_config_dir() -> PathBuf {
444 if let Some(s) = CONFIG_FOLDER.clone() {
445 s
446 } else if let Some(proj_dirs) = project_directory() {
447 proj_dirs.config_local_dir().to_path_buf()
448 } else {
449 PathBuf::from(".").join(".config")
450 }
451}
452
453fn project_directory() -> Option<ProjectDirs> {
454 ProjectDirs::from("com", "ethswarm-tools", env!("CARGO_PKG_NAME"))
455}
456
457#[derive(Clone, Debug, Default)]
458pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Action>>);
459
460impl<'de> Deserialize<'de> for KeyBindings {
461 fn deserialize<D>(deserializer: D) -> color_eyre::Result<Self, D::Error>
462 where
463 D: Deserializer<'de>,
464 {
465 let parsed_map = HashMap::<Mode, HashMap<String, Action>>::deserialize(deserializer)?;
466
467 let keybindings = parsed_map
468 .into_iter()
469 .map(|(mode, inner_map)| {
470 let converted_inner_map = inner_map
471 .into_iter()
472 .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd))
473 .collect();
474 (mode, converted_inner_map)
475 })
476 .collect();
477
478 Ok(KeyBindings(keybindings))
479 }
480}
481
482fn parse_key_event(raw: &str) -> color_eyre::Result<KeyEvent, String> {
483 let raw_lower = raw.to_ascii_lowercase();
484 let (remaining, modifiers) = extract_modifiers(&raw_lower);
485 parse_key_code_with_modifiers(remaining, modifiers)
486}
487
488fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) {
489 let mut modifiers = KeyModifiers::empty();
490 let mut current = raw;
491
492 loop {
493 match current {
494 rest if rest.starts_with("ctrl-") => {
495 modifiers.insert(KeyModifiers::CONTROL);
496 current = &rest[5..];
497 }
498 rest if rest.starts_with("alt-") => {
499 modifiers.insert(KeyModifiers::ALT);
500 current = &rest[4..];
501 }
502 rest if rest.starts_with("shift-") => {
503 modifiers.insert(KeyModifiers::SHIFT);
504 current = &rest[6..];
505 }
506 _ => break, };
508 }
509
510 (current, modifiers)
511}
512
513fn parse_key_code_with_modifiers(
514 raw: &str,
515 mut modifiers: KeyModifiers,
516) -> color_eyre::Result<KeyEvent, String> {
517 let c = match raw {
518 "esc" => KeyCode::Esc,
519 "enter" => KeyCode::Enter,
520 "left" => KeyCode::Left,
521 "right" => KeyCode::Right,
522 "up" => KeyCode::Up,
523 "down" => KeyCode::Down,
524 "home" => KeyCode::Home,
525 "end" => KeyCode::End,
526 "pageup" => KeyCode::PageUp,
527 "pagedown" => KeyCode::PageDown,
528 "backtab" => {
529 modifiers.insert(KeyModifiers::SHIFT);
530 KeyCode::BackTab
531 }
532 "backspace" => KeyCode::Backspace,
533 "delete" => KeyCode::Delete,
534 "insert" => KeyCode::Insert,
535 "f1" => KeyCode::F(1),
536 "f2" => KeyCode::F(2),
537 "f3" => KeyCode::F(3),
538 "f4" => KeyCode::F(4),
539 "f5" => KeyCode::F(5),
540 "f6" => KeyCode::F(6),
541 "f7" => KeyCode::F(7),
542 "f8" => KeyCode::F(8),
543 "f9" => KeyCode::F(9),
544 "f10" => KeyCode::F(10),
545 "f11" => KeyCode::F(11),
546 "f12" => KeyCode::F(12),
547 "space" => KeyCode::Char(' '),
548 "hyphen" => KeyCode::Char('-'),
549 "minus" => KeyCode::Char('-'),
550 "tab" => KeyCode::Tab,
551 c if c.len() == 1 => {
552 let mut c = c.chars().next().unwrap();
553 if modifiers.contains(KeyModifiers::SHIFT) {
554 c = c.to_ascii_uppercase();
555 }
556 KeyCode::Char(c)
557 }
558 _ => return Err(format!("Unable to parse {raw}")),
559 };
560 Ok(KeyEvent::new(c, modifiers))
561}
562
563pub fn key_event_to_string(key_event: &KeyEvent) -> String {
564 let char;
565 let key_code = match key_event.code {
566 KeyCode::Backspace => "backspace",
567 KeyCode::Enter => "enter",
568 KeyCode::Left => "left",
569 KeyCode::Right => "right",
570 KeyCode::Up => "up",
571 KeyCode::Down => "down",
572 KeyCode::Home => "home",
573 KeyCode::End => "end",
574 KeyCode::PageUp => "pageup",
575 KeyCode::PageDown => "pagedown",
576 KeyCode::Tab => "tab",
577 KeyCode::BackTab => "backtab",
578 KeyCode::Delete => "delete",
579 KeyCode::Insert => "insert",
580 KeyCode::F(c) => {
581 char = format!("f({c})");
582 &char
583 }
584 KeyCode::Char(' ') => "space",
585 KeyCode::Char(c) => {
586 char = c.to_string();
587 &char
588 }
589 KeyCode::Esc => "esc",
590 KeyCode::Null => "",
591 KeyCode::CapsLock => "",
592 KeyCode::Menu => "",
593 KeyCode::ScrollLock => "",
594 KeyCode::Media(_) => "",
595 KeyCode::NumLock => "",
596 KeyCode::PrintScreen => "",
597 KeyCode::Pause => "",
598 KeyCode::KeypadBegin => "",
599 KeyCode::Modifier(_) => "",
600 };
601
602 let mut modifiers = Vec::with_capacity(3);
603
604 if key_event.modifiers.intersects(KeyModifiers::CONTROL) {
605 modifiers.push("ctrl");
606 }
607
608 if key_event.modifiers.intersects(KeyModifiers::SHIFT) {
609 modifiers.push("shift");
610 }
611
612 if key_event.modifiers.intersects(KeyModifiers::ALT) {
613 modifiers.push("alt");
614 }
615
616 let mut key = modifiers.join("-");
617
618 if !key.is_empty() {
619 key.push('-');
620 }
621 key.push_str(key_code);
622
623 key
624}
625
626pub fn parse_key_sequence(raw: &str) -> color_eyre::Result<Vec<KeyEvent>, String> {
627 if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
628 return Err(format!("Unable to parse `{}`", raw));
629 }
630 let raw = if !raw.contains("><") {
631 let raw = raw.strip_prefix('<').unwrap_or(raw);
632 raw.strip_prefix('>').unwrap_or(raw)
633 } else {
634 raw
635 };
636 let sequences = raw
637 .split("><")
638 .map(|seq| {
639 if let Some(s) = seq.strip_prefix('<') {
640 s
641 } else if let Some(s) = seq.strip_suffix('>') {
642 s
643 } else {
644 seq
645 }
646 })
647 .collect::<Vec<_>>();
648
649 sequences.into_iter().map(parse_key_event).collect()
650}
651
652#[derive(Clone, Debug, Default)]
653pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
654
655impl<'de> Deserialize<'de> for Styles {
656 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
657 where
658 D: Deserializer<'de>,
659 {
660 let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
661
662 let styles = parsed_map
663 .into_iter()
664 .map(|(mode, inner_map)| {
665 let converted_inner_map = inner_map
666 .into_iter()
667 .map(|(str, style)| (str, parse_style(&style)))
668 .collect();
669 (mode, converted_inner_map)
670 })
671 .collect();
672
673 Ok(Styles(styles))
674 }
675}
676
677pub fn parse_style(line: &str) -> Style {
678 let (foreground, background) =
679 line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len()));
680 let foreground = process_color_string(foreground);
681 let background = process_color_string(&background.replace("on ", ""));
682
683 let mut style = Style::default();
684 if let Some(fg) = parse_color(&foreground.0) {
685 style = style.fg(fg);
686 }
687 if let Some(bg) = parse_color(&background.0) {
688 style = style.bg(bg);
689 }
690 style = style.add_modifier(foreground.1 | background.1);
691 style
692}
693
694fn process_color_string(color_str: &str) -> (String, Modifier) {
695 let color = color_str
696 .replace("grey", "gray")
697 .replace("bright ", "")
698 .replace("bold ", "")
699 .replace("underline ", "")
700 .replace("inverse ", "");
701
702 let mut modifiers = Modifier::empty();
703 if color_str.contains("underline") {
704 modifiers |= Modifier::UNDERLINED;
705 }
706 if color_str.contains("bold") {
707 modifiers |= Modifier::BOLD;
708 }
709 if color_str.contains("inverse") {
710 modifiers |= Modifier::REVERSED;
711 }
712
713 (color, modifiers)
714}
715
716fn parse_color(s: &str) -> Option<Color> {
717 let s = s.trim_start();
718 let s = s.trim_end();
719 if s.contains("bright color") {
720 let s = s.trim_start_matches("bright ");
721 let c = s
722 .trim_start_matches("color")
723 .parse::<u8>()
724 .unwrap_or_default();
725 Some(Color::Indexed(c.wrapping_shl(8)))
726 } else if s.contains("color") {
727 let c = s
728 .trim_start_matches("color")
729 .parse::<u8>()
730 .unwrap_or_default();
731 Some(Color::Indexed(c))
732 } else if s.contains("gray") {
733 let c = 232
734 + s.trim_start_matches("gray")
735 .parse::<u8>()
736 .unwrap_or_default();
737 Some(Color::Indexed(c))
738 } else if s.contains("rgb") {
739 let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
740 let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
741 let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
742 let c = 16 + red * 36 + green * 6 + blue;
743 Some(Color::Indexed(c))
744 } else if s == "bold black" {
745 Some(Color::Indexed(8))
746 } else if s == "bold red" {
747 Some(Color::Indexed(9))
748 } else if s == "bold green" {
749 Some(Color::Indexed(10))
750 } else if s == "bold yellow" {
751 Some(Color::Indexed(11))
752 } else if s == "bold blue" {
753 Some(Color::Indexed(12))
754 } else if s == "bold magenta" {
755 Some(Color::Indexed(13))
756 } else if s == "bold cyan" {
757 Some(Color::Indexed(14))
758 } else if s == "bold white" {
759 Some(Color::Indexed(15))
760 } else if s == "black" {
761 Some(Color::Indexed(0))
762 } else if s == "red" {
763 Some(Color::Indexed(1))
764 } else if s == "green" {
765 Some(Color::Indexed(2))
766 } else if s == "yellow" {
767 Some(Color::Indexed(3))
768 } else if s == "blue" {
769 Some(Color::Indexed(4))
770 } else if s == "magenta" {
771 Some(Color::Indexed(5))
772 } else if s == "cyan" {
773 Some(Color::Indexed(6))
774 } else if s == "white" {
775 Some(Color::Indexed(7))
776 } else {
777 None
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use pretty_assertions::assert_eq;
784
785 use super::*;
786
787 #[test]
788 fn test_parse_style_default() {
789 let style = parse_style("");
790 assert_eq!(style, Style::default());
791 }
792
793 #[test]
794 fn test_parse_style_foreground() {
795 let style = parse_style("red");
796 assert_eq!(style.fg, Some(Color::Indexed(1)));
797 }
798
799 #[test]
800 fn test_parse_style_background() {
801 let style = parse_style("on blue");
802 assert_eq!(style.bg, Some(Color::Indexed(4)));
803 }
804
805 #[test]
806 fn test_parse_style_modifiers() {
807 let style = parse_style("underline red on blue");
808 assert_eq!(style.fg, Some(Color::Indexed(1)));
809 assert_eq!(style.bg, Some(Color::Indexed(4)));
810 }
811
812 #[test]
813 fn test_process_color_string() {
814 let (color, modifiers) = process_color_string("underline bold inverse gray");
815 assert_eq!(color, "gray");
816 assert!(modifiers.contains(Modifier::UNDERLINED));
817 assert!(modifiers.contains(Modifier::BOLD));
818 assert!(modifiers.contains(Modifier::REVERSED));
819 }
820
821 #[test]
822 fn test_parse_color_rgb() {
823 let color = parse_color("rgb123");
824 let expected = 16 + 36 + 2 * 6 + 3;
825 assert_eq!(color, Some(Color::Indexed(expected)));
826 }
827
828 #[test]
829 fn test_parse_color_unknown() {
830 let color = parse_color("unknown");
831 assert_eq!(color, None);
832 }
833
834 #[test]
835 fn test_config() -> color_eyre::Result<()> {
836 let c = Config::new()?;
841 assert_eq!(
842 c.keybindings
843 .0
844 .get(&Mode::Home)
845 .unwrap()
846 .get(&parse_key_sequence("<Ctrl-c>").unwrap_or_default())
847 .unwrap(),
848 &Action::Quit
849 );
850 Ok(())
851 }
852
853 #[test]
854 fn test_simple_keys() {
855 assert_eq!(
856 parse_key_event("a").unwrap(),
857 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
858 );
859
860 assert_eq!(
861 parse_key_event("enter").unwrap(),
862 KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
863 );
864
865 assert_eq!(
866 parse_key_event("esc").unwrap(),
867 KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
868 );
869 }
870
871 #[test]
872 fn test_with_modifiers() {
873 assert_eq!(
874 parse_key_event("ctrl-a").unwrap(),
875 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
876 );
877
878 assert_eq!(
879 parse_key_event("alt-enter").unwrap(),
880 KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
881 );
882
883 assert_eq!(
884 parse_key_event("shift-esc").unwrap(),
885 KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
886 );
887 }
888
889 #[test]
890 fn test_multiple_modifiers() {
891 assert_eq!(
892 parse_key_event("ctrl-alt-a").unwrap(),
893 KeyEvent::new(
894 KeyCode::Char('a'),
895 KeyModifiers::CONTROL | KeyModifiers::ALT
896 )
897 );
898
899 assert_eq!(
900 parse_key_event("ctrl-shift-enter").unwrap(),
901 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
902 );
903 }
904
905 #[test]
906 fn test_reverse_multiple_modifiers() {
907 assert_eq!(
908 key_event_to_string(&KeyEvent::new(
909 KeyCode::Char('a'),
910 KeyModifiers::CONTROL | KeyModifiers::ALT
911 )),
912 "ctrl-alt-a".to_string()
913 );
914 }
915
916 #[test]
917 fn test_invalid_keys() {
918 assert!(parse_key_event("invalid-key").is_err());
919 assert!(parse_key_event("ctrl-invalid-key").is_err());
920 }
921
922 #[test]
923 fn test_case_insensitivity() {
924 assert_eq!(
925 parse_key_event("CTRL-a").unwrap(),
926 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
927 );
928
929 assert_eq!(
930 parse_key_event("AlT-eNtEr").unwrap(),
931 KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
932 );
933 }
934}