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. When the history
244/// file is enabled, rotation keeps disk usage bounded — the active
245/// file rolls over to `<path>.1` once it crosses `rotate_size_mb`,
246/// and only the most-recent `keep_files` rotations are retained.
247#[derive(Clone, Debug, Deserialize)]
248pub struct PubsubConfig {
249    /// Path to a JSONL file that bee-tui appends to whenever a
250    /// pubsub frame arrives. The file is created with mode 0600
251    /// (owner read/write only) so payloads can't accidentally be
252    /// world-readable on a multi-user host. Each line is one
253    /// JSON object with the same shape `--once feed-probe`'s
254    /// data field uses.
255    #[serde(default)]
256    pub history_file: Option<PathBuf>,
257    /// Active history file rolls over once it reaches this many
258    /// MiB. Default 64 MiB. Zero disables rotation (file grows
259    /// unbounded — operator's responsibility to truncate).
260    #[serde(default = "default_pubsub_rotate_size_mb")]
261    pub rotate_size_mb: u64,
262    /// How many rotated history files (`<path>.1` .. `<path>.N`)
263    /// to retain. Default 5. At the 64 MiB default that's a
264    /// ~320 MiB ceiling; older rotations are unlinked.
265    #[serde(default = "default_pubsub_keep_files")]
266    pub keep_files: u32,
267}
268
269impl Default for PubsubConfig {
270    fn default() -> Self {
271        Self {
272            history_file: None,
273            rotate_size_mb: default_pubsub_rotate_size_mb(),
274            keep_files: default_pubsub_keep_files(),
275        }
276    }
277}
278
279fn default_pubsub_rotate_size_mb() -> u64 {
280    64
281}
282
283fn default_pubsub_keep_files() -> u32 {
284    5
285}
286
287/// `[alerts]` table from `config.toml`. Off by default — without a
288/// `webhook_url`, the alerter is a no-op. The debounce knob exists
289/// so a flapping gate doesn't pin an operator's Slack channel.
290#[derive(Clone, Debug, Deserialize)]
291pub struct AlertsConfig {
292    /// Slack/Discord-compatible incoming-webhook URL. When absent
293    /// (the default), no alerts are sent.
294    #[serde(default)]
295    pub webhook_url: Option<String>,
296    /// Per-gate debounce window in seconds. After firing for gate X,
297    /// no further alert for X until this elapses regardless of how
298    /// many times the gate flapped. Default 300 (5 min).
299    #[serde(default = "default_alerts_debounce_secs")]
300    pub debounce_secs: u64,
301}
302
303impl Default for AlertsConfig {
304    fn default() -> Self {
305        Self {
306            webhook_url: None,
307            debounce_secs: default_alerts_debounce_secs(),
308        }
309    }
310}
311
312fn default_alerts_debounce_secs() -> u64 {
313    crate::alerts::DEFAULT_DEBOUNCE_SECS
314}
315
316/// `[ui]` table from `config.toml`. Every field has a sensible
317/// default so the entire section can be omitted without breaking
318/// startup.
319#[derive(Clone, Debug, Default, Deserialize)]
320pub struct UiConfig {
321    /// Theme name. Recognised values: `"default"`, `"mono"`. Anything
322    /// else falls back to the default theme with a warning logged.
323    #[serde(default = "default_theme")]
324    pub theme: String,
325    /// When set, screens fall back to ASCII-only glyphs (✓ → `[X]`)
326    /// for terminals that don't render Unicode reliably. Not yet
327    /// wired through every component; reserved for follow-up.
328    #[serde(default)]
329    pub ascii_fallback: bool,
330    /// Polling-cadence preset. Recognised values:
331    /// - `"live"` — original 2 s health / 5 s topology+tags / 30 s swap+lottery+transactions / 60 s network. Most chatty; useful when actively diagnosing.
332    /// - `"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.
333    /// - `"slow"` — minimal (8 s / 20 s / 60 s / 120 s). For "leave it open all day" monitoring.
334    ///
335    /// Unknown values fall back to `default` with a tracing warning.
336    #[serde(default = "default_refresh")]
337    pub refresh: String,
338}
339
340fn default_theme() -> String {
341    "default".into()
342}
343
344fn default_refresh() -> String {
345    "default".into()
346}
347
348impl Config {
349    /// Pick the active node profile: first entry with `default = true`,
350    /// otherwise the first entry, otherwise [`None`].
351    pub fn active_node(&self) -> Option<&NodeConfig> {
352        self.nodes
353            .iter()
354            .find(|n| n.default)
355            .or_else(|| self.nodes.first())
356    }
357}
358
359/// Default node list when the user hasn't configured any: a single
360/// `local` profile pointing at `http://localhost:1633`.
361fn default_nodes() -> Vec<NodeConfig> {
362    vec![NodeConfig {
363        name: "local".to_string(),
364        url: "http://localhost:1633".to_string(),
365        token: None,
366        default: true,
367    }]
368}
369
370lazy_static! {
371    pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
372    pub static ref DATA_FOLDER: Option<PathBuf> =
373        env::var(format!("{}_DATA", PROJECT_NAME.clone()))
374            .ok()
375            .map(PathBuf::from);
376    pub static ref CONFIG_FOLDER: Option<PathBuf> =
377        env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
378            .ok()
379            .map(PathBuf::from);
380}
381
382impl Config {
383    pub fn new() -> color_eyre::Result<Self, config::ConfigError> {
384        let default_config: Config = json5::from_str(CONFIG).unwrap();
385        let data_dir = get_data_dir();
386        let config_dir = get_config_dir();
387        let mut builder = config::Config::builder()
388            .set_default("data_dir", data_dir.to_str().unwrap())?
389            .set_default("config_dir", config_dir.to_str().unwrap())?;
390
391        let config_files = [
392            ("config.json5", config::FileFormat::Json5),
393            ("config.json", config::FileFormat::Json),
394            ("config.yaml", config::FileFormat::Yaml),
395            ("config.toml", config::FileFormat::Toml),
396            ("config.ini", config::FileFormat::Ini),
397        ];
398        let mut found_config = false;
399        for (file, format) in &config_files {
400            let source = config::File::from(config_dir.join(file))
401                .format(*format)
402                .required(false);
403            builder = builder.add_source(source);
404            if config_dir.join(file).exists() {
405                found_config = true
406            }
407        }
408        if !found_config {
409            error!("No configuration file found. Application may not behave as expected");
410        }
411
412        let mut cfg: Self = builder.build()?.try_deserialize()?;
413
414        for (mode, default_bindings) in default_config.keybindings.0.iter() {
415            let user_bindings = cfg.keybindings.0.entry(*mode).or_default();
416            for (key, cmd) in default_bindings.iter() {
417                user_bindings
418                    .entry(key.clone())
419                    .or_insert_with(|| cmd.clone());
420            }
421        }
422        for (mode, default_styles) in default_config.styles.0.iter() {
423            let user_styles = cfg.styles.0.entry(*mode).or_default();
424            for (style_key, style) in default_styles.iter() {
425                user_styles.entry(style_key.clone()).or_insert(*style);
426            }
427        }
428
429        Ok(cfg)
430    }
431}
432
433pub fn get_data_dir() -> PathBuf {
434    if let Some(s) = DATA_FOLDER.clone() {
435        s
436    } else if let Some(proj_dirs) = project_directory() {
437        proj_dirs.data_local_dir().to_path_buf()
438    } else {
439        PathBuf::from(".").join(".data")
440    }
441}
442
443pub fn get_config_dir() -> PathBuf {
444    if let Some(s) = CONFIG_FOLDER.clone() {
445        s
446    } else if let Some(proj_dirs) = project_directory() {
447        proj_dirs.config_local_dir().to_path_buf()
448    } else {
449        PathBuf::from(".").join(".config")
450    }
451}
452
453fn project_directory() -> Option<ProjectDirs> {
454    ProjectDirs::from("com", "ethswarm-tools", env!("CARGO_PKG_NAME"))
455}
456
457#[derive(Clone, Debug, Default)]
458pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Action>>);
459
460impl<'de> Deserialize<'de> for KeyBindings {
461    fn deserialize<D>(deserializer: D) -> color_eyre::Result<Self, D::Error>
462    where
463        D: Deserializer<'de>,
464    {
465        let parsed_map = HashMap::<Mode, HashMap<String, Action>>::deserialize(deserializer)?;
466
467        let keybindings = parsed_map
468            .into_iter()
469            .map(|(mode, inner_map)| {
470                let converted_inner_map = inner_map
471                    .into_iter()
472                    .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd))
473                    .collect();
474                (mode, converted_inner_map)
475            })
476            .collect();
477
478        Ok(KeyBindings(keybindings))
479    }
480}
481
482fn parse_key_event(raw: &str) -> color_eyre::Result<KeyEvent, String> {
483    let raw_lower = raw.to_ascii_lowercase();
484    let (remaining, modifiers) = extract_modifiers(&raw_lower);
485    parse_key_code_with_modifiers(remaining, modifiers)
486}
487
488fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) {
489    let mut modifiers = KeyModifiers::empty();
490    let mut current = raw;
491
492    loop {
493        match current {
494            rest if rest.starts_with("ctrl-") => {
495                modifiers.insert(KeyModifiers::CONTROL);
496                current = &rest[5..];
497            }
498            rest if rest.starts_with("alt-") => {
499                modifiers.insert(KeyModifiers::ALT);
500                current = &rest[4..];
501            }
502            rest if rest.starts_with("shift-") => {
503                modifiers.insert(KeyModifiers::SHIFT);
504                current = &rest[6..];
505            }
506            _ => break, // break out of the loop if no known prefix is detected
507        };
508    }
509
510    (current, modifiers)
511}
512
513fn parse_key_code_with_modifiers(
514    raw: &str,
515    mut modifiers: KeyModifiers,
516) -> color_eyre::Result<KeyEvent, String> {
517    let c = match raw {
518        "esc" => KeyCode::Esc,
519        "enter" => KeyCode::Enter,
520        "left" => KeyCode::Left,
521        "right" => KeyCode::Right,
522        "up" => KeyCode::Up,
523        "down" => KeyCode::Down,
524        "home" => KeyCode::Home,
525        "end" => KeyCode::End,
526        "pageup" => KeyCode::PageUp,
527        "pagedown" => KeyCode::PageDown,
528        "backtab" => {
529            modifiers.insert(KeyModifiers::SHIFT);
530            KeyCode::BackTab
531        }
532        "backspace" => KeyCode::Backspace,
533        "delete" => KeyCode::Delete,
534        "insert" => KeyCode::Insert,
535        "f1" => KeyCode::F(1),
536        "f2" => KeyCode::F(2),
537        "f3" => KeyCode::F(3),
538        "f4" => KeyCode::F(4),
539        "f5" => KeyCode::F(5),
540        "f6" => KeyCode::F(6),
541        "f7" => KeyCode::F(7),
542        "f8" => KeyCode::F(8),
543        "f9" => KeyCode::F(9),
544        "f10" => KeyCode::F(10),
545        "f11" => KeyCode::F(11),
546        "f12" => KeyCode::F(12),
547        "space" => KeyCode::Char(' '),
548        "hyphen" => KeyCode::Char('-'),
549        "minus" => KeyCode::Char('-'),
550        "tab" => KeyCode::Tab,
551        c if c.len() == 1 => {
552            let mut c = c.chars().next().unwrap();
553            if modifiers.contains(KeyModifiers::SHIFT) {
554                c = c.to_ascii_uppercase();
555            }
556            KeyCode::Char(c)
557        }
558        _ => return Err(format!("Unable to parse {raw}")),
559    };
560    Ok(KeyEvent::new(c, modifiers))
561}
562
563pub fn key_event_to_string(key_event: &KeyEvent) -> String {
564    let char;
565    let key_code = match key_event.code {
566        KeyCode::Backspace => "backspace",
567        KeyCode::Enter => "enter",
568        KeyCode::Left => "left",
569        KeyCode::Right => "right",
570        KeyCode::Up => "up",
571        KeyCode::Down => "down",
572        KeyCode::Home => "home",
573        KeyCode::End => "end",
574        KeyCode::PageUp => "pageup",
575        KeyCode::PageDown => "pagedown",
576        KeyCode::Tab => "tab",
577        KeyCode::BackTab => "backtab",
578        KeyCode::Delete => "delete",
579        KeyCode::Insert => "insert",
580        KeyCode::F(c) => {
581            char = format!("f({c})");
582            &char
583        }
584        KeyCode::Char(' ') => "space",
585        KeyCode::Char(c) => {
586            char = c.to_string();
587            &char
588        }
589        KeyCode::Esc => "esc",
590        KeyCode::Null => "",
591        KeyCode::CapsLock => "",
592        KeyCode::Menu => "",
593        KeyCode::ScrollLock => "",
594        KeyCode::Media(_) => "",
595        KeyCode::NumLock => "",
596        KeyCode::PrintScreen => "",
597        KeyCode::Pause => "",
598        KeyCode::KeypadBegin => "",
599        KeyCode::Modifier(_) => "",
600    };
601
602    let mut modifiers = Vec::with_capacity(3);
603
604    if key_event.modifiers.intersects(KeyModifiers::CONTROL) {
605        modifiers.push("ctrl");
606    }
607
608    if key_event.modifiers.intersects(KeyModifiers::SHIFT) {
609        modifiers.push("shift");
610    }
611
612    if key_event.modifiers.intersects(KeyModifiers::ALT) {
613        modifiers.push("alt");
614    }
615
616    let mut key = modifiers.join("-");
617
618    if !key.is_empty() {
619        key.push('-');
620    }
621    key.push_str(key_code);
622
623    key
624}
625
626pub fn parse_key_sequence(raw: &str) -> color_eyre::Result<Vec<KeyEvent>, String> {
627    if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
628        return Err(format!("Unable to parse `{}`", raw));
629    }
630    let raw = if !raw.contains("><") {
631        let raw = raw.strip_prefix('<').unwrap_or(raw);
632        raw.strip_prefix('>').unwrap_or(raw)
633    } else {
634        raw
635    };
636    let sequences = raw
637        .split("><")
638        .map(|seq| {
639            if let Some(s) = seq.strip_prefix('<') {
640                s
641            } else if let Some(s) = seq.strip_suffix('>') {
642                s
643            } else {
644                seq
645            }
646        })
647        .collect::<Vec<_>>();
648
649    sequences.into_iter().map(parse_key_event).collect()
650}
651
652#[derive(Clone, Debug, Default)]
653pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
654
655impl<'de> Deserialize<'de> for Styles {
656    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
657    where
658        D: Deserializer<'de>,
659    {
660        let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
661
662        let styles = parsed_map
663            .into_iter()
664            .map(|(mode, inner_map)| {
665                let converted_inner_map = inner_map
666                    .into_iter()
667                    .map(|(str, style)| (str, parse_style(&style)))
668                    .collect();
669                (mode, converted_inner_map)
670            })
671            .collect();
672
673        Ok(Styles(styles))
674    }
675}
676
677pub fn parse_style(line: &str) -> Style {
678    let (foreground, background) =
679        line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len()));
680    let foreground = process_color_string(foreground);
681    let background = process_color_string(&background.replace("on ", ""));
682
683    let mut style = Style::default();
684    if let Some(fg) = parse_color(&foreground.0) {
685        style = style.fg(fg);
686    }
687    if let Some(bg) = parse_color(&background.0) {
688        style = style.bg(bg);
689    }
690    style = style.add_modifier(foreground.1 | background.1);
691    style
692}
693
694fn process_color_string(color_str: &str) -> (String, Modifier) {
695    let color = color_str
696        .replace("grey", "gray")
697        .replace("bright ", "")
698        .replace("bold ", "")
699        .replace("underline ", "")
700        .replace("inverse ", "");
701
702    let mut modifiers = Modifier::empty();
703    if color_str.contains("underline") {
704        modifiers |= Modifier::UNDERLINED;
705    }
706    if color_str.contains("bold") {
707        modifiers |= Modifier::BOLD;
708    }
709    if color_str.contains("inverse") {
710        modifiers |= Modifier::REVERSED;
711    }
712
713    (color, modifiers)
714}
715
716fn parse_color(s: &str) -> Option<Color> {
717    let s = s.trim_start();
718    let s = s.trim_end();
719    if s.contains("bright color") {
720        let s = s.trim_start_matches("bright ");
721        let c = s
722            .trim_start_matches("color")
723            .parse::<u8>()
724            .unwrap_or_default();
725        Some(Color::Indexed(c.wrapping_shl(8)))
726    } else if s.contains("color") {
727        let c = s
728            .trim_start_matches("color")
729            .parse::<u8>()
730            .unwrap_or_default();
731        Some(Color::Indexed(c))
732    } else if s.contains("gray") {
733        let c = 232
734            + s.trim_start_matches("gray")
735                .parse::<u8>()
736                .unwrap_or_default();
737        Some(Color::Indexed(c))
738    } else if s.contains("rgb") {
739        let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
740        let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
741        let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
742        let c = 16 + red * 36 + green * 6 + blue;
743        Some(Color::Indexed(c))
744    } else if s == "bold black" {
745        Some(Color::Indexed(8))
746    } else if s == "bold red" {
747        Some(Color::Indexed(9))
748    } else if s == "bold green" {
749        Some(Color::Indexed(10))
750    } else if s == "bold yellow" {
751        Some(Color::Indexed(11))
752    } else if s == "bold blue" {
753        Some(Color::Indexed(12))
754    } else if s == "bold magenta" {
755        Some(Color::Indexed(13))
756    } else if s == "bold cyan" {
757        Some(Color::Indexed(14))
758    } else if s == "bold white" {
759        Some(Color::Indexed(15))
760    } else if s == "black" {
761        Some(Color::Indexed(0))
762    } else if s == "red" {
763        Some(Color::Indexed(1))
764    } else if s == "green" {
765        Some(Color::Indexed(2))
766    } else if s == "yellow" {
767        Some(Color::Indexed(3))
768    } else if s == "blue" {
769        Some(Color::Indexed(4))
770    } else if s == "magenta" {
771        Some(Color::Indexed(5))
772    } else if s == "cyan" {
773        Some(Color::Indexed(6))
774    } else if s == "white" {
775        Some(Color::Indexed(7))
776    } else {
777        None
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use pretty_assertions::assert_eq;
784
785    use super::*;
786
787    #[test]
788    fn test_parse_style_default() {
789        let style = parse_style("");
790        assert_eq!(style, Style::default());
791    }
792
793    #[test]
794    fn test_parse_style_foreground() {
795        let style = parse_style("red");
796        assert_eq!(style.fg, Some(Color::Indexed(1)));
797    }
798
799    #[test]
800    fn test_parse_style_background() {
801        let style = parse_style("on blue");
802        assert_eq!(style.bg, Some(Color::Indexed(4)));
803    }
804
805    #[test]
806    fn test_parse_style_modifiers() {
807        let style = parse_style("underline red on blue");
808        assert_eq!(style.fg, Some(Color::Indexed(1)));
809        assert_eq!(style.bg, Some(Color::Indexed(4)));
810    }
811
812    #[test]
813    fn test_process_color_string() {
814        let (color, modifiers) = process_color_string("underline bold inverse gray");
815        assert_eq!(color, "gray");
816        assert!(modifiers.contains(Modifier::UNDERLINED));
817        assert!(modifiers.contains(Modifier::BOLD));
818        assert!(modifiers.contains(Modifier::REVERSED));
819    }
820
821    #[test]
822    fn test_parse_color_rgb() {
823        let color = parse_color("rgb123");
824        let expected = 16 + 36 + 2 * 6 + 3;
825        assert_eq!(color, Some(Color::Indexed(expected)));
826    }
827
828    #[test]
829    fn test_parse_color_unknown() {
830        let color = parse_color("unknown");
831        assert_eq!(color, None);
832    }
833
834    #[test]
835    fn test_config() -> color_eyre::Result<()> {
836        // Plain `q` is intercepted in App::handle_key_event for the
837        // double-tap quit guard, so it is intentionally NOT in the
838        // keybindings map. Ctrl+C remains as the immediate-quit
839        // escape hatch.
840        let c = Config::new()?;
841        assert_eq!(
842            c.keybindings
843                .0
844                .get(&Mode::Home)
845                .unwrap()
846                .get(&parse_key_sequence("<Ctrl-c>").unwrap_or_default())
847                .unwrap(),
848            &Action::Quit
849        );
850        Ok(())
851    }
852
853    #[test]
854    fn test_simple_keys() {
855        assert_eq!(
856            parse_key_event("a").unwrap(),
857            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
858        );
859
860        assert_eq!(
861            parse_key_event("enter").unwrap(),
862            KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
863        );
864
865        assert_eq!(
866            parse_key_event("esc").unwrap(),
867            KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
868        );
869    }
870
871    #[test]
872    fn test_with_modifiers() {
873        assert_eq!(
874            parse_key_event("ctrl-a").unwrap(),
875            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
876        );
877
878        assert_eq!(
879            parse_key_event("alt-enter").unwrap(),
880            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
881        );
882
883        assert_eq!(
884            parse_key_event("shift-esc").unwrap(),
885            KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
886        );
887    }
888
889    #[test]
890    fn test_multiple_modifiers() {
891        assert_eq!(
892            parse_key_event("ctrl-alt-a").unwrap(),
893            KeyEvent::new(
894                KeyCode::Char('a'),
895                KeyModifiers::CONTROL | KeyModifiers::ALT
896            )
897        );
898
899        assert_eq!(
900            parse_key_event("ctrl-shift-enter").unwrap(),
901            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
902        );
903    }
904
905    #[test]
906    fn test_reverse_multiple_modifiers() {
907        assert_eq!(
908            key_event_to_string(&KeyEvent::new(
909                KeyCode::Char('a'),
910                KeyModifiers::CONTROL | KeyModifiers::ALT
911            )),
912            "ctrl-alt-a".to_string()
913        );
914    }
915
916    #[test]
917    fn test_invalid_keys() {
918        assert!(parse_key_event("invalid-key").is_err());
919        assert!(parse_key_event("ctrl-invalid-key").is_err());
920    }
921
922    #[test]
923    fn test_case_insensitivity() {
924        assert_eq!(
925            parse_key_event("CTRL-a").unwrap(),
926            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
927        );
928
929        assert_eq!(
930            parse_key_event("AlT-eNtEr").unwrap(),
931            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
932        );
933    }
934}