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}
69
70/// `[ui]` table from `config.toml`. Every field has a sensible
71/// default so the entire section can be omitted without breaking
72/// startup.
73#[derive(Clone, Debug, Default, Deserialize)]
74pub struct UiConfig {
75    /// Theme name. Recognised values: `"default"`, `"mono"`. Anything
76    /// else falls back to the default theme with a warning logged.
77    #[serde(default = "default_theme")]
78    pub theme: String,
79    /// When set, screens fall back to ASCII-only glyphs (✓ → `[X]`)
80    /// for terminals that don't render Unicode reliably. Not yet
81    /// wired through every component; reserved for follow-up.
82    #[serde(default)]
83    pub ascii_fallback: bool,
84}
85
86fn default_theme() -> String {
87    "default".into()
88}
89
90impl Config {
91    /// Pick the active node profile: first entry with `default = true`,
92    /// otherwise the first entry, otherwise [`None`].
93    pub fn active_node(&self) -> Option<&NodeConfig> {
94        self.nodes
95            .iter()
96            .find(|n| n.default)
97            .or_else(|| self.nodes.first())
98    }
99}
100
101/// Default node list when the user hasn't configured any: a single
102/// `local` profile pointing at `http://localhost:1633`.
103fn default_nodes() -> Vec<NodeConfig> {
104    vec![NodeConfig {
105        name: "local".to_string(),
106        url: "http://localhost:1633".to_string(),
107        token: None,
108        default: true,
109    }]
110}
111
112lazy_static! {
113    pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
114    pub static ref DATA_FOLDER: Option<PathBuf> =
115        env::var(format!("{}_DATA", PROJECT_NAME.clone()))
116            .ok()
117            .map(PathBuf::from);
118    pub static ref CONFIG_FOLDER: Option<PathBuf> =
119        env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
120            .ok()
121            .map(PathBuf::from);
122}
123
124impl Config {
125    pub fn new() -> color_eyre::Result<Self, config::ConfigError> {
126        let default_config: Config = json5::from_str(CONFIG).unwrap();
127        let data_dir = get_data_dir();
128        let config_dir = get_config_dir();
129        let mut builder = config::Config::builder()
130            .set_default("data_dir", data_dir.to_str().unwrap())?
131            .set_default("config_dir", config_dir.to_str().unwrap())?;
132
133        let config_files = [
134            ("config.json5", config::FileFormat::Json5),
135            ("config.json", config::FileFormat::Json),
136            ("config.yaml", config::FileFormat::Yaml),
137            ("config.toml", config::FileFormat::Toml),
138            ("config.ini", config::FileFormat::Ini),
139        ];
140        let mut found_config = false;
141        for (file, format) in &config_files {
142            let source = config::File::from(config_dir.join(file))
143                .format(*format)
144                .required(false);
145            builder = builder.add_source(source);
146            if config_dir.join(file).exists() {
147                found_config = true
148            }
149        }
150        if !found_config {
151            error!("No configuration file found. Application may not behave as expected");
152        }
153
154        let mut cfg: Self = builder.build()?.try_deserialize()?;
155
156        for (mode, default_bindings) in default_config.keybindings.0.iter() {
157            let user_bindings = cfg.keybindings.0.entry(*mode).or_default();
158            for (key, cmd) in default_bindings.iter() {
159                user_bindings
160                    .entry(key.clone())
161                    .or_insert_with(|| cmd.clone());
162            }
163        }
164        for (mode, default_styles) in default_config.styles.0.iter() {
165            let user_styles = cfg.styles.0.entry(*mode).or_default();
166            for (style_key, style) in default_styles.iter() {
167                user_styles.entry(style_key.clone()).or_insert(*style);
168            }
169        }
170
171        Ok(cfg)
172    }
173}
174
175pub fn get_data_dir() -> PathBuf {
176    if let Some(s) = DATA_FOLDER.clone() {
177        s
178    } else if let Some(proj_dirs) = project_directory() {
179        proj_dirs.data_local_dir().to_path_buf()
180    } else {
181        PathBuf::from(".").join(".data")
182    }
183}
184
185pub fn get_config_dir() -> PathBuf {
186    if let Some(s) = CONFIG_FOLDER.clone() {
187        s
188    } else if let Some(proj_dirs) = project_directory() {
189        proj_dirs.config_local_dir().to_path_buf()
190    } else {
191        PathBuf::from(".").join(".config")
192    }
193}
194
195fn project_directory() -> Option<ProjectDirs> {
196    ProjectDirs::from("com", "ethswarm-tools", env!("CARGO_PKG_NAME"))
197}
198
199#[derive(Clone, Debug, Default)]
200pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Action>>);
201
202impl<'de> Deserialize<'de> for KeyBindings {
203    fn deserialize<D>(deserializer: D) -> color_eyre::Result<Self, D::Error>
204    where
205        D: Deserializer<'de>,
206    {
207        let parsed_map = HashMap::<Mode, HashMap<String, Action>>::deserialize(deserializer)?;
208
209        let keybindings = parsed_map
210            .into_iter()
211            .map(|(mode, inner_map)| {
212                let converted_inner_map = inner_map
213                    .into_iter()
214                    .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd))
215                    .collect();
216                (mode, converted_inner_map)
217            })
218            .collect();
219
220        Ok(KeyBindings(keybindings))
221    }
222}
223
224fn parse_key_event(raw: &str) -> color_eyre::Result<KeyEvent, String> {
225    let raw_lower = raw.to_ascii_lowercase();
226    let (remaining, modifiers) = extract_modifiers(&raw_lower);
227    parse_key_code_with_modifiers(remaining, modifiers)
228}
229
230fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) {
231    let mut modifiers = KeyModifiers::empty();
232    let mut current = raw;
233
234    loop {
235        match current {
236            rest if rest.starts_with("ctrl-") => {
237                modifiers.insert(KeyModifiers::CONTROL);
238                current = &rest[5..];
239            }
240            rest if rest.starts_with("alt-") => {
241                modifiers.insert(KeyModifiers::ALT);
242                current = &rest[4..];
243            }
244            rest if rest.starts_with("shift-") => {
245                modifiers.insert(KeyModifiers::SHIFT);
246                current = &rest[6..];
247            }
248            _ => break, // break out of the loop if no known prefix is detected
249        };
250    }
251
252    (current, modifiers)
253}
254
255fn parse_key_code_with_modifiers(
256    raw: &str,
257    mut modifiers: KeyModifiers,
258) -> color_eyre::Result<KeyEvent, String> {
259    let c = match raw {
260        "esc" => KeyCode::Esc,
261        "enter" => KeyCode::Enter,
262        "left" => KeyCode::Left,
263        "right" => KeyCode::Right,
264        "up" => KeyCode::Up,
265        "down" => KeyCode::Down,
266        "home" => KeyCode::Home,
267        "end" => KeyCode::End,
268        "pageup" => KeyCode::PageUp,
269        "pagedown" => KeyCode::PageDown,
270        "backtab" => {
271            modifiers.insert(KeyModifiers::SHIFT);
272            KeyCode::BackTab
273        }
274        "backspace" => KeyCode::Backspace,
275        "delete" => KeyCode::Delete,
276        "insert" => KeyCode::Insert,
277        "f1" => KeyCode::F(1),
278        "f2" => KeyCode::F(2),
279        "f3" => KeyCode::F(3),
280        "f4" => KeyCode::F(4),
281        "f5" => KeyCode::F(5),
282        "f6" => KeyCode::F(6),
283        "f7" => KeyCode::F(7),
284        "f8" => KeyCode::F(8),
285        "f9" => KeyCode::F(9),
286        "f10" => KeyCode::F(10),
287        "f11" => KeyCode::F(11),
288        "f12" => KeyCode::F(12),
289        "space" => KeyCode::Char(' '),
290        "hyphen" => KeyCode::Char('-'),
291        "minus" => KeyCode::Char('-'),
292        "tab" => KeyCode::Tab,
293        c if c.len() == 1 => {
294            let mut c = c.chars().next().unwrap();
295            if modifiers.contains(KeyModifiers::SHIFT) {
296                c = c.to_ascii_uppercase();
297            }
298            KeyCode::Char(c)
299        }
300        _ => return Err(format!("Unable to parse {raw}")),
301    };
302    Ok(KeyEvent::new(c, modifiers))
303}
304
305pub fn key_event_to_string(key_event: &KeyEvent) -> String {
306    let char;
307    let key_code = match key_event.code {
308        KeyCode::Backspace => "backspace",
309        KeyCode::Enter => "enter",
310        KeyCode::Left => "left",
311        KeyCode::Right => "right",
312        KeyCode::Up => "up",
313        KeyCode::Down => "down",
314        KeyCode::Home => "home",
315        KeyCode::End => "end",
316        KeyCode::PageUp => "pageup",
317        KeyCode::PageDown => "pagedown",
318        KeyCode::Tab => "tab",
319        KeyCode::BackTab => "backtab",
320        KeyCode::Delete => "delete",
321        KeyCode::Insert => "insert",
322        KeyCode::F(c) => {
323            char = format!("f({c})");
324            &char
325        }
326        KeyCode::Char(' ') => "space",
327        KeyCode::Char(c) => {
328            char = c.to_string();
329            &char
330        }
331        KeyCode::Esc => "esc",
332        KeyCode::Null => "",
333        KeyCode::CapsLock => "",
334        KeyCode::Menu => "",
335        KeyCode::ScrollLock => "",
336        KeyCode::Media(_) => "",
337        KeyCode::NumLock => "",
338        KeyCode::PrintScreen => "",
339        KeyCode::Pause => "",
340        KeyCode::KeypadBegin => "",
341        KeyCode::Modifier(_) => "",
342    };
343
344    let mut modifiers = Vec::with_capacity(3);
345
346    if key_event.modifiers.intersects(KeyModifiers::CONTROL) {
347        modifiers.push("ctrl");
348    }
349
350    if key_event.modifiers.intersects(KeyModifiers::SHIFT) {
351        modifiers.push("shift");
352    }
353
354    if key_event.modifiers.intersects(KeyModifiers::ALT) {
355        modifiers.push("alt");
356    }
357
358    let mut key = modifiers.join("-");
359
360    if !key.is_empty() {
361        key.push('-');
362    }
363    key.push_str(key_code);
364
365    key
366}
367
368pub fn parse_key_sequence(raw: &str) -> color_eyre::Result<Vec<KeyEvent>, String> {
369    if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
370        return Err(format!("Unable to parse `{}`", raw));
371    }
372    let raw = if !raw.contains("><") {
373        let raw = raw.strip_prefix('<').unwrap_or(raw);
374        raw.strip_prefix('>').unwrap_or(raw)
375    } else {
376        raw
377    };
378    let sequences = raw
379        .split("><")
380        .map(|seq| {
381            if let Some(s) = seq.strip_prefix('<') {
382                s
383            } else if let Some(s) = seq.strip_suffix('>') {
384                s
385            } else {
386                seq
387            }
388        })
389        .collect::<Vec<_>>();
390
391    sequences.into_iter().map(parse_key_event).collect()
392}
393
394#[derive(Clone, Debug, Default)]
395pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
396
397impl<'de> Deserialize<'de> for Styles {
398    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
399    where
400        D: Deserializer<'de>,
401    {
402        let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
403
404        let styles = parsed_map
405            .into_iter()
406            .map(|(mode, inner_map)| {
407                let converted_inner_map = inner_map
408                    .into_iter()
409                    .map(|(str, style)| (str, parse_style(&style)))
410                    .collect();
411                (mode, converted_inner_map)
412            })
413            .collect();
414
415        Ok(Styles(styles))
416    }
417}
418
419pub fn parse_style(line: &str) -> Style {
420    let (foreground, background) =
421        line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len()));
422    let foreground = process_color_string(foreground);
423    let background = process_color_string(&background.replace("on ", ""));
424
425    let mut style = Style::default();
426    if let Some(fg) = parse_color(&foreground.0) {
427        style = style.fg(fg);
428    }
429    if let Some(bg) = parse_color(&background.0) {
430        style = style.bg(bg);
431    }
432    style = style.add_modifier(foreground.1 | background.1);
433    style
434}
435
436fn process_color_string(color_str: &str) -> (String, Modifier) {
437    let color = color_str
438        .replace("grey", "gray")
439        .replace("bright ", "")
440        .replace("bold ", "")
441        .replace("underline ", "")
442        .replace("inverse ", "");
443
444    let mut modifiers = Modifier::empty();
445    if color_str.contains("underline") {
446        modifiers |= Modifier::UNDERLINED;
447    }
448    if color_str.contains("bold") {
449        modifiers |= Modifier::BOLD;
450    }
451    if color_str.contains("inverse") {
452        modifiers |= Modifier::REVERSED;
453    }
454
455    (color, modifiers)
456}
457
458fn parse_color(s: &str) -> Option<Color> {
459    let s = s.trim_start();
460    let s = s.trim_end();
461    if s.contains("bright color") {
462        let s = s.trim_start_matches("bright ");
463        let c = s
464            .trim_start_matches("color")
465            .parse::<u8>()
466            .unwrap_or_default();
467        Some(Color::Indexed(c.wrapping_shl(8)))
468    } else if s.contains("color") {
469        let c = s
470            .trim_start_matches("color")
471            .parse::<u8>()
472            .unwrap_or_default();
473        Some(Color::Indexed(c))
474    } else if s.contains("gray") {
475        let c = 232
476            + s.trim_start_matches("gray")
477                .parse::<u8>()
478                .unwrap_or_default();
479        Some(Color::Indexed(c))
480    } else if s.contains("rgb") {
481        let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
482        let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
483        let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
484        let c = 16 + red * 36 + green * 6 + blue;
485        Some(Color::Indexed(c))
486    } else if s == "bold black" {
487        Some(Color::Indexed(8))
488    } else if s == "bold red" {
489        Some(Color::Indexed(9))
490    } else if s == "bold green" {
491        Some(Color::Indexed(10))
492    } else if s == "bold yellow" {
493        Some(Color::Indexed(11))
494    } else if s == "bold blue" {
495        Some(Color::Indexed(12))
496    } else if s == "bold magenta" {
497        Some(Color::Indexed(13))
498    } else if s == "bold cyan" {
499        Some(Color::Indexed(14))
500    } else if s == "bold white" {
501        Some(Color::Indexed(15))
502    } else if s == "black" {
503        Some(Color::Indexed(0))
504    } else if s == "red" {
505        Some(Color::Indexed(1))
506    } else if s == "green" {
507        Some(Color::Indexed(2))
508    } else if s == "yellow" {
509        Some(Color::Indexed(3))
510    } else if s == "blue" {
511        Some(Color::Indexed(4))
512    } else if s == "magenta" {
513        Some(Color::Indexed(5))
514    } else if s == "cyan" {
515        Some(Color::Indexed(6))
516    } else if s == "white" {
517        Some(Color::Indexed(7))
518    } else {
519        None
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use pretty_assertions::assert_eq;
526
527    use super::*;
528
529    #[test]
530    fn test_parse_style_default() {
531        let style = parse_style("");
532        assert_eq!(style, Style::default());
533    }
534
535    #[test]
536    fn test_parse_style_foreground() {
537        let style = parse_style("red");
538        assert_eq!(style.fg, Some(Color::Indexed(1)));
539    }
540
541    #[test]
542    fn test_parse_style_background() {
543        let style = parse_style("on blue");
544        assert_eq!(style.bg, Some(Color::Indexed(4)));
545    }
546
547    #[test]
548    fn test_parse_style_modifiers() {
549        let style = parse_style("underline red on blue");
550        assert_eq!(style.fg, Some(Color::Indexed(1)));
551        assert_eq!(style.bg, Some(Color::Indexed(4)));
552    }
553
554    #[test]
555    fn test_process_color_string() {
556        let (color, modifiers) = process_color_string("underline bold inverse gray");
557        assert_eq!(color, "gray");
558        assert!(modifiers.contains(Modifier::UNDERLINED));
559        assert!(modifiers.contains(Modifier::BOLD));
560        assert!(modifiers.contains(Modifier::REVERSED));
561    }
562
563    #[test]
564    fn test_parse_color_rgb() {
565        let color = parse_color("rgb123");
566        let expected = 16 + 36 + 2 * 6 + 3;
567        assert_eq!(color, Some(Color::Indexed(expected)));
568    }
569
570    #[test]
571    fn test_parse_color_unknown() {
572        let color = parse_color("unknown");
573        assert_eq!(color, None);
574    }
575
576    #[test]
577    fn test_config() -> color_eyre::Result<()> {
578        let c = Config::new()?;
579        assert_eq!(
580            c.keybindings
581                .0
582                .get(&Mode::Home)
583                .unwrap()
584                .get(&parse_key_sequence("<q>").unwrap_or_default())
585                .unwrap(),
586            &Action::Quit
587        );
588        Ok(())
589    }
590
591    #[test]
592    fn test_simple_keys() {
593        assert_eq!(
594            parse_key_event("a").unwrap(),
595            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
596        );
597
598        assert_eq!(
599            parse_key_event("enter").unwrap(),
600            KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
601        );
602
603        assert_eq!(
604            parse_key_event("esc").unwrap(),
605            KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
606        );
607    }
608
609    #[test]
610    fn test_with_modifiers() {
611        assert_eq!(
612            parse_key_event("ctrl-a").unwrap(),
613            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
614        );
615
616        assert_eq!(
617            parse_key_event("alt-enter").unwrap(),
618            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
619        );
620
621        assert_eq!(
622            parse_key_event("shift-esc").unwrap(),
623            KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
624        );
625    }
626
627    #[test]
628    fn test_multiple_modifiers() {
629        assert_eq!(
630            parse_key_event("ctrl-alt-a").unwrap(),
631            KeyEvent::new(
632                KeyCode::Char('a'),
633                KeyModifiers::CONTROL | KeyModifiers::ALT
634            )
635        );
636
637        assert_eq!(
638            parse_key_event("ctrl-shift-enter").unwrap(),
639            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
640        );
641    }
642
643    #[test]
644    fn test_reverse_multiple_modifiers() {
645        assert_eq!(
646            key_event_to_string(&KeyEvent::new(
647                KeyCode::Char('a'),
648                KeyModifiers::CONTROL | KeyModifiers::ALT
649            )),
650            "ctrl-alt-a".to_string()
651        );
652    }
653
654    #[test]
655    fn test_invalid_keys() {
656        assert!(parse_key_event("invalid-key").is_err());
657        assert!(parse_key_event("ctrl-invalid-key").is_err());
658    }
659
660    #[test]
661    fn test_case_insensitivity() {
662        assert_eq!(
663            parse_key_event("CTRL-a").unwrap(),
664            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
665        );
666
667        assert_eq!(
668            parse_key_event("AlT-eNtEr").unwrap(),
669            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
670        );
671    }
672}