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    #[serde(deserialize_with = "deserialize_catalog_with_defaults")]
303    pub catalog: BTreeMap<String, AiModelConfig>,
304}
305
306/// Configuration for the prompts
307#[derive(Clone, Deserialize)]
308#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
309#[cfg_attr(not(test), serde(default))]
310pub struct AiPromptsConfig {
311    /// The prompt to use when generating command suggestions from natural language.
312    pub suggest: String,
313    /// The prompt to use when explaining the fix for a failed command.
314    pub fix: String,
315    /// The prompt to use when importing commands (e.g., from a natural language page).
316    pub import: String,
317    /// The prompt used to generate a command for a dynamic completion.
318    pub completion: String,
319}
320
321/// Configuration for the models to be used
322#[derive(Clone, Deserialize)]
323#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
324#[cfg_attr(not(test), serde(default))]
325pub struct AiModelsConfig {
326    /// The alias of the AI model to use for generating command suggestions from natural language.
327    /// This alias must correspond to a key in the `catalog` map.
328    pub suggest: String,
329    /// The alias of the AI model used to explain the fix for a failed command.
330    /// This alias must correspond to a key in the `catalog` map.
331    pub fix: String,
332    /// The alias of the AI model to use when importing commands (e.g., from a natural language page).
333    /// This alias must correspond to a key in the `catalog` map.
334    pub import: String,
335    /// The alias of the AI model to use when suggesting variable completion commands
336    /// This alias must correspond to a key in the `catalog` map.
337    pub completion: String,
338    /// The alias of a model to use as a fallback when the primary model for a task fails due to rate limiting.
339    /// This alias must correspond to a key in the `catalog` map.
340    pub fallback: String,
341}
342
343/// Represents the configuration for a specific AI model, distinguished by the provider
344#[derive(Clone, Deserialize)]
345#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
346#[serde(tag = "provider", rename_all = "snake_case")]
347pub enum AiModelConfig {
348    /// Configuration for OpenAI or compatible APIs
349    Openai(OpenAiModelConfig),
350    /// Configuration for Google Gemini API
351    Gemini(GeminiModelConfig),
352    /// Configuration for Anthropic API
353    Anthropic(AnthropicModelConfig),
354    /// Configuration for models served via Ollama
355    Ollama(OllamaModelConfig),
356}
357
358/// Configuration for connecting to an OpenAI or a compatible API
359#[derive(Clone, Deserialize)]
360#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
361pub struct OpenAiModelConfig {
362    /// The exact model identifier to use (e.g., "gpt-4o", "gpt-3.5-turbo")
363    pub model: String,
364    /// The base URL of the API endpoint. Defaults to the official OpenAI API.
365    ///
366    /// Can be overridden to use other compatible services (e.g., Azure OpenAI, LiteLLM).
367    #[serde(default = "default_openai_url")]
368    pub url: String,
369    /// The name of the environment variable containing the API key for this model. Defaults to `OPENAI_API_KEY`.
370    #[serde(default = "default_openai_api_key_env")]
371    pub api_key_env: String,
372}
373fn default_openai_url() -> String {
374    "https://api.openai.com/v1".to_string()
375}
376fn default_openai_api_key_env() -> String {
377    "OPENAI_API_KEY".to_string()
378}
379
380/// Configuration for connecting to the Google Gemini API
381#[derive(Clone, Deserialize)]
382#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
383pub struct GeminiModelConfig {
384    /// The exact model identifier to use (e.g., "gemini-2.5-flash-lite")
385    pub model: String,
386    /// The base URL of the API endpoint. Defaults to the official Google Gemini API.
387    #[serde(default = "default_gemini_url")]
388    pub url: String,
389    /// The name of the environment variable containing the API key for this model. Defaults to `GEMINI_API_KEY`.
390    #[serde(default = "default_gemini_api_key_env")]
391    pub api_key_env: String,
392}
393fn default_gemini_url() -> String {
394    "https://generativelanguage.googleapis.com/v1beta".to_string()
395}
396fn default_gemini_api_key_env() -> String {
397    "GEMINI_API_KEY".to_string()
398}
399
400/// Configuration for connecting to the Anthropic API
401#[derive(Clone, Deserialize)]
402#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
403pub struct AnthropicModelConfig {
404    /// The exact model identifier to use (e.g., "claude-sonnet-4-0")
405    pub model: String,
406    /// The base URL of the API endpoint. Defaults to the official Anthropic API
407    #[serde(default = "default_anthropic_url")]
408    pub url: String,
409    /// The name of the environment variable containing the API key for this model. Defaults to `ANTHROPIC_API_KEY`.
410    #[serde(default = "default_anthropic_api_key_env")]
411    pub api_key_env: String,
412}
413fn default_anthropic_url() -> String {
414    "https://api.anthropic.com/v1".to_string()
415}
416fn default_anthropic_api_key_env() -> String {
417    "ANTHROPIC_API_KEY".to_string()
418}
419
420/// Configuration for connecting to a local or remote Ollama instance
421#[derive(Clone, Deserialize)]
422#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
423pub struct OllamaModelConfig {
424    /// The model name as configured in Ollama (e.g., "llama3", "mistral")
425    pub model: String,
426    /// The base URL of the Ollama server. Defaults to the standard local address.
427    #[serde(default = "default_ollama_url")]
428    pub url: String,
429    /// The name of the environment variable containing the API key for this model. Defaults to `OLLAMA_API_KEY`.
430    #[serde(default = "default_ollama_api_key_env")]
431    pub api_key_env: String,
432}
433fn default_ollama_url() -> String {
434    "http://localhost:11434".to_string()
435}
436fn default_ollama_api_key_env() -> String {
437    "OLLAMA_API_KEY".to_string()
438}
439
440impl Config {
441    /// Initializes the application configuration.
442    ///
443    /// Attempts to load the configuration from the user's config directory (`config.toml`). If the file does not exist
444    /// or has missing fields, it falls back to default values.
445    pub fn init(config_file: Option<PathBuf>) -> Result<Self> {
446        // Initialize directories
447        let proj_dirs = ProjectDirs::from("org", "IntelliShell", "Intelli-Shell")
448            .wrap_err("Couldn't initialize project directory")?;
449        let config_dir = proj_dirs.config_dir().to_path_buf();
450
451        // Initialize the config
452        let config_path = config_file.unwrap_or_else(|| config_dir.join("config.toml"));
453        let mut config = if config_path.exists() {
454            // Read from the config file, if found
455            let config_str = fs::read_to_string(&config_path)
456                .wrap_err_with(|| format!("Couldn't read config file {}", config_path.display()))?;
457            toml::from_str(&config_str)
458                .wrap_err_with(|| format!("Couldn't parse config file {}", config_path.display()))?
459        } else {
460            // Use default values if not found
461            Config::default()
462        };
463        // If no data dir is provided, use the default
464        if config.data_dir.as_os_str().is_empty() {
465            config.data_dir = proj_dirs.data_dir().to_path_buf();
466        }
467
468        // Validate there are no conflicts on the key bindings
469        let conflicts = config.keybindings.find_conflicts();
470        if !conflicts.is_empty() {
471            return Err(eyre!(
472                "Couldn't parse config file {}\n\nThere are some key binding conflicts:\n{}",
473                config_path.display(),
474                conflicts
475                    .into_iter()
476                    .map(|(_, a)| format!("- {}", a.into_iter().map(|a| format!("{a:?}")).join(", ")))
477                    .join("\n")
478            ));
479        }
480
481        // Validate AI models are properly setup
482        if config.ai.enabled {
483            let AiModelsConfig {
484                suggest,
485                fix,
486                import,
487                completion,
488                fallback,
489            } = &config.ai.models;
490            let catalog = &config.ai.catalog;
491
492            let mut missing = Vec::new();
493            if !catalog.contains_key(suggest) {
494                missing.push((suggest, "suggest"));
495            }
496            if !catalog.contains_key(fix) {
497                missing.push((fix, "fix"));
498            }
499            if !catalog.contains_key(import) {
500                missing.push((import, "import"));
501            }
502            if !catalog.contains_key(completion) {
503                missing.push((completion, "completion"));
504            }
505            if !catalog.contains_key(fallback) {
506                missing.push((fallback, "fallback"));
507            }
508
509            if !missing.is_empty() {
510                return Err(eyre!(
511                    "Couldn't parse config file {}\n\nMissing model definitions on the catalog:\n{}",
512                    config_path.display(),
513                    missing
514                        .into_iter()
515                        .into_group_map()
516                        .into_iter()
517                        .map(|(k, v)| format!(
518                            "- {k} used in {}",
519                            v.into_iter().map(|v| format!("ai.models.{v}")).join(", ")
520                        ))
521                        .join("\n")
522                ));
523            }
524        }
525
526        // Create the data directory if not found
527        fs::create_dir_all(&config.data_dir)
528            .wrap_err_with(|| format!("Could't create data dir {}", config.data_dir.display()))?;
529
530        Ok(config)
531    }
532}
533
534impl KeyBindingsConfig {
535    /// Retrieves the [KeyBinding] for a specific action
536    pub fn get(&self, action: &KeyBindingAction) -> &KeyBinding {
537        self.0.get(action).unwrap()
538    }
539
540    /// Finds the [KeyBindingAction] associated with the given [KeyEvent], if any
541    pub fn get_action_matching(&self, event: &KeyEvent) -> Option<KeyBindingAction> {
542        self.0.iter().find_map(
543            |(action, binding)| {
544                if binding.matches(event) { Some(*action) } else { None }
545            },
546        )
547    }
548
549    /// Finds all ambiguous key bindings where a single `KeyEvent` maps to multiple `KeyBindingAction`s
550    pub fn find_conflicts(&self) -> Vec<(KeyEvent, Vec<KeyBindingAction>)> {
551        // A map to store each KeyEvent and the list of actions it's bound to.
552        let mut event_to_actions_map: HashMap<KeyEvent, Vec<KeyBindingAction>> = HashMap::new();
553
554        // Iterate over all configured actions and their bindings.
555        for (action, key_binding) in self.0.iter() {
556            // For each KeyEvent defined within the current KeyBinding...
557            for event_in_binding in key_binding.0.iter() {
558                // Record that this event maps to the current action.
559                event_to_actions_map.entry(*event_in_binding).or_default().push(*action);
560            }
561        }
562
563        // Filter the map to find KeyEvents that map to more than one action.
564        event_to_actions_map
565            .into_iter()
566            .filter_map(|(key_event, actions)| {
567                if actions.len() > 1 {
568                    Some((key_event, actions))
569                } else {
570                    None
571                }
572            })
573            .collect()
574    }
575}
576
577impl KeyBinding {
578    /// Checks if a given `KeyEvent` matches any of the key events configured for this key binding, considering only the
579    /// key `code` and its `modifiers`.
580    pub fn matches(&self, event: &KeyEvent) -> bool {
581        self.0
582            .iter()
583            .any(|e| e.code == event.code && e.modifiers == event.modifiers)
584    }
585}
586
587impl Theme {
588    /// Primary style applied when an item is highlighted, including the background color
589    pub fn highlight_primary_full(&self) -> ContentStyle {
590        if let Some(color) = self.highlight {
591            let mut ret = self.highlight_primary;
592            ret.background_color = Some(color);
593            ret
594        } else {
595            self.highlight_primary
596        }
597    }
598
599    /// Secondary style applied when an item is highlighted, including the background color
600    pub fn highlight_secondary_full(&self) -> ContentStyle {
601        if let Some(color) = self.highlight {
602            let mut ret = self.highlight_secondary;
603            ret.background_color = Some(color);
604            ret
605        } else {
606            self.highlight_secondary
607        }
608    }
609
610    /// Accent style applied when an item is highlighted, including the background color
611    pub fn highlight_accent_full(&self) -> ContentStyle {
612        if let Some(color) = self.highlight {
613            let mut ret = self.highlight_accent;
614            ret.background_color = Some(color);
615            ret
616        } else {
617            self.highlight_accent
618        }
619    }
620
621    /// Comments style applied when an item is highlighted, including the background color
622    pub fn highlight_comment_full(&self) -> ContentStyle {
623        if let Some(color) = self.highlight {
624            let mut ret = self.highlight_comment;
625            ret.background_color = Some(color);
626            ret
627        } else {
628            self.highlight_comment
629        }
630    }
631}
632
633impl AiConfig {
634    /// Retrieves a client configured for the `suggest` action
635    pub fn suggest_client(&self) -> crate::errors::Result<AiClient<'_>> {
636        AiClient::new(
637            &self.models.suggest,
638            self.catalog.get(&self.models.suggest).unwrap(),
639            &self.models.fallback,
640            self.catalog.get(&self.models.fallback),
641        )
642    }
643
644    /// Retrieves a client configured for the `fix` action
645    pub fn fix_client(&self) -> crate::errors::Result<AiClient<'_>> {
646        AiClient::new(
647            &self.models.fix,
648            self.catalog.get(&self.models.fix).unwrap(),
649            &self.models.fallback,
650            self.catalog.get(&self.models.fallback),
651        )
652    }
653
654    /// Retrieves a client configured for the `import` action
655    pub fn import_client(&self) -> crate::errors::Result<AiClient<'_>> {
656        AiClient::new(
657            &self.models.import,
658            self.catalog.get(&self.models.import).unwrap(),
659            &self.models.fallback,
660            self.catalog.get(&self.models.fallback),
661        )
662    }
663
664    /// Retrieves a client configured for the `completion` action
665    pub fn completion_client(&self) -> crate::errors::Result<AiClient<'_>> {
666        AiClient::new(
667            &self.models.completion,
668            self.catalog.get(&self.models.completion).unwrap(),
669            &self.models.fallback,
670            self.catalog.get(&self.models.fallback),
671        )
672    }
673}
674impl AiModelConfig {
675    pub fn provider(&self) -> &dyn AiProviderBase {
676        match self {
677            AiModelConfig::Openai(conf) => conf,
678            AiModelConfig::Gemini(conf) => conf,
679            AiModelConfig::Anthropic(conf) => conf,
680            AiModelConfig::Ollama(conf) => conf,
681        }
682    }
683}
684
685impl Default for Config {
686    fn default() -> Self {
687        Self {
688            data_dir: PathBuf::new(),
689            check_updates: true,
690            inline: true,
691            search: SearchConfig::default(),
692            logs: LogsConfig::default(),
693            keybindings: KeyBindingsConfig::default(),
694            theme: Theme::default(),
695            gist: GistConfig::default(),
696            tuning: SearchTuning::default(),
697            ai: AiConfig::default(),
698        }
699    }
700}
701impl Default for SearchConfig {
702    fn default() -> Self {
703        Self {
704            delay: 250,
705            mode: SearchMode::Auto,
706            user_only: false,
707            exec_on_alias_match: false,
708        }
709    }
710}
711impl Default for LogsConfig {
712    fn default() -> Self {
713        Self {
714            enabled: false,
715            filter: String::from("info"),
716        }
717    }
718}
719impl Default for KeyBindingsConfig {
720    fn default() -> Self {
721        Self(BTreeMap::from([
722            (KeyBindingAction::Quit, KeyBinding(vec![KeyEvent::from(KeyCode::Esc)])),
723            (
724                KeyBindingAction::Update,
725                KeyBinding(vec![
726                    KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
727                    KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
728                    KeyEvent::from(KeyCode::F(2)),
729                ]),
730            ),
731            (
732                KeyBindingAction::Delete,
733                KeyBinding(vec![KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)]),
734            ),
735            (
736                KeyBindingAction::Confirm,
737                KeyBinding(vec![KeyEvent::from(KeyCode::Tab), KeyEvent::from(KeyCode::Enter)]),
738            ),
739            (
740                KeyBindingAction::Execute,
741                KeyBinding(vec![
742                    KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
743                    KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
744                ]),
745            ),
746            (
747                KeyBindingAction::AI,
748                KeyBinding(vec![
749                    KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL),
750                    KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
751                ]),
752            ),
753            (
754                KeyBindingAction::SearchMode,
755                KeyBinding(vec![KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)]),
756            ),
757            (
758                KeyBindingAction::SearchUserOnly,
759                KeyBinding(vec![KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)]),
760            ),
761        ]))
762    }
763}
764impl Default for Theme {
765    fn default() -> Self {
766        let primary = ContentStyle::new();
767        let highlight_primary = primary;
768
769        let mut secondary = ContentStyle::new();
770        secondary.attributes.set(Attribute::Dim);
771        let highlight_secondary = ContentStyle::new();
772
773        let mut accent = ContentStyle::new();
774        accent.foreground_color = Some(Color::Yellow);
775        let highlight_accent = accent;
776
777        let mut comment = ContentStyle::new();
778        comment.foreground_color = Some(Color::Green);
779        comment.attributes.set(Attribute::Italic);
780        let highlight_comment = comment;
781
782        let mut error = ContentStyle::new();
783        error.foreground_color = Some(Color::DarkRed);
784
785        Self {
786            primary,
787            secondary,
788            accent,
789            comment,
790            error,
791            highlight: Some(Color::DarkGrey),
792            highlight_symbol: String::from("» "),
793            highlight_primary,
794            highlight_secondary,
795            highlight_accent,
796            highlight_comment,
797        }
798    }
799}
800impl Default for SearchCommandsTextTuning {
801    fn default() -> Self {
802        Self {
803            points: 600,
804            command: 2.0,
805            description: 1.0,
806            auto: SearchCommandsTextAutoTuning::default(),
807        }
808    }
809}
810impl Default for SearchCommandsTextAutoTuning {
811    fn default() -> Self {
812        Self {
813            prefix: 1.5,
814            fuzzy: 1.0,
815            relaxed: 0.5,
816            root: 2.0,
817        }
818    }
819}
820impl Default for SearchUsageTuning {
821    fn default() -> Self {
822        Self { points: 100 }
823    }
824}
825impl Default for SearchPathTuning {
826    fn default() -> Self {
827        Self {
828            points: 300,
829            exact: 1.0,
830            ancestor: 0.5,
831            descendant: 0.25,
832            unrelated: 0.1,
833        }
834    }
835}
836impl Default for SearchVariableCompletionTuning {
837    fn default() -> Self {
838        Self { points: 200 }
839    }
840}
841impl Default for SearchVariableContextTuning {
842    fn default() -> Self {
843        Self { points: 700 }
844    }
845}
846fn default_ai_catalog() -> BTreeMap<String, AiModelConfig> {
847    BTreeMap::from([
848        (
849            "main".to_string(),
850            AiModelConfig::Gemini(GeminiModelConfig {
851                model: "gemini-2.5-flash".to_string(),
852                url: default_gemini_url(),
853                api_key_env: default_gemini_api_key_env(),
854            }),
855        ),
856        (
857            "fallback".to_string(),
858            AiModelConfig::Gemini(GeminiModelConfig {
859                model: "gemini-2.0-flash-lite".to_string(),
860                url: default_gemini_url(),
861                api_key_env: default_gemini_api_key_env(),
862            }),
863        ),
864    ])
865}
866impl Default for AiConfig {
867    fn default() -> Self {
868        Self {
869            enabled: false,
870            models: AiModelsConfig::default(),
871            prompts: AiPromptsConfig::default(),
872            catalog: default_ai_catalog(),
873        }
874    }
875}
876impl Default for AiModelsConfig {
877    fn default() -> Self {
878        Self {
879            suggest: "main".to_string(),
880            fix: "main".to_string(),
881            import: "main".to_string(),
882            completion: "main".to_string(),
883            fallback: "fallback".to_string(),
884        }
885    }
886}
887impl Default for AiPromptsConfig {
888    fn default() -> Self {
889        Self {
890            suggest: String::from(
891                r#"##OS_SHELL_INFO##
892##WORKING_DIR##
893### Instructions
894You are an expert CLI assistant. Your task is to generate shell command templates based on the user's request.
895
896Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
897
898### Command Template Syntax
899When creating the `command` template string, you must use the following placeholder syntax:
900
901- **Standard Placeholder**: `{{variable-name}}`
902  - Use for regular arguments that the user needs to provide.
903  - _Example_: `echo "Hello, {{user-name}}!"`
904
905- **Choice Placeholder**: `{{option1|option2}}`
906  - Use when the user must choose from a specific set of options.
907  - _Example_: `git reset {{--soft|--hard}} HEAD~1`
908
909- **Function Placeholder**: `{{variable:function}}`
910  - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
911  - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
912  - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
913
914- **Secret/Ephemeral Placeholder**: `{{{...}}}`
915  - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description). 
916    This syntax can wrap any of the placeholder types above.
917  - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
918
919### Suggestion Strategy
920Your 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:
921
9221. **Explicit Single Suggestion:**
923   - If the user's request explicitly asks for **a single suggestion**, you **MUST** return a list containing exactly one suggestion object.
924   - To cover variations within this single command, make effective use of choice placeholders (e.g., `git reset {{--soft|--hard}}`).
925
9262. **Clear & Unambiguous Request:**
927   - If the request is straightforward and has one primary, standard solution, provide a **single, well-formed suggestion**.
928
9293. **Ambiguous or Multi-faceted Request:**
930   - 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**.
931   - Each distinct approach or interpretation **must be a separate suggestion object**.
932   - **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.
933   - **Order the suggestions by relevance**, with the most common or recommended solution appearing first.
934"#,
935            ),
936            fix: String::from(
937                r#"##OS_SHELL_INFO##
938##WORKING_DIR##
939##SHELL_HISTORY##
940### Instructions
941You are an expert command-line assistant. Your mission is to analyze a failed shell command and its error output, 
942diagnose the root cause, and provide a structured, actionable solution in a single JSON object.
943
944### Output Schema
945Your response MUST be a single, valid JSON object with no surrounding text or markdown. It must conform to the following structure:
946- `summary`: A very brief, 2-5 word summary of the error category. Examples: "Command Not Found", "Permission Denied", "Invalid Argument", "Git Typo".
947- `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.
948- `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.
949- `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.
950
951### Core Rules
9521. **JSON Only**: Your entire output must be a single, raw JSON object. Do not wrap it in code blocks or add any explanatory text.
9532. **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.
9543. **Strict Wrapping**: Hard-wrap all string values within the JSON to a maximum of 80 characters.
9554. **`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.
956"#,
957            ),
958            import: String::from(
959                r#"### Instructions
960You 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.
961
962Your entire response MUST be a single, valid JSON object conforming to the provided schema. Output nothing but the JSON object itself.
963
964Refer to the syntax definitions, process, and example below to construct your response.
965
966### Command Template Syntax
967When creating the `command` template string, you must use the following placeholder syntax:
968
969- **Standard Placeholder**: `{{variable-name}}`
970  - Use for regular arguments that the user needs to provide.
971  - _Example_: `echo "Hello, {{user-name}}!"`
972
973- **Choice Placeholder**: `{{option1|option2}}`
974  - Use when the user must choose from a specific set of options.
975  - _Example_: `git reset {{--soft|--hard}} HEAD~1`
976
977- **Function Placeholder**: `{{variable:function}}`
978  - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
979  - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
980  - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
981
982- **Secret/Ephemeral Placeholder**: `{{{...}}}`
983  - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description). 
984    This syntax can wrap any of the placeholder types above.
985  - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
986
987### Core Process
9881. **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.
9892. **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.
990
991### Output Generation
992For each unique and deduplicated command pattern you identify:
993- Create a suggestion object containing a `description` and a `command`.
994- The `description` must be a clear, single-sentence explanation of the command's purpose.
995- The `command` must be the final, generalized template string from the core process.
996"#,
997            ),
998            completion: String::from(
999                r#"##OS_SHELL_INFO##
1000### Instructions
1001You 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.
1002
1003Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
1004
1005### Core Task
1006The 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").
1007
1008### Command Template Syntax
1009To 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 `{{...}}`.
1010
1011- **Syntax**: `{{--parameter {{variable-name}}}}`
1012- **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.
1013- **All-or-Nothing**: If a block contains multiple variables, all of them must be present in the context for the block to be included.
1014
1015- **_Example_**:
1016  - **Template**: `kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}`
1017  - If the context provides a `namespace`, the executed command becomes: `kubectl get pods -n prod`
1018  - If the context provides both `namespace` and `context`, it becomes: `kubectl get pods --context my-cluster -n prod`
1019  - If the context is empty, it is simply: `kubectl get pods`
1020
1021### Requirements
10221. **JSON Only**: Your entire output must be a single, raw JSON object. Do not add any explanatory text.
10232. **Context is Key**: Every variable like `{{variable-name}}` must be part of a surrounding conditional block `{{...}}`. The command cannot ask for new information.
10243. **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.
10254. **Executable**: The command must be syntactically correct and executable.
1026"#,
1027            ),
1028        }
1029    }
1030}
1031
1032/// Custom deserialization function for the BTreeMap in KeyBindingsConfig.
1033///
1034/// Behavior depends on whether compiled for test or not:
1035/// - In test (`#[cfg(test)]`): Requires all `KeyBindingAction` variants to be present; otherwise, errors. No merging.
1036/// - In non-test (`#[cfg(not(test))]`): Merges user-provided bindings with defaults.
1037fn deserialize_bindings_with_defaults<'de, D>(
1038    deserializer: D,
1039) -> Result<BTreeMap<KeyBindingAction, KeyBinding>, D::Error>
1040where
1041    D: Deserializer<'de>,
1042{
1043    // Deserialize the map as provided in the config.
1044    let user_provided_bindings = BTreeMap::<KeyBindingAction, KeyBinding>::deserialize(deserializer)?;
1045
1046    #[cfg(test)]
1047    {
1048        use strum::IntoEnumIterator;
1049        // In test mode, all actions must be explicitly defined. No defaults are merged.
1050        for action_variant in KeyBindingAction::iter() {
1051            if !user_provided_bindings.contains_key(&action_variant) {
1052                return Err(D::Error::custom(format!(
1053                    "Missing key binding for action '{action_variant:?}'."
1054                )));
1055            }
1056        }
1057        Ok(user_provided_bindings)
1058    }
1059    #[cfg(not(test))]
1060    {
1061        // In non-test (production) mode, merge with defaults.
1062        // User-provided bindings override defaults for the actions they specify.
1063        let mut final_bindings = user_provided_bindings;
1064        let default_bindings = KeyBindingsConfig::default();
1065
1066        for (action, default_binding) in default_bindings.0 {
1067            final_bindings.entry(action).or_insert(default_binding);
1068        }
1069        Ok(final_bindings)
1070    }
1071}
1072
1073/// Deserializes a string or a vector of strings into a `Vec<KeyEvent>`.
1074///
1075/// This allows a key binding to be specified as a single string or a list of strings in the config file.
1076fn deserialize_key_events<'de, D>(deserializer: D) -> Result<Vec<KeyEvent>, D::Error>
1077where
1078    D: Deserializer<'de>,
1079{
1080    #[derive(Deserialize)]
1081    #[serde(untagged)]
1082    enum StringOrVec {
1083        Single(String),
1084        Multiple(Vec<String>),
1085    }
1086
1087    let strings = match StringOrVec::deserialize(deserializer)? {
1088        StringOrVec::Single(s) => vec![s],
1089        StringOrVec::Multiple(v) => v,
1090    };
1091
1092    strings
1093        .iter()
1094        .map(String::as_str)
1095        .map(parse_key_event)
1096        .map(|r| r.map_err(D::Error::custom))
1097        .collect()
1098}
1099
1100/// Deserializes a string into an optional [`Color`].
1101///
1102/// Supports color names, RGB (e.g., `rgb(255, 0, 100)`), hex (e.g., `#ff0064`), indexed colors (e.g., `6`), and "none"
1103/// for no color.
1104fn deserialize_color<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
1105where
1106    D: Deserializer<'de>,
1107{
1108    parse_color(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1109}
1110
1111/// Deserializes a string into a [`ContentStyle`].
1112///
1113/// Supports color names and modifiers (e.g., "red", "bold", "italic blue", "underline dim green").
1114fn deserialize_style<'de, D>(deserializer: D) -> Result<ContentStyle, D::Error>
1115where
1116    D: Deserializer<'de>,
1117{
1118    parse_style(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1119}
1120
1121/// Parses a string representation of a key event into a [`KeyEvent`].
1122///
1123/// Supports modifiers like `ctrl-`, `alt-`, `shift-` and standard key names/characters.
1124fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
1125    let raw_lower = raw.to_ascii_lowercase();
1126    let (remaining, modifiers) = extract_key_modifiers(&raw_lower);
1127    parse_key_code_with_modifiers(remaining, modifiers)
1128}
1129
1130/// Extracts key modifiers (ctrl, shift, alt) from the beginning of a key event string.
1131///
1132/// Returns the remaining string and the parsed modifiers.
1133fn extract_key_modifiers(raw: &str) -> (&str, KeyModifiers) {
1134    let mut modifiers = KeyModifiers::empty();
1135    let mut current = raw;
1136
1137    loop {
1138        match current {
1139            rest if rest.starts_with("ctrl-") || rest.starts_with("ctrl+") => {
1140                modifiers.insert(KeyModifiers::CONTROL);
1141                current = &rest[5..];
1142            }
1143            rest if rest.starts_with("shift-") || rest.starts_with("shift+") => {
1144                modifiers.insert(KeyModifiers::SHIFT);
1145                current = &rest[6..];
1146            }
1147            rest if rest.starts_with("alt-") || rest.starts_with("alt+") => {
1148                modifiers.insert(KeyModifiers::ALT);
1149                current = &rest[4..];
1150            }
1151            _ => break,
1152        };
1153    }
1154
1155    (current, modifiers)
1156}
1157
1158/// Parses the remaining string after extracting modifiers into a [`KeyCode`]
1159fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
1160    let code = match raw {
1161        "esc" => KeyCode::Esc,
1162        "enter" => KeyCode::Enter,
1163        "left" => KeyCode::Left,
1164        "right" => KeyCode::Right,
1165        "up" => KeyCode::Up,
1166        "down" => KeyCode::Down,
1167        "home" => KeyCode::Home,
1168        "end" => KeyCode::End,
1169        "pageup" => KeyCode::PageUp,
1170        "pagedown" => KeyCode::PageDown,
1171        "backtab" => {
1172            modifiers.insert(KeyModifiers::SHIFT);
1173            KeyCode::BackTab
1174        }
1175        "backspace" => KeyCode::Backspace,
1176        "delete" => KeyCode::Delete,
1177        "insert" => KeyCode::Insert,
1178        "f1" => KeyCode::F(1),
1179        "f2" => KeyCode::F(2),
1180        "f3" => KeyCode::F(3),
1181        "f4" => KeyCode::F(4),
1182        "f5" => KeyCode::F(5),
1183        "f6" => KeyCode::F(6),
1184        "f7" => KeyCode::F(7),
1185        "f8" => KeyCode::F(8),
1186        "f9" => KeyCode::F(9),
1187        "f10" => KeyCode::F(10),
1188        "f11" => KeyCode::F(11),
1189        "f12" => KeyCode::F(12),
1190        "space" | "spacebar" => KeyCode::Char(' '),
1191        "hyphen" => KeyCode::Char('-'),
1192        "minus" => KeyCode::Char('-'),
1193        "tab" => KeyCode::Tab,
1194        c if c.len() == 1 => {
1195            let mut c = c.chars().next().expect("just checked");
1196            if modifiers.contains(KeyModifiers::SHIFT) {
1197                c = c.to_ascii_uppercase();
1198            }
1199            KeyCode::Char(c)
1200        }
1201        _ => return Err(format!("Unable to parse key binding: {raw}")),
1202    };
1203    Ok(KeyEvent::new(code, modifiers))
1204}
1205
1206/// Parses a string into an optional [`Color`].
1207///
1208/// Handles named colors, RGB, hex, indexed colors, and "none".
1209fn parse_color(raw: &str) -> Result<Option<Color>, String> {
1210    let raw_lower = raw.to_ascii_lowercase();
1211    if raw.is_empty() || raw == "none" {
1212        Ok(None)
1213    } else {
1214        Ok(Some(parse_color_inner(&raw_lower)?))
1215    }
1216}
1217
1218/// Parses a string into a [`ContentStyle`], including attributes and foreground color.
1219///
1220/// Examples: "red", "bold", "italic blue", "underline dim green".
1221fn parse_style(raw: &str) -> Result<ContentStyle, String> {
1222    let raw_lower = raw.to_ascii_lowercase();
1223    let (remaining, attributes) = extract_style_attributes(&raw_lower);
1224    let mut style = ContentStyle::new();
1225    style.attributes = attributes;
1226    if !remaining.is_empty() && remaining != "default" {
1227        style.foreground_color = Some(parse_color_inner(remaining)?);
1228    }
1229    Ok(style)
1230}
1231
1232/// Extracts style attributes (bold, dim, italic, underline) from the beginning of a style string.
1233///
1234/// Returns the remaining string and the parsed attributes.
1235fn extract_style_attributes(raw: &str) -> (&str, Attributes) {
1236    let mut attributes = Attributes::none();
1237    let mut current = raw;
1238
1239    loop {
1240        match current {
1241            rest if rest.starts_with("bold") => {
1242                attributes.set(Attribute::Bold);
1243                current = &rest[4..];
1244                if current.starts_with(' ') {
1245                    current = &current[1..];
1246                }
1247            }
1248            rest if rest.starts_with("dim") => {
1249                attributes.set(Attribute::Dim);
1250                current = &rest[3..];
1251                if current.starts_with(' ') {
1252                    current = &current[1..];
1253                }
1254            }
1255            rest if rest.starts_with("italic") => {
1256                attributes.set(Attribute::Italic);
1257                current = &rest[6..];
1258                if current.starts_with(' ') {
1259                    current = &current[1..];
1260                }
1261            }
1262            rest if rest.starts_with("underline") => {
1263                attributes.set(Attribute::Underlined);
1264                current = &rest[9..];
1265                if current.starts_with(' ') {
1266                    current = &current[1..];
1267                }
1268            }
1269            rest if rest.starts_with("underlined") => {
1270                attributes.set(Attribute::Underlined);
1271                current = &rest[10..];
1272                if current.starts_with(' ') {
1273                    current = &current[1..];
1274                }
1275            }
1276            _ => break,
1277        };
1278    }
1279
1280    (current.trim(), attributes)
1281}
1282
1283/// Parses the color part of a style string.
1284///
1285/// Handles named colors, rgb, hex, and ansi values.
1286fn parse_color_inner(raw: &str) -> Result<Color, String> {
1287    Ok(match raw {
1288        "black" => Color::Black,
1289        "red" => Color::Red,
1290        "green" => Color::Green,
1291        "yellow" => Color::Yellow,
1292        "blue" => Color::Blue,
1293        "magenta" => Color::Magenta,
1294        "cyan" => Color::Cyan,
1295        "gray" | "grey" => Color::Grey,
1296        "dark gray" | "darkgray" | "dark grey" | "darkgrey" => Color::DarkGrey,
1297        "dark red" | "darkred" => Color::DarkRed,
1298        "dark green" | "darkgreen" => Color::DarkGreen,
1299        "dark yellow" | "darkyellow" => Color::DarkYellow,
1300        "dark blue" | "darkblue" => Color::DarkBlue,
1301        "dark magenta" | "darkmagenta" => Color::DarkMagenta,
1302        "dark cyan" | "darkcyan" => Color::DarkCyan,
1303        "white" => Color::White,
1304        rgb if rgb.starts_with("rgb(") => {
1305            let rgb = rgb.trim_start_matches("rgb(").trim_end_matches(")").split(',');
1306            let rgb = rgb
1307                .map(|c| c.trim().parse::<u8>())
1308                .collect::<Result<Vec<u8>, _>>()
1309                .map_err(|_| format!("Unable to parse color: {raw}"))?;
1310            if rgb.len() != 3 {
1311                return Err(format!("Unable to parse color: {raw}"));
1312            }
1313            Color::Rgb {
1314                r: rgb[0],
1315                g: rgb[1],
1316                b: rgb[2],
1317            }
1318        }
1319        hex if hex.starts_with("#") => {
1320            let hex = hex.trim_start_matches("#");
1321            if hex.len() != 6 {
1322                return Err(format!("Unable to parse color: {raw}"));
1323            }
1324            let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1325            let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1326            let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1327            Color::Rgb { r, g, b }
1328        }
1329        c => {
1330            if let Ok(c) = c.parse::<u8>() {
1331                Color::AnsiValue(c)
1332            } else {
1333                return Err(format!("Unable to parse color: {raw}"));
1334            }
1335        }
1336    })
1337}
1338
1339/// Custom deserialization for the AI model catalog that merges user-defined models with default models.
1340///
1341/// User-defined models in the configuration file will override any defaults with the same name.
1342/// Any default models not defined by the user will be added to the final catalog.
1343fn deserialize_catalog_with_defaults<'de, D>(deserializer: D) -> Result<BTreeMap<String, AiModelConfig>, D::Error>
1344where
1345    D: Deserializer<'de>,
1346{
1347    #[allow(unused_mut)]
1348    // Deserialize the map as provided in the user's config
1349    let mut user_catalog = BTreeMap::<String, AiModelConfig>::deserialize(deserializer)?;
1350
1351    // Get the default catalog and merge it in
1352    #[cfg(not(test))]
1353    for (key, default_model) in default_ai_catalog() {
1354        user_catalog.entry(key).or_insert(default_model);
1355    }
1356
1357    Ok(user_catalog)
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362    use pretty_assertions::assert_eq;
1363    use strum::IntoEnumIterator;
1364
1365    use super::*;
1366
1367    #[test]
1368    fn test_default_config() -> Result<()> {
1369        let config_str = fs::read_to_string("default_config.toml").wrap_err("Couldn't read default config file")?;
1370        let config: Config = toml::from_str(&config_str).wrap_err("Couldn't parse default config file")?;
1371
1372        assert_eq!(Config::default(), config);
1373
1374        Ok(())
1375    }
1376
1377    #[test]
1378    fn test_default_keybindings_complete() {
1379        let config = KeyBindingsConfig::default();
1380
1381        for action in KeyBindingAction::iter() {
1382            assert!(
1383                config.0.contains_key(&action),
1384                "Missing default binding for action: {action:?}"
1385            );
1386        }
1387    }
1388
1389    #[test]
1390    fn test_default_keybindings_no_conflicts() {
1391        let config = KeyBindingsConfig::default();
1392
1393        let conflicts = config.find_conflicts();
1394        assert_eq!(conflicts.len(), 0, "Key binding conflicts: {conflicts:?}");
1395    }
1396
1397    #[test]
1398    fn test_keybinding_matches() {
1399        let binding = KeyBinding(vec![
1400            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
1401            KeyEvent::from(KeyCode::Enter),
1402        ]);
1403
1404        // Should match exact events
1405        assert!(binding.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
1406        assert!(binding.matches(&KeyEvent::from(KeyCode::Enter)));
1407
1408        // Should not match events with different modifiers
1409        assert!(!binding.matches(&KeyEvent::new(
1410            KeyCode::Char('a'),
1411            KeyModifiers::CONTROL | KeyModifiers::ALT
1412        )));
1413
1414        // Should not match different key codes
1415        assert!(!binding.matches(&KeyEvent::from(KeyCode::Esc)));
1416    }
1417
1418    #[test]
1419    fn test_simple_keys() {
1420        assert_eq!(
1421            parse_key_event("a").unwrap(),
1422            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
1423        );
1424
1425        assert_eq!(
1426            parse_key_event("enter").unwrap(),
1427            KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
1428        );
1429
1430        assert_eq!(
1431            parse_key_event("esc").unwrap(),
1432            KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
1433        );
1434    }
1435
1436    #[test]
1437    fn test_with_modifiers() {
1438        assert_eq!(
1439            parse_key_event("ctrl-a").unwrap(),
1440            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
1441        );
1442
1443        assert_eq!(
1444            parse_key_event("alt-enter").unwrap(),
1445            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
1446        );
1447
1448        assert_eq!(
1449            parse_key_event("shift-esc").unwrap(),
1450            KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
1451        );
1452    }
1453
1454    #[test]
1455    fn test_multiple_modifiers() {
1456        assert_eq!(
1457            parse_key_event("ctrl-alt-a").unwrap(),
1458            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
1459        );
1460
1461        assert_eq!(
1462            parse_key_event("ctrl-shift-enter").unwrap(),
1463            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
1464        );
1465    }
1466
1467    #[test]
1468    fn test_invalid_keys() {
1469        let res = parse_key_event("invalid-key");
1470        assert_eq!(res, Err(String::from("Unable to parse key binding: invalid-key")));
1471    }
1472
1473    #[test]
1474    fn test_parse_color_none() {
1475        let color = parse_color("none").unwrap();
1476        assert_eq!(color, None);
1477    }
1478
1479    #[test]
1480    fn test_parse_color_simple() {
1481        let color = parse_color("red").unwrap();
1482        assert_eq!(color, Some(Color::Red));
1483    }
1484
1485    #[test]
1486    fn test_parse_color_rgb() {
1487        let color = parse_color("rgb(50, 25, 15)").unwrap();
1488        assert_eq!(color, Some(Color::Rgb { r: 50, g: 25, b: 15 }));
1489    }
1490
1491    #[test]
1492    fn test_parse_color_rgb_out_of_range() {
1493        let res = parse_color("rgb(500, 25, 15)");
1494        assert_eq!(res, Err(String::from("Unable to parse color: rgb(500, 25, 15)")));
1495    }
1496
1497    #[test]
1498    fn test_parse_color_rgb_invalid() {
1499        let res = parse_color("rgb(50, 25, 15, 5)");
1500        assert_eq!(res, Err(String::from("Unable to parse color: rgb(50, 25, 15, 5)")));
1501    }
1502
1503    #[test]
1504    fn test_parse_color_hex() {
1505        let color = parse_color("#4287f5").unwrap();
1506        assert_eq!(color, Some(Color::Rgb { r: 66, g: 135, b: 245 }));
1507    }
1508
1509    #[test]
1510    fn test_parse_color_hex_out_of_range() {
1511        let res = parse_color("#4287fg");
1512        assert_eq!(res, Err(String::from("Unable to parse color: #4287fg")));
1513    }
1514
1515    #[test]
1516    fn test_parse_color_hex_invalid() {
1517        let res = parse_color("#4287f50");
1518        assert_eq!(res, Err(String::from("Unable to parse color: #4287f50")));
1519    }
1520
1521    #[test]
1522    fn test_parse_color_index() {
1523        let color = parse_color("6").unwrap();
1524        assert_eq!(color, Some(Color::AnsiValue(6)));
1525    }
1526
1527    #[test]
1528    fn test_parse_color_fail() {
1529        let res = parse_color("1234");
1530        assert_eq!(res, Err(String::from("Unable to parse color: 1234")));
1531    }
1532
1533    #[test]
1534    fn test_parse_style_empty() {
1535        let style = parse_style("").unwrap();
1536        assert_eq!(style, ContentStyle::new());
1537    }
1538
1539    #[test]
1540    fn test_parse_style_default() {
1541        let style = parse_style("default").unwrap();
1542        assert_eq!(style, ContentStyle::new());
1543    }
1544
1545    #[test]
1546    fn test_parse_style_simple() {
1547        let style = parse_style("red").unwrap();
1548        assert_eq!(style.foreground_color, Some(Color::Red));
1549        assert_eq!(style.attributes, Attributes::none());
1550    }
1551
1552    #[test]
1553    fn test_parse_style_only_modifier() {
1554        let style = parse_style("bold").unwrap();
1555        assert_eq!(style.foreground_color, None);
1556        let mut expected_attributes = Attributes::none();
1557        expected_attributes.set(Attribute::Bold);
1558        assert_eq!(style.attributes, expected_attributes);
1559    }
1560
1561    #[test]
1562    fn test_parse_style_with_modifier() {
1563        let style = parse_style("italic red").unwrap();
1564        assert_eq!(style.foreground_color, Some(Color::Red));
1565        let mut expected_attributes = Attributes::none();
1566        expected_attributes.set(Attribute::Italic);
1567        assert_eq!(style.attributes, expected_attributes);
1568    }
1569
1570    #[test]
1571    fn test_parse_style_multiple_modifier() {
1572        let style = parse_style("underline dim dark red").unwrap();
1573        assert_eq!(style.foreground_color, Some(Color::DarkRed));
1574        let mut expected_attributes = Attributes::none();
1575        expected_attributes.set(Attribute::Underlined);
1576        expected_attributes.set(Attribute::Dim);
1577        assert_eq!(style.attributes, expected_attributes);
1578    }
1579}