1use std::{
2 collections::{BTreeMap, HashMap},
3 fs,
4 path::PathBuf,
5};
6
7use color_eyre::{
8 Result,
9 eyre::{Context, ContextCompat, eyre},
10};
11use crossterm::{
12 event::{KeyCode, KeyEvent, KeyModifiers},
13 style::{Attribute, Attributes, Color, ContentStyle},
14};
15use directories::ProjectDirs;
16use itertools::Itertools;
17use serde::{
18 Deserialize,
19 de::{Deserializer, Error},
20};
21
22use crate::{
23 ai::{AiClient, AiProviderBase},
24 model::SearchMode,
25};
26
27#[derive(Clone, Deserialize)]
29#[cfg_attr(test, derive(Debug, PartialEq))]
30#[cfg_attr(not(test), serde(default))]
31pub struct Config {
32 pub data_dir: PathBuf,
34 pub check_updates: bool,
36 pub inline: bool,
38 pub tui: TuiConfig,
40 pub search: SearchConfig,
42 pub logs: LogsConfig,
44 pub destructive: DestructiveConfig,
46 pub keybindings: KeyBindingsConfig,
48 pub theme: Theme,
50 pub gist: GistConfig,
52 pub tuning: SearchTuning,
54 pub ai: AiConfig,
56}
57
58#[derive(Clone, Copy, Deserialize)]
60#[cfg_attr(test, derive(Debug, PartialEq))]
61#[cfg_attr(not(test), serde(default))]
62pub struct SearchConfig {
63 pub delay: u64,
65 pub mode: SearchMode,
67 pub user_only: bool,
69 pub exec_on_alias_match: bool,
71}
72
73#[derive(Clone, Deserialize)]
75#[cfg_attr(test, derive(Debug, PartialEq))]
76#[cfg_attr(not(test), serde(default))]
77pub struct LogsConfig {
78 pub enabled: bool,
80 pub filter: String,
84}
85
86#[derive(Clone, Deserialize, Default)]
88#[cfg_attr(test, derive(Debug, PartialEq))]
89#[cfg_attr(not(test), serde(default))]
90pub struct DestructiveConfig {
91 pub patterns: Vec<RegexWrapper>,
93}
94
95#[derive(Clone, Debug)]
96pub struct RegexWrapper(regex::Regex);
97
98impl RegexWrapper {
99 pub fn new(re: regex::Regex) -> Self {
101 Self(re)
102 }
103
104 pub fn is_match(&self, text: &str) -> bool {
106 self.0.is_match(text)
107 }
108}
109
110impl PartialEq for RegexWrapper {
111 fn eq(&self, other: &Self) -> bool {
112 self.0.as_str() == other.0.as_str()
113 }
114}
115
116impl<'de> Deserialize<'de> for RegexWrapper {
117 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
118 where
119 D: serde::Deserializer<'de>,
120 {
121 let s = String::deserialize(deserializer)?;
122 let re = regex::Regex::new(&s).map_err(Error::custom)?;
123 Ok(RegexWrapper::new(re))
124 }
125}
126
127#[derive(Clone, Deserialize)]
132#[cfg_attr(test, derive(Debug, PartialEq))]
133#[cfg_attr(not(test), serde(default))]
134pub struct KeyBindingsConfig(
135 #[serde(deserialize_with = "deserialize_bindings_with_defaults")] BTreeMap<KeyBindingAction, KeyBinding>,
136);
137
138#[derive(Copy, Clone, Deserialize, PartialOrd, PartialEq, Eq, Ord, Debug)]
140#[cfg_attr(test, derive(strum::EnumIter))]
141#[serde(rename_all = "snake_case")]
142pub enum KeyBindingAction {
143 Quit,
145 Update,
147 Delete,
149 Confirm,
151 Execute,
153 #[serde(rename = "ai")]
155 AI,
156 SearchMode,
158 SearchUserOnly,
160 VariableNext,
162 VariablePrev,
164}
165
166#[derive(Clone, Deserialize)]
171#[cfg_attr(test, derive(Debug, PartialEq))]
172pub struct KeyBinding(#[serde(deserialize_with = "deserialize_key_events")] Vec<KeyEvent>);
173
174#[derive(Clone, Deserialize)]
178#[cfg_attr(test, derive(Debug, PartialEq))]
179#[cfg_attr(not(test), serde(default))]
180pub struct Theme {
181 #[serde(deserialize_with = "deserialize_style")]
183 pub primary: ContentStyle,
184 #[serde(deserialize_with = "deserialize_style")]
186 pub secondary: ContentStyle,
187 #[serde(deserialize_with = "deserialize_style")]
189 pub accent: ContentStyle,
190 #[serde(deserialize_with = "deserialize_style")]
192 pub comment: ContentStyle,
193 #[serde(deserialize_with = "deserialize_style")]
195 pub error: ContentStyle,
196 #[serde(deserialize_with = "deserialize_style")]
198 pub destructive: ContentStyle,
199 #[serde(deserialize_with = "deserialize_style")]
201 pub destructive_secondary: ContentStyle,
202 #[serde(deserialize_with = "deserialize_color")]
204 pub highlight: Option<Color>,
205 pub highlight_symbol: String,
207 #[serde(deserialize_with = "deserialize_style")]
209 pub highlight_primary: ContentStyle,
210 #[serde(deserialize_with = "deserialize_style")]
212 pub highlight_secondary: ContentStyle,
213 #[serde(deserialize_with = "deserialize_style")]
215 pub highlight_accent: ContentStyle,
216 #[serde(deserialize_with = "deserialize_style")]
218 pub highlight_comment: ContentStyle,
219 #[serde(deserialize_with = "deserialize_style")]
221 pub highlight_destructive: ContentStyle,
222 #[serde(deserialize_with = "deserialize_style")]
224 pub highlight_destructive_secondary: ContentStyle,
225}
226
227#[derive(Clone, Default, Deserialize)]
229#[cfg_attr(test, derive(Debug, PartialEq))]
230pub struct GistConfig {
231 pub id: String,
233 pub token: String,
235}
236
237#[derive(Clone, Copy, Deserialize)]
239#[cfg_attr(test, derive(Debug, PartialEq))]
240#[cfg_attr(not(test), serde(default))]
241pub struct TuiConfig {
242 pub keyboard_enhancement: bool,
244}
245
246#[derive(Clone, Copy, Default, Deserialize)]
248#[cfg_attr(test, derive(Debug, PartialEq))]
249#[cfg_attr(not(test), serde(default))]
250pub struct SearchTuning {
251 pub commands: SearchCommandTuning,
253 pub variables: SearchVariableTuning,
255}
256
257#[derive(Clone, Copy, Default, Deserialize)]
259#[cfg_attr(test, derive(Debug, PartialEq))]
260#[cfg_attr(not(test), serde(default))]
261pub struct SearchCommandTuning {
262 pub text: SearchCommandsTextTuning,
264 pub path: SearchPathTuning,
266 pub usage: SearchUsageTuning,
268}
269
270#[derive(Clone, Copy, Deserialize)]
272#[cfg_attr(test, derive(Debug, PartialEq))]
273#[cfg_attr(not(test), serde(default))]
274pub struct SearchCommandsTextTuning {
275 pub points: u32,
277 pub command: f64,
279 pub description: f64,
281 pub auto: SearchCommandsTextAutoTuning,
283}
284
285#[derive(Clone, Copy, Deserialize)]
287#[cfg_attr(test, derive(Debug, PartialEq))]
288#[cfg_attr(not(test), serde(default))]
289pub struct SearchCommandsTextAutoTuning {
290 pub prefix: f64,
292 pub fuzzy: f64,
294 pub relaxed: f64,
296 pub root: f64,
298}
299
300#[derive(Clone, Copy, Deserialize)]
302#[cfg_attr(test, derive(Debug, PartialEq))]
303#[cfg_attr(not(test), serde(default))]
304pub struct SearchPathTuning {
305 pub points: u32,
307 pub exact: f64,
309 pub ancestor: f64,
311 pub descendant: f64,
313 pub unrelated: f64,
315}
316
317#[derive(Clone, Copy, Deserialize)]
319#[cfg_attr(test, derive(Debug, PartialEq))]
320#[cfg_attr(not(test), serde(default))]
321pub struct SearchUsageTuning {
322 pub points: u32,
324}
325
326#[derive(Clone, Copy, Default, Deserialize)]
328#[cfg_attr(test, derive(Debug, PartialEq))]
329#[cfg_attr(not(test), serde(default))]
330pub struct SearchVariableTuning {
331 pub completion: SearchVariableCompletionTuning,
333 pub context: SearchVariableContextTuning,
335 pub path: SearchPathTuning,
337}
338
339#[derive(Clone, Copy, Deserialize)]
341#[cfg_attr(test, derive(Debug, PartialEq))]
342#[cfg_attr(not(test), serde(default))]
343pub struct SearchVariableCompletionTuning {
344 pub points: u32,
346}
347
348#[derive(Clone, Copy, Deserialize)]
350#[cfg_attr(test, derive(Debug, PartialEq))]
351#[cfg_attr(not(test), serde(default))]
352pub struct SearchVariableContextTuning {
353 pub points: u32,
355}
356
357#[derive(Clone, Deserialize)]
359#[cfg_attr(test, derive(Debug, PartialEq))]
360#[cfg_attr(not(test), serde(default))]
361pub struct AiConfig {
362 pub enabled: bool,
364 pub prompts: AiPromptsConfig,
366 pub models: AiModelsConfig,
368 #[serde(deserialize_with = "deserialize_catalog_with_defaults")]
373 pub catalog: BTreeMap<String, AiModelConfig>,
374}
375
376#[derive(Clone, Deserialize)]
378#[cfg_attr(test, derive(Debug, PartialEq))]
379#[cfg_attr(not(test), serde(default))]
380pub struct AiPromptsConfig {
381 pub suggest: String,
383 pub fix: String,
385 pub import: String,
387 pub completion: String,
389}
390
391#[derive(Clone, Deserialize)]
393#[cfg_attr(test, derive(Debug, PartialEq))]
394#[cfg_attr(not(test), serde(default))]
395pub struct AiModelsConfig {
396 pub suggest: String,
399 pub fix: String,
402 pub import: String,
405 pub completion: String,
408 pub fallback: String,
411}
412
413#[derive(Clone, Deserialize)]
415#[cfg_attr(test, derive(Debug, PartialEq))]
416#[serde(tag = "provider", rename_all = "snake_case")]
417pub enum AiModelConfig {
418 Openai(OpenAiModelConfig),
420 Gemini(GeminiModelConfig),
422 Anthropic(AnthropicModelConfig),
424 Ollama(OllamaModelConfig),
426}
427
428#[derive(Clone, Deserialize)]
430#[cfg_attr(test, derive(Debug, PartialEq))]
431pub struct OpenAiModelConfig {
432 pub model: String,
434 #[serde(default = "default_openai_url")]
438 pub url: String,
439 #[serde(default = "default_openai_api_key_env")]
441 pub api_key_env: String,
442}
443fn default_openai_url() -> String {
444 "https://api.openai.com/v1".to_string()
445}
446fn default_openai_api_key_env() -> String {
447 "OPENAI_API_KEY".to_string()
448}
449
450#[derive(Clone, Deserialize)]
452#[cfg_attr(test, derive(Debug, PartialEq))]
453pub struct GeminiModelConfig {
454 pub model: String,
456 #[serde(default = "default_gemini_url")]
458 pub url: String,
459 #[serde(default = "default_gemini_api_key_env")]
461 pub api_key_env: String,
462}
463fn default_gemini_url() -> String {
464 "https://generativelanguage.googleapis.com/v1beta".to_string()
465}
466fn default_gemini_api_key_env() -> String {
467 "GEMINI_API_KEY".to_string()
468}
469
470#[derive(Clone, Deserialize)]
472#[cfg_attr(test, derive(Debug, PartialEq))]
473pub struct AnthropicModelConfig {
474 pub model: String,
476 #[serde(default = "default_anthropic_url")]
478 pub url: String,
479 #[serde(default = "default_anthropic_api_key_env")]
481 pub api_key_env: String,
482}
483fn default_anthropic_url() -> String {
484 "https://api.anthropic.com/v1".to_string()
485}
486fn default_anthropic_api_key_env() -> String {
487 "ANTHROPIC_API_KEY".to_string()
488}
489
490#[derive(Clone, Deserialize)]
492#[cfg_attr(test, derive(Debug, PartialEq))]
493pub struct OllamaModelConfig {
494 pub model: String,
496 #[serde(default = "default_ollama_url")]
498 pub url: String,
499 #[serde(default = "default_ollama_api_key_env")]
501 pub api_key_env: String,
502}
503fn default_ollama_url() -> String {
504 "http://localhost:11434".to_string()
505}
506fn default_ollama_api_key_env() -> String {
507 "OLLAMA_API_KEY".to_string()
508}
509
510pub struct ConfigLoadStats {
512 pub default_config_path: bool,
514 pub config_path: PathBuf,
516 pub config_loaded: bool,
518 pub default_data_dir: bool,
520}
521
522impl Config {
523 pub fn init(config_file: Option<PathBuf>) -> Result<(Self, ConfigLoadStats)> {
528 let proj_dirs = ProjectDirs::from("org", "IntelliShell", "Intelli-Shell")
530 .wrap_err("Couldn't initialize project directory")?;
531 let config_dir = proj_dirs.config_dir().to_path_buf();
532
533 let mut stats = ConfigLoadStats {
535 default_config_path: config_file.is_none(),
536 config_path: config_file.unwrap_or_else(|| config_dir.join("config.toml")),
537 config_loaded: false,
538 default_data_dir: false,
539 };
540 let mut config = if stats.config_path.exists() {
541 stats.config_loaded = true;
542 let config_str = fs::read_to_string(&stats.config_path)
544 .wrap_err_with(|| format!("Couldn't read config file {}", stats.config_path.display()))?;
545 toml::from_str(&config_str)
546 .wrap_err_with(|| format!("Couldn't parse config file {}", stats.config_path.display()))?
547 } else {
548 Config::default()
550 };
551 if config.data_dir.as_os_str().is_empty() {
553 stats.default_data_dir = true;
554 config.data_dir = proj_dirs.data_dir().to_path_buf();
555 }
556
557 let conflicts = config.keybindings.find_conflicts();
559 if !conflicts.is_empty() {
560 return Err(eyre!(
561 "Couldn't parse config file {}\n\nThere are some key binding conflicts:\n{}",
562 stats.config_path.display(),
563 conflicts
564 .into_iter()
565 .map(|(_, a)| format!("- {}", a.into_iter().map(|a| format!("{a:?}")).join(", ")))
566 .join("\n")
567 ));
568 }
569
570 if config.ai.enabled {
572 let AiModelsConfig {
573 suggest,
574 fix,
575 import,
576 completion,
577 fallback,
578 } = &config.ai.models;
579 let catalog = &config.ai.catalog;
580
581 let mut missing = Vec::new();
582 if !catalog.contains_key(suggest) {
583 missing.push((suggest, "suggest"));
584 }
585 if !catalog.contains_key(fix) {
586 missing.push((fix, "fix"));
587 }
588 if !catalog.contains_key(import) {
589 missing.push((import, "import"));
590 }
591 if !catalog.contains_key(completion) {
592 missing.push((completion, "completion"));
593 }
594 if !catalog.contains_key(fallback) {
595 missing.push((fallback, "fallback"));
596 }
597
598 if !missing.is_empty() {
599 return Err(eyre!(
600 "Couldn't parse config file {}\n\nMissing model definitions on the catalog:\n{}",
601 stats.config_path.display(),
602 missing
603 .into_iter()
604 .into_group_map()
605 .into_iter()
606 .map(|(k, v)| format!(
607 "- {k} used in {}",
608 v.into_iter().map(|v| format!("ai.models.{v}")).join(", ")
609 ))
610 .join("\n")
611 ));
612 }
613 }
614
615 fs::create_dir_all(&config.data_dir)
617 .wrap_err_with(|| format!("Could't create data dir {}", config.data_dir.display()))?;
618
619 Ok((config, stats))
620 }
621}
622
623impl KeyBindingsConfig {
624 pub fn get(&self, action: &KeyBindingAction) -> &KeyBinding {
626 self.0.get(action).unwrap()
627 }
628
629 pub fn get_action_matching(&self, event: &KeyEvent) -> Option<KeyBindingAction> {
631 self.0.iter().find_map(
632 |(action, binding)| {
633 if binding.matches(event) { Some(*action) } else { None }
634 },
635 )
636 }
637
638 pub fn find_conflicts(&self) -> Vec<(KeyEvent, Vec<KeyBindingAction>)> {
640 let mut event_to_actions_map: HashMap<KeyEvent, Vec<KeyBindingAction>> = HashMap::new();
642
643 for (action, key_binding) in self.0.iter() {
645 for event_in_binding in key_binding.0.iter() {
647 event_to_actions_map.entry(*event_in_binding).or_default().push(*action);
649 }
650 }
651
652 event_to_actions_map
654 .into_iter()
655 .filter_map(|(key_event, actions)| {
656 if actions.len() > 1 {
657 Some((key_event, actions))
658 } else {
659 None
660 }
661 })
662 .collect()
663 }
664}
665
666impl KeyBinding {
667 pub fn matches(&self, event: &KeyEvent) -> bool {
670 self.0
671 .iter()
672 .any(|e| e.code == event.code && e.modifiers == event.modifiers)
673 }
674}
675
676impl Theme {
677 pub fn highlight_primary_full(&self) -> ContentStyle {
679 if let Some(color) = self.highlight {
680 let mut ret = self.highlight_primary;
681 ret.background_color = Some(color);
682 ret
683 } else {
684 self.highlight_primary
685 }
686 }
687
688 pub fn highlight_secondary_full(&self) -> ContentStyle {
690 if let Some(color) = self.highlight {
691 let mut ret = self.highlight_secondary;
692 ret.background_color = Some(color);
693 ret
694 } else {
695 self.highlight_secondary
696 }
697 }
698
699 pub fn highlight_accent_full(&self) -> ContentStyle {
701 if let Some(color) = self.highlight {
702 let mut ret = self.highlight_accent;
703 ret.background_color = Some(color);
704 ret
705 } else {
706 self.highlight_accent
707 }
708 }
709
710 pub fn highlight_comment_full(&self) -> ContentStyle {
712 if let Some(color) = self.highlight {
713 let mut ret = self.highlight_comment;
714 ret.background_color = Some(color);
715 ret
716 } else {
717 self.highlight_comment
718 }
719 }
720}
721
722impl AiConfig {
723 pub fn suggest_client(&self) -> crate::errors::Result<AiClient<'_>> {
725 AiClient::new(
726 &self.models.suggest,
727 self.catalog.get(&self.models.suggest).unwrap(),
728 &self.models.fallback,
729 self.catalog.get(&self.models.fallback),
730 )
731 }
732
733 pub fn fix_client(&self) -> crate::errors::Result<AiClient<'_>> {
735 AiClient::new(
736 &self.models.fix,
737 self.catalog.get(&self.models.fix).unwrap(),
738 &self.models.fallback,
739 self.catalog.get(&self.models.fallback),
740 )
741 }
742
743 pub fn import_client(&self) -> crate::errors::Result<AiClient<'_>> {
745 AiClient::new(
746 &self.models.import,
747 self.catalog.get(&self.models.import).unwrap(),
748 &self.models.fallback,
749 self.catalog.get(&self.models.fallback),
750 )
751 }
752
753 pub fn completion_client(&self) -> crate::errors::Result<AiClient<'_>> {
755 AiClient::new(
756 &self.models.completion,
757 self.catalog.get(&self.models.completion).unwrap(),
758 &self.models.fallback,
759 self.catalog.get(&self.models.fallback),
760 )
761 }
762}
763impl AiModelConfig {
764 pub fn provider(&self) -> &dyn AiProviderBase {
765 match self {
766 AiModelConfig::Openai(conf) => conf,
767 AiModelConfig::Gemini(conf) => conf,
768 AiModelConfig::Anthropic(conf) => conf,
769 AiModelConfig::Ollama(conf) => conf,
770 }
771 }
772}
773
774impl Default for Config {
775 fn default() -> Self {
776 Self {
777 data_dir: PathBuf::new(),
778 check_updates: true,
779 inline: true,
780 tui: TuiConfig::default(),
781 search: SearchConfig::default(),
782 logs: LogsConfig::default(),
783 destructive: DestructiveConfig::default(),
784 keybindings: KeyBindingsConfig::default(),
785 theme: Theme::default(),
786 gist: GistConfig::default(),
787 tuning: SearchTuning::default(),
788 ai: AiConfig::default(),
789 }
790 }
791}
792impl Default for TuiConfig {
793 fn default() -> Self {
794 Self {
795 keyboard_enhancement: !cfg!(target_os = "macos"),
796 }
797 }
798}
799impl Default for SearchConfig {
800 fn default() -> Self {
801 Self {
802 delay: 250,
803 mode: SearchMode::Auto,
804 user_only: false,
805 exec_on_alias_match: false,
806 }
807 }
808}
809impl Default for LogsConfig {
810 fn default() -> Self {
811 Self {
812 enabled: false,
813 filter: String::from("info"),
814 }
815 }
816}
817impl Default for KeyBindingsConfig {
818 fn default() -> Self {
819 Self(BTreeMap::from([
820 (KeyBindingAction::Quit, KeyBinding(vec![KeyEvent::from(KeyCode::Esc)])),
821 (
822 KeyBindingAction::Update,
823 KeyBinding(vec![
824 KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
825 KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
826 KeyEvent::from(KeyCode::F(2)),
827 ]),
828 ),
829 (
830 KeyBindingAction::Delete,
831 KeyBinding(vec![KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)]),
832 ),
833 (
834 KeyBindingAction::Confirm,
835 KeyBinding(vec![KeyEvent::from(KeyCode::Tab), KeyEvent::from(KeyCode::Enter)]),
836 ),
837 (
838 KeyBindingAction::Execute,
839 KeyBinding(vec![
840 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
841 KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
842 ]),
843 ),
844 (
845 KeyBindingAction::AI,
846 KeyBinding(vec![
847 KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL),
848 KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
849 ]),
850 ),
851 (
852 KeyBindingAction::SearchMode,
853 KeyBinding(vec![KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)]),
854 ),
855 (
856 KeyBindingAction::SearchUserOnly,
857 KeyBinding(vec![KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)]),
858 ),
859 (
860 KeyBindingAction::VariableNext,
861 KeyBinding(vec![KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL)]),
862 ),
863 (
864 KeyBindingAction::VariablePrev,
865 KeyBinding(vec![
866 KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT),
867 KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
868 ]),
869 ),
870 ]))
871 }
872}
873impl Default for Theme {
874 fn default() -> Self {
875 let primary = ContentStyle::new();
876 let highlight_primary = primary;
877
878 let mut secondary = ContentStyle::new();
879 secondary.attributes.set(Attribute::Dim);
880 let highlight_secondary = ContentStyle::new();
881
882 let mut accent = ContentStyle::new();
883 accent.foreground_color = Some(Color::Yellow);
884 let highlight_accent = accent;
885
886 let mut comment = ContentStyle::new();
887 comment.foreground_color = Some(Color::Green);
888 comment.attributes.set(Attribute::Italic);
889 let highlight_comment = comment;
890
891 let mut error = ContentStyle::new();
892 error.foreground_color = Some(Color::DarkRed);
893
894 let mut destructive = ContentStyle::new();
895 destructive.foreground_color = Some(Color::DarkRed);
896 let mut destructive_secondary = ContentStyle::new();
897 destructive_secondary.foreground_color = Some(Color::DarkRed);
898 destructive_secondary.attributes.set(Attribute::Dim);
899 let mut highlight_destructive = ContentStyle::new();
900 highlight_destructive.foreground_color = Some(Color::Red);
901 let mut highlight_destructive_secondary = ContentStyle::new();
902 highlight_destructive_secondary.foreground_color = Some(Color::Red);
903 highlight_destructive_secondary.attributes.set(Attribute::Dim);
904
905 Self {
906 primary,
907 secondary,
908 accent,
909 comment,
910 error,
911 destructive,
912 destructive_secondary,
913 highlight: Some(Color::DarkGrey),
914 highlight_symbol: String::from("» "),
915 highlight_primary,
916 highlight_secondary,
917 highlight_accent,
918 highlight_comment,
919 highlight_destructive,
920 highlight_destructive_secondary,
921 }
922 }
923}
924impl Default for SearchCommandsTextTuning {
925 fn default() -> Self {
926 Self {
927 points: 600,
928 command: 2.0,
929 description: 1.0,
930 auto: SearchCommandsTextAutoTuning::default(),
931 }
932 }
933}
934impl Default for SearchCommandsTextAutoTuning {
935 fn default() -> Self {
936 Self {
937 prefix: 1.5,
938 fuzzy: 1.0,
939 relaxed: 0.5,
940 root: 2.0,
941 }
942 }
943}
944impl Default for SearchUsageTuning {
945 fn default() -> Self {
946 Self { points: 100 }
947 }
948}
949impl Default for SearchPathTuning {
950 fn default() -> Self {
951 Self {
952 points: 300,
953 exact: 1.0,
954 ancestor: 0.5,
955 descendant: 0.25,
956 unrelated: 0.1,
957 }
958 }
959}
960impl Default for SearchVariableCompletionTuning {
961 fn default() -> Self {
962 Self { points: 200 }
963 }
964}
965impl Default for SearchVariableContextTuning {
966 fn default() -> Self {
967 Self { points: 700 }
968 }
969}
970fn default_ai_catalog() -> BTreeMap<String, AiModelConfig> {
971 BTreeMap::from([
972 (
973 "main".to_string(),
974 AiModelConfig::Gemini(GeminiModelConfig {
975 model: "gemini-flash-latest".to_string(),
976 url: default_gemini_url(),
977 api_key_env: default_gemini_api_key_env(),
978 }),
979 ),
980 (
981 "fallback".to_string(),
982 AiModelConfig::Gemini(GeminiModelConfig {
983 model: "gemini-flash-lite-latest".to_string(),
984 url: default_gemini_url(),
985 api_key_env: default_gemini_api_key_env(),
986 }),
987 ),
988 ])
989}
990impl Default for AiConfig {
991 fn default() -> Self {
992 Self {
993 enabled: false,
994 models: AiModelsConfig::default(),
995 prompts: AiPromptsConfig::default(),
996 catalog: default_ai_catalog(),
997 }
998 }
999}
1000impl Default for AiModelsConfig {
1001 fn default() -> Self {
1002 Self {
1003 suggest: "main".to_string(),
1004 fix: "main".to_string(),
1005 import: "main".to_string(),
1006 completion: "main".to_string(),
1007 fallback: "fallback".to_string(),
1008 }
1009 }
1010}
1011impl Default for AiPromptsConfig {
1012 fn default() -> Self {
1013 Self {
1014 suggest: String::from(
1015 r#"##OS_SHELL_INFO##
1016##WORKING_DIR##
1017### Instructions
1018You are an expert CLI assistant. Your task is to generate shell command templates based on the user's request.
1019
1020Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
1021
1022### Shell Paradigm, Syntax, and Versioning
1023**This is the most important instruction.** Shells have fundamentally different syntaxes, data models, and features depending on their family and version. You MUST adhere strictly to these constraints.
1024
10251. **Recognize the Shell Paradigm:**
1026 - **POSIX / Text-Stream (bash, zsh, fish):** Operate on **text streams**. Use tools like `grep`, `sed`, `awk`.
1027 - **Object-Pipeline (PowerShell, Nushell):** Operate on **structured data (objects)**. You MUST use internal commands for filtering/selection. AVOID external text-processing tools.
1028 - **Legacy (cmd.exe):** Has unique syntax for loops (`FOR`), variables (`%VAR%`), and filtering (`findstr`).
1029
10302. **Generate Idiomatic Code:**
1031 - Use the shell's built-in features and standard library.
1032 - Follow the shell's naming and style conventions (e.g., `Verb-Noun` in PowerShell).
1033 - Leverage the shell's core strengths (e.g., object manipulation in Nushell).
1034
10353. **Ensure Syntactic Correctness:**
1036 - Pay close attention to variable syntax (`$var`, `$env:VAR`, `$env.VAR`, `%VAR%`).
1037 - Use the correct operators and quoting rules for the target shell.
1038
10394. **Pay Critical Attention to the Version:**
1040 - The shell version is a primary constraint, not a suggestion. This is especially true for shells with rapid development cycles like **Nushell**.
1041 - You **MUST** generate commands that are compatible with the user's specified version.
1042 - Be aware of **breaking changes**. If a command was renamed, replaced, or deprecated in the user's version, you MUST provide the modern, correct equivalent.
1043
1044### Command Template Syntax
1045When creating the `command` template string, you must use the following placeholder syntax:
1046
1047- **Standard Placeholder**: `{{variable-name}}`
1048 - Use for regular arguments that the user needs to provide.
1049 - _Example_: `echo "Hello, {{user-name}}!"`
1050
1051- **Choice Placeholder**: `{{option1|option2}}`
1052 - Use when the user must choose from a specific set of options.
1053 - _Example_: `git reset {{--soft|--hard}} HEAD~1`
1054
1055- **Function Placeholder**: `{{variable:function}}`
1056 - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
1057 - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
1058 - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
1059
1060- **Secret/Ephemeral Placeholder**: `{{{...}}}`
1061 - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description).
1062 This syntax can wrap any of the placeholder types above.
1063 - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
1064
1065### Suggestion Strategy
1066Your primary goal is to provide the most relevant and comprehensive set of command templates. Adhere strictly to the following principles when deciding how many suggestions to provide:
1067
10681. **Explicit Single Suggestion:**
1069 - If the user's request explicitly asks for **a single suggestion**, you **MUST** return a list containing exactly one suggestion object.
1070 - To cover variations within this single command, make effective use of choice placeholders (e.g., `git reset {{--soft|--hard}}`).
1071
10722. **Clear & Unambiguous Request:**
1073 - If the request is straightforward and has one primary, standard solution, provide a **single, well-formed suggestion**.
1074
10753. **Ambiguous or Multi-faceted Request:**
1076 - If a request is ambiguous, has multiple valid interpretations, or can be solved using several distinct tools or methods, you **MUST provide a comprehensive list of suggestions**.
1077 - Each distinct approach or interpretation **must be a separate suggestion object**.
1078 - **Be comprehensive and do not limit your suggestions**. For example, a request for "undo a git commit" could mean `git reset`, `git revert`, or `git checkout`. A request to "find files" could yield suggestions for `find`, `fd`, and `locate`. Provide all valid, distinct alternatives.
1079 - **Order the suggestions by relevance**, with the most common or recommended solution appearing first.
1080"#,
1081 ),
1082 fix: String::from(
1083 r#"##OS_SHELL_INFO##
1084##WORKING_DIR##
1085##SHELL_HISTORY##
1086### Instructions
1087You are an expert command-line assistant. Your mission is to analyze a failed shell command and its error output,
1088diagnose the root cause, and provide a structured, actionable solution in a single JSON object.
1089
1090### Output Schema
1091Your response MUST be a single, valid JSON object with no surrounding text or markdown. It must conform to the following structure:
1092- `summary`: A very brief, 2-5 word summary of the error category. Examples: "Command Not Found", "Permission Denied", "Invalid Argument", "Git Typo".
1093- `diagnosis`: A detailed, human-readable explanation of the root cause of the error. This section should explain *what* went wrong and *why*, based on the provided command and error message. It should not contain the solution.
1094- `proposal`: A human-readable description of the recommended next steps. This can be a description of a fix, diagnostic commands to run, or a suggested workaround.
1095- `fixed_command`: The corrected, valid, ready-to-execute command string. This field should *only* be populated if a direct command correction is the primary solution (e.g., fixing a typo). For complex issues requiring explanation or privilege changes, this should be an empty string.
1096
1097### Core Rules
10981. **JSON Only**: Your entire output must be a single, raw JSON object. Do not wrap it in code blocks or add any explanatory text.
10992. **Holistic Analysis**: Analyze the command's context, syntax, and common user errors. Don't just parse the error message. Consider the user's likely intent.
11003. **Strict Wrapping**: Hard-wrap all string values within the JSON to a maximum of 80 characters.
11014. **`fixed_command` Logic**: Always populate `fixed_command` with the most likely command to resolve the error. Only leave this field as an empty string if the user's intent is unclear from the context.
1102"#,
1103 ),
1104 import: String::from(
1105 r#"### Instructions
1106You are an expert tool that extracts and generalizes shell command patterns from arbitrary text content. Your goal is to analyze the provided text, identify all unique command patterns, and present them as a list of suggestions.
1107
1108Your entire response MUST be a single, valid JSON object conforming to the provided schema. Output nothing but the JSON object itself.
1109
1110Refer to the syntax definitions, process, and example below to construct your response.
1111
1112### Command Template Syntax
1113When creating the `command` template string, you must use the following placeholder syntax:
1114
1115- **Standard Placeholder**: `{{variable-name}}`
1116 - Use for regular arguments that the user needs to provide.
1117 - _Example_: `echo "Hello, {{user-name}}!"`
1118
1119- **Choice Placeholder**: `{{option1|option2}}`
1120 - Use when the user must choose from a specific set of options.
1121 - _Example_: `git reset {{--soft|--hard}} HEAD~1`
1122
1123- **Function Placeholder**: `{{variable:function}}`
1124 - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
1125 - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
1126 - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
1127
1128- **Secret/Ephemeral Placeholder**: `{{{...}}}`
1129 - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description).
1130 This syntax can wrap any of the placeholder types above.
1131 - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
1132
1133### Core Process
11341. **Extract & Generalize**: Scan the text to find all shell commands. Generalize each one into a template by replacing specific values with the appropriate placeholder type defined in the **Command Template Syntax** section.
11352. **Deduplicate**: Consolidate multiple commands that follow the same pattern into a single, representative template. For example, `git checkout bugfix/some-bug` and `git checkout feature/login` must be merged into a single `git checkout {{feature|bugfix}}/{{{description:kebab}}}` suggestion.
1136
1137### Output Generation
1138For each unique and deduplicated command pattern you identify:
1139- Create a suggestion object containing a `description` and a `command`.
1140- The `description` must be a clear, single-sentence explanation of the command's purpose.
1141- The `command` must be the final, generalized template string from the core process.
1142"#,
1143 ),
1144 completion: String::from(
1145 r#"##OS_SHELL_INFO##
1146### Instructions
1147You are an expert CLI assistant. Your task is to generate a single-line shell command that will be executed in the background to fetch a list of dynamic command-line completions for a given variable.
1148
1149Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
1150
1151### Core Task
1152The command you create will be run non-interactively to generate a list of suggestions for the user. It must adapt to information that is already known (the "context").
1153
1154### Command Template Syntax
1155To make the command context-aware, you must use a special syntax for optional parts of the command. Any segment of the command that depends on contextual information must be wrapped in double curly braces `{{...}}`.
1156
1157- **Syntax**: `{{--parameter {{variable-name}}}}`
1158- **Rule**: The entire block, including the parameter and its variable, will only be included in the final command if the `variable-name` exists in the context. If the variable is not present, the entire block is omitted.
1159- **All-or-Nothing**: If a block contains multiple variables, all of them must be present in the context for the block to be included.
1160
1161- **_Example_**:
1162 - **Template**: `kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}`
1163 - If the context provides a `namespace`, the executed command becomes: `kubectl get pods -n prod`
1164 - If the context provides both `namespace` and `context`, it becomes: `kubectl get pods --context my-cluster -n prod`
1165 - If the context is empty, it is simply: `kubectl get pods`
1166
1167### Requirements
11681. **JSON Only**: Your entire output must be a single, raw JSON object. Do not add any explanatory text.
11692. **Context is Key**: Every variable like `{{variable-name}}` must be part of a surrounding conditional block `{{...}}`. The command cannot ask for new information.
11703. **Produce a List**: The final command, after resolving the context, must print a list of strings to standard output, with each item on a new line. This list will be the source for the completions.
11714. **Executable**: The command must be syntactically correct and executable.
1172"#,
1173 ),
1174 }
1175 }
1176}
1177
1178fn deserialize_bindings_with_defaults<'de, D>(
1184 deserializer: D,
1185) -> Result<BTreeMap<KeyBindingAction, KeyBinding>, D::Error>
1186where
1187 D: Deserializer<'de>,
1188{
1189 let user_provided_bindings = BTreeMap::<KeyBindingAction, KeyBinding>::deserialize(deserializer)?;
1191
1192 #[cfg(test)]
1193 {
1194 use strum::IntoEnumIterator;
1195 for action_variant in KeyBindingAction::iter() {
1197 if !user_provided_bindings.contains_key(&action_variant) {
1198 return Err(D::Error::custom(format!(
1199 "Missing key binding for action '{action_variant:?}'."
1200 )));
1201 }
1202 }
1203 Ok(user_provided_bindings)
1204 }
1205 #[cfg(not(test))]
1206 {
1207 let mut final_bindings = user_provided_bindings;
1210 let default_bindings = KeyBindingsConfig::default();
1211
1212 for (action, default_binding) in default_bindings.0 {
1213 final_bindings.entry(action).or_insert(default_binding);
1214 }
1215 Ok(final_bindings)
1216 }
1217}
1218
1219fn deserialize_key_events<'de, D>(deserializer: D) -> Result<Vec<KeyEvent>, D::Error>
1223where
1224 D: Deserializer<'de>,
1225{
1226 #[derive(Deserialize)]
1227 #[serde(untagged)]
1228 enum StringOrVec {
1229 Single(String),
1230 Multiple(Vec<String>),
1231 }
1232
1233 let strings = match StringOrVec::deserialize(deserializer)? {
1234 StringOrVec::Single(s) => vec![s],
1235 StringOrVec::Multiple(v) => v,
1236 };
1237
1238 strings
1239 .iter()
1240 .map(String::as_str)
1241 .map(parse_key_event)
1242 .map(|r| r.map_err(D::Error::custom))
1243 .collect()
1244}
1245
1246fn deserialize_color<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
1251where
1252 D: Deserializer<'de>,
1253{
1254 parse_color(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1255}
1256
1257fn deserialize_style<'de, D>(deserializer: D) -> Result<ContentStyle, D::Error>
1261where
1262 D: Deserializer<'de>,
1263{
1264 parse_style(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1265}
1266
1267fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
1271 let raw_lower = raw.to_ascii_lowercase();
1272 let (remaining, modifiers) = extract_key_modifiers(&raw_lower);
1273 parse_key_code_with_modifiers(remaining, modifiers)
1274}
1275
1276fn extract_key_modifiers(raw: &str) -> (&str, KeyModifiers) {
1280 let mut modifiers = KeyModifiers::empty();
1281 let mut current = raw;
1282
1283 loop {
1284 match current {
1285 rest if rest.starts_with("ctrl-") || rest.starts_with("ctrl+") => {
1286 modifiers.insert(KeyModifiers::CONTROL);
1287 current = &rest[5..];
1288 }
1289 rest if rest.starts_with("shift-") || rest.starts_with("shift+") => {
1290 modifiers.insert(KeyModifiers::SHIFT);
1291 current = &rest[6..];
1292 }
1293 rest if rest.starts_with("alt-") || rest.starts_with("alt+") => {
1294 modifiers.insert(KeyModifiers::ALT);
1295 current = &rest[4..];
1296 }
1297 _ => break,
1298 };
1299 }
1300
1301 (current, modifiers)
1302}
1303
1304fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
1306 let code = match raw {
1307 "esc" => KeyCode::Esc,
1308 "enter" => KeyCode::Enter,
1309 "left" => KeyCode::Left,
1310 "right" => KeyCode::Right,
1311 "up" => KeyCode::Up,
1312 "down" => KeyCode::Down,
1313 "home" => KeyCode::Home,
1314 "end" => KeyCode::End,
1315 "pageup" => KeyCode::PageUp,
1316 "pagedown" => KeyCode::PageDown,
1317 "backtab" => {
1318 modifiers.insert(KeyModifiers::SHIFT);
1319 KeyCode::BackTab
1320 }
1321 "backspace" => KeyCode::Backspace,
1322 "delete" => KeyCode::Delete,
1323 "insert" => KeyCode::Insert,
1324 "f1" => KeyCode::F(1),
1325 "f2" => KeyCode::F(2),
1326 "f3" => KeyCode::F(3),
1327 "f4" => KeyCode::F(4),
1328 "f5" => KeyCode::F(5),
1329 "f6" => KeyCode::F(6),
1330 "f7" => KeyCode::F(7),
1331 "f8" => KeyCode::F(8),
1332 "f9" => KeyCode::F(9),
1333 "f10" => KeyCode::F(10),
1334 "f11" => KeyCode::F(11),
1335 "f12" => KeyCode::F(12),
1336 "space" | "spacebar" => KeyCode::Char(' '),
1337 "hyphen" => KeyCode::Char('-'),
1338 "minus" => KeyCode::Char('-'),
1339 "tab" => KeyCode::Tab,
1340 c if c.len() == 1 => {
1341 let mut c = c.chars().next().expect("just checked");
1342 if modifiers.contains(KeyModifiers::SHIFT) {
1343 c = c.to_ascii_uppercase();
1344 }
1345 KeyCode::Char(c)
1346 }
1347 _ => return Err(format!("Unable to parse key binding: {raw}")),
1348 };
1349 Ok(KeyEvent::new(code, modifiers))
1350}
1351
1352fn parse_color(raw: &str) -> Result<Option<Color>, String> {
1356 let raw_lower = raw.to_ascii_lowercase();
1357 if raw.is_empty() || raw == "none" {
1358 Ok(None)
1359 } else {
1360 Ok(Some(parse_color_inner(&raw_lower)?))
1361 }
1362}
1363
1364fn parse_style(raw: &str) -> Result<ContentStyle, String> {
1368 let raw_lower = raw.to_ascii_lowercase();
1369 let (remaining, attributes) = extract_style_attributes(&raw_lower);
1370 let mut style = ContentStyle::new();
1371 style.attributes = attributes;
1372 if !remaining.is_empty() && remaining != "default" {
1373 style.foreground_color = Some(parse_color_inner(remaining)?);
1374 }
1375 Ok(style)
1376}
1377
1378fn extract_style_attributes(raw: &str) -> (&str, Attributes) {
1382 let mut attributes = Attributes::none();
1383 let mut current = raw;
1384
1385 loop {
1386 match current {
1387 rest if rest.starts_with("bold") => {
1388 attributes.set(Attribute::Bold);
1389 current = &rest[4..];
1390 if current.starts_with(' ') {
1391 current = ¤t[1..];
1392 }
1393 }
1394 rest if rest.starts_with("dim") => {
1395 attributes.set(Attribute::Dim);
1396 current = &rest[3..];
1397 if current.starts_with(' ') {
1398 current = ¤t[1..];
1399 }
1400 }
1401 rest if rest.starts_with("italic") => {
1402 attributes.set(Attribute::Italic);
1403 current = &rest[6..];
1404 if current.starts_with(' ') {
1405 current = ¤t[1..];
1406 }
1407 }
1408 rest if rest.starts_with("underline") => {
1409 attributes.set(Attribute::Underlined);
1410 current = &rest[9..];
1411 if current.starts_with(' ') {
1412 current = ¤t[1..];
1413 }
1414 }
1415 rest if rest.starts_with("underlined") => {
1416 attributes.set(Attribute::Underlined);
1417 current = &rest[10..];
1418 if current.starts_with(' ') {
1419 current = ¤t[1..];
1420 }
1421 }
1422 _ => break,
1423 };
1424 }
1425
1426 (current.trim(), attributes)
1427}
1428
1429fn parse_color_inner(raw: &str) -> Result<Color, String> {
1433 Ok(match raw {
1434 "black" => Color::Black,
1435 "red" => Color::Red,
1436 "green" => Color::Green,
1437 "yellow" => Color::Yellow,
1438 "blue" => Color::Blue,
1439 "magenta" => Color::Magenta,
1440 "cyan" => Color::Cyan,
1441 "gray" | "grey" => Color::Grey,
1442 "dark gray" | "darkgray" | "dark grey" | "darkgrey" => Color::DarkGrey,
1443 "dark red" | "darkred" => Color::DarkRed,
1444 "dark green" | "darkgreen" => Color::DarkGreen,
1445 "dark yellow" | "darkyellow" => Color::DarkYellow,
1446 "dark blue" | "darkblue" => Color::DarkBlue,
1447 "dark magenta" | "darkmagenta" => Color::DarkMagenta,
1448 "dark cyan" | "darkcyan" => Color::DarkCyan,
1449 "white" => Color::White,
1450 rgb if rgb.starts_with("rgb(") => {
1451 let rgb = rgb.trim_start_matches("rgb(").trim_end_matches(")").split(',');
1452 let rgb = rgb
1453 .map(|c| c.trim().parse::<u8>())
1454 .collect::<Result<Vec<u8>, _>>()
1455 .map_err(|_| format!("Unable to parse color: {raw}"))?;
1456 if rgb.len() != 3 {
1457 return Err(format!("Unable to parse color: {raw}"));
1458 }
1459 Color::Rgb {
1460 r: rgb[0],
1461 g: rgb[1],
1462 b: rgb[2],
1463 }
1464 }
1465 hex if hex.starts_with("#") => {
1466 let hex = hex.trim_start_matches("#");
1467 if hex.len() != 6 {
1468 return Err(format!("Unable to parse color: {raw}"));
1469 }
1470 let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1471 let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1472 let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1473 Color::Rgb { r, g, b }
1474 }
1475 c => {
1476 if let Ok(c) = c.parse::<u8>() {
1477 Color::AnsiValue(c)
1478 } else {
1479 return Err(format!("Unable to parse color: {raw}"));
1480 }
1481 }
1482 })
1483}
1484
1485fn deserialize_catalog_with_defaults<'de, D>(deserializer: D) -> Result<BTreeMap<String, AiModelConfig>, D::Error>
1490where
1491 D: Deserializer<'de>,
1492{
1493 #[allow(unused_mut)]
1494 let mut user_catalog = BTreeMap::<String, AiModelConfig>::deserialize(deserializer)?;
1496
1497 #[cfg(not(test))]
1499 for (key, default_model) in default_ai_catalog() {
1500 user_catalog.entry(key).or_insert(default_model);
1501 }
1502
1503 Ok(user_catalog)
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508 use pretty_assertions::assert_eq;
1509 use strum::IntoEnumIterator;
1510
1511 use super::*;
1512
1513 #[test]
1514 fn test_default_config() -> Result<()> {
1515 let config_str = fs::read_to_string("default_config.toml").wrap_err("Couldn't read default config file")?;
1516 let config: Config = toml::from_str(&config_str).wrap_err("Couldn't parse default config file")?;
1517
1518 assert_eq!(Config::default(), config);
1519
1520 Ok(())
1521 }
1522
1523 #[test]
1524 fn test_default_keybindings_complete() {
1525 let config = KeyBindingsConfig::default();
1526
1527 for action in KeyBindingAction::iter() {
1528 assert!(
1529 config.0.contains_key(&action),
1530 "Missing default binding for action: {action:?}"
1531 );
1532 }
1533 }
1534
1535 #[test]
1536 fn test_default_keybindings_no_conflicts() {
1537 let config = KeyBindingsConfig::default();
1538
1539 let conflicts = config.find_conflicts();
1540 assert_eq!(conflicts.len(), 0, "Key binding conflicts: {conflicts:?}");
1541 }
1542
1543 #[test]
1544 fn test_keybinding_matches() {
1545 let binding = KeyBinding(vec![
1546 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
1547 KeyEvent::from(KeyCode::Enter),
1548 ]);
1549
1550 assert!(binding.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
1552 assert!(binding.matches(&KeyEvent::from(KeyCode::Enter)));
1553
1554 assert!(!binding.matches(&KeyEvent::new(
1556 KeyCode::Char('a'),
1557 KeyModifiers::CONTROL | KeyModifiers::ALT
1558 )));
1559
1560 assert!(!binding.matches(&KeyEvent::from(KeyCode::Esc)));
1562 }
1563
1564 #[test]
1565 fn test_simple_keys() {
1566 assert_eq!(
1567 parse_key_event("a").unwrap(),
1568 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
1569 );
1570
1571 assert_eq!(
1572 parse_key_event("enter").unwrap(),
1573 KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
1574 );
1575
1576 assert_eq!(
1577 parse_key_event("esc").unwrap(),
1578 KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
1579 );
1580 }
1581
1582 #[test]
1583 fn test_with_modifiers() {
1584 assert_eq!(
1585 parse_key_event("ctrl-a").unwrap(),
1586 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
1587 );
1588
1589 assert_eq!(
1590 parse_key_event("alt-enter").unwrap(),
1591 KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
1592 );
1593
1594 assert_eq!(
1595 parse_key_event("shift-esc").unwrap(),
1596 KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
1597 );
1598 }
1599
1600 #[test]
1601 fn test_multiple_modifiers() {
1602 assert_eq!(
1603 parse_key_event("ctrl-alt-a").unwrap(),
1604 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
1605 );
1606
1607 assert_eq!(
1608 parse_key_event("ctrl-shift-enter").unwrap(),
1609 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
1610 );
1611 }
1612
1613 #[test]
1614 fn test_invalid_keys() {
1615 let res = parse_key_event("invalid-key");
1616 assert_eq!(res, Err(String::from("Unable to parse key binding: invalid-key")));
1617 }
1618
1619 #[test]
1620 fn test_parse_color_none() {
1621 let color = parse_color("none").unwrap();
1622 assert_eq!(color, None);
1623 }
1624
1625 #[test]
1626 fn test_parse_color_simple() {
1627 let color = parse_color("red").unwrap();
1628 assert_eq!(color, Some(Color::Red));
1629 }
1630
1631 #[test]
1632 fn test_parse_color_rgb() {
1633 let color = parse_color("rgb(50, 25, 15)").unwrap();
1634 assert_eq!(color, Some(Color::Rgb { r: 50, g: 25, b: 15 }));
1635 }
1636
1637 #[test]
1638 fn test_parse_color_rgb_out_of_range() {
1639 let res = parse_color("rgb(500, 25, 15)");
1640 assert_eq!(res, Err(String::from("Unable to parse color: rgb(500, 25, 15)")));
1641 }
1642
1643 #[test]
1644 fn test_parse_color_rgb_invalid() {
1645 let res = parse_color("rgb(50, 25, 15, 5)");
1646 assert_eq!(res, Err(String::from("Unable to parse color: rgb(50, 25, 15, 5)")));
1647 }
1648
1649 #[test]
1650 fn test_parse_color_hex() {
1651 let color = parse_color("#4287f5").unwrap();
1652 assert_eq!(color, Some(Color::Rgb { r: 66, g: 135, b: 245 }));
1653 }
1654
1655 #[test]
1656 fn test_parse_color_hex_out_of_range() {
1657 let res = parse_color("#4287fg");
1658 assert_eq!(res, Err(String::from("Unable to parse color: #4287fg")));
1659 }
1660
1661 #[test]
1662 fn test_parse_color_hex_invalid() {
1663 let res = parse_color("#4287f50");
1664 assert_eq!(res, Err(String::from("Unable to parse color: #4287f50")));
1665 }
1666
1667 #[test]
1668 fn test_parse_color_index() {
1669 let color = parse_color("6").unwrap();
1670 assert_eq!(color, Some(Color::AnsiValue(6)));
1671 }
1672
1673 #[test]
1674 fn test_parse_color_fail() {
1675 let res = parse_color("1234");
1676 assert_eq!(res, Err(String::from("Unable to parse color: 1234")));
1677 }
1678
1679 #[test]
1680 fn test_parse_style_empty() {
1681 let style = parse_style("").unwrap();
1682 assert_eq!(style, ContentStyle::new());
1683 }
1684
1685 #[test]
1686 fn test_parse_style_default() {
1687 let style = parse_style("default").unwrap();
1688 assert_eq!(style, ContentStyle::new());
1689 }
1690
1691 #[test]
1692 fn test_parse_style_simple() {
1693 let style = parse_style("red").unwrap();
1694 assert_eq!(style.foreground_color, Some(Color::Red));
1695 assert_eq!(style.attributes, Attributes::none());
1696 }
1697
1698 #[test]
1699 fn test_parse_style_only_modifier() {
1700 let style = parse_style("bold").unwrap();
1701 assert_eq!(style.foreground_color, None);
1702 let mut expected_attributes = Attributes::none();
1703 expected_attributes.set(Attribute::Bold);
1704 assert_eq!(style.attributes, expected_attributes);
1705 }
1706
1707 #[test]
1708 fn test_parse_style_with_modifier() {
1709 let style = parse_style("italic red").unwrap();
1710 assert_eq!(style.foreground_color, Some(Color::Red));
1711 let mut expected_attributes = Attributes::none();
1712 expected_attributes.set(Attribute::Italic);
1713 assert_eq!(style.attributes, expected_attributes);
1714 }
1715
1716 #[test]
1717 fn test_parse_style_multiple_modifier() {
1718 let style = parse_style("underline dim dark red").unwrap();
1719 assert_eq!(style.foreground_color, Some(Color::DarkRed));
1720 let mut expected_attributes = Attributes::none();
1721 expected_attributes.set(Attribute::Underlined);
1722 expected_attributes.set(Attribute::Dim);
1723 assert_eq!(style.attributes, expected_attributes);
1724 }
1725}