intelli_shell/
config.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    fs,
4    path::PathBuf,
5};
6
7use color_eyre::{
8    Result,
9    eyre::{Context, ContextCompat, eyre},
10};
11use crossterm::{
12    event::{KeyCode, KeyEvent, KeyModifiers},
13    style::{Attribute, Attributes, Color, ContentStyle},
14};
15use directories::ProjectDirs;
16use itertools::Itertools;
17use serde::{
18    Deserialize,
19    de::{Deserializer, Error},
20};
21
22use crate::model::SearchMode;
23
24/// Main configuration struct for the application
25#[derive(Clone, Deserialize)]
26#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
27#[cfg_attr(not(test), serde(default))]
28pub struct Config {
29    /// Directory where the data must be stored
30    pub data_dir: PathBuf,
31    /// Whether to check for updates
32    pub check_updates: bool,
33    /// Whether the TUI must be rendered "inline" below the shell prompt
34    pub inline: bool,
35    /// Configuration for the search command
36    pub search: SearchConfig,
37    /// Configuration settings for application logging
38    pub logs: LogsConfig,
39    /// Configuration for the key bindings used within the TUI
40    pub keybindings: KeyBindingsConfig,
41    /// Configuration for the visual theme of the TUI
42    pub theme: Theme,
43    /// Configuration for the default gist when importing or exporting
44    pub gist: GistConfig,
45    /// Configuration to tune the search algorithm
46    pub tuning: SearchTuning,
47}
48
49/// Configuration for the search command
50#[derive(Clone, Copy, Deserialize)]
51#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
52#[cfg_attr(not(test), serde(default))]
53pub struct SearchConfig {
54    /// The delay (in ms) to wait and accumulate type events before triggering the query
55    pub delay: u64,
56    /// The default search mode
57    pub mode: SearchMode,
58    /// Whether to search for user commands only by default (excluding tldr)
59    pub user_only: bool,
60}
61
62/// Configuration settings for application logging
63#[derive(Clone, Deserialize)]
64#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
65#[cfg_attr(not(test), serde(default))]
66pub struct LogsConfig {
67    /// Whether application logging is enabled
68    pub enabled: bool,
69    /// The log filter to apply, controlling which logs are recorded.
70    ///
71    /// This string supports the `tracing-subscriber`'s environment filter syntax.
72    pub filter: String,
73}
74
75/// Configuration for the key bindings used in the Terminal User Interface (TUI).
76///
77/// This struct holds the `KeyBinding` instances for various actions within the application's TUI, allowing users to
78/// customize their interaction with the interface.
79#[derive(Clone, Deserialize)]
80#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
81#[cfg_attr(not(test), serde(default))]
82pub struct KeyBindingsConfig(
83    #[serde(deserialize_with = "deserialize_bindings_with_defaults")] BTreeMap<KeyBindingAction, KeyBinding>,
84);
85
86/// Represents the distinct actions within the application that can be configured with specific key bindings
87#[derive(Copy, Clone, Deserialize, PartialOrd, PartialEq, Eq, Ord, Debug)]
88#[cfg_attr(test, derive(strum::EnumIter))]
89#[serde(rename_all = "snake_case")]
90pub enum KeyBindingAction {
91    /// Exit the TUI gracefully
92    Quit,
93    /// Update the currently highlighted record or item
94    Update,
95    /// Delete the currently highlighted record or item
96    Delete,
97    /// Confirm a selection or action related to the highlighted record
98    Confirm,
99    /// Execute the action associated with the highlighted record or item
100    Execute,
101    /// Toggle the search mode
102    SearchMode,
103    /// Toggle whether to search for user commands only or include tldr's
104    SearchUserOnly,
105}
106
107/// Represents a single logical key binding that can be triggered by one or more physical `KeyEvent`s.
108///
109/// Internally, it is stored as a `Vec<KeyEvent>` because multiple different key press combinations can map to the same
110/// action.
111#[derive(Clone, Deserialize)]
112#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
113pub struct KeyBinding(#[serde(deserialize_with = "deserialize_key_events")] Vec<KeyEvent>);
114
115/// TUI theme configuration.
116///
117/// Defines the colors, styles, and highlighting behavior for the Terminal User Interface.
118#[derive(Clone, Deserialize)]
119#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
120#[cfg_attr(not(test), serde(default))]
121pub struct Theme {
122    /// To be used as the primary style, like for selected items or main text
123    #[serde(deserialize_with = "deserialize_style")]
124    pub primary: ContentStyle,
125    /// To be used as the secondary style, like for unselected items or less important text
126    #[serde(deserialize_with = "deserialize_style")]
127    pub secondary: ContentStyle,
128    /// Accent style, typically used for highlighting specific elements like aliases or important keywords
129    #[serde(deserialize_with = "deserialize_style")]
130    pub accent: ContentStyle,
131    /// Style for comments or less prominent information
132    #[serde(deserialize_with = "deserialize_style")]
133    pub comment: ContentStyle,
134    /// Style for errors
135    #[serde(deserialize_with = "deserialize_style")]
136    pub error: ContentStyle,
137    /// Optional background color for highlighted items
138    #[serde(deserialize_with = "deserialize_color")]
139    pub highlight: Option<Color>,
140    /// The symbol displayed next to a highlighted item
141    pub highlight_symbol: String,
142    /// Primary style applied when an item is highlighted
143    #[serde(deserialize_with = "deserialize_style")]
144    pub highlight_primary: ContentStyle,
145    /// Secondary style applied when an item is highlighted
146    #[serde(deserialize_with = "deserialize_style")]
147    pub highlight_secondary: ContentStyle,
148    /// Accent style applied when an item is highlighted
149    #[serde(deserialize_with = "deserialize_style")]
150    pub highlight_accent: ContentStyle,
151    /// Comments style applied when an item is highlighted
152    #[serde(deserialize_with = "deserialize_style")]
153    pub highlight_comment: ContentStyle,
154}
155
156/// Configuration settings for the default gist
157#[derive(Clone, Default, Deserialize)]
158#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
159pub struct GistConfig {
160    /// Gist unique identifier
161    pub id: String,
162    /// Authentication token to use when writing to the gist
163    pub token: String,
164}
165
166/// Holds all tunable parameters for the command and variable search ranking algorithms
167#[derive(Clone, Copy, Default, Deserialize)]
168#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
169#[cfg_attr(not(test), serde(default))]
170pub struct SearchTuning {
171    /// Configuration for the command search ranking
172    pub commands: SearchCommandTuning,
173    /// Configuration for the variable values ranking
174    pub variables: SearchVariableTuning,
175}
176
177/// Configures the ranking parameters for command search
178#[derive(Clone, Copy, Default, Deserialize)]
179#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
180#[cfg_attr(not(test), serde(default))]
181pub struct SearchCommandTuning {
182    /// Defines weights and points for the text relevance component
183    pub text: SearchCommandsTextTuning,
184    /// Defines weights and points for the path-aware usage component
185    pub path: SearchPathTuning,
186    /// Defines points for the total usage component
187    pub usage: SearchUsageTuning,
188}
189
190/// Defines weights and points for the text relevance (FTS) score component
191#[derive(Clone, Copy, Deserialize)]
192#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
193#[cfg_attr(not(test), serde(default))]
194pub struct SearchCommandsTextTuning {
195    /// Points assigned to the normalized text relevance score in the final calculation
196    pub points: u32,
197    /// Weight for the command within the FTS bm25 calculation
198    pub command: f64,
199    /// Weight for the description field within the FTS bm25 calculation
200    pub description: f64,
201    /// Specific weights for the different strategies within the 'auto' search algorithm
202    pub auto: SearchCommandsTextAutoTuning,
203}
204
205/// Tunable weights for the different matching strategies within the 'auto' search mode
206#[derive(Clone, Copy, Deserialize)]
207#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
208#[cfg_attr(not(test), serde(default))]
209pub struct SearchCommandsTextAutoTuning {
210    /// Weight multiplier for results from the prefix-based FTS query
211    pub prefix: f64,
212    /// Weight multiplier for results from the fuzzy, all-words-match FTS query
213    pub fuzzy: f64,
214    /// Weight multiplier for results from the relaxed, any-word-match FTS query
215    pub relaxed: f64,
216    /// Boost multiplier to add when the first search term matches the start of the command's text
217    pub root: f64,
218}
219
220/// Configures the path-aware scoring model
221#[derive(Clone, Copy, Deserialize)]
222#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
223#[cfg_attr(not(test), serde(default))]
224pub struct SearchPathTuning {
225    /// Points assigned to the normalized path score in the final calculation
226    pub points: u32,
227    /// Weight for a usage record that matches the current working directory exactly
228    pub exact: f64,
229    /// Weight for a usage record from an ancestor (parent) directory
230    pub ancestor: f64,
231    /// Weight for a usage record from a descendant (child) directory
232    pub descendant: f64,
233    /// Weight for a usage record from any other unrelated path
234    pub unrelated: f64,
235}
236
237/// Configures the total usage scoring model
238#[derive(Clone, Copy, Deserialize)]
239#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
240#[cfg_attr(not(test), serde(default))]
241pub struct SearchUsageTuning {
242    /// Points assigned to the normalized total usage in the final calculation
243    pub points: u32,
244}
245
246/// Configures the ranking parameters for variable values ranking
247#[derive(Clone, Copy, Default, Deserialize)]
248#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
249#[cfg_attr(not(test), serde(default))]
250pub struct SearchVariableTuning {
251    /// Defines points for the context relevance component
252    pub context: SearchVariableContextTuning,
253    /// Defines weights and points for the path-aware usage component
254    pub path: SearchPathTuning,
255}
256
257/// Defines points for the context relevance score component of variable values
258#[derive(Clone, Copy, Deserialize)]
259#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
260#[cfg_attr(not(test), serde(default))]
261pub struct SearchVariableContextTuning {
262    /// Points assigned for matching contextual information (e.g. other selected values)
263    pub points: u32,
264}
265
266impl Config {
267    /// Initializes the application configuration.
268    ///
269    /// Attempts to load the configuration from the user's config directory (`config.toml`). If the file does not exist
270    /// or has missing fields, it falls back to default values.
271    pub fn init(config_file: Option<PathBuf>) -> Result<Self> {
272        // Initialize directories
273        let proj_dirs = ProjectDirs::from("org", "IntelliShell", "Intelli-Shell")
274            .wrap_err("Couldn't initialize project directory")?;
275        let config_dir = proj_dirs.config_dir().to_path_buf();
276
277        // Initialize the config
278        let config_path = config_file.unwrap_or_else(|| config_dir.join("config.toml"));
279        let mut config = if config_path.exists() {
280            // Read from the config file, if found
281            let config_str = fs::read_to_string(&config_path)
282                .wrap_err_with(|| format!("Couldn't read config file {}", config_path.display()))?;
283            toml::from_str(&config_str)
284                .wrap_err_with(|| format!("Couldn't parse config file {}", config_path.display()))?
285        } else {
286            // Use default values if not found
287            Config::default()
288        };
289        // If no data dir is provided, use the default
290        if config.data_dir.as_os_str().is_empty() {
291            config.data_dir = proj_dirs.data_dir().to_path_buf();
292        }
293
294        // Validate there are no conflicts on the key bindings
295        let conflicts = config.keybindings.find_conflicts();
296        if !conflicts.is_empty() {
297            return Err(eyre!(
298                "Invalid config, there are some key binding conflicts:\n{}",
299                conflicts
300                    .into_iter()
301                    .map(|(_, a)| format!("- {}", a.into_iter().map(|a| format!("{a:?}")).join(", ")))
302                    .join("\n")
303            ));
304        }
305
306        // Create the data directory if not found
307        fs::create_dir_all(&config.data_dir)
308            .wrap_err_with(|| format!("Could't create data dir {}", config.data_dir.display()))?;
309
310        Ok(config)
311    }
312}
313
314impl KeyBindingsConfig {
315    /// Retrieves the [KeyBinding] for a specific action
316    pub fn get(&self, action: &KeyBindingAction) -> &KeyBinding {
317        self.0.get(action).unwrap()
318    }
319
320    /// Finds the [KeyBindingAction] associated with the given [KeyEvent], if any
321    pub fn get_action_matching(&self, event: &KeyEvent) -> Option<KeyBindingAction> {
322        self.0.iter().find_map(
323            |(action, binding)| {
324                if binding.matches(event) { Some(*action) } else { None }
325            },
326        )
327    }
328
329    /// Finds all ambiguous key bindings where a single `KeyEvent` maps to multiple `KeyBindingAction`s
330    pub fn find_conflicts(&self) -> Vec<(KeyEvent, Vec<KeyBindingAction>)> {
331        // A map to store each KeyEvent and the list of actions it's bound to.
332        let mut event_to_actions_map: HashMap<KeyEvent, Vec<KeyBindingAction>> = HashMap::new();
333
334        // Iterate over all configured actions and their bindings.
335        for (action, key_binding) in self.0.iter() {
336            // For each KeyEvent defined within the current KeyBinding...
337            for event_in_binding in key_binding.0.iter() {
338                // Record that this event maps to the current action.
339                event_to_actions_map.entry(*event_in_binding).or_default().push(*action);
340            }
341        }
342
343        // Filter the map to find KeyEvents that map to more than one action.
344        event_to_actions_map
345            .into_iter()
346            .filter_map(|(key_event, actions)| {
347                if actions.len() > 1 {
348                    Some((key_event, actions))
349                } else {
350                    None
351                }
352            })
353            .collect()
354    }
355}
356
357impl KeyBinding {
358    /// Checks if a given `KeyEvent` matches any of the key events configured for this key binding, considering only the
359    /// key `code` and its `modifiers`.
360    pub fn matches(&self, event: &KeyEvent) -> bool {
361        self.0
362            .iter()
363            .any(|e| e.code == event.code && e.modifiers == event.modifiers)
364    }
365}
366
367impl Theme {
368    /// Primary style applied when an item is highlighted, including the background color
369    pub fn highlight_primary_full(&self) -> ContentStyle {
370        if let Some(color) = self.highlight {
371            let mut ret = self.highlight_primary;
372            ret.background_color = Some(color);
373            ret
374        } else {
375            self.highlight_primary
376        }
377    }
378
379    /// Secondary style applied when an item is highlighted, including the background color
380    pub fn highlight_secondary_full(&self) -> ContentStyle {
381        if let Some(color) = self.highlight {
382            let mut ret = self.highlight_secondary;
383            ret.background_color = Some(color);
384            ret
385        } else {
386            self.highlight_secondary
387        }
388    }
389
390    /// Accent style applied when an item is highlighted, including the background color
391    pub fn highlight_accent_full(&self) -> ContentStyle {
392        if let Some(color) = self.highlight {
393            let mut ret = self.highlight_accent;
394            ret.background_color = Some(color);
395            ret
396        } else {
397            self.highlight_accent
398        }
399    }
400
401    /// Comments style applied when an item is highlighted, including the background color
402    pub fn highlight_comment_full(&self) -> ContentStyle {
403        if let Some(color) = self.highlight {
404            let mut ret = self.highlight_comment;
405            ret.background_color = Some(color);
406            ret
407        } else {
408            self.highlight_comment
409        }
410    }
411}
412
413impl Default for Config {
414    fn default() -> Self {
415        Self {
416            data_dir: PathBuf::new(),
417            check_updates: true,
418            inline: true,
419            search: SearchConfig::default(),
420            logs: LogsConfig::default(),
421            keybindings: KeyBindingsConfig::default(),
422            theme: Theme::default(),
423            gist: GistConfig::default(),
424            tuning: SearchTuning::default(),
425        }
426    }
427}
428impl Default for SearchConfig {
429    fn default() -> Self {
430        Self {
431            delay: 250,
432            mode: SearchMode::Auto,
433            user_only: false,
434        }
435    }
436}
437impl Default for LogsConfig {
438    fn default() -> Self {
439        Self {
440            enabled: false,
441            filter: String::from("info"),
442        }
443    }
444}
445impl Default for KeyBindingsConfig {
446    fn default() -> Self {
447        Self(BTreeMap::from([
448            (KeyBindingAction::Quit, KeyBinding(vec![KeyEvent::from(KeyCode::Esc)])),
449            (
450                KeyBindingAction::Update,
451                KeyBinding(vec![
452                    KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
453                    KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
454                    KeyEvent::from(KeyCode::F(2)),
455                ]),
456            ),
457            (
458                KeyBindingAction::Delete,
459                KeyBinding(vec![KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)]),
460            ),
461            (
462                KeyBindingAction::Confirm,
463                KeyBinding(vec![KeyEvent::from(KeyCode::Tab), KeyEvent::from(KeyCode::Enter)]),
464            ),
465            (
466                KeyBindingAction::Execute,
467                KeyBinding(vec![
468                    KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
469                    KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
470                ]),
471            ),
472            (
473                KeyBindingAction::SearchMode,
474                KeyBinding(vec![KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)]),
475            ),
476            (
477                KeyBindingAction::SearchUserOnly,
478                KeyBinding(vec![KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)]),
479            ),
480        ]))
481    }
482}
483impl Default for Theme {
484    fn default() -> Self {
485        let primary = ContentStyle::new();
486        let highlight_primary = primary;
487
488        let mut secondary = ContentStyle::new();
489        secondary.attributes.set(Attribute::Dim);
490        let highlight_secondary = secondary;
491
492        let mut accent = ContentStyle::new();
493        accent.foreground_color = Some(Color::Yellow);
494        let highlight_accent = accent;
495
496        let mut comment = ContentStyle::new();
497        comment.foreground_color = Some(Color::Green);
498        comment.attributes.set(Attribute::Italic);
499        let highlight_comment = comment;
500
501        let mut error = ContentStyle::new();
502        error.foreground_color = Some(Color::DarkRed);
503
504        Self {
505            primary,
506            secondary,
507            accent,
508            comment,
509            error,
510            highlight: Some(Color::DarkGrey),
511            highlight_symbol: String::from("ยป "),
512            highlight_primary,
513            highlight_secondary,
514            highlight_accent,
515            highlight_comment,
516        }
517    }
518}
519impl Default for SearchCommandsTextTuning {
520    fn default() -> Self {
521        Self {
522            points: 600,
523            command: 2.0,
524            description: 1.0,
525            auto: SearchCommandsTextAutoTuning::default(),
526        }
527    }
528}
529impl Default for SearchCommandsTextAutoTuning {
530    fn default() -> Self {
531        Self {
532            prefix: 1.5,
533            fuzzy: 1.0,
534            relaxed: 0.5,
535            root: 2.0,
536        }
537    }
538}
539impl Default for SearchUsageTuning {
540    fn default() -> Self {
541        Self { points: 100 }
542    }
543}
544impl Default for SearchPathTuning {
545    fn default() -> Self {
546        Self {
547            points: 300,
548            exact: 1.0,
549            ancestor: 0.5,
550            descendant: 0.25,
551            unrelated: 0.1,
552        }
553    }
554}
555impl Default for SearchVariableContextTuning {
556    fn default() -> Self {
557        Self { points: 700 }
558    }
559}
560
561/// Custom deserialization function for the BTreeMap in KeyBindingsConfig.
562///
563/// Behavior depends on whether compiled for test or not:
564/// - In test (`#[cfg(test)]`): Requires all `KeyBindingAction` variants to be present; otherwise, errors. No merging.
565/// - In non-test (`#[cfg(not(test))]`): Merges user-provided bindings with defaults.
566fn deserialize_bindings_with_defaults<'de, D>(
567    deserializer: D,
568) -> Result<BTreeMap<KeyBindingAction, KeyBinding>, D::Error>
569where
570    D: Deserializer<'de>,
571{
572    // Deserialize the map as provided in the config.
573    let user_provided_bindings = BTreeMap::<KeyBindingAction, KeyBinding>::deserialize(deserializer)?;
574
575    #[cfg(test)]
576    {
577        use strum::IntoEnumIterator;
578        // In test mode, all actions must be explicitly defined. No defaults are merged.
579        for action_variant in KeyBindingAction::iter() {
580            if !user_provided_bindings.contains_key(&action_variant) {
581                return Err(D::Error::custom(format!(
582                    "Missing key binding for action '{action_variant:?}'."
583                )));
584            }
585        }
586        Ok(user_provided_bindings)
587    }
588    #[cfg(not(test))]
589    {
590        // In non-test (production) mode, merge with defaults.
591        // User-provided bindings override defaults for the actions they specify.
592        let mut final_bindings = user_provided_bindings;
593        let default_bindings = KeyBindingsConfig::default();
594
595        for (action, default_binding) in default_bindings.0 {
596            final_bindings.entry(action).or_insert(default_binding);
597        }
598        Ok(final_bindings)
599    }
600}
601
602/// Deserializes a string or a vector of strings into a `Vec<KeyEvent>`.
603///
604/// This allows a key binding to be specified as a single string or a list of strings in the config file.
605fn deserialize_key_events<'de, D>(deserializer: D) -> Result<Vec<KeyEvent>, D::Error>
606where
607    D: Deserializer<'de>,
608{
609    #[derive(Deserialize)]
610    #[serde(untagged)]
611    enum StringOrVec {
612        Single(String),
613        Multiple(Vec<String>),
614    }
615
616    let strings = match StringOrVec::deserialize(deserializer)? {
617        StringOrVec::Single(s) => vec![s],
618        StringOrVec::Multiple(v) => v,
619    };
620
621    strings
622        .iter()
623        .map(String::as_str)
624        .map(parse_key_event)
625        .map(|r| r.map_err(D::Error::custom))
626        .collect()
627}
628
629/// Deserializes a string into an optional [`Color`].
630///
631/// Supports color names, RGB (e.g., `rgb(255, 0, 100)`), hex (e.g., `#ff0064`), indexed colors (e.g., `6`), and "none"
632/// for no color.
633fn deserialize_color<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
634where
635    D: Deserializer<'de>,
636{
637    parse_color(&String::deserialize(deserializer)?).map_err(D::Error::custom)
638}
639
640/// Deserializes a string into a [`ContentStyle`].
641///
642/// Supports color names and modifiers (e.g., "red", "bold", "italic blue", "underline dim green").
643fn deserialize_style<'de, D>(deserializer: D) -> Result<ContentStyle, D::Error>
644where
645    D: Deserializer<'de>,
646{
647    parse_style(&String::deserialize(deserializer)?).map_err(D::Error::custom)
648}
649
650/// Parses a string representation of a key event into a [`KeyEvent`].
651///
652/// Supports modifiers like `ctrl-`, `alt-`, `shift-` and standard key names/characters.
653fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
654    let raw_lower = raw.to_ascii_lowercase();
655    let (remaining, modifiers) = extract_key_modifiers(&raw_lower);
656    parse_key_code_with_modifiers(remaining, modifiers)
657}
658
659/// Extracts key modifiers (ctrl, shift, alt) from the beginning of a key event string.
660///
661/// Returns the remaining string and the parsed modifiers.
662fn extract_key_modifiers(raw: &str) -> (&str, KeyModifiers) {
663    let mut modifiers = KeyModifiers::empty();
664    let mut current = raw;
665
666    loop {
667        match current {
668            rest if rest.starts_with("ctrl-") || rest.starts_with("ctrl+") => {
669                modifiers.insert(KeyModifiers::CONTROL);
670                current = &rest[5..];
671            }
672            rest if rest.starts_with("shift-") || rest.starts_with("shift+") => {
673                modifiers.insert(KeyModifiers::SHIFT);
674                current = &rest[6..];
675            }
676            rest if rest.starts_with("alt-") || rest.starts_with("alt+") => {
677                modifiers.insert(KeyModifiers::ALT);
678                current = &rest[4..];
679            }
680            _ => break,
681        };
682    }
683
684    (current, modifiers)
685}
686
687/// Parses the remaining string after extracting modifiers into a [`KeyCode`]
688fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
689    let code = match raw {
690        "esc" => KeyCode::Esc,
691        "enter" => KeyCode::Enter,
692        "left" => KeyCode::Left,
693        "right" => KeyCode::Right,
694        "up" => KeyCode::Up,
695        "down" => KeyCode::Down,
696        "home" => KeyCode::Home,
697        "end" => KeyCode::End,
698        "pageup" => KeyCode::PageUp,
699        "pagedown" => KeyCode::PageDown,
700        "backtab" => {
701            modifiers.insert(KeyModifiers::SHIFT);
702            KeyCode::BackTab
703        }
704        "backspace" => KeyCode::Backspace,
705        "delete" => KeyCode::Delete,
706        "insert" => KeyCode::Insert,
707        "f1" => KeyCode::F(1),
708        "f2" => KeyCode::F(2),
709        "f3" => KeyCode::F(3),
710        "f4" => KeyCode::F(4),
711        "f5" => KeyCode::F(5),
712        "f6" => KeyCode::F(6),
713        "f7" => KeyCode::F(7),
714        "f8" => KeyCode::F(8),
715        "f9" => KeyCode::F(9),
716        "f10" => KeyCode::F(10),
717        "f11" => KeyCode::F(11),
718        "f12" => KeyCode::F(12),
719        "space" | "spacebar" => KeyCode::Char(' '),
720        "hyphen" => KeyCode::Char('-'),
721        "minus" => KeyCode::Char('-'),
722        "tab" => KeyCode::Tab,
723        c if c.len() == 1 => {
724            let mut c = c.chars().next().expect("just checked");
725            if modifiers.contains(KeyModifiers::SHIFT) {
726                c = c.to_ascii_uppercase();
727            }
728            KeyCode::Char(c)
729        }
730        _ => return Err(format!("Unable to parse key binding: {raw}")),
731    };
732    Ok(KeyEvent::new(code, modifiers))
733}
734
735/// Parses a string into an optional [`Color`].
736///
737/// Handles named colors, RGB, hex, indexed colors, and "none".
738fn parse_color(raw: &str) -> Result<Option<Color>, String> {
739    let raw_lower = raw.to_ascii_lowercase();
740    if raw.is_empty() || raw == "none" {
741        Ok(None)
742    } else {
743        Ok(Some(parse_color_inner(&raw_lower)?))
744    }
745}
746
747/// Parses a string into a [`ContentStyle`], including attributes and foreground color.
748///
749/// Examples: "red", "bold", "italic blue", "underline dim green".
750fn parse_style(raw: &str) -> Result<ContentStyle, String> {
751    let raw_lower = raw.to_ascii_lowercase();
752    let (remaining, attributes) = extract_style_attributes(&raw_lower);
753    let mut style = ContentStyle::new();
754    style.attributes = attributes;
755    if !remaining.is_empty() && remaining != "default" {
756        style.foreground_color = Some(parse_color_inner(remaining)?);
757    }
758    Ok(style)
759}
760
761/// Extracts style attributes (bold, dim, italic, underline) from the beginning of a style string.
762///
763/// Returns the remaining string and the parsed attributes.
764fn extract_style_attributes(raw: &str) -> (&str, Attributes) {
765    let mut attributes = Attributes::none();
766    let mut current = raw;
767
768    loop {
769        match current {
770            rest if rest.starts_with("bold") => {
771                attributes.set(Attribute::Bold);
772                current = &rest[4..];
773                if current.starts_with(' ') {
774                    current = &current[1..];
775                }
776            }
777            rest if rest.starts_with("dim") => {
778                attributes.set(Attribute::Dim);
779                current = &rest[3..];
780                if current.starts_with(' ') {
781                    current = &current[1..];
782                }
783            }
784            rest if rest.starts_with("italic") => {
785                attributes.set(Attribute::Italic);
786                current = &rest[6..];
787                if current.starts_with(' ') {
788                    current = &current[1..];
789                }
790            }
791            rest if rest.starts_with("underline") => {
792                attributes.set(Attribute::Underlined);
793                current = &rest[9..];
794                if current.starts_with(' ') {
795                    current = &current[1..];
796                }
797            }
798            rest if rest.starts_with("underlined") => {
799                attributes.set(Attribute::Underlined);
800                current = &rest[10..];
801                if current.starts_with(' ') {
802                    current = &current[1..];
803                }
804            }
805            _ => break,
806        };
807    }
808
809    (current.trim(), attributes)
810}
811
812/// Parses the color part of a style string.
813///
814/// Handles named colors, rgb, hex, and ansi values.
815fn parse_color_inner(raw: &str) -> Result<Color, String> {
816    Ok(match raw {
817        "black" => Color::Black,
818        "red" => Color::Red,
819        "green" => Color::Green,
820        "yellow" => Color::Yellow,
821        "blue" => Color::Blue,
822        "magenta" => Color::Magenta,
823        "cyan" => Color::Cyan,
824        "gray" | "grey" => Color::Grey,
825        "dark gray" | "darkgray" | "dark grey" | "darkgrey" => Color::DarkGrey,
826        "dark red" | "darkred" => Color::DarkRed,
827        "dark green" | "darkgreen" => Color::DarkGreen,
828        "dark yellow" | "darkyellow" => Color::DarkYellow,
829        "dark blue" | "darkblue" => Color::DarkBlue,
830        "dark magenta" | "darkmagenta" => Color::DarkMagenta,
831        "dark cyan" | "darkcyan" => Color::DarkCyan,
832        "white" => Color::White,
833        rgb if rgb.starts_with("rgb(") => {
834            let rgb = rgb.trim_start_matches("rgb(").trim_end_matches(")").split(',');
835            let rgb = rgb
836                .map(|c| c.trim().parse::<u8>())
837                .collect::<Result<Vec<u8>, _>>()
838                .map_err(|_| format!("Unable to parse color: {raw}"))?;
839            if rgb.len() != 3 {
840                return Err(format!("Unable to parse color: {raw}"));
841            }
842            Color::Rgb {
843                r: rgb[0],
844                g: rgb[1],
845                b: rgb[2],
846            }
847        }
848        hex if hex.starts_with("#") => {
849            let hex = hex.trim_start_matches("#");
850            if hex.len() != 6 {
851                return Err(format!("Unable to parse color: {raw}"));
852            }
853            let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
854            let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
855            let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
856            Color::Rgb { r, g, b }
857        }
858        c => {
859            if let Ok(c) = c.parse::<u8>() {
860                Color::AnsiValue(c)
861            } else {
862                return Err(format!("Unable to parse color: {raw}"));
863            }
864        }
865    })
866}
867
868#[cfg(test)]
869mod tests {
870    use pretty_assertions::assert_eq;
871    use strum::IntoEnumIterator;
872
873    use super::*;
874
875    #[test]
876    fn test_default_config() -> Result<()> {
877        let config_str = fs::read_to_string("default_config.toml").wrap_err("Couldn't read default config file")?;
878        let config: Config = toml::from_str(&config_str).wrap_err("Couldn't parse default config file")?;
879
880        assert_eq!(Config::default(), config);
881
882        Ok(())
883    }
884
885    #[test]
886    fn test_default_keybindings_complete() {
887        let config = KeyBindingsConfig::default();
888
889        for action in KeyBindingAction::iter() {
890            assert!(
891                config.0.contains_key(&action),
892                "Missing default binding for action: {action:?}"
893            );
894        }
895    }
896
897    #[test]
898    fn test_default_keybindings_no_conflicts() {
899        let config = KeyBindingsConfig::default();
900
901        let conflicts = config.find_conflicts();
902        assert_eq!(conflicts.len(), 0, "Key binding conflicts: {conflicts:?}");
903    }
904
905    #[test]
906    fn test_keybinding_matches() {
907        let binding = KeyBinding(vec![
908            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
909            KeyEvent::from(KeyCode::Enter),
910        ]);
911
912        // Should match exact events
913        assert!(binding.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
914        assert!(binding.matches(&KeyEvent::from(KeyCode::Enter)));
915
916        // Should not match events with different modifiers
917        assert!(!binding.matches(&KeyEvent::new(
918            KeyCode::Char('a'),
919            KeyModifiers::CONTROL | KeyModifiers::ALT
920        )));
921
922        // Should not match different key codes
923        assert!(!binding.matches(&KeyEvent::from(KeyCode::Esc)));
924    }
925
926    #[test]
927    fn test_simple_keys() {
928        assert_eq!(
929            parse_key_event("a").unwrap(),
930            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
931        );
932
933        assert_eq!(
934            parse_key_event("enter").unwrap(),
935            KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
936        );
937
938        assert_eq!(
939            parse_key_event("esc").unwrap(),
940            KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
941        );
942    }
943
944    #[test]
945    fn test_with_modifiers() {
946        assert_eq!(
947            parse_key_event("ctrl-a").unwrap(),
948            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
949        );
950
951        assert_eq!(
952            parse_key_event("alt-enter").unwrap(),
953            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
954        );
955
956        assert_eq!(
957            parse_key_event("shift-esc").unwrap(),
958            KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
959        );
960    }
961
962    #[test]
963    fn test_multiple_modifiers() {
964        assert_eq!(
965            parse_key_event("ctrl-alt-a").unwrap(),
966            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
967        );
968
969        assert_eq!(
970            parse_key_event("ctrl-shift-enter").unwrap(),
971            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
972        );
973    }
974
975    #[test]
976    fn test_invalid_keys() {
977        let res = parse_key_event("invalid-key");
978        assert_eq!(res, Err(String::from("Unable to parse key binding: invalid-key")));
979    }
980
981    #[test]
982    fn test_parse_color_none() {
983        let color = parse_color("none").unwrap();
984        assert_eq!(color, None);
985    }
986
987    #[test]
988    fn test_parse_color_simple() {
989        let color = parse_color("red").unwrap();
990        assert_eq!(color, Some(Color::Red));
991    }
992
993    #[test]
994    fn test_parse_color_rgb() {
995        let color = parse_color("rgb(50, 25, 15)").unwrap();
996        assert_eq!(color, Some(Color::Rgb { r: 50, g: 25, b: 15 }));
997    }
998
999    #[test]
1000    fn test_parse_color_rgb_out_of_range() {
1001        let res = parse_color("rgb(500, 25, 15)");
1002        assert_eq!(res, Err(String::from("Unable to parse color: rgb(500, 25, 15)")));
1003    }
1004
1005    #[test]
1006    fn test_parse_color_rgb_invalid() {
1007        let res = parse_color("rgb(50, 25, 15, 5)");
1008        assert_eq!(res, Err(String::from("Unable to parse color: rgb(50, 25, 15, 5)")));
1009    }
1010
1011    #[test]
1012    fn test_parse_color_hex() {
1013        let color = parse_color("#4287f5").unwrap();
1014        assert_eq!(color, Some(Color::Rgb { r: 66, g: 135, b: 245 }));
1015    }
1016
1017    #[test]
1018    fn test_parse_color_hex_out_of_range() {
1019        let res = parse_color("#4287fg");
1020        assert_eq!(res, Err(String::from("Unable to parse color: #4287fg")));
1021    }
1022
1023    #[test]
1024    fn test_parse_color_hex_invalid() {
1025        let res = parse_color("#4287f50");
1026        assert_eq!(res, Err(String::from("Unable to parse color: #4287f50")));
1027    }
1028
1029    #[test]
1030    fn test_parse_color_index() {
1031        let color = parse_color("6").unwrap();
1032        assert_eq!(color, Some(Color::AnsiValue(6)));
1033    }
1034
1035    #[test]
1036    fn test_parse_color_fail() {
1037        let res = parse_color("1234");
1038        assert_eq!(res, Err(String::from("Unable to parse color: 1234")));
1039    }
1040
1041    #[test]
1042    fn test_parse_style_empty() {
1043        let style = parse_style("").unwrap();
1044        assert_eq!(style, ContentStyle::new());
1045    }
1046
1047    #[test]
1048    fn test_parse_style_default() {
1049        let style = parse_style("default").unwrap();
1050        assert_eq!(style, ContentStyle::new());
1051    }
1052
1053    #[test]
1054    fn test_parse_style_simple() {
1055        let style = parse_style("red").unwrap();
1056        assert_eq!(style.foreground_color, Some(Color::Red));
1057        assert_eq!(style.attributes, Attributes::none());
1058    }
1059
1060    #[test]
1061    fn test_parse_style_only_modifier() {
1062        let style = parse_style("bold").unwrap();
1063        assert_eq!(style.foreground_color, None);
1064        let mut expected_attributes = Attributes::none();
1065        expected_attributes.set(Attribute::Bold);
1066        assert_eq!(style.attributes, expected_attributes);
1067    }
1068
1069    #[test]
1070    fn test_parse_style_with_modifier() {
1071        let style = parse_style("italic red").unwrap();
1072        assert_eq!(style.foreground_color, Some(Color::Red));
1073        let mut expected_attributes = Attributes::none();
1074        expected_attributes.set(Attribute::Italic);
1075        assert_eq!(style.attributes, expected_attributes);
1076    }
1077
1078    #[test]
1079    fn test_parse_style_multiple_modifier() {
1080        let style = parse_style("underline dim dark red").unwrap();
1081        assert_eq!(style.foreground_color, Some(Color::DarkRed));
1082        let mut expected_attributes = Attributes::none();
1083        expected_attributes.set(Attribute::Underlined);
1084        expected_attributes.set(Attribute::Dim);
1085        assert_eq!(style.attributes, expected_attributes);
1086    }
1087}