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::{
23    ai::{AiClient, AiProviderBase},
24    model::SearchMode,
25};
26
27/// Main configuration struct for the application
28#[derive(Clone, Deserialize)]
29#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
30#[cfg_attr(not(test), serde(default))]
31pub struct Config {
32    /// Directory where the data must be stored
33    pub data_dir: PathBuf,
34    /// Whether to check for updates
35    pub check_updates: bool,
36    /// Whether the TUI must be rendered "inline" below the shell prompt
37    pub inline: bool,
38    /// Configuration for the search command
39    pub search: SearchConfig,
40    /// Configuration settings for application logging
41    pub logs: LogsConfig,
42    /// Configuration for the key bindings used within the TUI
43    pub keybindings: KeyBindingsConfig,
44    /// Configuration for the visual theme of the TUI
45    pub theme: Theme,
46    /// Configuration for the default gist when importing or exporting
47    pub gist: GistConfig,
48    /// Configuration to tune the search algorithm
49    pub tuning: SearchTuning,
50    /// Configuration for the AI integration
51    pub ai: AiConfig,
52}
53
54/// Configuration for the search command
55#[derive(Clone, Copy, Deserialize)]
56#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
57#[cfg_attr(not(test), serde(default))]
58pub struct SearchConfig {
59    /// The delay (in ms) to wait and accumulate type events before triggering the query
60    pub delay: u64,
61    /// The default search mode
62    pub mode: SearchMode,
63    /// Whether to search for user commands only by default (excluding tldr)
64    pub user_only: bool,
65    /// Whether to directly execute the command if it matches an alias exactly, instead of just selecting
66    pub exec_on_alias_match: bool,
67}
68
69/// Configuration settings for application logging
70#[derive(Clone, Deserialize)]
71#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
72#[cfg_attr(not(test), serde(default))]
73pub struct LogsConfig {
74    /// Whether application logging is enabled
75    pub enabled: bool,
76    /// The log filter to apply, controlling which logs are recorded.
77    ///
78    /// This string supports the `tracing-subscriber`'s environment filter syntax.
79    pub filter: String,
80}
81
82/// Configuration for the key bindings used in the Terminal User Interface (TUI).
83///
84/// This struct holds the `KeyBinding` instances for various actions within the application's TUI, allowing users to
85/// customize their interaction with the interface.
86#[derive(Clone, Deserialize)]
87#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
88#[cfg_attr(not(test), serde(default))]
89pub struct KeyBindingsConfig(
90    #[serde(deserialize_with = "deserialize_bindings_with_defaults")] BTreeMap<KeyBindingAction, KeyBinding>,
91);
92
93/// Represents the distinct actions within the application that can be configured with specific key bindings
94#[derive(Copy, Clone, Deserialize, PartialOrd, PartialEq, Eq, Ord, Debug)]
95#[cfg_attr(test, derive(strum::EnumIter))]
96#[serde(rename_all = "snake_case")]
97pub enum KeyBindingAction {
98    /// Exit the TUI gracefully
99    Quit,
100    /// Update the currently highlighted record or item
101    Update,
102    /// Delete the currently highlighted record or item
103    Delete,
104    /// Confirm a selection or action related to the highlighted record
105    Confirm,
106    /// Execute the action associated with the highlighted record or item
107    Execute,
108    /// Execute the action associated with the highlighted record or item
109    #[serde(rename = "ai")]
110    AI,
111    /// Toggle the search mode
112    SearchMode,
113    /// Toggle whether to search for user commands only or include tldr's
114    SearchUserOnly,
115}
116
117/// Represents a single logical key binding that can be triggered by one or more physical `KeyEvent`s.
118///
119/// Internally, it is stored as a `Vec<KeyEvent>` because multiple different key press combinations can map to the same
120/// action.
121#[derive(Clone, Deserialize)]
122#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
123pub struct KeyBinding(#[serde(deserialize_with = "deserialize_key_events")] Vec<KeyEvent>);
124
125/// TUI theme configuration.
126///
127/// Defines the colors, styles, and highlighting behavior for the Terminal User Interface.
128#[derive(Clone, Deserialize)]
129#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
130#[cfg_attr(not(test), serde(default))]
131pub struct Theme {
132    /// To be used as the primary style, like for selected items or main text
133    #[serde(deserialize_with = "deserialize_style")]
134    pub primary: ContentStyle,
135    /// To be used as the secondary style, like for unselected items or less important text
136    #[serde(deserialize_with = "deserialize_style")]
137    pub secondary: ContentStyle,
138    /// Accent style, typically used for highlighting specific elements like aliases or important keywords
139    #[serde(deserialize_with = "deserialize_style")]
140    pub accent: ContentStyle,
141    /// Style for comments or less prominent information
142    #[serde(deserialize_with = "deserialize_style")]
143    pub comment: ContentStyle,
144    /// Style for errors
145    #[serde(deserialize_with = "deserialize_style")]
146    pub error: ContentStyle,
147    /// Optional background color for highlighted items
148    #[serde(deserialize_with = "deserialize_color")]
149    pub highlight: Option<Color>,
150    /// The symbol displayed next to a highlighted item
151    pub highlight_symbol: String,
152    /// Primary style applied when an item is highlighted
153    #[serde(deserialize_with = "deserialize_style")]
154    pub highlight_primary: ContentStyle,
155    /// Secondary style applied when an item is highlighted
156    #[serde(deserialize_with = "deserialize_style")]
157    pub highlight_secondary: ContentStyle,
158    /// Accent style applied when an item is highlighted
159    #[serde(deserialize_with = "deserialize_style")]
160    pub highlight_accent: ContentStyle,
161    /// Comments style applied when an item is highlighted
162    #[serde(deserialize_with = "deserialize_style")]
163    pub highlight_comment: ContentStyle,
164}
165
166/// Configuration settings for the default gist
167#[derive(Clone, Default, Deserialize)]
168#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
169pub struct GistConfig {
170    /// Gist unique identifier
171    pub id: String,
172    /// Authentication token to use when writing to the gist
173    pub token: String,
174}
175
176/// Holds all tunable parameters for the command and variable search ranking algorithms
177#[derive(Clone, Copy, Default, Deserialize)]
178#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
179#[cfg_attr(not(test), serde(default))]
180pub struct SearchTuning {
181    /// Configuration for the command search ranking
182    pub commands: SearchCommandTuning,
183    /// Configuration for the variable values ranking
184    pub variables: SearchVariableTuning,
185}
186
187/// Configures the ranking parameters for command search
188#[derive(Clone, Copy, Default, Deserialize)]
189#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
190#[cfg_attr(not(test), serde(default))]
191pub struct SearchCommandTuning {
192    /// Defines weights and points for the text relevance component
193    pub text: SearchCommandsTextTuning,
194    /// Defines weights and points for the path-aware usage component
195    pub path: SearchPathTuning,
196    /// Defines points for the total usage component
197    pub usage: SearchUsageTuning,
198}
199
200/// Defines weights and points for the text relevance (FTS) score component
201#[derive(Clone, Copy, Deserialize)]
202#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
203#[cfg_attr(not(test), serde(default))]
204pub struct SearchCommandsTextTuning {
205    /// Points assigned to the normalized text relevance score in the final calculation
206    pub points: u32,
207    /// Weight for the command within the FTS bm25 calculation
208    pub command: f64,
209    /// Weight for the description field within the FTS bm25 calculation
210    pub description: f64,
211    /// Specific weights for the different strategies within the 'auto' search algorithm
212    pub auto: SearchCommandsTextAutoTuning,
213}
214
215/// Tunable weights for the different matching strategies within the 'auto' search mode
216#[derive(Clone, Copy, Deserialize)]
217#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
218#[cfg_attr(not(test), serde(default))]
219pub struct SearchCommandsTextAutoTuning {
220    /// Weight multiplier for results from the prefix-based FTS query
221    pub prefix: f64,
222    /// Weight multiplier for results from the fuzzy, all-words-match FTS query
223    pub fuzzy: f64,
224    /// Weight multiplier for results from the relaxed, any-word-match FTS query
225    pub relaxed: f64,
226    /// Boost multiplier to add when the first search term matches the start of the command's text
227    pub root: f64,
228}
229
230/// Configures the path-aware scoring model
231#[derive(Clone, Copy, Deserialize)]
232#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
233#[cfg_attr(not(test), serde(default))]
234pub struct SearchPathTuning {
235    /// Points assigned to the normalized path score in the final calculation
236    pub points: u32,
237    /// Weight for a usage record that matches the current working directory exactly
238    pub exact: f64,
239    /// Weight for a usage record from an ancestor (parent) directory
240    pub ancestor: f64,
241    /// Weight for a usage record from a descendant (child) directory
242    pub descendant: f64,
243    /// Weight for a usage record from any other unrelated path
244    pub unrelated: f64,
245}
246
247/// Configures the total usage scoring model
248#[derive(Clone, Copy, Deserialize)]
249#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
250#[cfg_attr(not(test), serde(default))]
251pub struct SearchUsageTuning {
252    /// Points assigned to the normalized total usage in the final calculation
253    pub points: u32,
254}
255
256/// Configures the ranking parameters for variable values ranking
257#[derive(Clone, Copy, Default, Deserialize)]
258#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
259#[cfg_attr(not(test), serde(default))]
260pub struct SearchVariableTuning {
261    /// Defines points for completions relevance component
262    pub completion: SearchVariableCompletionTuning,
263    /// Defines points for the context relevance component
264    pub context: SearchVariableContextTuning,
265    /// Defines weights and points for the path-aware usage component
266    pub path: SearchPathTuning,
267}
268
269/// Defines points for the completions relevance score component of variable values
270#[derive(Clone, Copy, Deserialize)]
271#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
272#[cfg_attr(not(test), serde(default))]
273pub struct SearchVariableCompletionTuning {
274    /// Points assigned for values present on the completions
275    pub points: u32,
276}
277
278/// Defines points for the context relevance score component of variable values
279#[derive(Clone, Copy, Deserialize)]
280#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
281#[cfg_attr(not(test), serde(default))]
282pub struct SearchVariableContextTuning {
283    /// Points assigned for matching contextual information (e.g. other selected values)
284    pub points: u32,
285}
286
287/// Main configuration for all AI-related features
288#[derive(Clone, Deserialize)]
289#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
290#[cfg_attr(not(test), serde(default))]
291pub struct AiConfig {
292    /// A global switch to enable or disable all AI-powered functionality
293    pub enabled: bool,
294    /// Prompts used by the different ai-enabled features
295    pub prompts: AiPromptsConfig,
296    /// Which models from the catalog are used by which feature
297    pub models: AiModelsConfig,
298    /// A collection of named AI model configurations.
299    ///
300    /// Each entry maps a custom alias (e.g., `fast-model`, `smart-model`) to its specific provider settings. These
301    /// aliases are then referenced by the `suggest`, `fix`, `import`, and `fallback` fields.
302    pub catalog: BTreeMap<String, AiModelConfig>,
303}
304
305/// Configuration for the prompts
306#[derive(Clone, Deserialize)]
307#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
308#[cfg_attr(not(test), serde(default))]
309pub struct AiPromptsConfig {
310    /// The prompt to use when generating command suggestions from natural language.
311    pub suggest: String,
312    /// The prompt to use when explaining the fix for a failed command.
313    pub fix: String,
314    /// The prompt to use when importing commands (e.g., from a natural language page).
315    pub import: String,
316    /// The prompt used to generate a command for a dynamic completion.
317    pub completion: String,
318}
319
320/// Configuration for the models to be used
321#[derive(Clone, Deserialize)]
322#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
323#[cfg_attr(not(test), serde(default))]
324pub struct AiModelsConfig {
325    /// The alias of the AI model to use for generating command suggestions from natural language.
326    /// This alias must correspond to a key in the `catalog` map.
327    pub suggest: String,
328    /// The alias of the AI model used to explain the fix for a failed command.
329    /// This alias must correspond to a key in the `catalog` map.
330    pub fix: String,
331    /// The alias of the AI model to use when importing commands (e.g., from a natural language page).
332    /// This alias must correspond to a key in the `catalog` map.
333    pub import: String,
334    /// The alias of the AI model to use when suggesting variable completion commands
335    /// This alias must correspond to a key in the `catalog` map.
336    pub completion: String,
337    /// The alias of a model to use as a fallback when the primary model for a task fails due to rate limiting.
338    /// This alias must correspond to a key in the `catalog` map.
339    pub fallback: String,
340}
341
342/// Represents the configuration for a specific AI model, distinguished by the provider
343#[derive(Clone, Deserialize)]
344#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
345#[serde(tag = "provider", rename_all = "snake_case")]
346pub enum AiModelConfig {
347    /// Configuration for OpenAI or compatible APIs
348    Openai(OpenAiModelConfig),
349    /// Configuration for Google Gemini API
350    Gemini(GeminiModelConfig),
351    /// Configuration for Anthropic API
352    Anthropic(AnthropicModelConfig),
353    /// Configuration for models served via Ollama
354    Ollama(OllamaModelConfig),
355}
356
357/// Configuration for connecting to an OpenAI or a compatible API
358#[derive(Clone, Deserialize)]
359#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
360pub struct OpenAiModelConfig {
361    /// The exact model identifier to use (e.g., "gpt-4o", "gpt-3.5-turbo")
362    pub model: String,
363    /// The base URL of the API endpoint. Defaults to the official OpenAI API.
364    ///
365    /// Can be overridden to use other compatible services (e.g., Azure OpenAI, LiteLLM).
366    #[serde(default = "default_openai_url")]
367    pub url: String,
368    /// The name of the environment variable containing the API key for this model. Defaults to `OPENAI_API_KEY`.
369    #[serde(default = "default_openai_api_key_env")]
370    pub api_key_env: String,
371}
372fn default_openai_url() -> String {
373    "https://api.openai.com/v1".to_string()
374}
375fn default_openai_api_key_env() -> String {
376    "OPENAI_API_KEY".to_string()
377}
378
379/// Configuration for connecting to the Google Gemini API
380#[derive(Clone, Deserialize)]
381#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
382pub struct GeminiModelConfig {
383    /// The exact model identifier to use (e.g., "gemini-2.5-flash-lite")
384    pub model: String,
385    /// The base URL of the API endpoint. Defaults to the official Google Gemini API.
386    #[serde(default = "default_gemini_url")]
387    pub url: String,
388    /// The name of the environment variable containing the API key for this model. Defaults to `GEMINI_API_KEY`.
389    #[serde(default = "default_gemini_api_key_env")]
390    pub api_key_env: String,
391}
392fn default_gemini_url() -> String {
393    "https://generativelanguage.googleapis.com/v1beta".to_string()
394}
395fn default_gemini_api_key_env() -> String {
396    "GEMINI_API_KEY".to_string()
397}
398
399/// Configuration for connecting to the Anthropic API
400#[derive(Clone, Deserialize)]
401#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
402pub struct AnthropicModelConfig {
403    /// The exact model identifier to use (e.g., "claude-sonnet-4-0")
404    pub model: String,
405    /// The base URL of the API endpoint. Defaults to the official Anthropic API
406    #[serde(default = "default_anthropic_url")]
407    pub url: String,
408    /// The name of the environment variable containing the API key for this model. Defaults to `ANTHROPIC_API_KEY`.
409    #[serde(default = "default_anthropic_api_key_env")]
410    pub api_key_env: String,
411}
412fn default_anthropic_url() -> String {
413    "https://api.anthropic.com/v1".to_string()
414}
415fn default_anthropic_api_key_env() -> String {
416    "ANTHROPIC_API_KEY".to_string()
417}
418
419/// Configuration for connecting to a local or remote Ollama instance
420#[derive(Clone, Deserialize)]
421#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
422pub struct OllamaModelConfig {
423    /// The model name as configured in Ollama (e.g., "llama3", "mistral")
424    pub model: String,
425    /// The base URL of the Ollama server. Defaults to the standard local address.
426    #[serde(default = "default_ollama_url")]
427    pub url: String,
428    /// The name of the environment variable containing the API key for this model. Defaults to `OLLAMA_API_KEY`.
429    #[serde(default = "default_ollama_api_key_env")]
430    pub api_key_env: String,
431}
432fn default_ollama_url() -> String {
433    "http://localhost:11434".to_string()
434}
435fn default_ollama_api_key_env() -> String {
436    "OLLAMA_API_KEY".to_string()
437}
438
439impl Config {
440    /// Initializes the application configuration.
441    ///
442    /// Attempts to load the configuration from the user's config directory (`config.toml`). If the file does not exist
443    /// or has missing fields, it falls back to default values.
444    pub fn init(config_file: Option<PathBuf>) -> Result<Self> {
445        // Initialize directories
446        let proj_dirs = ProjectDirs::from("org", "IntelliShell", "Intelli-Shell")
447            .wrap_err("Couldn't initialize project directory")?;
448        let config_dir = proj_dirs.config_dir().to_path_buf();
449
450        // Initialize the config
451        let config_path = config_file.unwrap_or_else(|| config_dir.join("config.toml"));
452        let mut config = if config_path.exists() {
453            // Read from the config file, if found
454            let config_str = fs::read_to_string(&config_path)
455                .wrap_err_with(|| format!("Couldn't read config file {}", config_path.display()))?;
456            toml::from_str(&config_str)
457                .wrap_err_with(|| format!("Couldn't parse config file {}", config_path.display()))?
458        } else {
459            // Use default values if not found
460            Config::default()
461        };
462        // If no data dir is provided, use the default
463        if config.data_dir.as_os_str().is_empty() {
464            config.data_dir = proj_dirs.data_dir().to_path_buf();
465        }
466
467        // Validate there are no conflicts on the key bindings
468        let conflicts = config.keybindings.find_conflicts();
469        if !conflicts.is_empty() {
470            return Err(eyre!(
471                "Couldn't parse config file {}\n\nThere are some key binding conflicts:\n{}",
472                config_path.display(),
473                conflicts
474                    .into_iter()
475                    .map(|(_, a)| format!("- {}", a.into_iter().map(|a| format!("{a:?}")).join(", ")))
476                    .join("\n")
477            ));
478        }
479
480        // Validate AI models are properly setup
481        if config.ai.enabled {
482            let AiModelsConfig {
483                suggest,
484                fix,
485                import,
486                completion,
487                fallback,
488            } = &config.ai.models;
489            let catalog = &config.ai.catalog;
490
491            let mut missing = Vec::new();
492            if !catalog.contains_key(suggest) {
493                missing.push((suggest, "suggest"));
494            }
495            if !catalog.contains_key(fix) {
496                missing.push((fix, "fix"));
497            }
498            if !catalog.contains_key(import) {
499                missing.push((import, "import"));
500            }
501            if !catalog.contains_key(completion) {
502                missing.push((completion, "completion"));
503            }
504            if !catalog.contains_key(fallback) {
505                missing.push((fallback, "fallback"));
506            }
507
508            if !missing.is_empty() {
509                return Err(eyre!(
510                    "Couldn't parse config file {}\n\nMissing model definitions on the catalog:\n{}",
511                    config_path.display(),
512                    missing
513                        .into_iter()
514                        .into_group_map()
515                        .into_iter()
516                        .map(|(k, v)| format!(
517                            "- {k} used in {}",
518                            v.into_iter().map(|v| format!("ai.models.{v}")).join(", ")
519                        ))
520                        .join("\n")
521                ));
522            }
523        }
524
525        // Create the data directory if not found
526        fs::create_dir_all(&config.data_dir)
527            .wrap_err_with(|| format!("Could't create data dir {}", config.data_dir.display()))?;
528
529        Ok(config)
530    }
531}
532
533impl KeyBindingsConfig {
534    /// Retrieves the [KeyBinding] for a specific action
535    pub fn get(&self, action: &KeyBindingAction) -> &KeyBinding {
536        self.0.get(action).unwrap()
537    }
538
539    /// Finds the [KeyBindingAction] associated with the given [KeyEvent], if any
540    pub fn get_action_matching(&self, event: &KeyEvent) -> Option<KeyBindingAction> {
541        self.0.iter().find_map(
542            |(action, binding)| {
543                if binding.matches(event) { Some(*action) } else { None }
544            },
545        )
546    }
547
548    /// Finds all ambiguous key bindings where a single `KeyEvent` maps to multiple `KeyBindingAction`s
549    pub fn find_conflicts(&self) -> Vec<(KeyEvent, Vec<KeyBindingAction>)> {
550        // A map to store each KeyEvent and the list of actions it's bound to.
551        let mut event_to_actions_map: HashMap<KeyEvent, Vec<KeyBindingAction>> = HashMap::new();
552
553        // Iterate over all configured actions and their bindings.
554        for (action, key_binding) in self.0.iter() {
555            // For each KeyEvent defined within the current KeyBinding...
556            for event_in_binding in key_binding.0.iter() {
557                // Record that this event maps to the current action.
558                event_to_actions_map.entry(*event_in_binding).or_default().push(*action);
559            }
560        }
561
562        // Filter the map to find KeyEvents that map to more than one action.
563        event_to_actions_map
564            .into_iter()
565            .filter_map(|(key_event, actions)| {
566                if actions.len() > 1 {
567                    Some((key_event, actions))
568                } else {
569                    None
570                }
571            })
572            .collect()
573    }
574}
575
576impl KeyBinding {
577    /// Checks if a given `KeyEvent` matches any of the key events configured for this key binding, considering only the
578    /// key `code` and its `modifiers`.
579    pub fn matches(&self, event: &KeyEvent) -> bool {
580        self.0
581            .iter()
582            .any(|e| e.code == event.code && e.modifiers == event.modifiers)
583    }
584}
585
586impl Theme {
587    /// Primary style applied when an item is highlighted, including the background color
588    pub fn highlight_primary_full(&self) -> ContentStyle {
589        if let Some(color) = self.highlight {
590            let mut ret = self.highlight_primary;
591            ret.background_color = Some(color);
592            ret
593        } else {
594            self.highlight_primary
595        }
596    }
597
598    /// Secondary style applied when an item is highlighted, including the background color
599    pub fn highlight_secondary_full(&self) -> ContentStyle {
600        if let Some(color) = self.highlight {
601            let mut ret = self.highlight_secondary;
602            ret.background_color = Some(color);
603            ret
604        } else {
605            self.highlight_secondary
606        }
607    }
608
609    /// Accent style applied when an item is highlighted, including the background color
610    pub fn highlight_accent_full(&self) -> ContentStyle {
611        if let Some(color) = self.highlight {
612            let mut ret = self.highlight_accent;
613            ret.background_color = Some(color);
614            ret
615        } else {
616            self.highlight_accent
617        }
618    }
619
620    /// Comments style applied when an item is highlighted, including the background color
621    pub fn highlight_comment_full(&self) -> ContentStyle {
622        if let Some(color) = self.highlight {
623            let mut ret = self.highlight_comment;
624            ret.background_color = Some(color);
625            ret
626        } else {
627            self.highlight_comment
628        }
629    }
630}
631
632impl AiConfig {
633    /// Retrieves a client configured for the `suggest` action
634    pub fn suggest_client(&self) -> crate::errors::Result<AiClient<'_>> {
635        AiClient::new(
636            &self.models.suggest,
637            self.catalog.get(&self.models.suggest).unwrap(),
638            &self.models.fallback,
639            self.catalog.get(&self.models.fallback),
640        )
641    }
642
643    /// Retrieves a client configured for the `fix` action
644    pub fn fix_client(&self) -> crate::errors::Result<AiClient<'_>> {
645        AiClient::new(
646            &self.models.fix,
647            self.catalog.get(&self.models.fix).unwrap(),
648            &self.models.fallback,
649            self.catalog.get(&self.models.fallback),
650        )
651    }
652
653    /// Retrieves a client configured for the `import` action
654    pub fn import_client(&self) -> crate::errors::Result<AiClient<'_>> {
655        AiClient::new(
656            &self.models.import,
657            self.catalog.get(&self.models.import).unwrap(),
658            &self.models.fallback,
659            self.catalog.get(&self.models.fallback),
660        )
661    }
662
663    /// Retrieves a client configured for the `completion` action
664    pub fn completion_client(&self) -> crate::errors::Result<AiClient<'_>> {
665        AiClient::new(
666            &self.models.completion,
667            self.catalog.get(&self.models.completion).unwrap(),
668            &self.models.fallback,
669            self.catalog.get(&self.models.fallback),
670        )
671    }
672}
673impl AiModelConfig {
674    pub fn provider(&self) -> &dyn AiProviderBase {
675        match self {
676            AiModelConfig::Openai(conf) => conf,
677            AiModelConfig::Gemini(conf) => conf,
678            AiModelConfig::Anthropic(conf) => conf,
679            AiModelConfig::Ollama(conf) => conf,
680        }
681    }
682}
683
684impl Default for Config {
685    fn default() -> Self {
686        Self {
687            data_dir: PathBuf::new(),
688            check_updates: true,
689            inline: true,
690            search: SearchConfig::default(),
691            logs: LogsConfig::default(),
692            keybindings: KeyBindingsConfig::default(),
693            theme: Theme::default(),
694            gist: GistConfig::default(),
695            tuning: SearchTuning::default(),
696            ai: AiConfig::default(),
697        }
698    }
699}
700impl Default for SearchConfig {
701    fn default() -> Self {
702        Self {
703            delay: 250,
704            mode: SearchMode::Auto,
705            user_only: false,
706            exec_on_alias_match: false,
707        }
708    }
709}
710impl Default for LogsConfig {
711    fn default() -> Self {
712        Self {
713            enabled: false,
714            filter: String::from("info"),
715        }
716    }
717}
718impl Default for KeyBindingsConfig {
719    fn default() -> Self {
720        Self(BTreeMap::from([
721            (KeyBindingAction::Quit, KeyBinding(vec![KeyEvent::from(KeyCode::Esc)])),
722            (
723                KeyBindingAction::Update,
724                KeyBinding(vec![
725                    KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
726                    KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
727                    KeyEvent::from(KeyCode::F(2)),
728                ]),
729            ),
730            (
731                KeyBindingAction::Delete,
732                KeyBinding(vec![KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)]),
733            ),
734            (
735                KeyBindingAction::Confirm,
736                KeyBinding(vec![KeyEvent::from(KeyCode::Tab), KeyEvent::from(KeyCode::Enter)]),
737            ),
738            (
739                KeyBindingAction::Execute,
740                KeyBinding(vec![
741                    KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
742                    KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
743                ]),
744            ),
745            (
746                KeyBindingAction::AI,
747                KeyBinding(vec![
748                    KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL),
749                    KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
750                ]),
751            ),
752            (
753                KeyBindingAction::SearchMode,
754                KeyBinding(vec![KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)]),
755            ),
756            (
757                KeyBindingAction::SearchUserOnly,
758                KeyBinding(vec![KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)]),
759            ),
760        ]))
761    }
762}
763impl Default for Theme {
764    fn default() -> Self {
765        let primary = ContentStyle::new();
766        let highlight_primary = primary;
767
768        let mut secondary = ContentStyle::new();
769        secondary.attributes.set(Attribute::Dim);
770        let highlight_secondary = ContentStyle::new();
771
772        let mut accent = ContentStyle::new();
773        accent.foreground_color = Some(Color::Yellow);
774        let highlight_accent = accent;
775
776        let mut comment = ContentStyle::new();
777        comment.foreground_color = Some(Color::Green);
778        comment.attributes.set(Attribute::Italic);
779        let highlight_comment = comment;
780
781        let mut error = ContentStyle::new();
782        error.foreground_color = Some(Color::DarkRed);
783
784        Self {
785            primary,
786            secondary,
787            accent,
788            comment,
789            error,
790            highlight: Some(Color::AnsiValue(249)),
791            highlight_symbol: String::from("» "),
792            highlight_primary,
793            highlight_secondary,
794            highlight_accent,
795            highlight_comment,
796        }
797    }
798}
799impl Default for SearchCommandsTextTuning {
800    fn default() -> Self {
801        Self {
802            points: 600,
803            command: 2.0,
804            description: 1.0,
805            auto: SearchCommandsTextAutoTuning::default(),
806        }
807    }
808}
809impl Default for SearchCommandsTextAutoTuning {
810    fn default() -> Self {
811        Self {
812            prefix: 1.5,
813            fuzzy: 1.0,
814            relaxed: 0.5,
815            root: 2.0,
816        }
817    }
818}
819impl Default for SearchUsageTuning {
820    fn default() -> Self {
821        Self { points: 100 }
822    }
823}
824impl Default for SearchPathTuning {
825    fn default() -> Self {
826        Self {
827            points: 300,
828            exact: 1.0,
829            ancestor: 0.5,
830            descendant: 0.25,
831            unrelated: 0.1,
832        }
833    }
834}
835impl Default for SearchVariableCompletionTuning {
836    fn default() -> Self {
837        Self { points: 200 }
838    }
839}
840impl Default for SearchVariableContextTuning {
841    fn default() -> Self {
842        Self { points: 700 }
843    }
844}
845impl Default for AiConfig {
846    fn default() -> Self {
847        Self {
848            enabled: false,
849            models: AiModelsConfig::default(),
850            prompts: AiPromptsConfig::default(),
851            catalog: BTreeMap::from([
852                (
853                    "gemini".to_string(),
854                    AiModelConfig::Gemini(GeminiModelConfig {
855                        model: "gemini-2.5-flash".to_string(),
856                        url: default_gemini_url(),
857                        api_key_env: default_gemini_api_key_env(),
858                    }),
859                ),
860                (
861                    "gemini-fallback".to_string(),
862                    AiModelConfig::Gemini(GeminiModelConfig {
863                        model: "gemini-2.0-flash-lite".to_string(),
864                        url: default_gemini_url(),
865                        api_key_env: default_gemini_api_key_env(),
866                    }),
867                ),
868            ]),
869        }
870    }
871}
872impl Default for AiModelsConfig {
873    fn default() -> Self {
874        Self {
875            suggest: "gemini".to_string(),
876            fix: "gemini".to_string(),
877            import: "gemini".to_string(),
878            completion: "gemini".to_string(),
879            fallback: "gemini-fallback".to_string(),
880        }
881    }
882}
883impl Default for AiPromptsConfig {
884    fn default() -> Self {
885        Self {
886            suggest: String::from(
887                r#"##OS_SHELL_INFO##
888##WORKING_DIR##
889### Instructions
890You are an expert CLI assistant. Your task is to generate shell command templates based on the user's request.
891
892Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
893
894### Command Template Syntax
895When creating the `command` template string, you must use the following placeholder syntax:
896
897- **Standard Placeholder**: `{{variable-name}}`
898  - Use for regular arguments that the user needs to provide.
899  - _Example_: `echo "Hello, {{user-name}}!"`
900
901- **Choice Placeholder**: `{{option1|option2}}`
902  - Use when the user must choose from a specific set of options.
903  - _Example_: `git reset {{--soft|--hard}} HEAD~1`
904
905- **Function Placeholder**: `{{variable:function}}`
906  - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
907  - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
908  - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
909
910- **Secret/Ephemeral Placeholder**: `{{{...}}}`
911  - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description). 
912    This syntax can wrap any of the placeholder types above.
913  - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
914
915### Suggestion Strategy
916Your primary goal is to provide the most relevant and comprehensive set of command templates. Adhere strictly to the following principles when deciding how many suggestions to provide:
917
9181. **Explicit Single Suggestion:**
919   - If the user's request explicitly asks for **a single suggestion**, you **MUST** return a list containing exactly one suggestion object.
920   - To cover variations within this single command, make effective use of choice placeholders (e.g., `git reset {{--soft|--hard}}`).
921
9222. **Clear & Unambiguous Request:**
923   - If the request is straightforward and has one primary, standard solution, provide a **single, well-formed suggestion**.
924
9253. **Ambiguous or Multi-faceted Request:**
926   - If a request is ambiguous, has multiple valid interpretations, or can be solved using several distinct tools or methods, you **MUST provide a comprehensive list of suggestions**.
927   - Each distinct approach or interpretation **must be a separate suggestion object**.
928   - **Be comprehensive and do not limit your suggestions**. For example, a request for "undo a git commit" could mean `git reset`, `git revert`, or `git checkout`. A request to "find files" could yield suggestions for `find`, `fd`, and `locate`. Provide all valid, distinct alternatives.
929   - **Order the suggestions by relevance**, with the most common or recommended solution appearing first.
930"#,
931            ),
932            fix: String::from(
933                r#"##OS_SHELL_INFO##
934##WORKING_DIR##
935##SHELL_HISTORY##
936### Instructions
937You are an expert command-line assistant. Your mission is to analyze a failed shell command and its error output, 
938diagnose the root cause, and provide a structured, actionable solution in a single JSON object.
939
940### Output Schema
941Your response MUST be a single, valid JSON object with no surrounding text or markdown. It must conform to the following structure:
942- `summary`: A very brief, 2-5 word summary of the error category. Examples: "Command Not Found", "Permission Denied", "Invalid Argument", "Git Typo".
943- `diagnosis`: A detailed, human-readable explanation of the root cause of the error. This section should explain *what* went wrong and *why*, based on the provided command and error message. It should not contain the solution.
944- `proposal`: A human-readable description of the recommended next steps. This can be a description of a fix, diagnostic commands to run, or a suggested workaround.
945- `fixed_command`: The corrected, valid, ready-to-execute command string. This field should *only* be populated if a direct command correction is the primary solution (e.g., fixing a typo). For complex issues requiring explanation or privilege changes, this should be an empty string.
946
947### Core Rules
9481. **JSON Only**: Your entire output must be a single, raw JSON object. Do not wrap it in code blocks or add any explanatory text.
9492. **Holistic Analysis**: Analyze the command's context, syntax, and common user errors. Don't just parse the error message. Consider the user's likely intent.
9503. **Strict Wrapping**: Hard-wrap all string values within the JSON to a maximum of 80 characters.
9514. **`fixed_command` Logic**: Always populate `fixed_command` with the most likely command to resolve the error. Only leave this field as an empty string if the user's intent is unclear from the context.
952"#,
953            ),
954            import: String::from(
955                r#"### Instructions
956You are an expert tool that extracts and generalizes shell command patterns from arbitrary text content. Your goal is to analyze the provided text, identify all unique command patterns, and present them as a list of suggestions.
957
958Your entire response MUST be a single, valid JSON object conforming to the provided schema. Output nothing but the JSON object itself.
959
960Refer to the syntax definitions, process, and example below to construct your response.
961
962### Command Template Syntax
963When creating the `command` template string, you must use the following placeholder syntax:
964
965- **Standard Placeholder**: `{{variable-name}}`
966  - Use for regular arguments that the user needs to provide.
967  - _Example_: `echo "Hello, {{user-name}}!"`
968
969- **Choice Placeholder**: `{{option1|option2}}`
970  - Use when the user must choose from a specific set of options.
971  - _Example_: `git reset {{--soft|--hard}} HEAD~1`
972
973- **Function Placeholder**: `{{variable:function}}`
974  - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
975  - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
976  - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
977
978- **Secret/Ephemeral Placeholder**: `{{{...}}}`
979  - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description). 
980    This syntax can wrap any of the placeholder types above.
981  - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
982
983### Core Process
9841. **Extract & Generalize**: Scan the text to find all shell commands. Generalize each one into a template by replacing specific values with the appropriate placeholder type defined in the **Command Template Syntax** section.
9852. **Deduplicate**: Consolidate multiple commands that follow the same pattern into a single, representative template. For example, `git checkout bugfix/some-bug` and `git checkout feature/login` must be merged into a single `git checkout {{feature|bugfix}}/{{{description:kebab}}}` suggestion.
986
987### Output Generation
988For each unique and deduplicated command pattern you identify:
989- Create a suggestion object containing a `description` and a `command`.
990- The `description` must be a clear, single-sentence explanation of the command's purpose.
991- The `command` must be the final, generalized template string from the core process.
992"#,
993            ),
994            completion: String::from(
995                r#"##OS_SHELL_INFO##
996### Instructions
997You are an expert CLI assistant. Your task is to generate a single-line shell command that will be executed in the background to fetch a list of dynamic command-line completions for a given variable.
998
999Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
1000
1001### Core Task
1002The command you create will be run non-interactively to generate a list of suggestions for the user. It must adapt to information that is already known (the "context").
1003
1004### Command Template Syntax
1005To make the command context-aware, you must use a special syntax for optional parts of the command. Any segment of the command that depends on contextual information must be wrapped in double curly braces `{{...}}`.
1006
1007- **Syntax**: `{{--parameter {{variable-name}}}}`
1008- **Rule**: The entire block, including the parameter and its variable, will only be included in the final command if the `variable-name` exists in the context. If the variable is not present, the entire block is omitted.
1009- **All-or-Nothing**: If a block contains multiple variables, all of them must be present in the context for the block to be included.
1010
1011- **_Example_**:
1012  - **Template**: `kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}`
1013  - If the context provides a `namespace`, the executed command becomes: `kubectl get pods -n prod`
1014  - If the context provides both `namespace` and `context`, it becomes: `kubectl get pods --context my-cluster -n prod`
1015  - If the context is empty, it is simply: `kubectl get pods`
1016
1017### Requirements
10181. **JSON Only**: Your entire output must be a single, raw JSON object. Do not add any explanatory text.
10192. **Context is Key**: Every variable like `{{variable-name}}` must be part of a surrounding conditional block `{{...}}`. The command cannot ask for new information.
10203. **Produce a List**: The final command, after resolving the context, must print a list of strings to standard output, with each item on a new line. This list will be the source for the completions.
10214. **Executable**: The command must be syntactically correct and executable.
1022"#,
1023            ),
1024        }
1025    }
1026}
1027
1028/// Custom deserialization function for the BTreeMap in KeyBindingsConfig.
1029///
1030/// Behavior depends on whether compiled for test or not:
1031/// - In test (`#[cfg(test)]`): Requires all `KeyBindingAction` variants to be present; otherwise, errors. No merging.
1032/// - In non-test (`#[cfg(not(test))]`): Merges user-provided bindings with defaults.
1033fn deserialize_bindings_with_defaults<'de, D>(
1034    deserializer: D,
1035) -> Result<BTreeMap<KeyBindingAction, KeyBinding>, D::Error>
1036where
1037    D: Deserializer<'de>,
1038{
1039    // Deserialize the map as provided in the config.
1040    let user_provided_bindings = BTreeMap::<KeyBindingAction, KeyBinding>::deserialize(deserializer)?;
1041
1042    #[cfg(test)]
1043    {
1044        use strum::IntoEnumIterator;
1045        // In test mode, all actions must be explicitly defined. No defaults are merged.
1046        for action_variant in KeyBindingAction::iter() {
1047            if !user_provided_bindings.contains_key(&action_variant) {
1048                return Err(D::Error::custom(format!(
1049                    "Missing key binding for action '{action_variant:?}'."
1050                )));
1051            }
1052        }
1053        Ok(user_provided_bindings)
1054    }
1055    #[cfg(not(test))]
1056    {
1057        // In non-test (production) mode, merge with defaults.
1058        // User-provided bindings override defaults for the actions they specify.
1059        let mut final_bindings = user_provided_bindings;
1060        let default_bindings = KeyBindingsConfig::default();
1061
1062        for (action, default_binding) in default_bindings.0 {
1063            final_bindings.entry(action).or_insert(default_binding);
1064        }
1065        Ok(final_bindings)
1066    }
1067}
1068
1069/// Deserializes a string or a vector of strings into a `Vec<KeyEvent>`.
1070///
1071/// This allows a key binding to be specified as a single string or a list of strings in the config file.
1072fn deserialize_key_events<'de, D>(deserializer: D) -> Result<Vec<KeyEvent>, D::Error>
1073where
1074    D: Deserializer<'de>,
1075{
1076    #[derive(Deserialize)]
1077    #[serde(untagged)]
1078    enum StringOrVec {
1079        Single(String),
1080        Multiple(Vec<String>),
1081    }
1082
1083    let strings = match StringOrVec::deserialize(deserializer)? {
1084        StringOrVec::Single(s) => vec![s],
1085        StringOrVec::Multiple(v) => v,
1086    };
1087
1088    strings
1089        .iter()
1090        .map(String::as_str)
1091        .map(parse_key_event)
1092        .map(|r| r.map_err(D::Error::custom))
1093        .collect()
1094}
1095
1096/// Deserializes a string into an optional [`Color`].
1097///
1098/// Supports color names, RGB (e.g., `rgb(255, 0, 100)`), hex (e.g., `#ff0064`), indexed colors (e.g., `6`), and "none"
1099/// for no color.
1100fn deserialize_color<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
1101where
1102    D: Deserializer<'de>,
1103{
1104    parse_color(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1105}
1106
1107/// Deserializes a string into a [`ContentStyle`].
1108///
1109/// Supports color names and modifiers (e.g., "red", "bold", "italic blue", "underline dim green").
1110fn deserialize_style<'de, D>(deserializer: D) -> Result<ContentStyle, D::Error>
1111where
1112    D: Deserializer<'de>,
1113{
1114    parse_style(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1115}
1116
1117/// Parses a string representation of a key event into a [`KeyEvent`].
1118///
1119/// Supports modifiers like `ctrl-`, `alt-`, `shift-` and standard key names/characters.
1120fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
1121    let raw_lower = raw.to_ascii_lowercase();
1122    let (remaining, modifiers) = extract_key_modifiers(&raw_lower);
1123    parse_key_code_with_modifiers(remaining, modifiers)
1124}
1125
1126/// Extracts key modifiers (ctrl, shift, alt) from the beginning of a key event string.
1127///
1128/// Returns the remaining string and the parsed modifiers.
1129fn extract_key_modifiers(raw: &str) -> (&str, KeyModifiers) {
1130    let mut modifiers = KeyModifiers::empty();
1131    let mut current = raw;
1132
1133    loop {
1134        match current {
1135            rest if rest.starts_with("ctrl-") || rest.starts_with("ctrl+") => {
1136                modifiers.insert(KeyModifiers::CONTROL);
1137                current = &rest[5..];
1138            }
1139            rest if rest.starts_with("shift-") || rest.starts_with("shift+") => {
1140                modifiers.insert(KeyModifiers::SHIFT);
1141                current = &rest[6..];
1142            }
1143            rest if rest.starts_with("alt-") || rest.starts_with("alt+") => {
1144                modifiers.insert(KeyModifiers::ALT);
1145                current = &rest[4..];
1146            }
1147            _ => break,
1148        };
1149    }
1150
1151    (current, modifiers)
1152}
1153
1154/// Parses the remaining string after extracting modifiers into a [`KeyCode`]
1155fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
1156    let code = match raw {
1157        "esc" => KeyCode::Esc,
1158        "enter" => KeyCode::Enter,
1159        "left" => KeyCode::Left,
1160        "right" => KeyCode::Right,
1161        "up" => KeyCode::Up,
1162        "down" => KeyCode::Down,
1163        "home" => KeyCode::Home,
1164        "end" => KeyCode::End,
1165        "pageup" => KeyCode::PageUp,
1166        "pagedown" => KeyCode::PageDown,
1167        "backtab" => {
1168            modifiers.insert(KeyModifiers::SHIFT);
1169            KeyCode::BackTab
1170        }
1171        "backspace" => KeyCode::Backspace,
1172        "delete" => KeyCode::Delete,
1173        "insert" => KeyCode::Insert,
1174        "f1" => KeyCode::F(1),
1175        "f2" => KeyCode::F(2),
1176        "f3" => KeyCode::F(3),
1177        "f4" => KeyCode::F(4),
1178        "f5" => KeyCode::F(5),
1179        "f6" => KeyCode::F(6),
1180        "f7" => KeyCode::F(7),
1181        "f8" => KeyCode::F(8),
1182        "f9" => KeyCode::F(9),
1183        "f10" => KeyCode::F(10),
1184        "f11" => KeyCode::F(11),
1185        "f12" => KeyCode::F(12),
1186        "space" | "spacebar" => KeyCode::Char(' '),
1187        "hyphen" => KeyCode::Char('-'),
1188        "minus" => KeyCode::Char('-'),
1189        "tab" => KeyCode::Tab,
1190        c if c.len() == 1 => {
1191            let mut c = c.chars().next().expect("just checked");
1192            if modifiers.contains(KeyModifiers::SHIFT) {
1193                c = c.to_ascii_uppercase();
1194            }
1195            KeyCode::Char(c)
1196        }
1197        _ => return Err(format!("Unable to parse key binding: {raw}")),
1198    };
1199    Ok(KeyEvent::new(code, modifiers))
1200}
1201
1202/// Parses a string into an optional [`Color`].
1203///
1204/// Handles named colors, RGB, hex, indexed colors, and "none".
1205fn parse_color(raw: &str) -> Result<Option<Color>, String> {
1206    let raw_lower = raw.to_ascii_lowercase();
1207    if raw.is_empty() || raw == "none" {
1208        Ok(None)
1209    } else {
1210        Ok(Some(parse_color_inner(&raw_lower)?))
1211    }
1212}
1213
1214/// Parses a string into a [`ContentStyle`], including attributes and foreground color.
1215///
1216/// Examples: "red", "bold", "italic blue", "underline dim green".
1217fn parse_style(raw: &str) -> Result<ContentStyle, String> {
1218    let raw_lower = raw.to_ascii_lowercase();
1219    let (remaining, attributes) = extract_style_attributes(&raw_lower);
1220    let mut style = ContentStyle::new();
1221    style.attributes = attributes;
1222    if !remaining.is_empty() && remaining != "default" {
1223        style.foreground_color = Some(parse_color_inner(remaining)?);
1224    }
1225    Ok(style)
1226}
1227
1228/// Extracts style attributes (bold, dim, italic, underline) from the beginning of a style string.
1229///
1230/// Returns the remaining string and the parsed attributes.
1231fn extract_style_attributes(raw: &str) -> (&str, Attributes) {
1232    let mut attributes = Attributes::none();
1233    let mut current = raw;
1234
1235    loop {
1236        match current {
1237            rest if rest.starts_with("bold") => {
1238                attributes.set(Attribute::Bold);
1239                current = &rest[4..];
1240                if current.starts_with(' ') {
1241                    current = &current[1..];
1242                }
1243            }
1244            rest if rest.starts_with("dim") => {
1245                attributes.set(Attribute::Dim);
1246                current = &rest[3..];
1247                if current.starts_with(' ') {
1248                    current = &current[1..];
1249                }
1250            }
1251            rest if rest.starts_with("italic") => {
1252                attributes.set(Attribute::Italic);
1253                current = &rest[6..];
1254                if current.starts_with(' ') {
1255                    current = &current[1..];
1256                }
1257            }
1258            rest if rest.starts_with("underline") => {
1259                attributes.set(Attribute::Underlined);
1260                current = &rest[9..];
1261                if current.starts_with(' ') {
1262                    current = &current[1..];
1263                }
1264            }
1265            rest if rest.starts_with("underlined") => {
1266                attributes.set(Attribute::Underlined);
1267                current = &rest[10..];
1268                if current.starts_with(' ') {
1269                    current = &current[1..];
1270                }
1271            }
1272            _ => break,
1273        };
1274    }
1275
1276    (current.trim(), attributes)
1277}
1278
1279/// Parses the color part of a style string.
1280///
1281/// Handles named colors, rgb, hex, and ansi values.
1282fn parse_color_inner(raw: &str) -> Result<Color, String> {
1283    Ok(match raw {
1284        "black" => Color::Black,
1285        "red" => Color::Red,
1286        "green" => Color::Green,
1287        "yellow" => Color::Yellow,
1288        "blue" => Color::Blue,
1289        "magenta" => Color::Magenta,
1290        "cyan" => Color::Cyan,
1291        "gray" | "grey" => Color::Grey,
1292        "dark gray" | "darkgray" | "dark grey" | "darkgrey" => Color::DarkGrey,
1293        "dark red" | "darkred" => Color::DarkRed,
1294        "dark green" | "darkgreen" => Color::DarkGreen,
1295        "dark yellow" | "darkyellow" => Color::DarkYellow,
1296        "dark blue" | "darkblue" => Color::DarkBlue,
1297        "dark magenta" | "darkmagenta" => Color::DarkMagenta,
1298        "dark cyan" | "darkcyan" => Color::DarkCyan,
1299        "white" => Color::White,
1300        rgb if rgb.starts_with("rgb(") => {
1301            let rgb = rgb.trim_start_matches("rgb(").trim_end_matches(")").split(',');
1302            let rgb = rgb
1303                .map(|c| c.trim().parse::<u8>())
1304                .collect::<Result<Vec<u8>, _>>()
1305                .map_err(|_| format!("Unable to parse color: {raw}"))?;
1306            if rgb.len() != 3 {
1307                return Err(format!("Unable to parse color: {raw}"));
1308            }
1309            Color::Rgb {
1310                r: rgb[0],
1311                g: rgb[1],
1312                b: rgb[2],
1313            }
1314        }
1315        hex if hex.starts_with("#") => {
1316            let hex = hex.trim_start_matches("#");
1317            if hex.len() != 6 {
1318                return Err(format!("Unable to parse color: {raw}"));
1319            }
1320            let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1321            let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1322            let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1323            Color::Rgb { r, g, b }
1324        }
1325        c => {
1326            if let Ok(c) = c.parse::<u8>() {
1327                Color::AnsiValue(c)
1328            } else {
1329                return Err(format!("Unable to parse color: {raw}"));
1330            }
1331        }
1332    })
1333}
1334
1335#[cfg(test)]
1336mod tests {
1337    use pretty_assertions::assert_eq;
1338    use strum::IntoEnumIterator;
1339
1340    use super::*;
1341
1342    #[test]
1343    fn test_default_config() -> Result<()> {
1344        let config_str = fs::read_to_string("default_config.toml").wrap_err("Couldn't read default config file")?;
1345        let config: Config = toml::from_str(&config_str).wrap_err("Couldn't parse default config file")?;
1346
1347        assert_eq!(Config::default(), config);
1348
1349        Ok(())
1350    }
1351
1352    #[test]
1353    fn test_default_keybindings_complete() {
1354        let config = KeyBindingsConfig::default();
1355
1356        for action in KeyBindingAction::iter() {
1357            assert!(
1358                config.0.contains_key(&action),
1359                "Missing default binding for action: {action:?}"
1360            );
1361        }
1362    }
1363
1364    #[test]
1365    fn test_default_keybindings_no_conflicts() {
1366        let config = KeyBindingsConfig::default();
1367
1368        let conflicts = config.find_conflicts();
1369        assert_eq!(conflicts.len(), 0, "Key binding conflicts: {conflicts:?}");
1370    }
1371
1372    #[test]
1373    fn test_keybinding_matches() {
1374        let binding = KeyBinding(vec![
1375            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
1376            KeyEvent::from(KeyCode::Enter),
1377        ]);
1378
1379        // Should match exact events
1380        assert!(binding.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
1381        assert!(binding.matches(&KeyEvent::from(KeyCode::Enter)));
1382
1383        // Should not match events with different modifiers
1384        assert!(!binding.matches(&KeyEvent::new(
1385            KeyCode::Char('a'),
1386            KeyModifiers::CONTROL | KeyModifiers::ALT
1387        )));
1388
1389        // Should not match different key codes
1390        assert!(!binding.matches(&KeyEvent::from(KeyCode::Esc)));
1391    }
1392
1393    #[test]
1394    fn test_simple_keys() {
1395        assert_eq!(
1396            parse_key_event("a").unwrap(),
1397            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
1398        );
1399
1400        assert_eq!(
1401            parse_key_event("enter").unwrap(),
1402            KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
1403        );
1404
1405        assert_eq!(
1406            parse_key_event("esc").unwrap(),
1407            KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
1408        );
1409    }
1410
1411    #[test]
1412    fn test_with_modifiers() {
1413        assert_eq!(
1414            parse_key_event("ctrl-a").unwrap(),
1415            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
1416        );
1417
1418        assert_eq!(
1419            parse_key_event("alt-enter").unwrap(),
1420            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
1421        );
1422
1423        assert_eq!(
1424            parse_key_event("shift-esc").unwrap(),
1425            KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
1426        );
1427    }
1428
1429    #[test]
1430    fn test_multiple_modifiers() {
1431        assert_eq!(
1432            parse_key_event("ctrl-alt-a").unwrap(),
1433            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
1434        );
1435
1436        assert_eq!(
1437            parse_key_event("ctrl-shift-enter").unwrap(),
1438            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
1439        );
1440    }
1441
1442    #[test]
1443    fn test_invalid_keys() {
1444        let res = parse_key_event("invalid-key");
1445        assert_eq!(res, Err(String::from("Unable to parse key binding: invalid-key")));
1446    }
1447
1448    #[test]
1449    fn test_parse_color_none() {
1450        let color = parse_color("none").unwrap();
1451        assert_eq!(color, None);
1452    }
1453
1454    #[test]
1455    fn test_parse_color_simple() {
1456        let color = parse_color("red").unwrap();
1457        assert_eq!(color, Some(Color::Red));
1458    }
1459
1460    #[test]
1461    fn test_parse_color_rgb() {
1462        let color = parse_color("rgb(50, 25, 15)").unwrap();
1463        assert_eq!(color, Some(Color::Rgb { r: 50, g: 25, b: 15 }));
1464    }
1465
1466    #[test]
1467    fn test_parse_color_rgb_out_of_range() {
1468        let res = parse_color("rgb(500, 25, 15)");
1469        assert_eq!(res, Err(String::from("Unable to parse color: rgb(500, 25, 15)")));
1470    }
1471
1472    #[test]
1473    fn test_parse_color_rgb_invalid() {
1474        let res = parse_color("rgb(50, 25, 15, 5)");
1475        assert_eq!(res, Err(String::from("Unable to parse color: rgb(50, 25, 15, 5)")));
1476    }
1477
1478    #[test]
1479    fn test_parse_color_hex() {
1480        let color = parse_color("#4287f5").unwrap();
1481        assert_eq!(color, Some(Color::Rgb { r: 66, g: 135, b: 245 }));
1482    }
1483
1484    #[test]
1485    fn test_parse_color_hex_out_of_range() {
1486        let res = parse_color("#4287fg");
1487        assert_eq!(res, Err(String::from("Unable to parse color: #4287fg")));
1488    }
1489
1490    #[test]
1491    fn test_parse_color_hex_invalid() {
1492        let res = parse_color("#4287f50");
1493        assert_eq!(res, Err(String::from("Unable to parse color: #4287f50")));
1494    }
1495
1496    #[test]
1497    fn test_parse_color_index() {
1498        let color = parse_color("6").unwrap();
1499        assert_eq!(color, Some(Color::AnsiValue(6)));
1500    }
1501
1502    #[test]
1503    fn test_parse_color_fail() {
1504        let res = parse_color("1234");
1505        assert_eq!(res, Err(String::from("Unable to parse color: 1234")));
1506    }
1507
1508    #[test]
1509    fn test_parse_style_empty() {
1510        let style = parse_style("").unwrap();
1511        assert_eq!(style, ContentStyle::new());
1512    }
1513
1514    #[test]
1515    fn test_parse_style_default() {
1516        let style = parse_style("default").unwrap();
1517        assert_eq!(style, ContentStyle::new());
1518    }
1519
1520    #[test]
1521    fn test_parse_style_simple() {
1522        let style = parse_style("red").unwrap();
1523        assert_eq!(style.foreground_color, Some(Color::Red));
1524        assert_eq!(style.attributes, Attributes::none());
1525    }
1526
1527    #[test]
1528    fn test_parse_style_only_modifier() {
1529        let style = parse_style("bold").unwrap();
1530        assert_eq!(style.foreground_color, None);
1531        let mut expected_attributes = Attributes::none();
1532        expected_attributes.set(Attribute::Bold);
1533        assert_eq!(style.attributes, expected_attributes);
1534    }
1535
1536    #[test]
1537    fn test_parse_style_with_modifier() {
1538        let style = parse_style("italic red").unwrap();
1539        assert_eq!(style.foreground_color, Some(Color::Red));
1540        let mut expected_attributes = Attributes::none();
1541        expected_attributes.set(Attribute::Italic);
1542        assert_eq!(style.attributes, expected_attributes);
1543    }
1544
1545    #[test]
1546    fn test_parse_style_multiple_modifier() {
1547        let style = parse_style("underline dim dark red").unwrap();
1548        assert_eq!(style.foreground_color, Some(Color::DarkRed));
1549        let mut expected_attributes = Attributes::none();
1550        expected_attributes.set(Attribute::Underlined);
1551        expected_attributes.set(Attribute::Dim);
1552        assert_eq!(style.attributes, expected_attributes);
1553    }
1554}