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