Skip to main content

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