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