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-flash-latest".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-flash-lite-latest".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### Shell Paradigm, Syntax, and Versioning
899**This is the most important instruction.** Shells have fundamentally different syntaxes, data models, and features depending on their family and version. You MUST adhere strictly to these constraints.
900
9011. **Recognize the Shell Paradigm:**
902   - **POSIX / Text-Stream (bash, zsh, fish):** Operate on **text streams**. Use tools like `grep`, `sed`, `awk`.
903   - **Object-Pipeline (PowerShell, Nushell):** Operate on **structured data (objects)**. You MUST use internal commands for filtering/selection. AVOID external text-processing tools.
904   - **Legacy (cmd.exe):** Has unique syntax for loops (`FOR`), variables (`%VAR%`), and filtering (`findstr`).
905
9062. **Generate Idiomatic Code:**
907   - Use the shell's built-in features and standard library.
908   - Follow the shell's naming and style conventions (e.g., `Verb-Noun` in PowerShell).
909   - Leverage the shell's core strengths (e.g., object manipulation in Nushell).
910
9113. **Ensure Syntactic Correctness:**
912   - Pay close attention to variable syntax (`$var`, `$env:VAR`, `$env.VAR`, `%VAR%`).
913   - Use the correct operators and quoting rules for the target shell.
914
9154. **Pay Critical Attention to the Version:**
916   - The shell version is a primary constraint, not a suggestion. This is especially true for shells with rapid development cycles like **Nushell**.
917   - You **MUST** generate commands that are compatible with the user's specified version.
918   - Be aware of **breaking changes**. If a command was renamed, replaced, or deprecated in the user's version, you MUST provide the modern, correct equivalent.
919
920### Command Template Syntax
921When creating the `command` template string, you must use the following placeholder syntax:
922
923- **Standard Placeholder**: `{{variable-name}}`
924  - Use for regular arguments that the user needs to provide.
925  - _Example_: `echo "Hello, {{user-name}}!"`
926
927- **Choice Placeholder**: `{{option1|option2}}`
928  - Use when the user must choose from a specific set of options.
929  - _Example_: `git reset {{--soft|--hard}} HEAD~1`
930
931- **Function Placeholder**: `{{variable:function}}`
932  - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
933  - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
934  - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
935
936- **Secret/Ephemeral Placeholder**: `{{{...}}}`
937  - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description). 
938    This syntax can wrap any of the placeholder types above.
939  - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
940
941### Suggestion Strategy
942Your 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:
943
9441. **Explicit Single Suggestion:**
945   - If the user's request explicitly asks for **a single suggestion**, you **MUST** return a list containing exactly one suggestion object.
946   - To cover variations within this single command, make effective use of choice placeholders (e.g., `git reset {{--soft|--hard}}`).
947
9482. **Clear & Unambiguous Request:**
949   - If the request is straightforward and has one primary, standard solution, provide a **single, well-formed suggestion**.
950
9513. **Ambiguous or Multi-faceted Request:**
952   - 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**.
953   - Each distinct approach or interpretation **must be a separate suggestion object**.
954   - **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.
955   - **Order the suggestions by relevance**, with the most common or recommended solution appearing first.
956"#,
957            ),
958            fix: String::from(
959                r#"##OS_SHELL_INFO##
960##WORKING_DIR##
961##SHELL_HISTORY##
962### Instructions
963You are an expert command-line assistant. Your mission is to analyze a failed shell command and its error output, 
964diagnose the root cause, and provide a structured, actionable solution in a single JSON object.
965
966### Output Schema
967Your response MUST be a single, valid JSON object with no surrounding text or markdown. It must conform to the following structure:
968- `summary`: A very brief, 2-5 word summary of the error category. Examples: "Command Not Found", "Permission Denied", "Invalid Argument", "Git Typo".
969- `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.
970- `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.
971- `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.
972
973### Core Rules
9741. **JSON Only**: Your entire output must be a single, raw JSON object. Do not wrap it in code blocks or add any explanatory text.
9752. **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.
9763. **Strict Wrapping**: Hard-wrap all string values within the JSON to a maximum of 80 characters.
9774. **`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.
978"#,
979            ),
980            import: String::from(
981                r#"### Instructions
982You 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.
983
984Your entire response MUST be a single, valid JSON object conforming to the provided schema. Output nothing but the JSON object itself.
985
986Refer to the syntax definitions, process, and example below to construct your response.
987
988### Command Template Syntax
989When creating the `command` template string, you must use the following placeholder syntax:
990
991- **Standard Placeholder**: `{{variable-name}}`
992  - Use for regular arguments that the user needs to provide.
993  - _Example_: `echo "Hello, {{user-name}}!"`
994
995- **Choice Placeholder**: `{{option1|option2}}`
996  - Use when the user must choose from a specific set of options.
997  - _Example_: `git reset {{--soft|--hard}} HEAD~1`
998
999- **Function Placeholder**: `{{variable:function}}`
1000  - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
1001  - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
1002  - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
1003
1004- **Secret/Ephemeral Placeholder**: `{{{...}}}`
1005  - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description). 
1006    This syntax can wrap any of the placeholder types above.
1007  - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
1008
1009### Core Process
10101. **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.
10112. **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.
1012
1013### Output Generation
1014For each unique and deduplicated command pattern you identify:
1015- Create a suggestion object containing a `description` and a `command`.
1016- The `description` must be a clear, single-sentence explanation of the command's purpose.
1017- The `command` must be the final, generalized template string from the core process.
1018"#,
1019            ),
1020            completion: String::from(
1021                r#"##OS_SHELL_INFO##
1022### Instructions
1023You 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.
1024
1025Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
1026
1027### Core Task
1028The 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").
1029
1030### Command Template Syntax
1031To 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 `{{...}}`.
1032
1033- **Syntax**: `{{--parameter {{variable-name}}}}`
1034- **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.
1035- **All-or-Nothing**: If a block contains multiple variables, all of them must be present in the context for the block to be included.
1036
1037- **_Example_**:
1038  - **Template**: `kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}`
1039  - If the context provides a `namespace`, the executed command becomes: `kubectl get pods -n prod`
1040  - If the context provides both `namespace` and `context`, it becomes: `kubectl get pods --context my-cluster -n prod`
1041  - If the context is empty, it is simply: `kubectl get pods`
1042
1043### Requirements
10441. **JSON Only**: Your entire output must be a single, raw JSON object. Do not add any explanatory text.
10452. **Context is Key**: Every variable like `{{variable-name}}` must be part of a surrounding conditional block `{{...}}`. The command cannot ask for new information.
10463. **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.
10474. **Executable**: The command must be syntactically correct and executable.
1048"#,
1049            ),
1050        }
1051    }
1052}
1053
1054/// Custom deserialization function for the BTreeMap in KeyBindingsConfig.
1055///
1056/// Behavior depends on whether compiled for test or not:
1057/// - In test (`#[cfg(test)]`): Requires all `KeyBindingAction` variants to be present; otherwise, errors. No merging.
1058/// - In non-test (`#[cfg(not(test))]`): Merges user-provided bindings with defaults.
1059fn deserialize_bindings_with_defaults<'de, D>(
1060    deserializer: D,
1061) -> Result<BTreeMap<KeyBindingAction, KeyBinding>, D::Error>
1062where
1063    D: Deserializer<'de>,
1064{
1065    // Deserialize the map as provided in the config.
1066    let user_provided_bindings = BTreeMap::<KeyBindingAction, KeyBinding>::deserialize(deserializer)?;
1067
1068    #[cfg(test)]
1069    {
1070        use strum::IntoEnumIterator;
1071        // In test mode, all actions must be explicitly defined. No defaults are merged.
1072        for action_variant in KeyBindingAction::iter() {
1073            if !user_provided_bindings.contains_key(&action_variant) {
1074                return Err(D::Error::custom(format!(
1075                    "Missing key binding for action '{action_variant:?}'."
1076                )));
1077            }
1078        }
1079        Ok(user_provided_bindings)
1080    }
1081    #[cfg(not(test))]
1082    {
1083        // In non-test (production) mode, merge with defaults.
1084        // User-provided bindings override defaults for the actions they specify.
1085        let mut final_bindings = user_provided_bindings;
1086        let default_bindings = KeyBindingsConfig::default();
1087
1088        for (action, default_binding) in default_bindings.0 {
1089            final_bindings.entry(action).or_insert(default_binding);
1090        }
1091        Ok(final_bindings)
1092    }
1093}
1094
1095/// Deserializes a string or a vector of strings into a `Vec<KeyEvent>`.
1096///
1097/// This allows a key binding to be specified as a single string or a list of strings in the config file.
1098fn deserialize_key_events<'de, D>(deserializer: D) -> Result<Vec<KeyEvent>, D::Error>
1099where
1100    D: Deserializer<'de>,
1101{
1102    #[derive(Deserialize)]
1103    #[serde(untagged)]
1104    enum StringOrVec {
1105        Single(String),
1106        Multiple(Vec<String>),
1107    }
1108
1109    let strings = match StringOrVec::deserialize(deserializer)? {
1110        StringOrVec::Single(s) => vec![s],
1111        StringOrVec::Multiple(v) => v,
1112    };
1113
1114    strings
1115        .iter()
1116        .map(String::as_str)
1117        .map(parse_key_event)
1118        .map(|r| r.map_err(D::Error::custom))
1119        .collect()
1120}
1121
1122/// Deserializes a string into an optional [`Color`].
1123///
1124/// Supports color names, RGB (e.g., `rgb(255, 0, 100)`), hex (e.g., `#ff0064`), indexed colors (e.g., `6`), and "none"
1125/// for no color.
1126fn deserialize_color<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
1127where
1128    D: Deserializer<'de>,
1129{
1130    parse_color(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1131}
1132
1133/// Deserializes a string into a [`ContentStyle`].
1134///
1135/// Supports color names and modifiers (e.g., "red", "bold", "italic blue", "underline dim green").
1136fn deserialize_style<'de, D>(deserializer: D) -> Result<ContentStyle, D::Error>
1137where
1138    D: Deserializer<'de>,
1139{
1140    parse_style(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1141}
1142
1143/// Parses a string representation of a key event into a [`KeyEvent`].
1144///
1145/// Supports modifiers like `ctrl-`, `alt-`, `shift-` and standard key names/characters.
1146fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
1147    let raw_lower = raw.to_ascii_lowercase();
1148    let (remaining, modifiers) = extract_key_modifiers(&raw_lower);
1149    parse_key_code_with_modifiers(remaining, modifiers)
1150}
1151
1152/// Extracts key modifiers (ctrl, shift, alt) from the beginning of a key event string.
1153///
1154/// Returns the remaining string and the parsed modifiers.
1155fn extract_key_modifiers(raw: &str) -> (&str, KeyModifiers) {
1156    let mut modifiers = KeyModifiers::empty();
1157    let mut current = raw;
1158
1159    loop {
1160        match current {
1161            rest if rest.starts_with("ctrl-") || rest.starts_with("ctrl+") => {
1162                modifiers.insert(KeyModifiers::CONTROL);
1163                current = &rest[5..];
1164            }
1165            rest if rest.starts_with("shift-") || rest.starts_with("shift+") => {
1166                modifiers.insert(KeyModifiers::SHIFT);
1167                current = &rest[6..];
1168            }
1169            rest if rest.starts_with("alt-") || rest.starts_with("alt+") => {
1170                modifiers.insert(KeyModifiers::ALT);
1171                current = &rest[4..];
1172            }
1173            _ => break,
1174        };
1175    }
1176
1177    (current, modifiers)
1178}
1179
1180/// Parses the remaining string after extracting modifiers into a [`KeyCode`]
1181fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
1182    let code = match raw {
1183        "esc" => KeyCode::Esc,
1184        "enter" => KeyCode::Enter,
1185        "left" => KeyCode::Left,
1186        "right" => KeyCode::Right,
1187        "up" => KeyCode::Up,
1188        "down" => KeyCode::Down,
1189        "home" => KeyCode::Home,
1190        "end" => KeyCode::End,
1191        "pageup" => KeyCode::PageUp,
1192        "pagedown" => KeyCode::PageDown,
1193        "backtab" => {
1194            modifiers.insert(KeyModifiers::SHIFT);
1195            KeyCode::BackTab
1196        }
1197        "backspace" => KeyCode::Backspace,
1198        "delete" => KeyCode::Delete,
1199        "insert" => KeyCode::Insert,
1200        "f1" => KeyCode::F(1),
1201        "f2" => KeyCode::F(2),
1202        "f3" => KeyCode::F(3),
1203        "f4" => KeyCode::F(4),
1204        "f5" => KeyCode::F(5),
1205        "f6" => KeyCode::F(6),
1206        "f7" => KeyCode::F(7),
1207        "f8" => KeyCode::F(8),
1208        "f9" => KeyCode::F(9),
1209        "f10" => KeyCode::F(10),
1210        "f11" => KeyCode::F(11),
1211        "f12" => KeyCode::F(12),
1212        "space" | "spacebar" => KeyCode::Char(' '),
1213        "hyphen" => KeyCode::Char('-'),
1214        "minus" => KeyCode::Char('-'),
1215        "tab" => KeyCode::Tab,
1216        c if c.len() == 1 => {
1217            let mut c = c.chars().next().expect("just checked");
1218            if modifiers.contains(KeyModifiers::SHIFT) {
1219                c = c.to_ascii_uppercase();
1220            }
1221            KeyCode::Char(c)
1222        }
1223        _ => return Err(format!("Unable to parse key binding: {raw}")),
1224    };
1225    Ok(KeyEvent::new(code, modifiers))
1226}
1227
1228/// Parses a string into an optional [`Color`].
1229///
1230/// Handles named colors, RGB, hex, indexed colors, and "none".
1231fn parse_color(raw: &str) -> Result<Option<Color>, String> {
1232    let raw_lower = raw.to_ascii_lowercase();
1233    if raw.is_empty() || raw == "none" {
1234        Ok(None)
1235    } else {
1236        Ok(Some(parse_color_inner(&raw_lower)?))
1237    }
1238}
1239
1240/// Parses a string into a [`ContentStyle`], including attributes and foreground color.
1241///
1242/// Examples: "red", "bold", "italic blue", "underline dim green".
1243fn parse_style(raw: &str) -> Result<ContentStyle, String> {
1244    let raw_lower = raw.to_ascii_lowercase();
1245    let (remaining, attributes) = extract_style_attributes(&raw_lower);
1246    let mut style = ContentStyle::new();
1247    style.attributes = attributes;
1248    if !remaining.is_empty() && remaining != "default" {
1249        style.foreground_color = Some(parse_color_inner(remaining)?);
1250    }
1251    Ok(style)
1252}
1253
1254/// Extracts style attributes (bold, dim, italic, underline) from the beginning of a style string.
1255///
1256/// Returns the remaining string and the parsed attributes.
1257fn extract_style_attributes(raw: &str) -> (&str, Attributes) {
1258    let mut attributes = Attributes::none();
1259    let mut current = raw;
1260
1261    loop {
1262        match current {
1263            rest if rest.starts_with("bold") => {
1264                attributes.set(Attribute::Bold);
1265                current = &rest[4..];
1266                if current.starts_with(' ') {
1267                    current = &current[1..];
1268                }
1269            }
1270            rest if rest.starts_with("dim") => {
1271                attributes.set(Attribute::Dim);
1272                current = &rest[3..];
1273                if current.starts_with(' ') {
1274                    current = &current[1..];
1275                }
1276            }
1277            rest if rest.starts_with("italic") => {
1278                attributes.set(Attribute::Italic);
1279                current = &rest[6..];
1280                if current.starts_with(' ') {
1281                    current = &current[1..];
1282                }
1283            }
1284            rest if rest.starts_with("underline") => {
1285                attributes.set(Attribute::Underlined);
1286                current = &rest[9..];
1287                if current.starts_with(' ') {
1288                    current = &current[1..];
1289                }
1290            }
1291            rest if rest.starts_with("underlined") => {
1292                attributes.set(Attribute::Underlined);
1293                current = &rest[10..];
1294                if current.starts_with(' ') {
1295                    current = &current[1..];
1296                }
1297            }
1298            _ => break,
1299        };
1300    }
1301
1302    (current.trim(), attributes)
1303}
1304
1305/// Parses the color part of a style string.
1306///
1307/// Handles named colors, rgb, hex, and ansi values.
1308fn parse_color_inner(raw: &str) -> Result<Color, String> {
1309    Ok(match raw {
1310        "black" => Color::Black,
1311        "red" => Color::Red,
1312        "green" => Color::Green,
1313        "yellow" => Color::Yellow,
1314        "blue" => Color::Blue,
1315        "magenta" => Color::Magenta,
1316        "cyan" => Color::Cyan,
1317        "gray" | "grey" => Color::Grey,
1318        "dark gray" | "darkgray" | "dark grey" | "darkgrey" => Color::DarkGrey,
1319        "dark red" | "darkred" => Color::DarkRed,
1320        "dark green" | "darkgreen" => Color::DarkGreen,
1321        "dark yellow" | "darkyellow" => Color::DarkYellow,
1322        "dark blue" | "darkblue" => Color::DarkBlue,
1323        "dark magenta" | "darkmagenta" => Color::DarkMagenta,
1324        "dark cyan" | "darkcyan" => Color::DarkCyan,
1325        "white" => Color::White,
1326        rgb if rgb.starts_with("rgb(") => {
1327            let rgb = rgb.trim_start_matches("rgb(").trim_end_matches(")").split(',');
1328            let rgb = rgb
1329                .map(|c| c.trim().parse::<u8>())
1330                .collect::<Result<Vec<u8>, _>>()
1331                .map_err(|_| format!("Unable to parse color: {raw}"))?;
1332            if rgb.len() != 3 {
1333                return Err(format!("Unable to parse color: {raw}"));
1334            }
1335            Color::Rgb {
1336                r: rgb[0],
1337                g: rgb[1],
1338                b: rgb[2],
1339            }
1340        }
1341        hex if hex.starts_with("#") => {
1342            let hex = hex.trim_start_matches("#");
1343            if hex.len() != 6 {
1344                return Err(format!("Unable to parse color: {raw}"));
1345            }
1346            let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1347            let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1348            let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1349            Color::Rgb { r, g, b }
1350        }
1351        c => {
1352            if let Ok(c) = c.parse::<u8>() {
1353                Color::AnsiValue(c)
1354            } else {
1355                return Err(format!("Unable to parse color: {raw}"));
1356            }
1357        }
1358    })
1359}
1360
1361/// Custom deserialization for the AI model catalog that merges user-defined models with default models.
1362///
1363/// User-defined models in the configuration file will override any defaults with the same name.
1364/// Any default models not defined by the user will be added to the final catalog.
1365fn deserialize_catalog_with_defaults<'de, D>(deserializer: D) -> Result<BTreeMap<String, AiModelConfig>, D::Error>
1366where
1367    D: Deserializer<'de>,
1368{
1369    #[allow(unused_mut)]
1370    // Deserialize the map as provided in the user's config
1371    let mut user_catalog = BTreeMap::<String, AiModelConfig>::deserialize(deserializer)?;
1372
1373    // Get the default catalog and merge it in
1374    #[cfg(not(test))]
1375    for (key, default_model) in default_ai_catalog() {
1376        user_catalog.entry(key).or_insert(default_model);
1377    }
1378
1379    Ok(user_catalog)
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384    use pretty_assertions::assert_eq;
1385    use strum::IntoEnumIterator;
1386
1387    use super::*;
1388
1389    #[test]
1390    fn test_default_config() -> Result<()> {
1391        let config_str = fs::read_to_string("default_config.toml").wrap_err("Couldn't read default config file")?;
1392        let config: Config = toml::from_str(&config_str).wrap_err("Couldn't parse default config file")?;
1393
1394        assert_eq!(Config::default(), config);
1395
1396        Ok(())
1397    }
1398
1399    #[test]
1400    fn test_default_keybindings_complete() {
1401        let config = KeyBindingsConfig::default();
1402
1403        for action in KeyBindingAction::iter() {
1404            assert!(
1405                config.0.contains_key(&action),
1406                "Missing default binding for action: {action:?}"
1407            );
1408        }
1409    }
1410
1411    #[test]
1412    fn test_default_keybindings_no_conflicts() {
1413        let config = KeyBindingsConfig::default();
1414
1415        let conflicts = config.find_conflicts();
1416        assert_eq!(conflicts.len(), 0, "Key binding conflicts: {conflicts:?}");
1417    }
1418
1419    #[test]
1420    fn test_keybinding_matches() {
1421        let binding = KeyBinding(vec![
1422            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
1423            KeyEvent::from(KeyCode::Enter),
1424        ]);
1425
1426        // Should match exact events
1427        assert!(binding.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
1428        assert!(binding.matches(&KeyEvent::from(KeyCode::Enter)));
1429
1430        // Should not match events with different modifiers
1431        assert!(!binding.matches(&KeyEvent::new(
1432            KeyCode::Char('a'),
1433            KeyModifiers::CONTROL | KeyModifiers::ALT
1434        )));
1435
1436        // Should not match different key codes
1437        assert!(!binding.matches(&KeyEvent::from(KeyCode::Esc)));
1438    }
1439
1440    #[test]
1441    fn test_simple_keys() {
1442        assert_eq!(
1443            parse_key_event("a").unwrap(),
1444            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
1445        );
1446
1447        assert_eq!(
1448            parse_key_event("enter").unwrap(),
1449            KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
1450        );
1451
1452        assert_eq!(
1453            parse_key_event("esc").unwrap(),
1454            KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
1455        );
1456    }
1457
1458    #[test]
1459    fn test_with_modifiers() {
1460        assert_eq!(
1461            parse_key_event("ctrl-a").unwrap(),
1462            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
1463        );
1464
1465        assert_eq!(
1466            parse_key_event("alt-enter").unwrap(),
1467            KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
1468        );
1469
1470        assert_eq!(
1471            parse_key_event("shift-esc").unwrap(),
1472            KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
1473        );
1474    }
1475
1476    #[test]
1477    fn test_multiple_modifiers() {
1478        assert_eq!(
1479            parse_key_event("ctrl-alt-a").unwrap(),
1480            KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
1481        );
1482
1483        assert_eq!(
1484            parse_key_event("ctrl-shift-enter").unwrap(),
1485            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
1486        );
1487    }
1488
1489    #[test]
1490    fn test_invalid_keys() {
1491        let res = parse_key_event("invalid-key");
1492        assert_eq!(res, Err(String::from("Unable to parse key binding: invalid-key")));
1493    }
1494
1495    #[test]
1496    fn test_parse_color_none() {
1497        let color = parse_color("none").unwrap();
1498        assert_eq!(color, None);
1499    }
1500
1501    #[test]
1502    fn test_parse_color_simple() {
1503        let color = parse_color("red").unwrap();
1504        assert_eq!(color, Some(Color::Red));
1505    }
1506
1507    #[test]
1508    fn test_parse_color_rgb() {
1509        let color = parse_color("rgb(50, 25, 15)").unwrap();
1510        assert_eq!(color, Some(Color::Rgb { r: 50, g: 25, b: 15 }));
1511    }
1512
1513    #[test]
1514    fn test_parse_color_rgb_out_of_range() {
1515        let res = parse_color("rgb(500, 25, 15)");
1516        assert_eq!(res, Err(String::from("Unable to parse color: rgb(500, 25, 15)")));
1517    }
1518
1519    #[test]
1520    fn test_parse_color_rgb_invalid() {
1521        let res = parse_color("rgb(50, 25, 15, 5)");
1522        assert_eq!(res, Err(String::from("Unable to parse color: rgb(50, 25, 15, 5)")));
1523    }
1524
1525    #[test]
1526    fn test_parse_color_hex() {
1527        let color = parse_color("#4287f5").unwrap();
1528        assert_eq!(color, Some(Color::Rgb { r: 66, g: 135, b: 245 }));
1529    }
1530
1531    #[test]
1532    fn test_parse_color_hex_out_of_range() {
1533        let res = parse_color("#4287fg");
1534        assert_eq!(res, Err(String::from("Unable to parse color: #4287fg")));
1535    }
1536
1537    #[test]
1538    fn test_parse_color_hex_invalid() {
1539        let res = parse_color("#4287f50");
1540        assert_eq!(res, Err(String::from("Unable to parse color: #4287f50")));
1541    }
1542
1543    #[test]
1544    fn test_parse_color_index() {
1545        let color = parse_color("6").unwrap();
1546        assert_eq!(color, Some(Color::AnsiValue(6)));
1547    }
1548
1549    #[test]
1550    fn test_parse_color_fail() {
1551        let res = parse_color("1234");
1552        assert_eq!(res, Err(String::from("Unable to parse color: 1234")));
1553    }
1554
1555    #[test]
1556    fn test_parse_style_empty() {
1557        let style = parse_style("").unwrap();
1558        assert_eq!(style, ContentStyle::new());
1559    }
1560
1561    #[test]
1562    fn test_parse_style_default() {
1563        let style = parse_style("default").unwrap();
1564        assert_eq!(style, ContentStyle::new());
1565    }
1566
1567    #[test]
1568    fn test_parse_style_simple() {
1569        let style = parse_style("red").unwrap();
1570        assert_eq!(style.foreground_color, Some(Color::Red));
1571        assert_eq!(style.attributes, Attributes::none());
1572    }
1573
1574    #[test]
1575    fn test_parse_style_only_modifier() {
1576        let style = parse_style("bold").unwrap();
1577        assert_eq!(style.foreground_color, None);
1578        let mut expected_attributes = Attributes::none();
1579        expected_attributes.set(Attribute::Bold);
1580        assert_eq!(style.attributes, expected_attributes);
1581    }
1582
1583    #[test]
1584    fn test_parse_style_with_modifier() {
1585        let style = parse_style("italic red").unwrap();
1586        assert_eq!(style.foreground_color, Some(Color::Red));
1587        let mut expected_attributes = Attributes::none();
1588        expected_attributes.set(Attribute::Italic);
1589        assert_eq!(style.attributes, expected_attributes);
1590    }
1591
1592    #[test]
1593    fn test_parse_style_multiple_modifier() {
1594        let style = parse_style("underline dim dark red").unwrap();
1595        assert_eq!(style.foreground_color, Some(Color::DarkRed));
1596        let mut expected_attributes = Attributes::none();
1597        expected_attributes.set(Attribute::Underlined);
1598        expected_attributes.set(Attribute::Dim);
1599        assert_eq!(style.attributes, expected_attributes);
1600    }
1601}