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