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 search: SearchConfig,
40 pub logs: LogsConfig,
42 pub keybindings: KeyBindingsConfig,
44 pub theme: Theme,
46 pub gist: GistConfig,
48 pub tuning: SearchTuning,
50 pub ai: AiConfig,
52}
53
54#[derive(Clone, Copy, Deserialize)]
56#[cfg_attr(test, derive(Debug, PartialEq))]
57#[cfg_attr(not(test), serde(default))]
58pub struct SearchConfig {
59 pub delay: u64,
61 pub mode: SearchMode,
63 pub user_only: bool,
65 pub exec_on_alias_match: bool,
67}
68
69#[derive(Clone, Deserialize)]
71#[cfg_attr(test, derive(Debug, PartialEq))]
72#[cfg_attr(not(test), serde(default))]
73pub struct LogsConfig {
74 pub enabled: bool,
76 pub filter: String,
80}
81
82#[derive(Clone, Deserialize)]
87#[cfg_attr(test, derive(Debug, PartialEq))]
88#[cfg_attr(not(test), serde(default))]
89pub struct KeyBindingsConfig(
90 #[serde(deserialize_with = "deserialize_bindings_with_defaults")] BTreeMap<KeyBindingAction, KeyBinding>,
91);
92
93#[derive(Copy, Clone, Deserialize, PartialOrd, PartialEq, Eq, Ord, Debug)]
95#[cfg_attr(test, derive(strum::EnumIter))]
96#[serde(rename_all = "snake_case")]
97pub enum KeyBindingAction {
98 Quit,
100 Update,
102 Delete,
104 Confirm,
106 Execute,
108 #[serde(rename = "ai")]
110 AI,
111 SearchMode,
113 SearchUserOnly,
115 VariableNext,
117 VariablePrev,
119}
120
121#[derive(Clone, Deserialize)]
126#[cfg_attr(test, derive(Debug, PartialEq))]
127pub struct KeyBinding(#[serde(deserialize_with = "deserialize_key_events")] Vec<KeyEvent>);
128
129#[derive(Clone, Deserialize)]
133#[cfg_attr(test, derive(Debug, PartialEq))]
134#[cfg_attr(not(test), serde(default))]
135pub struct Theme {
136 #[serde(deserialize_with = "deserialize_style")]
138 pub primary: ContentStyle,
139 #[serde(deserialize_with = "deserialize_style")]
141 pub secondary: ContentStyle,
142 #[serde(deserialize_with = "deserialize_style")]
144 pub accent: ContentStyle,
145 #[serde(deserialize_with = "deserialize_style")]
147 pub comment: ContentStyle,
148 #[serde(deserialize_with = "deserialize_style")]
150 pub error: ContentStyle,
151 #[serde(deserialize_with = "deserialize_color")]
153 pub highlight: Option<Color>,
154 pub highlight_symbol: String,
156 #[serde(deserialize_with = "deserialize_style")]
158 pub highlight_primary: ContentStyle,
159 #[serde(deserialize_with = "deserialize_style")]
161 pub highlight_secondary: ContentStyle,
162 #[serde(deserialize_with = "deserialize_style")]
164 pub highlight_accent: ContentStyle,
165 #[serde(deserialize_with = "deserialize_style")]
167 pub highlight_comment: ContentStyle,
168}
169
170#[derive(Clone, Default, Deserialize)]
172#[cfg_attr(test, derive(Debug, PartialEq))]
173pub struct GistConfig {
174 pub id: String,
176 pub token: String,
178}
179
180#[derive(Clone, Copy, Default, Deserialize)]
182#[cfg_attr(test, derive(Debug, PartialEq))]
183#[cfg_attr(not(test), serde(default))]
184pub struct SearchTuning {
185 pub commands: SearchCommandTuning,
187 pub variables: SearchVariableTuning,
189}
190
191#[derive(Clone, Copy, Default, Deserialize)]
193#[cfg_attr(test, derive(Debug, PartialEq))]
194#[cfg_attr(not(test), serde(default))]
195pub struct SearchCommandTuning {
196 pub text: SearchCommandsTextTuning,
198 pub path: SearchPathTuning,
200 pub usage: SearchUsageTuning,
202}
203
204#[derive(Clone, Copy, Deserialize)]
206#[cfg_attr(test, derive(Debug, PartialEq))]
207#[cfg_attr(not(test), serde(default))]
208pub struct SearchCommandsTextTuning {
209 pub points: u32,
211 pub command: f64,
213 pub description: f64,
215 pub auto: SearchCommandsTextAutoTuning,
217}
218
219#[derive(Clone, Copy, Deserialize)]
221#[cfg_attr(test, derive(Debug, PartialEq))]
222#[cfg_attr(not(test), serde(default))]
223pub struct SearchCommandsTextAutoTuning {
224 pub prefix: f64,
226 pub fuzzy: f64,
228 pub relaxed: f64,
230 pub root: f64,
232}
233
234#[derive(Clone, Copy, Deserialize)]
236#[cfg_attr(test, derive(Debug, PartialEq))]
237#[cfg_attr(not(test), serde(default))]
238pub struct SearchPathTuning {
239 pub points: u32,
241 pub exact: f64,
243 pub ancestor: f64,
245 pub descendant: f64,
247 pub unrelated: f64,
249}
250
251#[derive(Clone, Copy, Deserialize)]
253#[cfg_attr(test, derive(Debug, PartialEq))]
254#[cfg_attr(not(test), serde(default))]
255pub struct SearchUsageTuning {
256 pub points: u32,
258}
259
260#[derive(Clone, Copy, Default, Deserialize)]
262#[cfg_attr(test, derive(Debug, PartialEq))]
263#[cfg_attr(not(test), serde(default))]
264pub struct SearchVariableTuning {
265 pub completion: SearchVariableCompletionTuning,
267 pub context: SearchVariableContextTuning,
269 pub path: SearchPathTuning,
271}
272
273#[derive(Clone, Copy, Deserialize)]
275#[cfg_attr(test, derive(Debug, PartialEq))]
276#[cfg_attr(not(test), serde(default))]
277pub struct SearchVariableCompletionTuning {
278 pub points: u32,
280}
281
282#[derive(Clone, Copy, Deserialize)]
284#[cfg_attr(test, derive(Debug, PartialEq))]
285#[cfg_attr(not(test), serde(default))]
286pub struct SearchVariableContextTuning {
287 pub points: u32,
289}
290
291#[derive(Clone, Deserialize)]
293#[cfg_attr(test, derive(Debug, PartialEq))]
294#[cfg_attr(not(test), serde(default))]
295pub struct AiConfig {
296 pub enabled: bool,
298 pub prompts: AiPromptsConfig,
300 pub models: AiModelsConfig,
302 #[serde(deserialize_with = "deserialize_catalog_with_defaults")]
307 pub catalog: BTreeMap<String, AiModelConfig>,
308}
309
310#[derive(Clone, Deserialize)]
312#[cfg_attr(test, derive(Debug, PartialEq))]
313#[cfg_attr(not(test), serde(default))]
314pub struct AiPromptsConfig {
315 pub suggest: String,
317 pub fix: String,
319 pub import: String,
321 pub completion: String,
323}
324
325#[derive(Clone, Deserialize)]
327#[cfg_attr(test, derive(Debug, PartialEq))]
328#[cfg_attr(not(test), serde(default))]
329pub struct AiModelsConfig {
330 pub suggest: String,
333 pub fix: String,
336 pub import: String,
339 pub completion: String,
342 pub fallback: String,
345}
346
347#[derive(Clone, Deserialize)]
349#[cfg_attr(test, derive(Debug, PartialEq))]
350#[serde(tag = "provider", rename_all = "snake_case")]
351pub enum AiModelConfig {
352 Openai(OpenAiModelConfig),
354 Gemini(GeminiModelConfig),
356 Anthropic(AnthropicModelConfig),
358 Ollama(OllamaModelConfig),
360}
361
362#[derive(Clone, Deserialize)]
364#[cfg_attr(test, derive(Debug, PartialEq))]
365pub struct OpenAiModelConfig {
366 pub model: String,
368 #[serde(default = "default_openai_url")]
372 pub url: String,
373 #[serde(default = "default_openai_api_key_env")]
375 pub api_key_env: String,
376}
377fn default_openai_url() -> String {
378 "https://api.openai.com/v1".to_string()
379}
380fn default_openai_api_key_env() -> String {
381 "OPENAI_API_KEY".to_string()
382}
383
384#[derive(Clone, Deserialize)]
386#[cfg_attr(test, derive(Debug, PartialEq))]
387pub struct GeminiModelConfig {
388 pub model: String,
390 #[serde(default = "default_gemini_url")]
392 pub url: String,
393 #[serde(default = "default_gemini_api_key_env")]
395 pub api_key_env: String,
396}
397fn default_gemini_url() -> String {
398 "https://generativelanguage.googleapis.com/v1beta".to_string()
399}
400fn default_gemini_api_key_env() -> String {
401 "GEMINI_API_KEY".to_string()
402}
403
404#[derive(Clone, Deserialize)]
406#[cfg_attr(test, derive(Debug, PartialEq))]
407pub struct AnthropicModelConfig {
408 pub model: String,
410 #[serde(default = "default_anthropic_url")]
412 pub url: String,
413 #[serde(default = "default_anthropic_api_key_env")]
415 pub api_key_env: String,
416}
417fn default_anthropic_url() -> String {
418 "https://api.anthropic.com/v1".to_string()
419}
420fn default_anthropic_api_key_env() -> String {
421 "ANTHROPIC_API_KEY".to_string()
422}
423
424#[derive(Clone, Deserialize)]
426#[cfg_attr(test, derive(Debug, PartialEq))]
427pub struct OllamaModelConfig {
428 pub model: String,
430 #[serde(default = "default_ollama_url")]
432 pub url: String,
433 #[serde(default = "default_ollama_api_key_env")]
435 pub api_key_env: String,
436}
437fn default_ollama_url() -> String {
438 "http://localhost:11434".to_string()
439}
440fn default_ollama_api_key_env() -> String {
441 "OLLAMA_API_KEY".to_string()
442}
443
444pub struct ConfigLoadStats {
446 pub default_config_path: bool,
448 pub config_path: PathBuf,
450 pub config_loaded: bool,
452 pub default_data_dir: bool,
454}
455
456impl Config {
457 pub fn init(config_file: Option<PathBuf>) -> Result<(Self, ConfigLoadStats)> {
462 let proj_dirs = ProjectDirs::from("org", "IntelliShell", "Intelli-Shell")
464 .wrap_err("Couldn't initialize project directory")?;
465 let config_dir = proj_dirs.config_dir().to_path_buf();
466
467 let mut stats = ConfigLoadStats {
469 default_config_path: config_file.is_none(),
470 config_path: config_file.unwrap_or_else(|| config_dir.join("config.toml")),
471 config_loaded: false,
472 default_data_dir: false,
473 };
474 let mut config = if stats.config_path.exists() {
475 stats.config_loaded = true;
476 let config_str = fs::read_to_string(&stats.config_path)
478 .wrap_err_with(|| format!("Couldn't read config file {}", stats.config_path.display()))?;
479 toml::from_str(&config_str)
480 .wrap_err_with(|| format!("Couldn't parse config file {}", stats.config_path.display()))?
481 } else {
482 Config::default()
484 };
485 if config.data_dir.as_os_str().is_empty() {
487 stats.default_data_dir = true;
488 config.data_dir = proj_dirs.data_dir().to_path_buf();
489 }
490
491 let conflicts = config.keybindings.find_conflicts();
493 if !conflicts.is_empty() {
494 return Err(eyre!(
495 "Couldn't parse config file {}\n\nThere are some key binding conflicts:\n{}",
496 stats.config_path.display(),
497 conflicts
498 .into_iter()
499 .map(|(_, a)| format!("- {}", a.into_iter().map(|a| format!("{a:?}")).join(", ")))
500 .join("\n")
501 ));
502 }
503
504 if config.ai.enabled {
506 let AiModelsConfig {
507 suggest,
508 fix,
509 import,
510 completion,
511 fallback,
512 } = &config.ai.models;
513 let catalog = &config.ai.catalog;
514
515 let mut missing = Vec::new();
516 if !catalog.contains_key(suggest) {
517 missing.push((suggest, "suggest"));
518 }
519 if !catalog.contains_key(fix) {
520 missing.push((fix, "fix"));
521 }
522 if !catalog.contains_key(import) {
523 missing.push((import, "import"));
524 }
525 if !catalog.contains_key(completion) {
526 missing.push((completion, "completion"));
527 }
528 if !catalog.contains_key(fallback) {
529 missing.push((fallback, "fallback"));
530 }
531
532 if !missing.is_empty() {
533 return Err(eyre!(
534 "Couldn't parse config file {}\n\nMissing model definitions on the catalog:\n{}",
535 stats.config_path.display(),
536 missing
537 .into_iter()
538 .into_group_map()
539 .into_iter()
540 .map(|(k, v)| format!(
541 "- {k} used in {}",
542 v.into_iter().map(|v| format!("ai.models.{v}")).join(", ")
543 ))
544 .join("\n")
545 ));
546 }
547 }
548
549 fs::create_dir_all(&config.data_dir)
551 .wrap_err_with(|| format!("Could't create data dir {}", config.data_dir.display()))?;
552
553 Ok((config, stats))
554 }
555}
556
557impl KeyBindingsConfig {
558 pub fn get(&self, action: &KeyBindingAction) -> &KeyBinding {
560 self.0.get(action).unwrap()
561 }
562
563 pub fn get_action_matching(&self, event: &KeyEvent) -> Option<KeyBindingAction> {
565 self.0.iter().find_map(
566 |(action, binding)| {
567 if binding.matches(event) { Some(*action) } else { None }
568 },
569 )
570 }
571
572 pub fn find_conflicts(&self) -> Vec<(KeyEvent, Vec<KeyBindingAction>)> {
574 let mut event_to_actions_map: HashMap<KeyEvent, Vec<KeyBindingAction>> = HashMap::new();
576
577 for (action, key_binding) in self.0.iter() {
579 for event_in_binding in key_binding.0.iter() {
581 event_to_actions_map.entry(*event_in_binding).or_default().push(*action);
583 }
584 }
585
586 event_to_actions_map
588 .into_iter()
589 .filter_map(|(key_event, actions)| {
590 if actions.len() > 1 {
591 Some((key_event, actions))
592 } else {
593 None
594 }
595 })
596 .collect()
597 }
598}
599
600impl KeyBinding {
601 pub fn matches(&self, event: &KeyEvent) -> bool {
604 self.0
605 .iter()
606 .any(|e| e.code == event.code && e.modifiers == event.modifiers)
607 }
608}
609
610impl Theme {
611 pub fn highlight_primary_full(&self) -> ContentStyle {
613 if let Some(color) = self.highlight {
614 let mut ret = self.highlight_primary;
615 ret.background_color = Some(color);
616 ret
617 } else {
618 self.highlight_primary
619 }
620 }
621
622 pub fn highlight_secondary_full(&self) -> ContentStyle {
624 if let Some(color) = self.highlight {
625 let mut ret = self.highlight_secondary;
626 ret.background_color = Some(color);
627 ret
628 } else {
629 self.highlight_secondary
630 }
631 }
632
633 pub fn highlight_accent_full(&self) -> ContentStyle {
635 if let Some(color) = self.highlight {
636 let mut ret = self.highlight_accent;
637 ret.background_color = Some(color);
638 ret
639 } else {
640 self.highlight_accent
641 }
642 }
643
644 pub fn highlight_comment_full(&self) -> ContentStyle {
646 if let Some(color) = self.highlight {
647 let mut ret = self.highlight_comment;
648 ret.background_color = Some(color);
649 ret
650 } else {
651 self.highlight_comment
652 }
653 }
654}
655
656impl AiConfig {
657 pub fn suggest_client(&self) -> crate::errors::Result<AiClient<'_>> {
659 AiClient::new(
660 &self.models.suggest,
661 self.catalog.get(&self.models.suggest).unwrap(),
662 &self.models.fallback,
663 self.catalog.get(&self.models.fallback),
664 )
665 }
666
667 pub fn fix_client(&self) -> crate::errors::Result<AiClient<'_>> {
669 AiClient::new(
670 &self.models.fix,
671 self.catalog.get(&self.models.fix).unwrap(),
672 &self.models.fallback,
673 self.catalog.get(&self.models.fallback),
674 )
675 }
676
677 pub fn import_client(&self) -> crate::errors::Result<AiClient<'_>> {
679 AiClient::new(
680 &self.models.import,
681 self.catalog.get(&self.models.import).unwrap(),
682 &self.models.fallback,
683 self.catalog.get(&self.models.fallback),
684 )
685 }
686
687 pub fn completion_client(&self) -> crate::errors::Result<AiClient<'_>> {
689 AiClient::new(
690 &self.models.completion,
691 self.catalog.get(&self.models.completion).unwrap(),
692 &self.models.fallback,
693 self.catalog.get(&self.models.fallback),
694 )
695 }
696}
697impl AiModelConfig {
698 pub fn provider(&self) -> &dyn AiProviderBase {
699 match self {
700 AiModelConfig::Openai(conf) => conf,
701 AiModelConfig::Gemini(conf) => conf,
702 AiModelConfig::Anthropic(conf) => conf,
703 AiModelConfig::Ollama(conf) => conf,
704 }
705 }
706}
707
708impl Default for Config {
709 fn default() -> Self {
710 Self {
711 data_dir: PathBuf::new(),
712 check_updates: true,
713 inline: true,
714 search: SearchConfig::default(),
715 logs: LogsConfig::default(),
716 keybindings: KeyBindingsConfig::default(),
717 theme: Theme::default(),
718 gist: GistConfig::default(),
719 tuning: SearchTuning::default(),
720 ai: AiConfig::default(),
721 }
722 }
723}
724impl Default for SearchConfig {
725 fn default() -> Self {
726 Self {
727 delay: 250,
728 mode: SearchMode::Auto,
729 user_only: false,
730 exec_on_alias_match: false,
731 }
732 }
733}
734impl Default for LogsConfig {
735 fn default() -> Self {
736 Self {
737 enabled: false,
738 filter: String::from("info"),
739 }
740 }
741}
742impl Default for KeyBindingsConfig {
743 fn default() -> Self {
744 Self(BTreeMap::from([
745 (KeyBindingAction::Quit, KeyBinding(vec![KeyEvent::from(KeyCode::Esc)])),
746 (
747 KeyBindingAction::Update,
748 KeyBinding(vec![
749 KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
750 KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
751 KeyEvent::from(KeyCode::F(2)),
752 ]),
753 ),
754 (
755 KeyBindingAction::Delete,
756 KeyBinding(vec![KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)]),
757 ),
758 (
759 KeyBindingAction::Confirm,
760 KeyBinding(vec![KeyEvent::from(KeyCode::Tab), KeyEvent::from(KeyCode::Enter)]),
761 ),
762 (
763 KeyBindingAction::Execute,
764 KeyBinding(vec![
765 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
766 KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
767 ]),
768 ),
769 (
770 KeyBindingAction::AI,
771 KeyBinding(vec![
772 KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL),
773 KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
774 ]),
775 ),
776 (
777 KeyBindingAction::SearchMode,
778 KeyBinding(vec![KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)]),
779 ),
780 (
781 KeyBindingAction::SearchUserOnly,
782 KeyBinding(vec![KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)]),
783 ),
784 (
785 KeyBindingAction::VariableNext,
786 KeyBinding(vec![KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL)]),
787 ),
788 (
789 KeyBindingAction::VariablePrev,
790 KeyBinding(vec![
791 KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT),
792 KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
793 ]),
794 ),
795 ]))
796 }
797}
798impl Default for Theme {
799 fn default() -> Self {
800 let primary = ContentStyle::new();
801 let highlight_primary = primary;
802
803 let mut secondary = ContentStyle::new();
804 secondary.attributes.set(Attribute::Dim);
805 let highlight_secondary = ContentStyle::new();
806
807 let mut accent = ContentStyle::new();
808 accent.foreground_color = Some(Color::Yellow);
809 let highlight_accent = accent;
810
811 let mut comment = ContentStyle::new();
812 comment.foreground_color = Some(Color::Green);
813 comment.attributes.set(Attribute::Italic);
814 let highlight_comment = comment;
815
816 let mut error = ContentStyle::new();
817 error.foreground_color = Some(Color::DarkRed);
818
819 Self {
820 primary,
821 secondary,
822 accent,
823 comment,
824 error,
825 highlight: Some(Color::DarkGrey),
826 highlight_symbol: String::from("» "),
827 highlight_primary,
828 highlight_secondary,
829 highlight_accent,
830 highlight_comment,
831 }
832 }
833}
834impl Default for SearchCommandsTextTuning {
835 fn default() -> Self {
836 Self {
837 points: 600,
838 command: 2.0,
839 description: 1.0,
840 auto: SearchCommandsTextAutoTuning::default(),
841 }
842 }
843}
844impl Default for SearchCommandsTextAutoTuning {
845 fn default() -> Self {
846 Self {
847 prefix: 1.5,
848 fuzzy: 1.0,
849 relaxed: 0.5,
850 root: 2.0,
851 }
852 }
853}
854impl Default for SearchUsageTuning {
855 fn default() -> Self {
856 Self { points: 100 }
857 }
858}
859impl Default for SearchPathTuning {
860 fn default() -> Self {
861 Self {
862 points: 300,
863 exact: 1.0,
864 ancestor: 0.5,
865 descendant: 0.25,
866 unrelated: 0.1,
867 }
868 }
869}
870impl Default for SearchVariableCompletionTuning {
871 fn default() -> Self {
872 Self { points: 200 }
873 }
874}
875impl Default for SearchVariableContextTuning {
876 fn default() -> Self {
877 Self { points: 700 }
878 }
879}
880fn default_ai_catalog() -> BTreeMap<String, AiModelConfig> {
881 BTreeMap::from([
882 (
883 "main".to_string(),
884 AiModelConfig::Gemini(GeminiModelConfig {
885 model: "gemini-flash-latest".to_string(),
886 url: default_gemini_url(),
887 api_key_env: default_gemini_api_key_env(),
888 }),
889 ),
890 (
891 "fallback".to_string(),
892 AiModelConfig::Gemini(GeminiModelConfig {
893 model: "gemini-flash-lite-latest".to_string(),
894 url: default_gemini_url(),
895 api_key_env: default_gemini_api_key_env(),
896 }),
897 ),
898 ])
899}
900impl Default for AiConfig {
901 fn default() -> Self {
902 Self {
903 enabled: false,
904 models: AiModelsConfig::default(),
905 prompts: AiPromptsConfig::default(),
906 catalog: default_ai_catalog(),
907 }
908 }
909}
910impl Default for AiModelsConfig {
911 fn default() -> Self {
912 Self {
913 suggest: "main".to_string(),
914 fix: "main".to_string(),
915 import: "main".to_string(),
916 completion: "main".to_string(),
917 fallback: "fallback".to_string(),
918 }
919 }
920}
921impl Default for AiPromptsConfig {
922 fn default() -> Self {
923 Self {
924 suggest: String::from(
925 r#"##OS_SHELL_INFO##
926##WORKING_DIR##
927### Instructions
928You are an expert CLI assistant. Your task is to generate shell command templates based on the user's request.
929
930Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
931
932### Shell Paradigm, Syntax, and Versioning
933**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.
934
9351. **Recognize the Shell Paradigm:**
936 - **POSIX / Text-Stream (bash, zsh, fish):** Operate on **text streams**. Use tools like `grep`, `sed`, `awk`.
937 - **Object-Pipeline (PowerShell, Nushell):** Operate on **structured data (objects)**. You MUST use internal commands for filtering/selection. AVOID external text-processing tools.
938 - **Legacy (cmd.exe):** Has unique syntax for loops (`FOR`), variables (`%VAR%`), and filtering (`findstr`).
939
9402. **Generate Idiomatic Code:**
941 - Use the shell's built-in features and standard library.
942 - Follow the shell's naming and style conventions (e.g., `Verb-Noun` in PowerShell).
943 - Leverage the shell's core strengths (e.g., object manipulation in Nushell).
944
9453. **Ensure Syntactic Correctness:**
946 - Pay close attention to variable syntax (`$var`, `$env:VAR`, `$env.VAR`, `%VAR%`).
947 - Use the correct operators and quoting rules for the target shell.
948
9494. **Pay Critical Attention to the Version:**
950 - The shell version is a primary constraint, not a suggestion. This is especially true for shells with rapid development cycles like **Nushell**.
951 - You **MUST** generate commands that are compatible with the user's specified version.
952 - 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.
953
954### Command Template Syntax
955When creating the `command` template string, you must use the following placeholder syntax:
956
957- **Standard Placeholder**: `{{variable-name}}`
958 - Use for regular arguments that the user needs to provide.
959 - _Example_: `echo "Hello, {{user-name}}!"`
960
961- **Choice Placeholder**: `{{option1|option2}}`
962 - Use when the user must choose from a specific set of options.
963 - _Example_: `git reset {{--soft|--hard}} HEAD~1`
964
965- **Function Placeholder**: `{{variable:function}}`
966 - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
967 - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
968 - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
969
970- **Secret/Ephemeral Placeholder**: `{{{...}}}`
971 - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description).
972 This syntax can wrap any of the placeholder types above.
973 - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
974
975### Suggestion Strategy
976Your 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:
977
9781. **Explicit Single Suggestion:**
979 - If the user's request explicitly asks for **a single suggestion**, you **MUST** return a list containing exactly one suggestion object.
980 - To cover variations within this single command, make effective use of choice placeholders (e.g., `git reset {{--soft|--hard}}`).
981
9822. **Clear & Unambiguous Request:**
983 - If the request is straightforward and has one primary, standard solution, provide a **single, well-formed suggestion**.
984
9853. **Ambiguous or Multi-faceted Request:**
986 - 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**.
987 - Each distinct approach or interpretation **must be a separate suggestion object**.
988 - **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.
989 - **Order the suggestions by relevance**, with the most common or recommended solution appearing first.
990"#,
991 ),
992 fix: String::from(
993 r#"##OS_SHELL_INFO##
994##WORKING_DIR##
995##SHELL_HISTORY##
996### Instructions
997You are an expert command-line assistant. Your mission is to analyze a failed shell command and its error output,
998diagnose the root cause, and provide a structured, actionable solution in a single JSON object.
999
1000### Output Schema
1001Your response MUST be a single, valid JSON object with no surrounding text or markdown. It must conform to the following structure:
1002- `summary`: A very brief, 2-5 word summary of the error category. Examples: "Command Not Found", "Permission Denied", "Invalid Argument", "Git Typo".
1003- `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.
1004- `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.
1005- `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.
1006
1007### Core Rules
10081. **JSON Only**: Your entire output must be a single, raw JSON object. Do not wrap it in code blocks or add any explanatory text.
10092. **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.
10103. **Strict Wrapping**: Hard-wrap all string values within the JSON to a maximum of 80 characters.
10114. **`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.
1012"#,
1013 ),
1014 import: String::from(
1015 r#"### Instructions
1016You 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.
1017
1018Your entire response MUST be a single, valid JSON object conforming to the provided schema. Output nothing but the JSON object itself.
1019
1020Refer to the syntax definitions, process, and example below to construct your response.
1021
1022### Command Template Syntax
1023When creating the `command` template string, you must use the following placeholder syntax:
1024
1025- **Standard Placeholder**: `{{variable-name}}`
1026 - Use for regular arguments that the user needs to provide.
1027 - _Example_: `echo "Hello, {{user-name}}!"`
1028
1029- **Choice Placeholder**: `{{option1|option2}}`
1030 - Use when the user must choose from a specific set of options.
1031 - _Example_: `git reset {{--soft|--hard}} HEAD~1`
1032
1033- **Function Placeholder**: `{{variable:function}}`
1034 - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
1035 - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
1036 - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
1037
1038- **Secret/Ephemeral Placeholder**: `{{{...}}}`
1039 - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description).
1040 This syntax can wrap any of the placeholder types above.
1041 - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
1042
1043### Core Process
10441. **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.
10452. **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.
1046
1047### Output Generation
1048For each unique and deduplicated command pattern you identify:
1049- Create a suggestion object containing a `description` and a `command`.
1050- The `description` must be a clear, single-sentence explanation of the command's purpose.
1051- The `command` must be the final, generalized template string from the core process.
1052"#,
1053 ),
1054 completion: String::from(
1055 r#"##OS_SHELL_INFO##
1056### Instructions
1057You 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.
1058
1059Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
1060
1061### Core Task
1062The 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").
1063
1064### Command Template Syntax
1065To 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 `{{...}}`.
1066
1067- **Syntax**: `{{--parameter {{variable-name}}}}`
1068- **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.
1069- **All-or-Nothing**: If a block contains multiple variables, all of them must be present in the context for the block to be included.
1070
1071- **_Example_**:
1072 - **Template**: `kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}`
1073 - If the context provides a `namespace`, the executed command becomes: `kubectl get pods -n prod`
1074 - If the context provides both `namespace` and `context`, it becomes: `kubectl get pods --context my-cluster -n prod`
1075 - If the context is empty, it is simply: `kubectl get pods`
1076
1077### Requirements
10781. **JSON Only**: Your entire output must be a single, raw JSON object. Do not add any explanatory text.
10792. **Context is Key**: Every variable like `{{variable-name}}` must be part of a surrounding conditional block `{{...}}`. The command cannot ask for new information.
10803. **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.
10814. **Executable**: The command must be syntactically correct and executable.
1082"#,
1083 ),
1084 }
1085 }
1086}
1087
1088fn deserialize_bindings_with_defaults<'de, D>(
1094 deserializer: D,
1095) -> Result<BTreeMap<KeyBindingAction, KeyBinding>, D::Error>
1096where
1097 D: Deserializer<'de>,
1098{
1099 let user_provided_bindings = BTreeMap::<KeyBindingAction, KeyBinding>::deserialize(deserializer)?;
1101
1102 #[cfg(test)]
1103 {
1104 use strum::IntoEnumIterator;
1105 for action_variant in KeyBindingAction::iter() {
1107 if !user_provided_bindings.contains_key(&action_variant) {
1108 return Err(D::Error::custom(format!(
1109 "Missing key binding for action '{action_variant:?}'."
1110 )));
1111 }
1112 }
1113 Ok(user_provided_bindings)
1114 }
1115 #[cfg(not(test))]
1116 {
1117 let mut final_bindings = user_provided_bindings;
1120 let default_bindings = KeyBindingsConfig::default();
1121
1122 for (action, default_binding) in default_bindings.0 {
1123 final_bindings.entry(action).or_insert(default_binding);
1124 }
1125 Ok(final_bindings)
1126 }
1127}
1128
1129fn deserialize_key_events<'de, D>(deserializer: D) -> Result<Vec<KeyEvent>, D::Error>
1133where
1134 D: Deserializer<'de>,
1135{
1136 #[derive(Deserialize)]
1137 #[serde(untagged)]
1138 enum StringOrVec {
1139 Single(String),
1140 Multiple(Vec<String>),
1141 }
1142
1143 let strings = match StringOrVec::deserialize(deserializer)? {
1144 StringOrVec::Single(s) => vec![s],
1145 StringOrVec::Multiple(v) => v,
1146 };
1147
1148 strings
1149 .iter()
1150 .map(String::as_str)
1151 .map(parse_key_event)
1152 .map(|r| r.map_err(D::Error::custom))
1153 .collect()
1154}
1155
1156fn deserialize_color<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
1161where
1162 D: Deserializer<'de>,
1163{
1164 parse_color(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1165}
1166
1167fn deserialize_style<'de, D>(deserializer: D) -> Result<ContentStyle, D::Error>
1171where
1172 D: Deserializer<'de>,
1173{
1174 parse_style(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1175}
1176
1177fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
1181 let raw_lower = raw.to_ascii_lowercase();
1182 let (remaining, modifiers) = extract_key_modifiers(&raw_lower);
1183 parse_key_code_with_modifiers(remaining, modifiers)
1184}
1185
1186fn extract_key_modifiers(raw: &str) -> (&str, KeyModifiers) {
1190 let mut modifiers = KeyModifiers::empty();
1191 let mut current = raw;
1192
1193 loop {
1194 match current {
1195 rest if rest.starts_with("ctrl-") || rest.starts_with("ctrl+") => {
1196 modifiers.insert(KeyModifiers::CONTROL);
1197 current = &rest[5..];
1198 }
1199 rest if rest.starts_with("shift-") || rest.starts_with("shift+") => {
1200 modifiers.insert(KeyModifiers::SHIFT);
1201 current = &rest[6..];
1202 }
1203 rest if rest.starts_with("alt-") || rest.starts_with("alt+") => {
1204 modifiers.insert(KeyModifiers::ALT);
1205 current = &rest[4..];
1206 }
1207 _ => break,
1208 };
1209 }
1210
1211 (current, modifiers)
1212}
1213
1214fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
1216 let code = match raw {
1217 "esc" => KeyCode::Esc,
1218 "enter" => KeyCode::Enter,
1219 "left" => KeyCode::Left,
1220 "right" => KeyCode::Right,
1221 "up" => KeyCode::Up,
1222 "down" => KeyCode::Down,
1223 "home" => KeyCode::Home,
1224 "end" => KeyCode::End,
1225 "pageup" => KeyCode::PageUp,
1226 "pagedown" => KeyCode::PageDown,
1227 "backtab" => {
1228 modifiers.insert(KeyModifiers::SHIFT);
1229 KeyCode::BackTab
1230 }
1231 "backspace" => KeyCode::Backspace,
1232 "delete" => KeyCode::Delete,
1233 "insert" => KeyCode::Insert,
1234 "f1" => KeyCode::F(1),
1235 "f2" => KeyCode::F(2),
1236 "f3" => KeyCode::F(3),
1237 "f4" => KeyCode::F(4),
1238 "f5" => KeyCode::F(5),
1239 "f6" => KeyCode::F(6),
1240 "f7" => KeyCode::F(7),
1241 "f8" => KeyCode::F(8),
1242 "f9" => KeyCode::F(9),
1243 "f10" => KeyCode::F(10),
1244 "f11" => KeyCode::F(11),
1245 "f12" => KeyCode::F(12),
1246 "space" | "spacebar" => KeyCode::Char(' '),
1247 "hyphen" => KeyCode::Char('-'),
1248 "minus" => KeyCode::Char('-'),
1249 "tab" => KeyCode::Tab,
1250 c if c.len() == 1 => {
1251 let mut c = c.chars().next().expect("just checked");
1252 if modifiers.contains(KeyModifiers::SHIFT) {
1253 c = c.to_ascii_uppercase();
1254 }
1255 KeyCode::Char(c)
1256 }
1257 _ => return Err(format!("Unable to parse key binding: {raw}")),
1258 };
1259 Ok(KeyEvent::new(code, modifiers))
1260}
1261
1262fn parse_color(raw: &str) -> Result<Option<Color>, String> {
1266 let raw_lower = raw.to_ascii_lowercase();
1267 if raw.is_empty() || raw == "none" {
1268 Ok(None)
1269 } else {
1270 Ok(Some(parse_color_inner(&raw_lower)?))
1271 }
1272}
1273
1274fn parse_style(raw: &str) -> Result<ContentStyle, String> {
1278 let raw_lower = raw.to_ascii_lowercase();
1279 let (remaining, attributes) = extract_style_attributes(&raw_lower);
1280 let mut style = ContentStyle::new();
1281 style.attributes = attributes;
1282 if !remaining.is_empty() && remaining != "default" {
1283 style.foreground_color = Some(parse_color_inner(remaining)?);
1284 }
1285 Ok(style)
1286}
1287
1288fn extract_style_attributes(raw: &str) -> (&str, Attributes) {
1292 let mut attributes = Attributes::none();
1293 let mut current = raw;
1294
1295 loop {
1296 match current {
1297 rest if rest.starts_with("bold") => {
1298 attributes.set(Attribute::Bold);
1299 current = &rest[4..];
1300 if current.starts_with(' ') {
1301 current = ¤t[1..];
1302 }
1303 }
1304 rest if rest.starts_with("dim") => {
1305 attributes.set(Attribute::Dim);
1306 current = &rest[3..];
1307 if current.starts_with(' ') {
1308 current = ¤t[1..];
1309 }
1310 }
1311 rest if rest.starts_with("italic") => {
1312 attributes.set(Attribute::Italic);
1313 current = &rest[6..];
1314 if current.starts_with(' ') {
1315 current = ¤t[1..];
1316 }
1317 }
1318 rest if rest.starts_with("underline") => {
1319 attributes.set(Attribute::Underlined);
1320 current = &rest[9..];
1321 if current.starts_with(' ') {
1322 current = ¤t[1..];
1323 }
1324 }
1325 rest if rest.starts_with("underlined") => {
1326 attributes.set(Attribute::Underlined);
1327 current = &rest[10..];
1328 if current.starts_with(' ') {
1329 current = ¤t[1..];
1330 }
1331 }
1332 _ => break,
1333 };
1334 }
1335
1336 (current.trim(), attributes)
1337}
1338
1339fn parse_color_inner(raw: &str) -> Result<Color, String> {
1343 Ok(match raw {
1344 "black" => Color::Black,
1345 "red" => Color::Red,
1346 "green" => Color::Green,
1347 "yellow" => Color::Yellow,
1348 "blue" => Color::Blue,
1349 "magenta" => Color::Magenta,
1350 "cyan" => Color::Cyan,
1351 "gray" | "grey" => Color::Grey,
1352 "dark gray" | "darkgray" | "dark grey" | "darkgrey" => Color::DarkGrey,
1353 "dark red" | "darkred" => Color::DarkRed,
1354 "dark green" | "darkgreen" => Color::DarkGreen,
1355 "dark yellow" | "darkyellow" => Color::DarkYellow,
1356 "dark blue" | "darkblue" => Color::DarkBlue,
1357 "dark magenta" | "darkmagenta" => Color::DarkMagenta,
1358 "dark cyan" | "darkcyan" => Color::DarkCyan,
1359 "white" => Color::White,
1360 rgb if rgb.starts_with("rgb(") => {
1361 let rgb = rgb.trim_start_matches("rgb(").trim_end_matches(")").split(',');
1362 let rgb = rgb
1363 .map(|c| c.trim().parse::<u8>())
1364 .collect::<Result<Vec<u8>, _>>()
1365 .map_err(|_| format!("Unable to parse color: {raw}"))?;
1366 if rgb.len() != 3 {
1367 return Err(format!("Unable to parse color: {raw}"));
1368 }
1369 Color::Rgb {
1370 r: rgb[0],
1371 g: rgb[1],
1372 b: rgb[2],
1373 }
1374 }
1375 hex if hex.starts_with("#") => {
1376 let hex = hex.trim_start_matches("#");
1377 if hex.len() != 6 {
1378 return Err(format!("Unable to parse color: {raw}"));
1379 }
1380 let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1381 let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1382 let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1383 Color::Rgb { r, g, b }
1384 }
1385 c => {
1386 if let Ok(c) = c.parse::<u8>() {
1387 Color::AnsiValue(c)
1388 } else {
1389 return Err(format!("Unable to parse color: {raw}"));
1390 }
1391 }
1392 })
1393}
1394
1395fn deserialize_catalog_with_defaults<'de, D>(deserializer: D) -> Result<BTreeMap<String, AiModelConfig>, D::Error>
1400where
1401 D: Deserializer<'de>,
1402{
1403 #[allow(unused_mut)]
1404 let mut user_catalog = BTreeMap::<String, AiModelConfig>::deserialize(deserializer)?;
1406
1407 #[cfg(not(test))]
1409 for (key, default_model) in default_ai_catalog() {
1410 user_catalog.entry(key).or_insert(default_model);
1411 }
1412
1413 Ok(user_catalog)
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418 use pretty_assertions::assert_eq;
1419 use strum::IntoEnumIterator;
1420
1421 use super::*;
1422
1423 #[test]
1424 fn test_default_config() -> Result<()> {
1425 let config_str = fs::read_to_string("default_config.toml").wrap_err("Couldn't read default config file")?;
1426 let config: Config = toml::from_str(&config_str).wrap_err("Couldn't parse default config file")?;
1427
1428 assert_eq!(Config::default(), config);
1429
1430 Ok(())
1431 }
1432
1433 #[test]
1434 fn test_default_keybindings_complete() {
1435 let config = KeyBindingsConfig::default();
1436
1437 for action in KeyBindingAction::iter() {
1438 assert!(
1439 config.0.contains_key(&action),
1440 "Missing default binding for action: {action:?}"
1441 );
1442 }
1443 }
1444
1445 #[test]
1446 fn test_default_keybindings_no_conflicts() {
1447 let config = KeyBindingsConfig::default();
1448
1449 let conflicts = config.find_conflicts();
1450 assert_eq!(conflicts.len(), 0, "Key binding conflicts: {conflicts:?}");
1451 }
1452
1453 #[test]
1454 fn test_keybinding_matches() {
1455 let binding = KeyBinding(vec![
1456 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
1457 KeyEvent::from(KeyCode::Enter),
1458 ]);
1459
1460 assert!(binding.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
1462 assert!(binding.matches(&KeyEvent::from(KeyCode::Enter)));
1463
1464 assert!(!binding.matches(&KeyEvent::new(
1466 KeyCode::Char('a'),
1467 KeyModifiers::CONTROL | KeyModifiers::ALT
1468 )));
1469
1470 assert!(!binding.matches(&KeyEvent::from(KeyCode::Esc)));
1472 }
1473
1474 #[test]
1475 fn test_simple_keys() {
1476 assert_eq!(
1477 parse_key_event("a").unwrap(),
1478 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
1479 );
1480
1481 assert_eq!(
1482 parse_key_event("enter").unwrap(),
1483 KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
1484 );
1485
1486 assert_eq!(
1487 parse_key_event("esc").unwrap(),
1488 KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
1489 );
1490 }
1491
1492 #[test]
1493 fn test_with_modifiers() {
1494 assert_eq!(
1495 parse_key_event("ctrl-a").unwrap(),
1496 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
1497 );
1498
1499 assert_eq!(
1500 parse_key_event("alt-enter").unwrap(),
1501 KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
1502 );
1503
1504 assert_eq!(
1505 parse_key_event("shift-esc").unwrap(),
1506 KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
1507 );
1508 }
1509
1510 #[test]
1511 fn test_multiple_modifiers() {
1512 assert_eq!(
1513 parse_key_event("ctrl-alt-a").unwrap(),
1514 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
1515 );
1516
1517 assert_eq!(
1518 parse_key_event("ctrl-shift-enter").unwrap(),
1519 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
1520 );
1521 }
1522
1523 #[test]
1524 fn test_invalid_keys() {
1525 let res = parse_key_event("invalid-key");
1526 assert_eq!(res, Err(String::from("Unable to parse key binding: invalid-key")));
1527 }
1528
1529 #[test]
1530 fn test_parse_color_none() {
1531 let color = parse_color("none").unwrap();
1532 assert_eq!(color, None);
1533 }
1534
1535 #[test]
1536 fn test_parse_color_simple() {
1537 let color = parse_color("red").unwrap();
1538 assert_eq!(color, Some(Color::Red));
1539 }
1540
1541 #[test]
1542 fn test_parse_color_rgb() {
1543 let color = parse_color("rgb(50, 25, 15)").unwrap();
1544 assert_eq!(color, Some(Color::Rgb { r: 50, g: 25, b: 15 }));
1545 }
1546
1547 #[test]
1548 fn test_parse_color_rgb_out_of_range() {
1549 let res = parse_color("rgb(500, 25, 15)");
1550 assert_eq!(res, Err(String::from("Unable to parse color: rgb(500, 25, 15)")));
1551 }
1552
1553 #[test]
1554 fn test_parse_color_rgb_invalid() {
1555 let res = parse_color("rgb(50, 25, 15, 5)");
1556 assert_eq!(res, Err(String::from("Unable to parse color: rgb(50, 25, 15, 5)")));
1557 }
1558
1559 #[test]
1560 fn test_parse_color_hex() {
1561 let color = parse_color("#4287f5").unwrap();
1562 assert_eq!(color, Some(Color::Rgb { r: 66, g: 135, b: 245 }));
1563 }
1564
1565 #[test]
1566 fn test_parse_color_hex_out_of_range() {
1567 let res = parse_color("#4287fg");
1568 assert_eq!(res, Err(String::from("Unable to parse color: #4287fg")));
1569 }
1570
1571 #[test]
1572 fn test_parse_color_hex_invalid() {
1573 let res = parse_color("#4287f50");
1574 assert_eq!(res, Err(String::from("Unable to parse color: #4287f50")));
1575 }
1576
1577 #[test]
1578 fn test_parse_color_index() {
1579 let color = parse_color("6").unwrap();
1580 assert_eq!(color, Some(Color::AnsiValue(6)));
1581 }
1582
1583 #[test]
1584 fn test_parse_color_fail() {
1585 let res = parse_color("1234");
1586 assert_eq!(res, Err(String::from("Unable to parse color: 1234")));
1587 }
1588
1589 #[test]
1590 fn test_parse_style_empty() {
1591 let style = parse_style("").unwrap();
1592 assert_eq!(style, ContentStyle::new());
1593 }
1594
1595 #[test]
1596 fn test_parse_style_default() {
1597 let style = parse_style("default").unwrap();
1598 assert_eq!(style, ContentStyle::new());
1599 }
1600
1601 #[test]
1602 fn test_parse_style_simple() {
1603 let style = parse_style("red").unwrap();
1604 assert_eq!(style.foreground_color, Some(Color::Red));
1605 assert_eq!(style.attributes, Attributes::none());
1606 }
1607
1608 #[test]
1609 fn test_parse_style_only_modifier() {
1610 let style = parse_style("bold").unwrap();
1611 assert_eq!(style.foreground_color, None);
1612 let mut expected_attributes = Attributes::none();
1613 expected_attributes.set(Attribute::Bold);
1614 assert_eq!(style.attributes, expected_attributes);
1615 }
1616
1617 #[test]
1618 fn test_parse_style_with_modifier() {
1619 let style = parse_style("italic red").unwrap();
1620 assert_eq!(style.foreground_color, Some(Color::Red));
1621 let mut expected_attributes = Attributes::none();
1622 expected_attributes.set(Attribute::Italic);
1623 assert_eq!(style.attributes, expected_attributes);
1624 }
1625
1626 #[test]
1627 fn test_parse_style_multiple_modifier() {
1628 let style = parse_style("underline dim dark red").unwrap();
1629 assert_eq!(style.foreground_color, Some(Color::DarkRed));
1630 let mut expected_attributes = Attributes::none();
1631 expected_attributes.set(Attribute::Underlined);
1632 expected_attributes.set(Attribute::Dim);
1633 assert_eq!(style.attributes, expected_attributes);
1634 }
1635}