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(debug_assertions, 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(debug_assertions, 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(debug_assertions, 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(debug_assertions, 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}
116
117#[derive(Clone, Deserialize)]
122#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
123pub struct KeyBinding(#[serde(deserialize_with = "deserialize_key_events")] Vec<KeyEvent>);
124
125#[derive(Clone, Deserialize)]
129#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
130#[cfg_attr(not(test), serde(default))]
131pub struct Theme {
132 #[serde(deserialize_with = "deserialize_style")]
134 pub primary: ContentStyle,
135 #[serde(deserialize_with = "deserialize_style")]
137 pub secondary: ContentStyle,
138 #[serde(deserialize_with = "deserialize_style")]
140 pub accent: ContentStyle,
141 #[serde(deserialize_with = "deserialize_style")]
143 pub comment: ContentStyle,
144 #[serde(deserialize_with = "deserialize_style")]
146 pub error: ContentStyle,
147 #[serde(deserialize_with = "deserialize_color")]
149 pub highlight: Option<Color>,
150 pub highlight_symbol: String,
152 #[serde(deserialize_with = "deserialize_style")]
154 pub highlight_primary: ContentStyle,
155 #[serde(deserialize_with = "deserialize_style")]
157 pub highlight_secondary: ContentStyle,
158 #[serde(deserialize_with = "deserialize_style")]
160 pub highlight_accent: ContentStyle,
161 #[serde(deserialize_with = "deserialize_style")]
163 pub highlight_comment: ContentStyle,
164}
165
166#[derive(Clone, Default, Deserialize)]
168#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
169pub struct GistConfig {
170 pub id: String,
172 pub token: String,
174}
175
176#[derive(Clone, Copy, Default, Deserialize)]
178#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
179#[cfg_attr(not(test), serde(default))]
180pub struct SearchTuning {
181 pub commands: SearchCommandTuning,
183 pub variables: SearchVariableTuning,
185}
186
187#[derive(Clone, Copy, Default, Deserialize)]
189#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
190#[cfg_attr(not(test), serde(default))]
191pub struct SearchCommandTuning {
192 pub text: SearchCommandsTextTuning,
194 pub path: SearchPathTuning,
196 pub usage: SearchUsageTuning,
198}
199
200#[derive(Clone, Copy, Deserialize)]
202#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
203#[cfg_attr(not(test), serde(default))]
204pub struct SearchCommandsTextTuning {
205 pub points: u32,
207 pub command: f64,
209 pub description: f64,
211 pub auto: SearchCommandsTextAutoTuning,
213}
214
215#[derive(Clone, Copy, Deserialize)]
217#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
218#[cfg_attr(not(test), serde(default))]
219pub struct SearchCommandsTextAutoTuning {
220 pub prefix: f64,
222 pub fuzzy: f64,
224 pub relaxed: f64,
226 pub root: f64,
228}
229
230#[derive(Clone, Copy, Deserialize)]
232#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
233#[cfg_attr(not(test), serde(default))]
234pub struct SearchPathTuning {
235 pub points: u32,
237 pub exact: f64,
239 pub ancestor: f64,
241 pub descendant: f64,
243 pub unrelated: f64,
245}
246
247#[derive(Clone, Copy, Deserialize)]
249#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
250#[cfg_attr(not(test), serde(default))]
251pub struct SearchUsageTuning {
252 pub points: u32,
254}
255
256#[derive(Clone, Copy, Default, Deserialize)]
258#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
259#[cfg_attr(not(test), serde(default))]
260pub struct SearchVariableTuning {
261 pub completion: SearchVariableCompletionTuning,
263 pub context: SearchVariableContextTuning,
265 pub path: SearchPathTuning,
267}
268
269#[derive(Clone, Copy, Deserialize)]
271#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
272#[cfg_attr(not(test), serde(default))]
273pub struct SearchVariableCompletionTuning {
274 pub points: u32,
276}
277
278#[derive(Clone, Copy, Deserialize)]
280#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
281#[cfg_attr(not(test), serde(default))]
282pub struct SearchVariableContextTuning {
283 pub points: u32,
285}
286
287#[derive(Clone, Deserialize)]
289#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
290#[cfg_attr(not(test), serde(default))]
291pub struct AiConfig {
292 pub enabled: bool,
294 pub prompts: AiPromptsConfig,
296 pub models: AiModelsConfig,
298 #[serde(deserialize_with = "deserialize_catalog_with_defaults")]
303 pub catalog: BTreeMap<String, AiModelConfig>,
304}
305
306#[derive(Clone, Deserialize)]
308#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
309#[cfg_attr(not(test), serde(default))]
310pub struct AiPromptsConfig {
311 pub suggest: String,
313 pub fix: String,
315 pub import: String,
317 pub completion: String,
319}
320
321#[derive(Clone, Deserialize)]
323#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
324#[cfg_attr(not(test), serde(default))]
325pub struct AiModelsConfig {
326 pub suggest: String,
329 pub fix: String,
332 pub import: String,
335 pub completion: String,
338 pub fallback: String,
341}
342
343#[derive(Clone, Deserialize)]
345#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
346#[serde(tag = "provider", rename_all = "snake_case")]
347pub enum AiModelConfig {
348 Openai(OpenAiModelConfig),
350 Gemini(GeminiModelConfig),
352 Anthropic(AnthropicModelConfig),
354 Ollama(OllamaModelConfig),
356}
357
358#[derive(Clone, Deserialize)]
360#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
361pub struct OpenAiModelConfig {
362 pub model: String,
364 #[serde(default = "default_openai_url")]
368 pub url: String,
369 #[serde(default = "default_openai_api_key_env")]
371 pub api_key_env: String,
372}
373fn default_openai_url() -> String {
374 "https://api.openai.com/v1".to_string()
375}
376fn default_openai_api_key_env() -> String {
377 "OPENAI_API_KEY".to_string()
378}
379
380#[derive(Clone, Deserialize)]
382#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
383pub struct GeminiModelConfig {
384 pub model: String,
386 #[serde(default = "default_gemini_url")]
388 pub url: String,
389 #[serde(default = "default_gemini_api_key_env")]
391 pub api_key_env: String,
392}
393fn default_gemini_url() -> String {
394 "https://generativelanguage.googleapis.com/v1beta".to_string()
395}
396fn default_gemini_api_key_env() -> String {
397 "GEMINI_API_KEY".to_string()
398}
399
400#[derive(Clone, Deserialize)]
402#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
403pub struct AnthropicModelConfig {
404 pub model: String,
406 #[serde(default = "default_anthropic_url")]
408 pub url: String,
409 #[serde(default = "default_anthropic_api_key_env")]
411 pub api_key_env: String,
412}
413fn default_anthropic_url() -> String {
414 "https://api.anthropic.com/v1".to_string()
415}
416fn default_anthropic_api_key_env() -> String {
417 "ANTHROPIC_API_KEY".to_string()
418}
419
420#[derive(Clone, Deserialize)]
422#[cfg_attr(debug_assertions, derive(Debug, PartialEq))]
423pub struct OllamaModelConfig {
424 pub model: String,
426 #[serde(default = "default_ollama_url")]
428 pub url: String,
429 #[serde(default = "default_ollama_api_key_env")]
431 pub api_key_env: String,
432}
433fn default_ollama_url() -> String {
434 "http://localhost:11434".to_string()
435}
436fn default_ollama_api_key_env() -> String {
437 "OLLAMA_API_KEY".to_string()
438}
439
440impl Config {
441 pub fn init(config_file: Option<PathBuf>) -> Result<Self> {
446 let proj_dirs = ProjectDirs::from("org", "IntelliShell", "Intelli-Shell")
448 .wrap_err("Couldn't initialize project directory")?;
449 let config_dir = proj_dirs.config_dir().to_path_buf();
450
451 let config_path = config_file.unwrap_or_else(|| config_dir.join("config.toml"));
453 let mut config = if config_path.exists() {
454 let config_str = fs::read_to_string(&config_path)
456 .wrap_err_with(|| format!("Couldn't read config file {}", config_path.display()))?;
457 toml::from_str(&config_str)
458 .wrap_err_with(|| format!("Couldn't parse config file {}", config_path.display()))?
459 } else {
460 Config::default()
462 };
463 if config.data_dir.as_os_str().is_empty() {
465 config.data_dir = proj_dirs.data_dir().to_path_buf();
466 }
467
468 let conflicts = config.keybindings.find_conflicts();
470 if !conflicts.is_empty() {
471 return Err(eyre!(
472 "Couldn't parse config file {}\n\nThere are some key binding conflicts:\n{}",
473 config_path.display(),
474 conflicts
475 .into_iter()
476 .map(|(_, a)| format!("- {}", a.into_iter().map(|a| format!("{a:?}")).join(", ")))
477 .join("\n")
478 ));
479 }
480
481 if config.ai.enabled {
483 let AiModelsConfig {
484 suggest,
485 fix,
486 import,
487 completion,
488 fallback,
489 } = &config.ai.models;
490 let catalog = &config.ai.catalog;
491
492 let mut missing = Vec::new();
493 if !catalog.contains_key(suggest) {
494 missing.push((suggest, "suggest"));
495 }
496 if !catalog.contains_key(fix) {
497 missing.push((fix, "fix"));
498 }
499 if !catalog.contains_key(import) {
500 missing.push((import, "import"));
501 }
502 if !catalog.contains_key(completion) {
503 missing.push((completion, "completion"));
504 }
505 if !catalog.contains_key(fallback) {
506 missing.push((fallback, "fallback"));
507 }
508
509 if !missing.is_empty() {
510 return Err(eyre!(
511 "Couldn't parse config file {}\n\nMissing model definitions on the catalog:\n{}",
512 config_path.display(),
513 missing
514 .into_iter()
515 .into_group_map()
516 .into_iter()
517 .map(|(k, v)| format!(
518 "- {k} used in {}",
519 v.into_iter().map(|v| format!("ai.models.{v}")).join(", ")
520 ))
521 .join("\n")
522 ));
523 }
524 }
525
526 fs::create_dir_all(&config.data_dir)
528 .wrap_err_with(|| format!("Could't create data dir {}", config.data_dir.display()))?;
529
530 Ok(config)
531 }
532}
533
534impl KeyBindingsConfig {
535 pub fn get(&self, action: &KeyBindingAction) -> &KeyBinding {
537 self.0.get(action).unwrap()
538 }
539
540 pub fn get_action_matching(&self, event: &KeyEvent) -> Option<KeyBindingAction> {
542 self.0.iter().find_map(
543 |(action, binding)| {
544 if binding.matches(event) { Some(*action) } else { None }
545 },
546 )
547 }
548
549 pub fn find_conflicts(&self) -> Vec<(KeyEvent, Vec<KeyBindingAction>)> {
551 let mut event_to_actions_map: HashMap<KeyEvent, Vec<KeyBindingAction>> = HashMap::new();
553
554 for (action, key_binding) in self.0.iter() {
556 for event_in_binding in key_binding.0.iter() {
558 event_to_actions_map.entry(*event_in_binding).or_default().push(*action);
560 }
561 }
562
563 event_to_actions_map
565 .into_iter()
566 .filter_map(|(key_event, actions)| {
567 if actions.len() > 1 {
568 Some((key_event, actions))
569 } else {
570 None
571 }
572 })
573 .collect()
574 }
575}
576
577impl KeyBinding {
578 pub fn matches(&self, event: &KeyEvent) -> bool {
581 self.0
582 .iter()
583 .any(|e| e.code == event.code && e.modifiers == event.modifiers)
584 }
585}
586
587impl Theme {
588 pub fn highlight_primary_full(&self) -> ContentStyle {
590 if let Some(color) = self.highlight {
591 let mut ret = self.highlight_primary;
592 ret.background_color = Some(color);
593 ret
594 } else {
595 self.highlight_primary
596 }
597 }
598
599 pub fn highlight_secondary_full(&self) -> ContentStyle {
601 if let Some(color) = self.highlight {
602 let mut ret = self.highlight_secondary;
603 ret.background_color = Some(color);
604 ret
605 } else {
606 self.highlight_secondary
607 }
608 }
609
610 pub fn highlight_accent_full(&self) -> ContentStyle {
612 if let Some(color) = self.highlight {
613 let mut ret = self.highlight_accent;
614 ret.background_color = Some(color);
615 ret
616 } else {
617 self.highlight_accent
618 }
619 }
620
621 pub fn highlight_comment_full(&self) -> ContentStyle {
623 if let Some(color) = self.highlight {
624 let mut ret = self.highlight_comment;
625 ret.background_color = Some(color);
626 ret
627 } else {
628 self.highlight_comment
629 }
630 }
631}
632
633impl AiConfig {
634 pub fn suggest_client(&self) -> crate::errors::Result<AiClient<'_>> {
636 AiClient::new(
637 &self.models.suggest,
638 self.catalog.get(&self.models.suggest).unwrap(),
639 &self.models.fallback,
640 self.catalog.get(&self.models.fallback),
641 )
642 }
643
644 pub fn fix_client(&self) -> crate::errors::Result<AiClient<'_>> {
646 AiClient::new(
647 &self.models.fix,
648 self.catalog.get(&self.models.fix).unwrap(),
649 &self.models.fallback,
650 self.catalog.get(&self.models.fallback),
651 )
652 }
653
654 pub fn import_client(&self) -> crate::errors::Result<AiClient<'_>> {
656 AiClient::new(
657 &self.models.import,
658 self.catalog.get(&self.models.import).unwrap(),
659 &self.models.fallback,
660 self.catalog.get(&self.models.fallback),
661 )
662 }
663
664 pub fn completion_client(&self) -> crate::errors::Result<AiClient<'_>> {
666 AiClient::new(
667 &self.models.completion,
668 self.catalog.get(&self.models.completion).unwrap(),
669 &self.models.fallback,
670 self.catalog.get(&self.models.fallback),
671 )
672 }
673}
674impl AiModelConfig {
675 pub fn provider(&self) -> &dyn AiProviderBase {
676 match self {
677 AiModelConfig::Openai(conf) => conf,
678 AiModelConfig::Gemini(conf) => conf,
679 AiModelConfig::Anthropic(conf) => conf,
680 AiModelConfig::Ollama(conf) => conf,
681 }
682 }
683}
684
685impl Default for Config {
686 fn default() -> Self {
687 Self {
688 data_dir: PathBuf::new(),
689 check_updates: true,
690 inline: true,
691 search: SearchConfig::default(),
692 logs: LogsConfig::default(),
693 keybindings: KeyBindingsConfig::default(),
694 theme: Theme::default(),
695 gist: GistConfig::default(),
696 tuning: SearchTuning::default(),
697 ai: AiConfig::default(),
698 }
699 }
700}
701impl Default for SearchConfig {
702 fn default() -> Self {
703 Self {
704 delay: 250,
705 mode: SearchMode::Auto,
706 user_only: false,
707 exec_on_alias_match: false,
708 }
709 }
710}
711impl Default for LogsConfig {
712 fn default() -> Self {
713 Self {
714 enabled: false,
715 filter: String::from("info"),
716 }
717 }
718}
719impl Default for KeyBindingsConfig {
720 fn default() -> Self {
721 Self(BTreeMap::from([
722 (KeyBindingAction::Quit, KeyBinding(vec![KeyEvent::from(KeyCode::Esc)])),
723 (
724 KeyBindingAction::Update,
725 KeyBinding(vec![
726 KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
727 KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
728 KeyEvent::from(KeyCode::F(2)),
729 ]),
730 ),
731 (
732 KeyBindingAction::Delete,
733 KeyBinding(vec![KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)]),
734 ),
735 (
736 KeyBindingAction::Confirm,
737 KeyBinding(vec![KeyEvent::from(KeyCode::Tab), KeyEvent::from(KeyCode::Enter)]),
738 ),
739 (
740 KeyBindingAction::Execute,
741 KeyBinding(vec![
742 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
743 KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
744 ]),
745 ),
746 (
747 KeyBindingAction::AI,
748 KeyBinding(vec![
749 KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL),
750 KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
751 ]),
752 ),
753 (
754 KeyBindingAction::SearchMode,
755 KeyBinding(vec![KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)]),
756 ),
757 (
758 KeyBindingAction::SearchUserOnly,
759 KeyBinding(vec![KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)]),
760 ),
761 ]))
762 }
763}
764impl Default for Theme {
765 fn default() -> Self {
766 let primary = ContentStyle::new();
767 let highlight_primary = primary;
768
769 let mut secondary = ContentStyle::new();
770 secondary.attributes.set(Attribute::Dim);
771 let highlight_secondary = ContentStyle::new();
772
773 let mut accent = ContentStyle::new();
774 accent.foreground_color = Some(Color::Yellow);
775 let highlight_accent = accent;
776
777 let mut comment = ContentStyle::new();
778 comment.foreground_color = Some(Color::Green);
779 comment.attributes.set(Attribute::Italic);
780 let highlight_comment = comment;
781
782 let mut error = ContentStyle::new();
783 error.foreground_color = Some(Color::DarkRed);
784
785 Self {
786 primary,
787 secondary,
788 accent,
789 comment,
790 error,
791 highlight: Some(Color::DarkGrey),
792 highlight_symbol: String::from("» "),
793 highlight_primary,
794 highlight_secondary,
795 highlight_accent,
796 highlight_comment,
797 }
798 }
799}
800impl Default for SearchCommandsTextTuning {
801 fn default() -> Self {
802 Self {
803 points: 600,
804 command: 2.0,
805 description: 1.0,
806 auto: SearchCommandsTextAutoTuning::default(),
807 }
808 }
809}
810impl Default for SearchCommandsTextAutoTuning {
811 fn default() -> Self {
812 Self {
813 prefix: 1.5,
814 fuzzy: 1.0,
815 relaxed: 0.5,
816 root: 2.0,
817 }
818 }
819}
820impl Default for SearchUsageTuning {
821 fn default() -> Self {
822 Self { points: 100 }
823 }
824}
825impl Default for SearchPathTuning {
826 fn default() -> Self {
827 Self {
828 points: 300,
829 exact: 1.0,
830 ancestor: 0.5,
831 descendant: 0.25,
832 unrelated: 0.1,
833 }
834 }
835}
836impl Default for SearchVariableCompletionTuning {
837 fn default() -> Self {
838 Self { points: 200 }
839 }
840}
841impl Default for SearchVariableContextTuning {
842 fn default() -> Self {
843 Self { points: 700 }
844 }
845}
846fn default_ai_catalog() -> BTreeMap<String, AiModelConfig> {
847 BTreeMap::from([
848 (
849 "main".to_string(),
850 AiModelConfig::Gemini(GeminiModelConfig {
851 model: "gemini-2.5-flash".to_string(),
852 url: default_gemini_url(),
853 api_key_env: default_gemini_api_key_env(),
854 }),
855 ),
856 (
857 "fallback".to_string(),
858 AiModelConfig::Gemini(GeminiModelConfig {
859 model: "gemini-2.0-flash-lite".to_string(),
860 url: default_gemini_url(),
861 api_key_env: default_gemini_api_key_env(),
862 }),
863 ),
864 ])
865}
866impl Default for AiConfig {
867 fn default() -> Self {
868 Self {
869 enabled: false,
870 models: AiModelsConfig::default(),
871 prompts: AiPromptsConfig::default(),
872 catalog: default_ai_catalog(),
873 }
874 }
875}
876impl Default for AiModelsConfig {
877 fn default() -> Self {
878 Self {
879 suggest: "main".to_string(),
880 fix: "main".to_string(),
881 import: "main".to_string(),
882 completion: "main".to_string(),
883 fallback: "fallback".to_string(),
884 }
885 }
886}
887impl Default for AiPromptsConfig {
888 fn default() -> Self {
889 Self {
890 suggest: String::from(
891 r#"##OS_SHELL_INFO##
892##WORKING_DIR##
893### Instructions
894You are an expert CLI assistant. Your task is to generate shell command templates based on the user's request.
895
896Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
897
898### Command Template Syntax
899When creating the `command` template string, you must use the following placeholder syntax:
900
901- **Standard Placeholder**: `{{variable-name}}`
902 - Use for regular arguments that the user needs to provide.
903 - _Example_: `echo "Hello, {{user-name}}!"`
904
905- **Choice Placeholder**: `{{option1|option2}}`
906 - Use when the user must choose from a specific set of options.
907 - _Example_: `git reset {{--soft|--hard}} HEAD~1`
908
909- **Function Placeholder**: `{{variable:function}}`
910 - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
911 - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
912 - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
913
914- **Secret/Ephemeral Placeholder**: `{{{...}}}`
915 - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description).
916 This syntax can wrap any of the placeholder types above.
917 - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
918
919### Suggestion Strategy
920Your 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:
921
9221. **Explicit Single Suggestion:**
923 - If the user's request explicitly asks for **a single suggestion**, you **MUST** return a list containing exactly one suggestion object.
924 - To cover variations within this single command, make effective use of choice placeholders (e.g., `git reset {{--soft|--hard}}`).
925
9262. **Clear & Unambiguous Request:**
927 - If the request is straightforward and has one primary, standard solution, provide a **single, well-formed suggestion**.
928
9293. **Ambiguous or Multi-faceted Request:**
930 - 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**.
931 - Each distinct approach or interpretation **must be a separate suggestion object**.
932 - **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.
933 - **Order the suggestions by relevance**, with the most common or recommended solution appearing first.
934"#,
935 ),
936 fix: String::from(
937 r#"##OS_SHELL_INFO##
938##WORKING_DIR##
939##SHELL_HISTORY##
940### Instructions
941You are an expert command-line assistant. Your mission is to analyze a failed shell command and its error output,
942diagnose the root cause, and provide a structured, actionable solution in a single JSON object.
943
944### Output Schema
945Your response MUST be a single, valid JSON object with no surrounding text or markdown. It must conform to the following structure:
946- `summary`: A very brief, 2-5 word summary of the error category. Examples: "Command Not Found", "Permission Denied", "Invalid Argument", "Git Typo".
947- `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.
948- `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.
949- `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.
950
951### Core Rules
9521. **JSON Only**: Your entire output must be a single, raw JSON object. Do not wrap it in code blocks or add any explanatory text.
9532. **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.
9543. **Strict Wrapping**: Hard-wrap all string values within the JSON to a maximum of 80 characters.
9554. **`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.
956"#,
957 ),
958 import: String::from(
959 r#"### Instructions
960You 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.
961
962Your entire response MUST be a single, valid JSON object conforming to the provided schema. Output nothing but the JSON object itself.
963
964Refer to the syntax definitions, process, and example below to construct your response.
965
966### Command Template Syntax
967When creating the `command` template string, you must use the following placeholder syntax:
968
969- **Standard Placeholder**: `{{variable-name}}`
970 - Use for regular arguments that the user needs to provide.
971 - _Example_: `echo "Hello, {{user-name}}!"`
972
973- **Choice Placeholder**: `{{option1|option2}}`
974 - Use when the user must choose from a specific set of options.
975 - _Example_: `git reset {{--soft|--hard}} HEAD~1`
976
977- **Function Placeholder**: `{{variable:function}}`
978 - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
979 - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
980 - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
981
982- **Secret/Ephemeral Placeholder**: `{{{...}}}`
983 - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description).
984 This syntax can wrap any of the placeholder types above.
985 - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
986
987### Core Process
9881. **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.
9892. **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.
990
991### Output Generation
992For each unique and deduplicated command pattern you identify:
993- Create a suggestion object containing a `description` and a `command`.
994- The `description` must be a clear, single-sentence explanation of the command's purpose.
995- The `command` must be the final, generalized template string from the core process.
996"#,
997 ),
998 completion: String::from(
999 r#"##OS_SHELL_INFO##
1000### Instructions
1001You 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.
1002
1003Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
1004
1005### Core Task
1006The 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").
1007
1008### Command Template Syntax
1009To 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 `{{...}}`.
1010
1011- **Syntax**: `{{--parameter {{variable-name}}}}`
1012- **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.
1013- **All-or-Nothing**: If a block contains multiple variables, all of them must be present in the context for the block to be included.
1014
1015- **_Example_**:
1016 - **Template**: `kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}`
1017 - If the context provides a `namespace`, the executed command becomes: `kubectl get pods -n prod`
1018 - If the context provides both `namespace` and `context`, it becomes: `kubectl get pods --context my-cluster -n prod`
1019 - If the context is empty, it is simply: `kubectl get pods`
1020
1021### Requirements
10221. **JSON Only**: Your entire output must be a single, raw JSON object. Do not add any explanatory text.
10232. **Context is Key**: Every variable like `{{variable-name}}` must be part of a surrounding conditional block `{{...}}`. The command cannot ask for new information.
10243. **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.
10254. **Executable**: The command must be syntactically correct and executable.
1026"#,
1027 ),
1028 }
1029 }
1030}
1031
1032fn deserialize_bindings_with_defaults<'de, D>(
1038 deserializer: D,
1039) -> Result<BTreeMap<KeyBindingAction, KeyBinding>, D::Error>
1040where
1041 D: Deserializer<'de>,
1042{
1043 let user_provided_bindings = BTreeMap::<KeyBindingAction, KeyBinding>::deserialize(deserializer)?;
1045
1046 #[cfg(test)]
1047 {
1048 use strum::IntoEnumIterator;
1049 for action_variant in KeyBindingAction::iter() {
1051 if !user_provided_bindings.contains_key(&action_variant) {
1052 return Err(D::Error::custom(format!(
1053 "Missing key binding for action '{action_variant:?}'."
1054 )));
1055 }
1056 }
1057 Ok(user_provided_bindings)
1058 }
1059 #[cfg(not(test))]
1060 {
1061 let mut final_bindings = user_provided_bindings;
1064 let default_bindings = KeyBindingsConfig::default();
1065
1066 for (action, default_binding) in default_bindings.0 {
1067 final_bindings.entry(action).or_insert(default_binding);
1068 }
1069 Ok(final_bindings)
1070 }
1071}
1072
1073fn deserialize_key_events<'de, D>(deserializer: D) -> Result<Vec<KeyEvent>, D::Error>
1077where
1078 D: Deserializer<'de>,
1079{
1080 #[derive(Deserialize)]
1081 #[serde(untagged)]
1082 enum StringOrVec {
1083 Single(String),
1084 Multiple(Vec<String>),
1085 }
1086
1087 let strings = match StringOrVec::deserialize(deserializer)? {
1088 StringOrVec::Single(s) => vec![s],
1089 StringOrVec::Multiple(v) => v,
1090 };
1091
1092 strings
1093 .iter()
1094 .map(String::as_str)
1095 .map(parse_key_event)
1096 .map(|r| r.map_err(D::Error::custom))
1097 .collect()
1098}
1099
1100fn deserialize_color<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
1105where
1106 D: Deserializer<'de>,
1107{
1108 parse_color(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1109}
1110
1111fn deserialize_style<'de, D>(deserializer: D) -> Result<ContentStyle, D::Error>
1115where
1116 D: Deserializer<'de>,
1117{
1118 parse_style(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1119}
1120
1121fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
1125 let raw_lower = raw.to_ascii_lowercase();
1126 let (remaining, modifiers) = extract_key_modifiers(&raw_lower);
1127 parse_key_code_with_modifiers(remaining, modifiers)
1128}
1129
1130fn extract_key_modifiers(raw: &str) -> (&str, KeyModifiers) {
1134 let mut modifiers = KeyModifiers::empty();
1135 let mut current = raw;
1136
1137 loop {
1138 match current {
1139 rest if rest.starts_with("ctrl-") || rest.starts_with("ctrl+") => {
1140 modifiers.insert(KeyModifiers::CONTROL);
1141 current = &rest[5..];
1142 }
1143 rest if rest.starts_with("shift-") || rest.starts_with("shift+") => {
1144 modifiers.insert(KeyModifiers::SHIFT);
1145 current = &rest[6..];
1146 }
1147 rest if rest.starts_with("alt-") || rest.starts_with("alt+") => {
1148 modifiers.insert(KeyModifiers::ALT);
1149 current = &rest[4..];
1150 }
1151 _ => break,
1152 };
1153 }
1154
1155 (current, modifiers)
1156}
1157
1158fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
1160 let code = match raw {
1161 "esc" => KeyCode::Esc,
1162 "enter" => KeyCode::Enter,
1163 "left" => KeyCode::Left,
1164 "right" => KeyCode::Right,
1165 "up" => KeyCode::Up,
1166 "down" => KeyCode::Down,
1167 "home" => KeyCode::Home,
1168 "end" => KeyCode::End,
1169 "pageup" => KeyCode::PageUp,
1170 "pagedown" => KeyCode::PageDown,
1171 "backtab" => {
1172 modifiers.insert(KeyModifiers::SHIFT);
1173 KeyCode::BackTab
1174 }
1175 "backspace" => KeyCode::Backspace,
1176 "delete" => KeyCode::Delete,
1177 "insert" => KeyCode::Insert,
1178 "f1" => KeyCode::F(1),
1179 "f2" => KeyCode::F(2),
1180 "f3" => KeyCode::F(3),
1181 "f4" => KeyCode::F(4),
1182 "f5" => KeyCode::F(5),
1183 "f6" => KeyCode::F(6),
1184 "f7" => KeyCode::F(7),
1185 "f8" => KeyCode::F(8),
1186 "f9" => KeyCode::F(9),
1187 "f10" => KeyCode::F(10),
1188 "f11" => KeyCode::F(11),
1189 "f12" => KeyCode::F(12),
1190 "space" | "spacebar" => KeyCode::Char(' '),
1191 "hyphen" => KeyCode::Char('-'),
1192 "minus" => KeyCode::Char('-'),
1193 "tab" => KeyCode::Tab,
1194 c if c.len() == 1 => {
1195 let mut c = c.chars().next().expect("just checked");
1196 if modifiers.contains(KeyModifiers::SHIFT) {
1197 c = c.to_ascii_uppercase();
1198 }
1199 KeyCode::Char(c)
1200 }
1201 _ => return Err(format!("Unable to parse key binding: {raw}")),
1202 };
1203 Ok(KeyEvent::new(code, modifiers))
1204}
1205
1206fn parse_color(raw: &str) -> Result<Option<Color>, String> {
1210 let raw_lower = raw.to_ascii_lowercase();
1211 if raw.is_empty() || raw == "none" {
1212 Ok(None)
1213 } else {
1214 Ok(Some(parse_color_inner(&raw_lower)?))
1215 }
1216}
1217
1218fn parse_style(raw: &str) -> Result<ContentStyle, String> {
1222 let raw_lower = raw.to_ascii_lowercase();
1223 let (remaining, attributes) = extract_style_attributes(&raw_lower);
1224 let mut style = ContentStyle::new();
1225 style.attributes = attributes;
1226 if !remaining.is_empty() && remaining != "default" {
1227 style.foreground_color = Some(parse_color_inner(remaining)?);
1228 }
1229 Ok(style)
1230}
1231
1232fn extract_style_attributes(raw: &str) -> (&str, Attributes) {
1236 let mut attributes = Attributes::none();
1237 let mut current = raw;
1238
1239 loop {
1240 match current {
1241 rest if rest.starts_with("bold") => {
1242 attributes.set(Attribute::Bold);
1243 current = &rest[4..];
1244 if current.starts_with(' ') {
1245 current = ¤t[1..];
1246 }
1247 }
1248 rest if rest.starts_with("dim") => {
1249 attributes.set(Attribute::Dim);
1250 current = &rest[3..];
1251 if current.starts_with(' ') {
1252 current = ¤t[1..];
1253 }
1254 }
1255 rest if rest.starts_with("italic") => {
1256 attributes.set(Attribute::Italic);
1257 current = &rest[6..];
1258 if current.starts_with(' ') {
1259 current = ¤t[1..];
1260 }
1261 }
1262 rest if rest.starts_with("underline") => {
1263 attributes.set(Attribute::Underlined);
1264 current = &rest[9..];
1265 if current.starts_with(' ') {
1266 current = ¤t[1..];
1267 }
1268 }
1269 rest if rest.starts_with("underlined") => {
1270 attributes.set(Attribute::Underlined);
1271 current = &rest[10..];
1272 if current.starts_with(' ') {
1273 current = ¤t[1..];
1274 }
1275 }
1276 _ => break,
1277 };
1278 }
1279
1280 (current.trim(), attributes)
1281}
1282
1283fn parse_color_inner(raw: &str) -> Result<Color, String> {
1287 Ok(match raw {
1288 "black" => Color::Black,
1289 "red" => Color::Red,
1290 "green" => Color::Green,
1291 "yellow" => Color::Yellow,
1292 "blue" => Color::Blue,
1293 "magenta" => Color::Magenta,
1294 "cyan" => Color::Cyan,
1295 "gray" | "grey" => Color::Grey,
1296 "dark gray" | "darkgray" | "dark grey" | "darkgrey" => Color::DarkGrey,
1297 "dark red" | "darkred" => Color::DarkRed,
1298 "dark green" | "darkgreen" => Color::DarkGreen,
1299 "dark yellow" | "darkyellow" => Color::DarkYellow,
1300 "dark blue" | "darkblue" => Color::DarkBlue,
1301 "dark magenta" | "darkmagenta" => Color::DarkMagenta,
1302 "dark cyan" | "darkcyan" => Color::DarkCyan,
1303 "white" => Color::White,
1304 rgb if rgb.starts_with("rgb(") => {
1305 let rgb = rgb.trim_start_matches("rgb(").trim_end_matches(")").split(',');
1306 let rgb = rgb
1307 .map(|c| c.trim().parse::<u8>())
1308 .collect::<Result<Vec<u8>, _>>()
1309 .map_err(|_| format!("Unable to parse color: {raw}"))?;
1310 if rgb.len() != 3 {
1311 return Err(format!("Unable to parse color: {raw}"));
1312 }
1313 Color::Rgb {
1314 r: rgb[0],
1315 g: rgb[1],
1316 b: rgb[2],
1317 }
1318 }
1319 hex if hex.starts_with("#") => {
1320 let hex = hex.trim_start_matches("#");
1321 if hex.len() != 6 {
1322 return Err(format!("Unable to parse color: {raw}"));
1323 }
1324 let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1325 let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1326 let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1327 Color::Rgb { r, g, b }
1328 }
1329 c => {
1330 if let Ok(c) = c.parse::<u8>() {
1331 Color::AnsiValue(c)
1332 } else {
1333 return Err(format!("Unable to parse color: {raw}"));
1334 }
1335 }
1336 })
1337}
1338
1339fn deserialize_catalog_with_defaults<'de, D>(deserializer: D) -> Result<BTreeMap<String, AiModelConfig>, D::Error>
1344where
1345 D: Deserializer<'de>,
1346{
1347 #[allow(unused_mut)]
1348 let mut user_catalog = BTreeMap::<String, AiModelConfig>::deserialize(deserializer)?;
1350
1351 #[cfg(not(test))]
1353 for (key, default_model) in default_ai_catalog() {
1354 user_catalog.entry(key).or_insert(default_model);
1355 }
1356
1357 Ok(user_catalog)
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362 use pretty_assertions::assert_eq;
1363 use strum::IntoEnumIterator;
1364
1365 use super::*;
1366
1367 #[test]
1368 fn test_default_config() -> Result<()> {
1369 let config_str = fs::read_to_string("default_config.toml").wrap_err("Couldn't read default config file")?;
1370 let config: Config = toml::from_str(&config_str).wrap_err("Couldn't parse default config file")?;
1371
1372 assert_eq!(Config::default(), config);
1373
1374 Ok(())
1375 }
1376
1377 #[test]
1378 fn test_default_keybindings_complete() {
1379 let config = KeyBindingsConfig::default();
1380
1381 for action in KeyBindingAction::iter() {
1382 assert!(
1383 config.0.contains_key(&action),
1384 "Missing default binding for action: {action:?}"
1385 );
1386 }
1387 }
1388
1389 #[test]
1390 fn test_default_keybindings_no_conflicts() {
1391 let config = KeyBindingsConfig::default();
1392
1393 let conflicts = config.find_conflicts();
1394 assert_eq!(conflicts.len(), 0, "Key binding conflicts: {conflicts:?}");
1395 }
1396
1397 #[test]
1398 fn test_keybinding_matches() {
1399 let binding = KeyBinding(vec![
1400 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
1401 KeyEvent::from(KeyCode::Enter),
1402 ]);
1403
1404 assert!(binding.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
1406 assert!(binding.matches(&KeyEvent::from(KeyCode::Enter)));
1407
1408 assert!(!binding.matches(&KeyEvent::new(
1410 KeyCode::Char('a'),
1411 KeyModifiers::CONTROL | KeyModifiers::ALT
1412 )));
1413
1414 assert!(!binding.matches(&KeyEvent::from(KeyCode::Esc)));
1416 }
1417
1418 #[test]
1419 fn test_simple_keys() {
1420 assert_eq!(
1421 parse_key_event("a").unwrap(),
1422 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
1423 );
1424
1425 assert_eq!(
1426 parse_key_event("enter").unwrap(),
1427 KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
1428 );
1429
1430 assert_eq!(
1431 parse_key_event("esc").unwrap(),
1432 KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
1433 );
1434 }
1435
1436 #[test]
1437 fn test_with_modifiers() {
1438 assert_eq!(
1439 parse_key_event("ctrl-a").unwrap(),
1440 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
1441 );
1442
1443 assert_eq!(
1444 parse_key_event("alt-enter").unwrap(),
1445 KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
1446 );
1447
1448 assert_eq!(
1449 parse_key_event("shift-esc").unwrap(),
1450 KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
1451 );
1452 }
1453
1454 #[test]
1455 fn test_multiple_modifiers() {
1456 assert_eq!(
1457 parse_key_event("ctrl-alt-a").unwrap(),
1458 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
1459 );
1460
1461 assert_eq!(
1462 parse_key_event("ctrl-shift-enter").unwrap(),
1463 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
1464 );
1465 }
1466
1467 #[test]
1468 fn test_invalid_keys() {
1469 let res = parse_key_event("invalid-key");
1470 assert_eq!(res, Err(String::from("Unable to parse key binding: invalid-key")));
1471 }
1472
1473 #[test]
1474 fn test_parse_color_none() {
1475 let color = parse_color("none").unwrap();
1476 assert_eq!(color, None);
1477 }
1478
1479 #[test]
1480 fn test_parse_color_simple() {
1481 let color = parse_color("red").unwrap();
1482 assert_eq!(color, Some(Color::Red));
1483 }
1484
1485 #[test]
1486 fn test_parse_color_rgb() {
1487 let color = parse_color("rgb(50, 25, 15)").unwrap();
1488 assert_eq!(color, Some(Color::Rgb { r: 50, g: 25, b: 15 }));
1489 }
1490
1491 #[test]
1492 fn test_parse_color_rgb_out_of_range() {
1493 let res = parse_color("rgb(500, 25, 15)");
1494 assert_eq!(res, Err(String::from("Unable to parse color: rgb(500, 25, 15)")));
1495 }
1496
1497 #[test]
1498 fn test_parse_color_rgb_invalid() {
1499 let res = parse_color("rgb(50, 25, 15, 5)");
1500 assert_eq!(res, Err(String::from("Unable to parse color: rgb(50, 25, 15, 5)")));
1501 }
1502
1503 #[test]
1504 fn test_parse_color_hex() {
1505 let color = parse_color("#4287f5").unwrap();
1506 assert_eq!(color, Some(Color::Rgb { r: 66, g: 135, b: 245 }));
1507 }
1508
1509 #[test]
1510 fn test_parse_color_hex_out_of_range() {
1511 let res = parse_color("#4287fg");
1512 assert_eq!(res, Err(String::from("Unable to parse color: #4287fg")));
1513 }
1514
1515 #[test]
1516 fn test_parse_color_hex_invalid() {
1517 let res = parse_color("#4287f50");
1518 assert_eq!(res, Err(String::from("Unable to parse color: #4287f50")));
1519 }
1520
1521 #[test]
1522 fn test_parse_color_index() {
1523 let color = parse_color("6").unwrap();
1524 assert_eq!(color, Some(Color::AnsiValue(6)));
1525 }
1526
1527 #[test]
1528 fn test_parse_color_fail() {
1529 let res = parse_color("1234");
1530 assert_eq!(res, Err(String::from("Unable to parse color: 1234")));
1531 }
1532
1533 #[test]
1534 fn test_parse_style_empty() {
1535 let style = parse_style("").unwrap();
1536 assert_eq!(style, ContentStyle::new());
1537 }
1538
1539 #[test]
1540 fn test_parse_style_default() {
1541 let style = parse_style("default").unwrap();
1542 assert_eq!(style, ContentStyle::new());
1543 }
1544
1545 #[test]
1546 fn test_parse_style_simple() {
1547 let style = parse_style("red").unwrap();
1548 assert_eq!(style.foreground_color, Some(Color::Red));
1549 assert_eq!(style.attributes, Attributes::none());
1550 }
1551
1552 #[test]
1553 fn test_parse_style_only_modifier() {
1554 let style = parse_style("bold").unwrap();
1555 assert_eq!(style.foreground_color, None);
1556 let mut expected_attributes = Attributes::none();
1557 expected_attributes.set(Attribute::Bold);
1558 assert_eq!(style.attributes, expected_attributes);
1559 }
1560
1561 #[test]
1562 fn test_parse_style_with_modifier() {
1563 let style = parse_style("italic red").unwrap();
1564 assert_eq!(style.foreground_color, Some(Color::Red));
1565 let mut expected_attributes = Attributes::none();
1566 expected_attributes.set(Attribute::Italic);
1567 assert_eq!(style.attributes, expected_attributes);
1568 }
1569
1570 #[test]
1571 fn test_parse_style_multiple_modifier() {
1572 let style = parse_style("underline dim dark red").unwrap();
1573 assert_eq!(style.foreground_color, Some(Color::DarkRed));
1574 let mut expected_attributes = Attributes::none();
1575 expected_attributes.set(Attribute::Underlined);
1576 expected_attributes.set(Attribute::Dim);
1577 assert_eq!(style.attributes, expected_attributes);
1578 }
1579}