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-flash-latest".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-flash-lite-latest".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### Shell Paradigm, Syntax, and Versioning
899**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.
900
9011. **Recognize the Shell Paradigm:**
902 - **POSIX / Text-Stream (bash, zsh, fish):** Operate on **text streams**. Use tools like `grep`, `sed`, `awk`.
903 - **Object-Pipeline (PowerShell, Nushell):** Operate on **structured data (objects)**. You MUST use internal commands for filtering/selection. AVOID external text-processing tools.
904 - **Legacy (cmd.exe):** Has unique syntax for loops (`FOR`), variables (`%VAR%`), and filtering (`findstr`).
905
9062. **Generate Idiomatic Code:**
907 - Use the shell's built-in features and standard library.
908 - Follow the shell's naming and style conventions (e.g., `Verb-Noun` in PowerShell).
909 - Leverage the shell's core strengths (e.g., object manipulation in Nushell).
910
9113. **Ensure Syntactic Correctness:**
912 - Pay close attention to variable syntax (`$var`, `$env:VAR`, `$env.VAR`, `%VAR%`).
913 - Use the correct operators and quoting rules for the target shell.
914
9154. **Pay Critical Attention to the Version:**
916 - The shell version is a primary constraint, not a suggestion. This is especially true for shells with rapid development cycles like **Nushell**.
917 - You **MUST** generate commands that are compatible with the user's specified version.
918 - 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.
919
920### Command Template Syntax
921When creating the `command` template string, you must use the following placeholder syntax:
922
923- **Standard Placeholder**: `{{variable-name}}`
924 - Use for regular arguments that the user needs to provide.
925 - _Example_: `echo "Hello, {{user-name}}!"`
926
927- **Choice Placeholder**: `{{option1|option2}}`
928 - Use when the user must choose from a specific set of options.
929 - _Example_: `git reset {{--soft|--hard}} HEAD~1`
930
931- **Function Placeholder**: `{{variable:function}}`
932 - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
933 - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
934 - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
935
936- **Secret/Ephemeral Placeholder**: `{{{...}}}`
937 - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description).
938 This syntax can wrap any of the placeholder types above.
939 - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
940
941### Suggestion Strategy
942Your 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:
943
9441. **Explicit Single Suggestion:**
945 - If the user's request explicitly asks for **a single suggestion**, you **MUST** return a list containing exactly one suggestion object.
946 - To cover variations within this single command, make effective use of choice placeholders (e.g., `git reset {{--soft|--hard}}`).
947
9482. **Clear & Unambiguous Request:**
949 - If the request is straightforward and has one primary, standard solution, provide a **single, well-formed suggestion**.
950
9513. **Ambiguous or Multi-faceted Request:**
952 - 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**.
953 - Each distinct approach or interpretation **must be a separate suggestion object**.
954 - **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.
955 - **Order the suggestions by relevance**, with the most common or recommended solution appearing first.
956"#,
957 ),
958 fix: String::from(
959 r#"##OS_SHELL_INFO##
960##WORKING_DIR##
961##SHELL_HISTORY##
962### Instructions
963You are an expert command-line assistant. Your mission is to analyze a failed shell command and its error output,
964diagnose the root cause, and provide a structured, actionable solution in a single JSON object.
965
966### Output Schema
967Your response MUST be a single, valid JSON object with no surrounding text or markdown. It must conform to the following structure:
968- `summary`: A very brief, 2-5 word summary of the error category. Examples: "Command Not Found", "Permission Denied", "Invalid Argument", "Git Typo".
969- `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.
970- `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.
971- `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.
972
973### Core Rules
9741. **JSON Only**: Your entire output must be a single, raw JSON object. Do not wrap it in code blocks or add any explanatory text.
9752. **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.
9763. **Strict Wrapping**: Hard-wrap all string values within the JSON to a maximum of 80 characters.
9774. **`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.
978"#,
979 ),
980 import: String::from(
981 r#"### Instructions
982You 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.
983
984Your entire response MUST be a single, valid JSON object conforming to the provided schema. Output nothing but the JSON object itself.
985
986Refer to the syntax definitions, process, and example below to construct your response.
987
988### Command Template Syntax
989When creating the `command` template string, you must use the following placeholder syntax:
990
991- **Standard Placeholder**: `{{variable-name}}`
992 - Use for regular arguments that the user needs to provide.
993 - _Example_: `echo "Hello, {{user-name}}!"`
994
995- **Choice Placeholder**: `{{option1|option2}}`
996 - Use when the user must choose from a specific set of options.
997 - _Example_: `git reset {{--soft|--hard}} HEAD~1`
998
999- **Function Placeholder**: `{{variable:function}}`
1000 - Use to apply a transformation function to the user's input. Multiple functions can be chained (e.g., `{{variable:snake:upper}}`).
1001 - Allowed functions: `kebab`, `snake`, `upper`, `lower`, `url`.
1002 - _Example_: For a user input of "My New Feature", `git checkout -b {{branch-name:kebab}}` would produce `git checkout -b my-new-feature`.
1003
1004- **Secret/Ephemeral Placeholder**: `{{{...}}}`
1005 - Use triple curly braces for sensitive values (like API keys, passwords) or for ephemeral content (like a commit message or a description).
1006 This syntax can wrap any of the placeholder types above.
1007 - _Example_: `export GITHUB_TOKEN={{{api-key}}}` or `git commit -m "{{{message}}}"`
1008
1009### Core Process
10101. **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.
10112. **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.
1012
1013### Output Generation
1014For each unique and deduplicated command pattern you identify:
1015- Create a suggestion object containing a `description` and a `command`.
1016- The `description` must be a clear, single-sentence explanation of the command's purpose.
1017- The `command` must be the final, generalized template string from the core process.
1018"#,
1019 ),
1020 completion: String::from(
1021 r#"##OS_SHELL_INFO##
1022### Instructions
1023You 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.
1024
1025Your entire response MUST be a single, valid JSON object conforming to the provided schema and nothing else.
1026
1027### Core Task
1028The 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").
1029
1030### Command Template Syntax
1031To 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 `{{...}}`.
1032
1033- **Syntax**: `{{--parameter {{variable-name}}}}`
1034- **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.
1035- **All-or-Nothing**: If a block contains multiple variables, all of them must be present in the context for the block to be included.
1036
1037- **_Example_**:
1038 - **Template**: `kubectl get pods {{--context {{context}}}} {{-n {{namespace}}}}`
1039 - If the context provides a `namespace`, the executed command becomes: `kubectl get pods -n prod`
1040 - If the context provides both `namespace` and `context`, it becomes: `kubectl get pods --context my-cluster -n prod`
1041 - If the context is empty, it is simply: `kubectl get pods`
1042
1043### Requirements
10441. **JSON Only**: Your entire output must be a single, raw JSON object. Do not add any explanatory text.
10452. **Context is Key**: Every variable like `{{variable-name}}` must be part of a surrounding conditional block `{{...}}`. The command cannot ask for new information.
10463. **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.
10474. **Executable**: The command must be syntactically correct and executable.
1048"#,
1049 ),
1050 }
1051 }
1052}
1053
1054fn deserialize_bindings_with_defaults<'de, D>(
1060 deserializer: D,
1061) -> Result<BTreeMap<KeyBindingAction, KeyBinding>, D::Error>
1062where
1063 D: Deserializer<'de>,
1064{
1065 let user_provided_bindings = BTreeMap::<KeyBindingAction, KeyBinding>::deserialize(deserializer)?;
1067
1068 #[cfg(test)]
1069 {
1070 use strum::IntoEnumIterator;
1071 for action_variant in KeyBindingAction::iter() {
1073 if !user_provided_bindings.contains_key(&action_variant) {
1074 return Err(D::Error::custom(format!(
1075 "Missing key binding for action '{action_variant:?}'."
1076 )));
1077 }
1078 }
1079 Ok(user_provided_bindings)
1080 }
1081 #[cfg(not(test))]
1082 {
1083 let mut final_bindings = user_provided_bindings;
1086 let default_bindings = KeyBindingsConfig::default();
1087
1088 for (action, default_binding) in default_bindings.0 {
1089 final_bindings.entry(action).or_insert(default_binding);
1090 }
1091 Ok(final_bindings)
1092 }
1093}
1094
1095fn deserialize_key_events<'de, D>(deserializer: D) -> Result<Vec<KeyEvent>, D::Error>
1099where
1100 D: Deserializer<'de>,
1101{
1102 #[derive(Deserialize)]
1103 #[serde(untagged)]
1104 enum StringOrVec {
1105 Single(String),
1106 Multiple(Vec<String>),
1107 }
1108
1109 let strings = match StringOrVec::deserialize(deserializer)? {
1110 StringOrVec::Single(s) => vec![s],
1111 StringOrVec::Multiple(v) => v,
1112 };
1113
1114 strings
1115 .iter()
1116 .map(String::as_str)
1117 .map(parse_key_event)
1118 .map(|r| r.map_err(D::Error::custom))
1119 .collect()
1120}
1121
1122fn deserialize_color<'de, D>(deserializer: D) -> Result<Option<Color>, D::Error>
1127where
1128 D: Deserializer<'de>,
1129{
1130 parse_color(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1131}
1132
1133fn deserialize_style<'de, D>(deserializer: D) -> Result<ContentStyle, D::Error>
1137where
1138 D: Deserializer<'de>,
1139{
1140 parse_style(&String::deserialize(deserializer)?).map_err(D::Error::custom)
1141}
1142
1143fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
1147 let raw_lower = raw.to_ascii_lowercase();
1148 let (remaining, modifiers) = extract_key_modifiers(&raw_lower);
1149 parse_key_code_with_modifiers(remaining, modifiers)
1150}
1151
1152fn extract_key_modifiers(raw: &str) -> (&str, KeyModifiers) {
1156 let mut modifiers = KeyModifiers::empty();
1157 let mut current = raw;
1158
1159 loop {
1160 match current {
1161 rest if rest.starts_with("ctrl-") || rest.starts_with("ctrl+") => {
1162 modifiers.insert(KeyModifiers::CONTROL);
1163 current = &rest[5..];
1164 }
1165 rest if rest.starts_with("shift-") || rest.starts_with("shift+") => {
1166 modifiers.insert(KeyModifiers::SHIFT);
1167 current = &rest[6..];
1168 }
1169 rest if rest.starts_with("alt-") || rest.starts_with("alt+") => {
1170 modifiers.insert(KeyModifiers::ALT);
1171 current = &rest[4..];
1172 }
1173 _ => break,
1174 };
1175 }
1176
1177 (current, modifiers)
1178}
1179
1180fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
1182 let code = match raw {
1183 "esc" => KeyCode::Esc,
1184 "enter" => KeyCode::Enter,
1185 "left" => KeyCode::Left,
1186 "right" => KeyCode::Right,
1187 "up" => KeyCode::Up,
1188 "down" => KeyCode::Down,
1189 "home" => KeyCode::Home,
1190 "end" => KeyCode::End,
1191 "pageup" => KeyCode::PageUp,
1192 "pagedown" => KeyCode::PageDown,
1193 "backtab" => {
1194 modifiers.insert(KeyModifiers::SHIFT);
1195 KeyCode::BackTab
1196 }
1197 "backspace" => KeyCode::Backspace,
1198 "delete" => KeyCode::Delete,
1199 "insert" => KeyCode::Insert,
1200 "f1" => KeyCode::F(1),
1201 "f2" => KeyCode::F(2),
1202 "f3" => KeyCode::F(3),
1203 "f4" => KeyCode::F(4),
1204 "f5" => KeyCode::F(5),
1205 "f6" => KeyCode::F(6),
1206 "f7" => KeyCode::F(7),
1207 "f8" => KeyCode::F(8),
1208 "f9" => KeyCode::F(9),
1209 "f10" => KeyCode::F(10),
1210 "f11" => KeyCode::F(11),
1211 "f12" => KeyCode::F(12),
1212 "space" | "spacebar" => KeyCode::Char(' '),
1213 "hyphen" => KeyCode::Char('-'),
1214 "minus" => KeyCode::Char('-'),
1215 "tab" => KeyCode::Tab,
1216 c if c.len() == 1 => {
1217 let mut c = c.chars().next().expect("just checked");
1218 if modifiers.contains(KeyModifiers::SHIFT) {
1219 c = c.to_ascii_uppercase();
1220 }
1221 KeyCode::Char(c)
1222 }
1223 _ => return Err(format!("Unable to parse key binding: {raw}")),
1224 };
1225 Ok(KeyEvent::new(code, modifiers))
1226}
1227
1228fn parse_color(raw: &str) -> Result<Option<Color>, String> {
1232 let raw_lower = raw.to_ascii_lowercase();
1233 if raw.is_empty() || raw == "none" {
1234 Ok(None)
1235 } else {
1236 Ok(Some(parse_color_inner(&raw_lower)?))
1237 }
1238}
1239
1240fn parse_style(raw: &str) -> Result<ContentStyle, String> {
1244 let raw_lower = raw.to_ascii_lowercase();
1245 let (remaining, attributes) = extract_style_attributes(&raw_lower);
1246 let mut style = ContentStyle::new();
1247 style.attributes = attributes;
1248 if !remaining.is_empty() && remaining != "default" {
1249 style.foreground_color = Some(parse_color_inner(remaining)?);
1250 }
1251 Ok(style)
1252}
1253
1254fn extract_style_attributes(raw: &str) -> (&str, Attributes) {
1258 let mut attributes = Attributes::none();
1259 let mut current = raw;
1260
1261 loop {
1262 match current {
1263 rest if rest.starts_with("bold") => {
1264 attributes.set(Attribute::Bold);
1265 current = &rest[4..];
1266 if current.starts_with(' ') {
1267 current = ¤t[1..];
1268 }
1269 }
1270 rest if rest.starts_with("dim") => {
1271 attributes.set(Attribute::Dim);
1272 current = &rest[3..];
1273 if current.starts_with(' ') {
1274 current = ¤t[1..];
1275 }
1276 }
1277 rest if rest.starts_with("italic") => {
1278 attributes.set(Attribute::Italic);
1279 current = &rest[6..];
1280 if current.starts_with(' ') {
1281 current = ¤t[1..];
1282 }
1283 }
1284 rest if rest.starts_with("underline") => {
1285 attributes.set(Attribute::Underlined);
1286 current = &rest[9..];
1287 if current.starts_with(' ') {
1288 current = ¤t[1..];
1289 }
1290 }
1291 rest if rest.starts_with("underlined") => {
1292 attributes.set(Attribute::Underlined);
1293 current = &rest[10..];
1294 if current.starts_with(' ') {
1295 current = ¤t[1..];
1296 }
1297 }
1298 _ => break,
1299 };
1300 }
1301
1302 (current.trim(), attributes)
1303}
1304
1305fn parse_color_inner(raw: &str) -> Result<Color, String> {
1309 Ok(match raw {
1310 "black" => Color::Black,
1311 "red" => Color::Red,
1312 "green" => Color::Green,
1313 "yellow" => Color::Yellow,
1314 "blue" => Color::Blue,
1315 "magenta" => Color::Magenta,
1316 "cyan" => Color::Cyan,
1317 "gray" | "grey" => Color::Grey,
1318 "dark gray" | "darkgray" | "dark grey" | "darkgrey" => Color::DarkGrey,
1319 "dark red" | "darkred" => Color::DarkRed,
1320 "dark green" | "darkgreen" => Color::DarkGreen,
1321 "dark yellow" | "darkyellow" => Color::DarkYellow,
1322 "dark blue" | "darkblue" => Color::DarkBlue,
1323 "dark magenta" | "darkmagenta" => Color::DarkMagenta,
1324 "dark cyan" | "darkcyan" => Color::DarkCyan,
1325 "white" => Color::White,
1326 rgb if rgb.starts_with("rgb(") => {
1327 let rgb = rgb.trim_start_matches("rgb(").trim_end_matches(")").split(',');
1328 let rgb = rgb
1329 .map(|c| c.trim().parse::<u8>())
1330 .collect::<Result<Vec<u8>, _>>()
1331 .map_err(|_| format!("Unable to parse color: {raw}"))?;
1332 if rgb.len() != 3 {
1333 return Err(format!("Unable to parse color: {raw}"));
1334 }
1335 Color::Rgb {
1336 r: rgb[0],
1337 g: rgb[1],
1338 b: rgb[2],
1339 }
1340 }
1341 hex if hex.starts_with("#") => {
1342 let hex = hex.trim_start_matches("#");
1343 if hex.len() != 6 {
1344 return Err(format!("Unable to parse color: {raw}"));
1345 }
1346 let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1347 let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1348 let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Unable to parse color: {raw}"))?;
1349 Color::Rgb { r, g, b }
1350 }
1351 c => {
1352 if let Ok(c) = c.parse::<u8>() {
1353 Color::AnsiValue(c)
1354 } else {
1355 return Err(format!("Unable to parse color: {raw}"));
1356 }
1357 }
1358 })
1359}
1360
1361fn deserialize_catalog_with_defaults<'de, D>(deserializer: D) -> Result<BTreeMap<String, AiModelConfig>, D::Error>
1366where
1367 D: Deserializer<'de>,
1368{
1369 #[allow(unused_mut)]
1370 let mut user_catalog = BTreeMap::<String, AiModelConfig>::deserialize(deserializer)?;
1372
1373 #[cfg(not(test))]
1375 for (key, default_model) in default_ai_catalog() {
1376 user_catalog.entry(key).or_insert(default_model);
1377 }
1378
1379 Ok(user_catalog)
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 use pretty_assertions::assert_eq;
1385 use strum::IntoEnumIterator;
1386
1387 use super::*;
1388
1389 #[test]
1390 fn test_default_config() -> Result<()> {
1391 let config_str = fs::read_to_string("default_config.toml").wrap_err("Couldn't read default config file")?;
1392 let config: Config = toml::from_str(&config_str).wrap_err("Couldn't parse default config file")?;
1393
1394 assert_eq!(Config::default(), config);
1395
1396 Ok(())
1397 }
1398
1399 #[test]
1400 fn test_default_keybindings_complete() {
1401 let config = KeyBindingsConfig::default();
1402
1403 for action in KeyBindingAction::iter() {
1404 assert!(
1405 config.0.contains_key(&action),
1406 "Missing default binding for action: {action:?}"
1407 );
1408 }
1409 }
1410
1411 #[test]
1412 fn test_default_keybindings_no_conflicts() {
1413 let config = KeyBindingsConfig::default();
1414
1415 let conflicts = config.find_conflicts();
1416 assert_eq!(conflicts.len(), 0, "Key binding conflicts: {conflicts:?}");
1417 }
1418
1419 #[test]
1420 fn test_keybinding_matches() {
1421 let binding = KeyBinding(vec![
1422 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
1423 KeyEvent::from(KeyCode::Enter),
1424 ]);
1425
1426 assert!(binding.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
1428 assert!(binding.matches(&KeyEvent::from(KeyCode::Enter)));
1429
1430 assert!(!binding.matches(&KeyEvent::new(
1432 KeyCode::Char('a'),
1433 KeyModifiers::CONTROL | KeyModifiers::ALT
1434 )));
1435
1436 assert!(!binding.matches(&KeyEvent::from(KeyCode::Esc)));
1438 }
1439
1440 #[test]
1441 fn test_simple_keys() {
1442 assert_eq!(
1443 parse_key_event("a").unwrap(),
1444 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
1445 );
1446
1447 assert_eq!(
1448 parse_key_event("enter").unwrap(),
1449 KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
1450 );
1451
1452 assert_eq!(
1453 parse_key_event("esc").unwrap(),
1454 KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
1455 );
1456 }
1457
1458 #[test]
1459 fn test_with_modifiers() {
1460 assert_eq!(
1461 parse_key_event("ctrl-a").unwrap(),
1462 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
1463 );
1464
1465 assert_eq!(
1466 parse_key_event("alt-enter").unwrap(),
1467 KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
1468 );
1469
1470 assert_eq!(
1471 parse_key_event("shift-esc").unwrap(),
1472 KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
1473 );
1474 }
1475
1476 #[test]
1477 fn test_multiple_modifiers() {
1478 assert_eq!(
1479 parse_key_event("ctrl-alt-a").unwrap(),
1480 KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
1481 );
1482
1483 assert_eq!(
1484 parse_key_event("ctrl-shift-enter").unwrap(),
1485 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
1486 );
1487 }
1488
1489 #[test]
1490 fn test_invalid_keys() {
1491 let res = parse_key_event("invalid-key");
1492 assert_eq!(res, Err(String::from("Unable to parse key binding: invalid-key")));
1493 }
1494
1495 #[test]
1496 fn test_parse_color_none() {
1497 let color = parse_color("none").unwrap();
1498 assert_eq!(color, None);
1499 }
1500
1501 #[test]
1502 fn test_parse_color_simple() {
1503 let color = parse_color("red").unwrap();
1504 assert_eq!(color, Some(Color::Red));
1505 }
1506
1507 #[test]
1508 fn test_parse_color_rgb() {
1509 let color = parse_color("rgb(50, 25, 15)").unwrap();
1510 assert_eq!(color, Some(Color::Rgb { r: 50, g: 25, b: 15 }));
1511 }
1512
1513 #[test]
1514 fn test_parse_color_rgb_out_of_range() {
1515 let res = parse_color("rgb(500, 25, 15)");
1516 assert_eq!(res, Err(String::from("Unable to parse color: rgb(500, 25, 15)")));
1517 }
1518
1519 #[test]
1520 fn test_parse_color_rgb_invalid() {
1521 let res = parse_color("rgb(50, 25, 15, 5)");
1522 assert_eq!(res, Err(String::from("Unable to parse color: rgb(50, 25, 15, 5)")));
1523 }
1524
1525 #[test]
1526 fn test_parse_color_hex() {
1527 let color = parse_color("#4287f5").unwrap();
1528 assert_eq!(color, Some(Color::Rgb { r: 66, g: 135, b: 245 }));
1529 }
1530
1531 #[test]
1532 fn test_parse_color_hex_out_of_range() {
1533 let res = parse_color("#4287fg");
1534 assert_eq!(res, Err(String::from("Unable to parse color: #4287fg")));
1535 }
1536
1537 #[test]
1538 fn test_parse_color_hex_invalid() {
1539 let res = parse_color("#4287f50");
1540 assert_eq!(res, Err(String::from("Unable to parse color: #4287f50")));
1541 }
1542
1543 #[test]
1544 fn test_parse_color_index() {
1545 let color = parse_color("6").unwrap();
1546 assert_eq!(color, Some(Color::AnsiValue(6)));
1547 }
1548
1549 #[test]
1550 fn test_parse_color_fail() {
1551 let res = parse_color("1234");
1552 assert_eq!(res, Err(String::from("Unable to parse color: 1234")));
1553 }
1554
1555 #[test]
1556 fn test_parse_style_empty() {
1557 let style = parse_style("").unwrap();
1558 assert_eq!(style, ContentStyle::new());
1559 }
1560
1561 #[test]
1562 fn test_parse_style_default() {
1563 let style = parse_style("default").unwrap();
1564 assert_eq!(style, ContentStyle::new());
1565 }
1566
1567 #[test]
1568 fn test_parse_style_simple() {
1569 let style = parse_style("red").unwrap();
1570 assert_eq!(style.foreground_color, Some(Color::Red));
1571 assert_eq!(style.attributes, Attributes::none());
1572 }
1573
1574 #[test]
1575 fn test_parse_style_only_modifier() {
1576 let style = parse_style("bold").unwrap();
1577 assert_eq!(style.foreground_color, None);
1578 let mut expected_attributes = Attributes::none();
1579 expected_attributes.set(Attribute::Bold);
1580 assert_eq!(style.attributes, expected_attributes);
1581 }
1582
1583 #[test]
1584 fn test_parse_style_with_modifier() {
1585 let style = parse_style("italic red").unwrap();
1586 assert_eq!(style.foreground_color, Some(Color::Red));
1587 let mut expected_attributes = Attributes::none();
1588 expected_attributes.set(Attribute::Italic);
1589 assert_eq!(style.attributes, expected_attributes);
1590 }
1591
1592 #[test]
1593 fn test_parse_style_multiple_modifier() {
1594 let style = parse_style("underline dim dark red").unwrap();
1595 assert_eq!(style.foreground_color, Some(Color::DarkRed));
1596 let mut expected_attributes = Attributes::none();
1597 expected_attributes.set(Attribute::Underlined);
1598 expected_attributes.set(Attribute::Dim);
1599 assert_eq!(style.attributes, expected_attributes);
1600 }
1601}