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