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