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