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#[derive(Clone, Deserialize)]
30#[cfg_attr(test, derive(Debug, PartialEq))]
31#[cfg_attr(not(test), serde(default))]
32pub struct Config {
33 pub data_dir: PathBuf,
35 pub check_updates: bool,
37 pub inline: bool,
39 pub tui: TuiConfig,
41 pub search: SearchConfig,
43 pub logs: LogsConfig,
45 pub destructive: DestructiveConfig,
47 pub hotkeys: ShellHotkeys,
49 pub keybindings: KeyBindingsConfig,
51 pub theme: Theme,
53 pub gist: GistConfig,
55 pub tuning: SearchTuning,
57 pub ai: AiConfig,
59}
60
61#[derive(Clone, Copy, Deserialize)]
63#[cfg_attr(test, derive(Debug, PartialEq))]
64#[cfg_attr(not(test), serde(default))]
65pub struct SearchConfig {
66 pub delay: u64,
68 pub mode: SearchMode,
70 pub user_only: bool,
72 pub exec_on_alias_match: bool,
74}
75
76#[derive(Clone, Deserialize)]
78#[cfg_attr(test, derive(Debug, PartialEq))]
79#[cfg_attr(not(test), serde(default))]
80pub struct LogsConfig {
81 pub enabled: bool,
83 pub filter: String,
87}
88
89#[derive(Clone, Deserialize, Default)]
91#[cfg_attr(test, derive(Debug, PartialEq))]
92#[cfg_attr(not(test), serde(default))]
93pub struct DestructiveConfig {
94 pub patterns: Vec<RegexWrapper>,
96}
97
98#[derive(Clone, Debug)]
99pub struct RegexWrapper(regex::Regex);
100
101impl RegexWrapper {
102 pub fn new(re: regex::Regex) -> Self {
104 Self(re)
105 }
106
107 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#[derive(Clone, Deserialize)]
132#[cfg_attr(test, derive(Debug, PartialEq))]
133#[cfg_attr(not(test), serde(default))]
134pub struct ShellHotkeys {
135 pub search: ShellBinding,
137 pub bookmark: ShellBinding,
139 pub variable: ShellBinding,
141 pub fix: ShellBinding,
143}
144
145#[derive(Clone, Copy, PartialEq, Eq, Debug)]
152pub struct ShellBinding {
153 pub modifier: ShellModifier,
155 pub key: ShellKey,
157}
158
159#[derive(Clone, Copy, PartialEq, Eq, Debug)]
161pub enum ShellModifier {
162 Ctrl,
164 Alt,
166}
167
168#[derive(Clone, Copy, PartialEq, Eq, Debug)]
170pub enum ShellKey {
171 Space,
173 Char(char),
175}
176
177#[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#[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 Quit,
195 Update,
197 Delete,
199 Confirm,
201 Execute,
203 #[serde(rename = "ai")]
205 AI,
206 SearchMode,
208 SearchUserOnly,
210 MoveNext,
212 MovePrev,
214 VariableNext,
216 VariablePrev,
218}
219
220#[derive(Clone, Deserialize)]
225#[cfg_attr(test, derive(Debug, PartialEq))]
226pub struct KeyBinding(#[serde(deserialize_with = "deserialize_key_events")] Vec<KeyEvent>);
227
228#[derive(Clone, Deserialize)]
232#[cfg_attr(test, derive(Debug, PartialEq))]
233#[cfg_attr(not(test), serde(default))]
234pub struct Theme {
235 #[serde(deserialize_with = "deserialize_style")]
237 pub primary: ContentStyle,
238 #[serde(deserialize_with = "deserialize_style")]
240 pub secondary: ContentStyle,
241 #[serde(deserialize_with = "deserialize_style")]
243 pub accent: ContentStyle,
244 #[serde(deserialize_with = "deserialize_style")]
246 pub comment: ContentStyle,
247 #[serde(deserialize_with = "deserialize_style")]
249 pub error: ContentStyle,
250 #[serde(deserialize_with = "deserialize_style")]
252 pub destructive: ContentStyle,
253 #[serde(deserialize_with = "deserialize_style")]
255 pub destructive_secondary: ContentStyle,
256 #[serde(deserialize_with = "deserialize_color")]
258 pub highlight: Option<Color>,
259 pub highlight_symbol: String,
261 #[serde(deserialize_with = "deserialize_style")]
263 pub highlight_primary: ContentStyle,
264 #[serde(deserialize_with = "deserialize_style")]
266 pub highlight_secondary: ContentStyle,
267 #[serde(deserialize_with = "deserialize_style")]
269 pub highlight_accent: ContentStyle,
270 #[serde(deserialize_with = "deserialize_style")]
272 pub highlight_comment: ContentStyle,
273 #[serde(deserialize_with = "deserialize_style")]
275 pub highlight_destructive: ContentStyle,
276 #[serde(deserialize_with = "deserialize_style")]
278 pub highlight_destructive_secondary: ContentStyle,
279}
280
281#[derive(Clone, Default, Deserialize)]
283#[cfg_attr(test, derive(Debug, PartialEq))]
284pub struct GistConfig {
285 pub id: String,
287 pub token: String,
289}
290
291#[derive(Clone, Copy, Deserialize)]
293#[cfg_attr(test, derive(Debug, PartialEq))]
294#[cfg_attr(not(test), serde(default))]
295pub struct TuiConfig {
296 pub keyboard_enhancement: bool,
298}
299
300#[derive(Clone, Copy, Default, Deserialize)]
302#[cfg_attr(test, derive(Debug, PartialEq))]
303#[cfg_attr(not(test), serde(default))]
304pub struct SearchTuning {
305 pub commands: SearchCommandTuning,
307 pub variables: SearchVariableTuning,
309}
310
311#[derive(Clone, Copy, Default, Deserialize)]
313#[cfg_attr(test, derive(Debug, PartialEq))]
314#[cfg_attr(not(test), serde(default))]
315pub struct SearchCommandTuning {
316 pub text: SearchCommandsTextTuning,
318 pub path: SearchPathTuning,
320 pub usage: SearchUsageTuning,
322}
323
324#[derive(Clone, Copy, Deserialize)]
326#[cfg_attr(test, derive(Debug, PartialEq))]
327#[cfg_attr(not(test), serde(default))]
328pub struct SearchCommandsTextTuning {
329 pub points: u32,
331 pub command: f64,
333 pub description: f64,
335 pub auto: SearchCommandsTextAutoTuning,
337}
338
339#[derive(Clone, Copy, Deserialize)]
341#[cfg_attr(test, derive(Debug, PartialEq))]
342#[cfg_attr(not(test), serde(default))]
343pub struct SearchCommandsTextAutoTuning {
344 pub prefix: f64,
346 pub fuzzy: f64,
348 pub relaxed: f64,
350 pub root: f64,
352}
353
354#[derive(Clone, Copy, Deserialize)]
356#[cfg_attr(test, derive(Debug, PartialEq))]
357#[cfg_attr(not(test), serde(default))]
358pub struct SearchPathTuning {
359 pub points: u32,
361 pub exact: f64,
363 pub ancestor: f64,
365 pub descendant: f64,
367 pub unrelated: f64,
369}
370
371#[derive(Clone, Copy, Deserialize)]
373#[cfg_attr(test, derive(Debug, PartialEq))]
374#[cfg_attr(not(test), serde(default))]
375pub struct SearchUsageTuning {
376 pub points: u32,
378}
379
380#[derive(Clone, Copy, Default, Deserialize)]
382#[cfg_attr(test, derive(Debug, PartialEq))]
383#[cfg_attr(not(test), serde(default))]
384pub struct SearchVariableTuning {
385 pub completion: SearchVariableCompletionTuning,
387 pub context: SearchVariableContextTuning,
389 pub path: SearchPathTuning,
391}
392
393#[derive(Clone, Copy, Deserialize)]
395#[cfg_attr(test, derive(Debug, PartialEq))]
396#[cfg_attr(not(test), serde(default))]
397pub struct SearchVariableCompletionTuning {
398 pub points: u32,
400}
401
402#[derive(Clone, Copy, Deserialize)]
404#[cfg_attr(test, derive(Debug, PartialEq))]
405#[cfg_attr(not(test), serde(default))]
406pub struct SearchVariableContextTuning {
407 pub points: u32,
409}
410
411#[derive(Clone, Deserialize)]
413#[cfg_attr(test, derive(Debug, PartialEq))]
414#[cfg_attr(not(test), serde(default))]
415pub struct AiConfig {
416 pub enabled: bool,
418 pub prompts: AiPromptsConfig,
420 pub models: AiModelsConfig,
422 #[serde(deserialize_with = "deserialize_catalog_with_defaults")]
427 pub catalog: BTreeMap<String, AiModelConfig>,
428}
429
430#[derive(Clone, Deserialize)]
432#[cfg_attr(test, derive(Debug, PartialEq))]
433#[cfg_attr(not(test), serde(default))]
434pub struct AiPromptsConfig {
435 pub suggest: String,
437 pub fix: String,
439 pub import: String,
441 pub completion: String,
443}
444
445#[derive(Clone, Deserialize)]
447#[cfg_attr(test, derive(Debug, PartialEq))]
448#[cfg_attr(not(test), serde(default))]
449pub struct AiModelsConfig {
450 pub suggest: String,
453 pub fix: String,
456 pub import: String,
459 pub completion: String,
462 pub fallback: String,
465}
466
467#[derive(Clone, Deserialize)]
469#[cfg_attr(test, derive(Debug, PartialEq))]
470#[serde(tag = "provider", rename_all = "snake_case")]
471pub enum AiModelConfig {
472 Openai(OpenAiModelConfig),
474 Gemini(GeminiModelConfig),
476 Anthropic(AnthropicModelConfig),
478 Ollama(OllamaModelConfig),
480}
481
482#[derive(Clone, Deserialize)]
484#[cfg_attr(test, derive(Debug, PartialEq))]
485pub struct OpenAiModelConfig {
486 pub model: String,
488 #[serde(default = "default_openai_url")]
492 pub url: String,
493 #[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#[derive(Clone, Deserialize)]
506#[cfg_attr(test, derive(Debug, PartialEq))]
507pub struct GeminiModelConfig {
508 pub model: String,
510 #[serde(default = "default_gemini_url")]
512 pub url: String,
513 #[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#[derive(Clone, Deserialize)]
526#[cfg_attr(test, derive(Debug, PartialEq))]
527pub struct AnthropicModelConfig {
528 pub model: String,
530 #[serde(default = "default_anthropic_url")]
532 pub url: String,
533 #[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#[derive(Clone, Deserialize)]
546#[cfg_attr(test, derive(Debug, PartialEq))]
547pub struct OllamaModelConfig {
548 pub model: String,
550 #[serde(default = "default_ollama_url")]
552 pub url: String,
553 #[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
564pub struct ConfigLoadStats {
566 pub default_config_path: bool,
568 pub config_path: PathBuf,
570 pub config_loaded: bool,
572 pub default_data_dir: bool,
574}
575
576impl Config {
577 pub fn init(config_file: Option<PathBuf>) -> Result<(Self, ConfigLoadStats)> {
582 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 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 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 Config::default()
604 };
605 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 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 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 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 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 pub fn to_key_event(&self) -> KeyEvent {
706 KeyEvent::from(*self)
707 }
708
709 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 pub fn get(&self, action: &KeyBindingAction) -> &KeyBinding {
769 self.0.get(action).unwrap()
770 }
771
772 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 pub fn find_conflicts(&self) -> Vec<(KeyEvent, Vec<KeyBindingAction>)> {
783 let mut event_to_actions_map: HashMap<KeyEvent, Vec<KeyBindingAction>> = HashMap::new();
785
786 for (action, key_binding) in self.0.iter() {
788 for event_in_binding in key_binding.0.iter() {
790 event_to_actions_map.entry(*event_in_binding).or_default().push(*action);
792 }
793 }
794
795 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 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 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 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 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 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 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 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 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 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
1352fn deserialize_bindings_with_defaults<'de, D>(
1358 deserializer: D,
1359) -> Result<BTreeMap<KeyBindingAction, KeyBinding>, D::Error>
1360where
1361 D: Deserializer<'de>,
1362{
1363 let user_provided_bindings = BTreeMap::<KeyBindingAction, KeyBinding>::deserialize(deserializer)?;
1365
1366 #[cfg(test)]
1367 {
1368 use strum::IntoEnumIterator;
1369 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 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
1393fn 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
1420fn 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
1431fn 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
1441pub 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
1450fn 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
1478fn 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
1526fn 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
1538fn 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
1552fn 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 = ¤t[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 = ¤t[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 = ¤t[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 = ¤t[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 = ¤t[1..];
1594 }
1595 }
1596 _ => break,
1597 };
1598 }
1599
1600 (current.trim(), attributes)
1601}
1602
1603fn 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
1659fn 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 let mut user_catalog = BTreeMap::<String, AiModelConfig>::deserialize(deserializer)?;
1670
1671 #[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 assert!(binding.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
1804 assert!(binding.matches(&KeyEvent::from(KeyCode::Enter)));
1805
1806 assert!(!binding.matches(&KeyEvent::new(
1808 KeyCode::Char('a'),
1809 KeyModifiers::CONTROL | KeyModifiers::ALT
1810 )));
1811
1812 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}