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