Skip to main content

bee_tui/
config.rs

1#![allow(dead_code)] // Remove this once you start using the code
2
3use 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/// One configured Bee node. Multiple may coexist; multi-node UX is
25/// targeted at v0.4 but the schema supports it from day one.
26#[derive(Clone, Debug, Deserialize)]
27pub struct NodeConfig {
28    /// Friendly label shown in the UI (e.g. `"prod-1"`, `"local"`).
29    pub name: String,
30    /// Bee API base URL (e.g. `"http://localhost:1633"`).
31    pub url: String,
32    /// Optional bearer token for restricted-mode nodes. Supports the
33    /// `@env:VAR_NAME` indirection — see [`NodeConfig::resolved_token`].
34    #[serde(default)]
35    pub token: Option<String>,
36    /// Marks the default profile loaded on startup. If no entry has
37    /// `default = true`, the first node in the list is used.
38    #[serde(default)]
39    pub default: bool,
40}
41
42impl NodeConfig {
43    /// Resolve `token` to its concrete value: `Some(env_var)` if the
44    /// configured value starts with `@env:`, otherwise the literal.
45    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    /// `[ui]` section — theme + ascii-fallback knobs.
66    #[serde(default)]
67    pub ui: UiConfig,
68    /// `[bee]` section — when present, bee-tui spawns the Bee node
69    /// itself before opening the cockpit. Absence keeps the legacy
70    /// behavior of connecting to an already-running Bee.
71    #[serde(default)]
72    pub bee: Option<BeeConfig>,
73    /// `[metrics]` section — when present and `enabled = true`,
74    /// bee-tui exposes a Prometheus `/metrics` endpoint on the
75    /// configured address. Default off because exposing an HTTP
76    /// listener should be an explicit operator opt-in.
77    #[serde(default)]
78    pub metrics: MetricsConfig,
79}
80
81/// `[bee]` table from `config.toml`. Both fields are required so a
82/// malformed `[bee]` block fails parse rather than silently spawning
83/// nothing.
84#[derive(Clone, Debug, Deserialize)]
85pub struct BeeConfig {
86    /// Path to the `bee` binary. Resolved relative to the working
87    /// directory if not absolute — operators usually run bee-tui from
88    /// the same shell they used to test the binary, so this is the
89    /// least surprising behavior.
90    pub bin: PathBuf,
91    /// Path to the Bee YAML config file the binary should be started
92    /// with. Same relative-to-cwd resolution as `bin`.
93    pub config: PathBuf,
94    /// `[bee.logs]` subsection — log rotation knobs. Both fields
95    /// optional; an absent `[bee.logs]` keeps defaults of 64 MiB
96    /// rotation at 5 retained files (~320 MiB ceiling).
97    #[serde(default)]
98    pub logs: BeeLogsConfig,
99}
100
101/// `[bee.logs]` table from `config.toml`. Bounds the size of the
102/// supervised Bee process's captured stdout+stderr file so a
103/// long-running node doesn't fill `$TMPDIR`.
104#[derive(Clone, Debug, Deserialize)]
105pub struct BeeLogsConfig {
106    /// Active log file rolls over once it reaches this many MiB.
107    /// Default 64 MiB — large enough that operator-relevant traces
108    /// fit in the live file, small enough that rotation happens
109    /// within a day or two on a busy node.
110    #[serde(default = "default_rotate_size_mb")]
111    pub rotate_size_mb: u64,
112    /// How many rotated files (`.1` .. `.N`) to retain. Default 5.
113    /// At the 64 MiB default that's ~320 MiB of log history kept on
114    /// disk; older content is unlinked.
115    #[serde(default = "default_keep_files")]
116    pub keep_files: u32,
117}
118
119impl Default for BeeLogsConfig {
120    fn default() -> Self {
121        Self {
122            rotate_size_mb: default_rotate_size_mb(),
123            keep_files: default_keep_files(),
124        }
125    }
126}
127
128fn default_rotate_size_mb() -> u64 {
129    64
130}
131fn default_keep_files() -> u32 {
132    5
133}
134
135/// `[metrics]` table from `config.toml`. Off by default — a
136/// Prometheus endpoint is a network-facing surface, even if it
137/// binds to localhost, so we make it a deliberate opt-in.
138#[derive(Clone, Debug, Deserialize)]
139pub struct MetricsConfig {
140    /// Master switch. `false` skips spawning the HTTP server
141    /// entirely.
142    #[serde(default)]
143    pub enabled: bool,
144    /// Bind address. Defaults to localhost; an operator who
145    /// genuinely wants `0.0.0.0` exposure has to type it.
146    #[serde(default = "default_metrics_addr")]
147    pub addr: String,
148}
149
150impl Default for MetricsConfig {
151    fn default() -> Self {
152        Self {
153            enabled: false,
154            addr: default_metrics_addr(),
155        }
156    }
157}
158
159fn default_metrics_addr() -> String {
160    "127.0.0.1:9101".into()
161}
162
163/// `[ui]` table from `config.toml`. Every field has a sensible
164/// default so the entire section can be omitted without breaking
165/// startup.
166#[derive(Clone, Debug, Default, Deserialize)]
167pub struct UiConfig {
168    /// Theme name. Recognised values: `"default"`, `"mono"`. Anything
169    /// else falls back to the default theme with a warning logged.
170    #[serde(default = "default_theme")]
171    pub theme: String,
172    /// When set, screens fall back to ASCII-only glyphs (✓ → `[X]`)
173    /// for terminals that don't render Unicode reliably. Not yet
174    /// wired through every component; reserved for follow-up.
175    #[serde(default)]
176    pub ascii_fallback: bool,
177    /// Polling-cadence preset. Recognised values:
178    /// - `"live"` — original 2 s health / 5 s topology+tags / 30 s swap+lottery+transactions / 60 s network. Most chatty; useful when actively diagnosing.
179    /// - `"default"` — calmer (4 s health / 10 s topology+tags / 30 s mid tier / 60 s network). About half the request volume of `live`. The default for new installs since the bottom log pane was tabbed.
180    /// - `"slow"` — minimal (8 s / 20 s / 60 s / 120 s). For "leave it open all day" monitoring.
181    ///
182    /// Unknown values fall back to `default` with a tracing warning.
183    #[serde(default = "default_refresh")]
184    pub refresh: String,
185}
186
187fn default_theme() -> String {
188    "default".into()
189}
190
191fn default_refresh() -> String {
192    "default".into()
193}
194
195impl Config {
196    /// Pick the active node profile: first entry with `default = true`,
197    /// otherwise the first entry, otherwise [`None`].
198    pub fn active_node(&self) -> Option<&NodeConfig> {
199        self.nodes
200            .iter()
201            .find(|n| n.default)
202            .or_else(|| self.nodes.first())
203    }
204}
205
206/// Default node list when the user hasn't configured any: a single
207/// `local` profile pointing at `http://localhost:1633`.
208fn default_nodes() -> Vec<NodeConfig> {
209    vec![NodeConfig {
210        name: "local".to_string(),
211        url: "http://localhost:1633".to_string(),
212        token: None,
213        default: true,
214    }]
215}
216
217lazy_static! {
218    pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
219    pub static ref DATA_FOLDER: Option<PathBuf> =
220        env::var(format!("{}_DATA", PROJECT_NAME.clone()))
221            .ok()
222            .map(PathBuf::from);
223    pub static ref CONFIG_FOLDER: Option<PathBuf> =
224        env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
225            .ok()
226            .map(PathBuf::from);
227}
228
229impl Config {
230    pub fn new() -> color_eyre::Result<Self, config::ConfigError> {
231        let default_config: Config = json5::from_str(CONFIG).unwrap();
232        let data_dir = get_data_dir();
233        let config_dir = get_config_dir();
234        let mut builder = config::Config::builder()
235            .set_default("data_dir", data_dir.to_str().unwrap())?
236            .set_default("config_dir", config_dir.to_str().unwrap())?;
237
238        let config_files = [
239            ("config.json5", config::FileFormat::Json5),
240            ("config.json", config::FileFormat::Json),
241            ("config.yaml", config::FileFormat::Yaml),
242            ("config.toml", config::FileFormat::Toml),
243            ("config.ini", config::FileFormat::Ini),
244        ];
245        let mut found_config = false;
246        for (file, format) in &config_files {
247            let source = config::File::from(config_dir.join(file))
248                .format(*format)
249                .required(false);
250            builder = builder.add_source(source);
251            if config_dir.join(file).exists() {
252                found_config = true
253            }
254        }
255        if !found_config {
256            error!("No configuration file found. Application may not behave as expected");
257        }
258
259        let mut cfg: Self = builder.build()?.try_deserialize()?;
260
261        for (mode, default_bindings) in default_config.keybindings.0.iter() {
262            let user_bindings = cfg.keybindings.0.entry(*mode).or_default();
263            for (key, cmd) in default_bindings.iter() {
264                user_bindings
265                    .entry(key.clone())
266                    .or_insert_with(|| cmd.clone());
267            }
268        }
269        for (mode, default_styles) in default_config.styles.0.iter() {
270            let user_styles = cfg.styles.0.entry(*mode).or_default();
271            for (style_key, style) in default_styles.iter() {
272                user_styles.entry(style_key.clone()).or_insert(*style);
273            }
274        }
275
276        Ok(cfg)
277    }
278}
279
280pub fn get_data_dir() -> PathBuf {
281    if let Some(s) = DATA_FOLDER.clone() {
282        s
283    } else if let Some(proj_dirs) = project_directory() {
284        proj_dirs.data_local_dir().to_path_buf()
285    } else {
286        PathBuf::from(".").join(".data")
287    }
288}
289
290pub fn get_config_dir() -> PathBuf {
291    if let Some(s) = CONFIG_FOLDER.clone() {
292        s
293    } else if let Some(proj_dirs) = project_directory() {
294        proj_dirs.config_local_dir().to_path_buf()
295    } else {
296        PathBuf::from(".").join(".config")
297    }
298}
299
300fn project_directory() -> Option<ProjectDirs> {
301    ProjectDirs::from("com", "ethswarm-tools", env!("CARGO_PKG_NAME"))
302}
303
304#[derive(Clone, Debug, Default)]
305pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Action>>);
306
307impl<'de> Deserialize<'de> for KeyBindings {
308    fn deserialize<D>(deserializer: D) -> color_eyre::Result<Self, D::Error>
309    where
310        D: Deserializer<'de>,
311    {
312        let parsed_map = HashMap::<Mode, HashMap<String, Action>>::deserialize(deserializer)?;
313
314        let keybindings = parsed_map
315            .into_iter()
316            .map(|(mode, inner_map)| {
317                let converted_inner_map = inner_map
318                    .into_iter()
319                    .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd))
320                    .collect();
321                (mode, converted_inner_map)
322            })
323            .collect();
324
325        Ok(KeyBindings(keybindings))
326    }
327}
328
329fn parse_key_event(raw: &str) -> color_eyre::Result<KeyEvent, String> {
330    let raw_lower = raw.to_ascii_lowercase();
331    let (remaining, modifiers) = extract_modifiers(&raw_lower);
332    parse_key_code_with_modifiers(remaining, modifiers)
333}
334
335fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) {
336    let mut modifiers = KeyModifiers::empty();
337    let mut current = raw;
338
339    loop {
340        match current {
341            rest if rest.starts_with("ctrl-") => {
342                modifiers.insert(KeyModifiers::CONTROL);
343                current = &rest[5..];
344            }
345            rest if rest.starts_with("alt-") => {
346                modifiers.insert(KeyModifiers::ALT);
347                current = &rest[4..];
348            }
349            rest if rest.starts_with("shift-") => {
350                modifiers.insert(KeyModifiers::SHIFT);
351                current = &rest[6..];
352            }
353            _ => break, // break out of the loop if no known prefix is detected
354        };
355    }
356
357    (current, modifiers)
358}
359
360fn parse_key_code_with_modifiers(
361    raw: &str,
362    mut modifiers: KeyModifiers,
363) -> color_eyre::Result<KeyEvent, String> {
364    let c = match raw {
365        "esc" => KeyCode::Esc,
366        "enter" => KeyCode::Enter,
367        "left" => KeyCode::Left,
368        "right" => KeyCode::Right,
369        "up" => KeyCode::Up,
370        "down" => KeyCode::Down,
371        "home" => KeyCode::Home,
372        "end" => KeyCode::End,
373        "pageup" => KeyCode::PageUp,
374        "pagedown" => KeyCode::PageDown,
375        "backtab" => {
376            modifiers.insert(KeyModifiers::SHIFT);
377            KeyCode::BackTab
378        }
379        "backspace" => KeyCode::Backspace,
380        "delete" => KeyCode::Delete,
381        "insert" => KeyCode::Insert,
382        "f1" => KeyCode::F(1),
383        "f2" => KeyCode::F(2),
384        "f3" => KeyCode::F(3),
385        "f4" => KeyCode::F(4),
386        "f5" => KeyCode::F(5),
387        "f6" => KeyCode::F(6),
388        "f7" => KeyCode::F(7),
389        "f8" => KeyCode::F(8),
390        "f9" => KeyCode::F(9),
391        "f10" => KeyCode::F(10),
392        "f11" => KeyCode::F(11),
393        "f12" => KeyCode::F(12),
394        "space" => KeyCode::Char(' '),
395        "hyphen" => KeyCode::Char('-'),
396        "minus" => KeyCode::Char('-'),
397        "tab" => KeyCode::Tab,
398        c if c.len() == 1 => {
399            let mut c = c.chars().next().unwrap();
400            if modifiers.contains(KeyModifiers::SHIFT) {
401                c = c.to_ascii_uppercase();
402            }
403            KeyCode::Char(c)
404        }
405        _ => return Err(format!("Unable to parse {raw}")),
406    };
407    Ok(KeyEvent::new(c, modifiers))
408}
409
410pub fn key_event_to_string(key_event: &KeyEvent) -> String {
411    let char;
412    let key_code = match key_event.code {
413        KeyCode::Backspace => "backspace",
414        KeyCode::Enter => "enter",
415        KeyCode::Left => "left",
416        KeyCode::Right => "right",
417        KeyCode::Up => "up",
418        KeyCode::Down => "down",
419        KeyCode::Home => "home",
420        KeyCode::End => "end",
421        KeyCode::PageUp => "pageup",
422        KeyCode::PageDown => "pagedown",
423        KeyCode::Tab => "tab",
424        KeyCode::BackTab => "backtab",
425        KeyCode::Delete => "delete",
426        KeyCode::Insert => "insert",
427        KeyCode::F(c) => {
428            char = format!("f({c})");
429            &char
430        }
431        KeyCode::Char(' ') => "space",
432        KeyCode::Char(c) => {
433            char = c.to_string();
434            &char
435        }
436        KeyCode::Esc => "esc",
437        KeyCode::Null => "",
438        KeyCode::CapsLock => "",
439        KeyCode::Menu => "",
440        KeyCode::ScrollLock => "",
441        KeyCode::Media(_) => "",
442        KeyCode::NumLock => "",
443        KeyCode::PrintScreen => "",
444        KeyCode::Pause => "",
445        KeyCode::KeypadBegin => "",
446        KeyCode::Modifier(_) => "",
447    };
448
449    let mut modifiers = Vec::with_capacity(3);
450
451    if key_event.modifiers.intersects(KeyModifiers::CONTROL) {
452        modifiers.push("ctrl");
453    }
454
455    if key_event.modifiers.intersects(KeyModifiers::SHIFT) {
456        modifiers.push("shift");
457    }
458
459    if key_event.modifiers.intersects(KeyModifiers::ALT) {
460        modifiers.push("alt");
461    }
462
463    let mut key = modifiers.join("-");
464
465    if !key.is_empty() {
466        key.push('-');
467    }
468    key.push_str(key_code);
469
470    key
471}
472
473pub fn parse_key_sequence(raw: &str) -> color_eyre::Result<Vec<KeyEvent>, String> {
474    if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
475        return Err(format!("Unable to parse `{}`", raw));
476    }
477    let raw = if !raw.contains("><") {
478        let raw = raw.strip_prefix('<').unwrap_or(raw);
479        raw.strip_prefix('>').unwrap_or(raw)
480    } else {
481        raw
482    };
483    let sequences = raw
484        .split("><")
485        .map(|seq| {
486            if let Some(s) = seq.strip_prefix('<') {
487                s
488            } else if let Some(s) = seq.strip_suffix('>') {
489                s
490            } else {
491                seq
492            }
493        })
494        .collect::<Vec<_>>();
495
496    sequences.into_iter().map(parse_key_event).collect()
497}
498
499#[derive(Clone, Debug, Default)]
500pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
501
502impl<'de> Deserialize<'de> for Styles {
503    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
504    where
505        D: Deserializer<'de>,
506    {
507        let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
508
509        let styles = parsed_map
510            .into_iter()
511            .map(|(mode, inner_map)| {
512                let converted_inner_map = inner_map
513                    .into_iter()
514                    .map(|(str, style)| (str, parse_style(&style)))
515                    .collect();
516                (mode, converted_inner_map)
517            })
518            .collect();
519
520        Ok(Styles(styles))
521    }
522}
523
524pub fn parse_style(line: &str) -> Style {
525    let (foreground, background) =
526        line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len()));
527    let foreground = process_color_string(foreground);
528    let background = process_color_string(&background.replace("on ", ""));
529
530    let mut style = Style::default();
531    if let Some(fg) = parse_color(&foreground.0) {
532        style = style.fg(fg);
533    }
534    if let Some(bg) = parse_color(&background.0) {
535        style = style.bg(bg);
536    }
537    style = style.add_modifier(foreground.1 | background.1);
538    style
539}
540
541fn process_color_string(color_str: &str) -> (String, Modifier) {
542    let color = color_str
543        .replace("grey", "gray")
544        .replace("bright ", "")
545        .replace("bold ", "")
546        .replace("underline ", "")
547        .replace("inverse ", "");
548
549    let mut modifiers = Modifier::empty();
550    if color_str.contains("underline") {
551        modifiers |= Modifier::UNDERLINED;
552    }
553    if color_str.contains("bold") {
554        modifiers |= Modifier::BOLD;
555    }
556    if color_str.contains("inverse") {
557        modifiers |= Modifier::REVERSED;
558    }
559
560    (color, modifiers)
561}
562
563fn parse_color(s: &str) -> Option<Color> {
564    let s = s.trim_start();
565    let s = s.trim_end();
566    if s.contains("bright color") {
567        let s = s.trim_start_matches("bright ");
568        let c = s
569            .trim_start_matches("color")
570            .parse::<u8>()
571            .unwrap_or_default();
572        Some(Color::Indexed(c.wrapping_shl(8)))
573    } else if s.contains("color") {
574        let c = s
575            .trim_start_matches("color")
576            .parse::<u8>()
577            .unwrap_or_default();
578        Some(Color::Indexed(c))
579    } else if s.contains("gray") {
580        let c = 232
581            + s.trim_start_matches("gray")
582                .parse::<u8>()
583                .unwrap_or_default();
584        Some(Color::Indexed(c))
585    } else if s.contains("rgb") {
586        let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
587        let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
588        let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
589        let c = 16 + red * 36 + green * 6 + blue;
590        Some(Color::Indexed(c))
591    } else if s == "bold black" {
592        Some(Color::Indexed(8))
593    } else if s == "bold red" {
594        Some(Color::Indexed(9))
595    } else if s == "bold green" {
596        Some(Color::Indexed(10))
597    } else if s == "bold yellow" {
598        Some(Color::Indexed(11))
599    } else if s == "bold blue" {
600        Some(Color::Indexed(12))
601    } else if s == "bold magenta" {
602        Some(Color::Indexed(13))
603    } else if s == "bold cyan" {
604        Some(Color::Indexed(14))
605    } else if s == "bold white" {
606        Some(Color::Indexed(15))
607    } else if s == "black" {
608        Some(Color::Indexed(0))
609    } else if s == "red" {
610        Some(Color::Indexed(1))
611    } else if s == "green" {
612        Some(Color::Indexed(2))
613    } else if s == "yellow" {
614        Some(Color::Indexed(3))
615    } else if s == "blue" {
616        Some(Color::Indexed(4))
617    } else if s == "magenta" {
618        Some(Color::Indexed(5))
619    } else if s == "cyan" {
620        Some(Color::Indexed(6))
621    } else if s == "white" {
622        Some(Color::Indexed(7))
623    } else {
624        None
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use pretty_assertions::assert_eq;
631
632    use super::*;
633
634    #[test]
635    fn test_parse_style_default() {
636        let style = parse_style("");
637        assert_eq!(style, Style::default());
638    }
639
640    #[test]
641    fn test_parse_style_foreground() {
642        let style = parse_style("red");
643        assert_eq!(style.fg, Some(Color::Indexed(1)));
644    }
645
646    #[test]
647    fn test_parse_style_background() {
648        let style = parse_style("on blue");
649        assert_eq!(style.bg, Some(Color::Indexed(4)));
650    }
651
652    #[test]
653    fn test_parse_style_modifiers() {
654        let style = parse_style("underline red on blue");
655        assert_eq!(style.fg, Some(Color::Indexed(1)));
656        assert_eq!(style.bg, Some(Color::Indexed(4)));
657    }
658
659    #[test]
660    fn test_process_color_string() {
661        let (color, modifiers) = process_color_string("underline bold inverse gray");
662        assert_eq!(color, "gray");
663        assert!(modifiers.contains(Modifier::UNDERLINED));
664        assert!(modifiers.contains(Modifier::BOLD));
665        assert!(modifiers.contains(Modifier::REVERSED));
666    }
667
668    #[test]
669    fn test_parse_color_rgb() {
670        let color = parse_color("rgb123");
671        let expected = 16 + 36 + 2 * 6 + 3;
672        assert_eq!(color, Some(Color::Indexed(expected)));
673    }
674
675    #[test]
676    fn test_parse_color_unknown() {
677        let color = parse_color("unknown");
678        assert_eq!(color, None);
679    }
680
681    #[test]
682    fn test_config() -> color_eyre::Result<()> {
683        // Plain `q` is intercepted in App::handle_key_event for the
684        // double-tap quit guard, so it is intentionally NOT in the
685        // keybindings map. Ctrl+C remains as the immediate-quit
686        // escape hatch.
687        let c = Config::new()?;
688        assert_eq!(
689            c.keybindings
690                .0
691                .get(&Mode::Home)
692                .unwrap()
693                .get(&parse_key_sequence("<Ctrl-c>").unwrap_or_default())
694                .unwrap(),
695            &Action::Quit
696        );
697        Ok(())
698    }
699
700    #[test]
701    fn test_simple_keys() {
702        assert_eq!(
703            parse_key_event("a").unwrap(),
704            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
705        );
706
707        assert_eq!(
708            parse_key_event("enter").unwrap(),
709            KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
710        );
711
712        assert_eq!(
713            parse_key_event("esc").unwrap(),
714            KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
715        );
716    }
717
718    #[test]
719    fn test_with_modifiers() {
720        assert_eq!(
721            parse_key_event("ctrl-a").unwrap(),
722            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
723        );
724
725        assert_eq!(
726            parse_key_event("alt-enter").unwrap(),
727            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
728        );
729
730        assert_eq!(
731            parse_key_event("shift-esc").unwrap(),
732            KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
733        );
734    }
735
736    #[test]
737    fn test_multiple_modifiers() {
738        assert_eq!(
739            parse_key_event("ctrl-alt-a").unwrap(),
740            KeyEvent::new(
741                KeyCode::Char('a'),
742                KeyModifiers::CONTROL | KeyModifiers::ALT
743            )
744        );
745
746        assert_eq!(
747            parse_key_event("ctrl-shift-enter").unwrap(),
748            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
749        );
750    }
751
752    #[test]
753    fn test_reverse_multiple_modifiers() {
754        assert_eq!(
755            key_event_to_string(&KeyEvent::new(
756                KeyCode::Char('a'),
757                KeyModifiers::CONTROL | KeyModifiers::ALT
758            )),
759            "ctrl-alt-a".to_string()
760        );
761    }
762
763    #[test]
764    fn test_invalid_keys() {
765        assert!(parse_key_event("invalid-key").is_err());
766        assert!(parse_key_event("ctrl-invalid-key").is_err());
767    }
768
769    #[test]
770    fn test_case_insensitivity() {
771        assert_eq!(
772            parse_key_event("CTRL-a").unwrap(),
773            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
774        );
775
776        assert_eq!(
777            parse_key_event("AlT-eNtEr").unwrap(),
778            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
779        );
780    }
781}