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