atuin_client/
theme.rs

1use config::{Config, File as ConfigFile, FileFormat};
2use lazy_static::lazy_static;
3use log;
4use palette::named;
5use serde::{Deserialize, Serialize};
6use serde_json;
7use std::collections::HashMap;
8use std::error;
9use std::io::{Error, ErrorKind};
10use std::path::PathBuf;
11use strum_macros;
12
13static DEFAULT_MAX_DEPTH: u8 = 10;
14
15// Collection of settable "meanings" that can have colors set.
16// NOTE: You can add a new meaning here without breaking backwards compatibility but please:
17//     - update the atuin/docs repository, which has a list of available meanings
18//     - add a fallback in the MEANING_FALLBACKS below, so that themes which do not have it
19//       get a sensible fallback (see Title as an example)
20#[derive(
21    Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display,
22)]
23#[strum(serialize_all = "camel_case")]
24pub enum Meaning {
25    AlertInfo,
26    AlertWarn,
27    AlertError,
28    Annotation,
29    Base,
30    Guidance,
31    Important,
32    Title,
33    Muted,
34}
35
36#[derive(Clone, Debug, Deserialize, Serialize)]
37pub struct ThemeConfig {
38    // Definition of the theme
39    pub theme: ThemeDefinitionConfigBlock,
40
41    // Colors
42    pub colors: HashMap<Meaning, String>,
43}
44
45#[derive(Clone, Debug, Deserialize, Serialize)]
46pub struct ThemeDefinitionConfigBlock {
47    /// Name of theme ("default" for base)
48    pub name: String,
49
50    /// Whether any theme should be treated as a parent _if available_
51    pub parent: Option<String>,
52}
53
54use crossterm::style::{Attribute, Attributes, Color, ContentStyle};
55
56// For now, a theme is loaded as a mapping of meanings to colors, but it may be desirable to
57// expand that in the future to general styles, so we populate a Meaning->ContentStyle hashmap.
58pub struct Theme {
59    pub name: String,
60    pub parent: Option<String>,
61    pub styles: HashMap<Meaning, ContentStyle>,
62}
63
64// Themes have a number of convenience functions for the most commonly used meanings.
65// The general purpose `as_style` routine gives back a style, but for ease-of-use and to keep
66// theme-related boilerplate minimal, the convenience functions give a color.
67impl Theme {
68    // This is the base "default" color, for general text
69    pub fn get_base(&self) -> ContentStyle {
70        self.styles[&Meaning::Base]
71    }
72
73    pub fn get_info(&self) -> ContentStyle {
74        self.get_alert(log::Level::Info)
75    }
76
77    pub fn get_warning(&self) -> ContentStyle {
78        self.get_alert(log::Level::Warn)
79    }
80
81    pub fn get_error(&self) -> ContentStyle {
82        self.get_alert(log::Level::Error)
83    }
84
85    // The alert meanings may be chosen by the Level enum, rather than the methods above
86    // or the full Meaning enum, to simplify programmatic selection of a log-level.
87    pub fn get_alert(&self, severity: log::Level) -> ContentStyle {
88        self.styles[ALERT_TYPES.get(&severity).unwrap()]
89    }
90
91    pub fn new(
92        name: String,
93        parent: Option<String>,
94        styles: HashMap<Meaning, ContentStyle>,
95    ) -> Theme {
96        Theme {
97            name,
98            parent,
99            styles,
100        }
101    }
102
103    pub fn closest_meaning<'a>(&self, meaning: &'a Meaning) -> &'a Meaning {
104        if self.styles.contains_key(meaning) {
105            meaning
106        } else if MEANING_FALLBACKS.contains_key(meaning) {
107            self.closest_meaning(&MEANING_FALLBACKS[meaning])
108        } else {
109            &Meaning::Base
110        }
111    }
112
113    // General access - if you have a meaning, this will give you a (crossterm) style
114    pub fn as_style(&self, meaning: Meaning) -> ContentStyle {
115        self.styles[self.closest_meaning(&meaning)]
116    }
117
118    // Turns a map of meanings to colornames into a theme
119    // If theme-debug is on, then we will print any colornames that we cannot load,
120    // but we do not have this on in general, as it could print unfiltered text to the terminal
121    // from a theme TOML file. However, it will always return a theme, falling back to
122    // defaults on error, so that a TOML file does not break loading
123    pub fn from_foreground_colors(
124        name: String,
125        parent: Option<&Theme>,
126        foreground_colors: HashMap<Meaning, String>,
127        debug: bool,
128    ) -> Theme {
129        let styles: HashMap<Meaning, ContentStyle> = foreground_colors
130            .iter()
131            .map(|(name, color)| {
132                (
133                    *name,
134                    StyleFactory::from_fg_string(color).unwrap_or_else(|err| {
135                        if debug {
136                            log::warn!("Tried to load string as a color unsuccessfully: ({name}={color}) {err}");
137                        }
138                        ContentStyle::default()
139                    }),
140                )
141            })
142            .collect();
143        Theme::from_map(name, parent, &styles)
144    }
145
146    // Boil down a meaning-color hashmap into a theme, by taking the defaults
147    // for any unknown colors
148    fn from_map(
149        name: String,
150        parent: Option<&Theme>,
151        overrides: &HashMap<Meaning, ContentStyle>,
152    ) -> Theme {
153        let styles = match parent {
154            Some(theme) => Box::new(theme.styles.clone()),
155            None => Box::new(DEFAULT_THEME.styles.clone()),
156        }
157        .iter()
158        .map(|(name, color)| match overrides.get(name) {
159            Some(value) => (*name, *value),
160            None => (*name, *color),
161        })
162        .collect();
163        Theme::new(name, parent.map(|p| p.name.clone()), styles)
164    }
165}
166
167// Use palette to get a color from a string name, if possible
168fn from_string(name: &str) -> Result<Color, String> {
169    if name.is_empty() {
170        return Err("Empty string".into());
171    }
172    let first_char = name.chars().next().unwrap();
173    match first_char {
174        '#' => {
175            let hexcode = &name[1..];
176            let vec: Vec<u8> = hexcode
177                .chars()
178                .collect::<Vec<char>>()
179                .chunks(2)
180                .map(|pair| u8::from_str_radix(pair.iter().collect::<String>().as_str(), 16))
181                .filter_map(|n| n.ok())
182                .collect();
183            if vec.len() != 3 {
184                return Err("Could not parse 3 hex values from string".into());
185            }
186            Ok(Color::Rgb {
187                r: vec[0],
188                g: vec[1],
189                b: vec[2],
190            })
191        }
192        '@' => {
193            // For full flexibility, we need to use serde_json, given
194            // crossterm's approach.
195            serde_json::from_str::<Color>(format!("\"{}\"", &name[1..]).as_str())
196                .map_err(|_| format!("Could not convert color name {name} to Crossterm color"))
197        }
198        _ => {
199            let srgb = named::from_str(name).ok_or("No such color in palette")?;
200            Ok(Color::Rgb {
201                r: srgb.red,
202                g: srgb.green,
203                b: srgb.blue,
204            })
205        }
206    }
207}
208
209pub struct StyleFactory {}
210
211impl StyleFactory {
212    fn from_fg_string(name: &str) -> Result<ContentStyle, String> {
213        match from_string(name) {
214            Ok(color) => Ok(Self::from_fg_color(color)),
215            Err(err) => Err(err),
216        }
217    }
218
219    // For succinctness, if we are confident that the name will be known,
220    // this routine is available to keep the code readable
221    fn known_fg_string(name: &str) -> ContentStyle {
222        Self::from_fg_string(name).unwrap()
223    }
224
225    fn from_fg_color(color: Color) -> ContentStyle {
226        ContentStyle {
227            foreground_color: Some(color),
228            ..ContentStyle::default()
229        }
230    }
231
232    fn from_fg_color_and_attributes(color: Color, attributes: Attributes) -> ContentStyle {
233        ContentStyle {
234            foreground_color: Some(color),
235            attributes,
236            ..ContentStyle::default()
237        }
238    }
239}
240
241// Built-in themes. Rather than having extra files added before any theming
242// is available, this gives a couple of basic options, demonstrating the use
243// of themes: autumn and marine
244lazy_static! {
245    static ref ALERT_TYPES: HashMap<log::Level, Meaning> = {
246        HashMap::from([
247            (log::Level::Info, Meaning::AlertInfo),
248            (log::Level::Warn, Meaning::AlertWarn),
249            (log::Level::Error, Meaning::AlertError),
250        ])
251    };
252    static ref MEANING_FALLBACKS: HashMap<Meaning, Meaning> = {
253        HashMap::from([
254            (Meaning::Guidance, Meaning::AlertInfo),
255            (Meaning::Annotation, Meaning::AlertInfo),
256            (Meaning::Title, Meaning::Important),
257        ])
258    };
259    static ref DEFAULT_THEME: Theme = {
260        Theme::new(
261            "default".to_string(),
262            None,
263            HashMap::from([
264                (
265                    Meaning::AlertError,
266                    StyleFactory::from_fg_color(Color::DarkRed),
267                ),
268                (
269                    Meaning::AlertWarn,
270                    StyleFactory::from_fg_color(Color::DarkYellow),
271                ),
272                (
273                    Meaning::AlertInfo,
274                    StyleFactory::from_fg_color(Color::DarkGreen),
275                ),
276                (
277                    Meaning::Annotation,
278                    StyleFactory::from_fg_color(Color::DarkGrey),
279                ),
280                (
281                    Meaning::Guidance,
282                    StyleFactory::from_fg_color(Color::DarkBlue),
283                ),
284                (
285                    Meaning::Important,
286                    StyleFactory::from_fg_color_and_attributes(
287                        Color::White,
288                        Attributes::from(Attribute::Bold),
289                    ),
290                ),
291                (Meaning::Muted, StyleFactory::from_fg_color(Color::Grey)),
292                (Meaning::Base, ContentStyle::default()),
293            ]),
294        )
295    };
296    static ref BUILTIN_THEMES: HashMap<&'static str, Theme> = {
297        HashMap::from([
298            ("default", HashMap::new()),
299            (
300                "(none)",
301                HashMap::from([
302                    (Meaning::AlertError, ContentStyle::default()),
303                    (Meaning::AlertWarn, ContentStyle::default()),
304                    (Meaning::AlertInfo, ContentStyle::default()),
305                    (Meaning::Annotation, ContentStyle::default()),
306                    (Meaning::Guidance, ContentStyle::default()),
307                    (Meaning::Important, ContentStyle::default()),
308                    (Meaning::Muted, ContentStyle::default()),
309                    (Meaning::Base, ContentStyle::default()),
310                ]),
311            ),
312            (
313                "autumn",
314                HashMap::from([
315                    (
316                        Meaning::AlertError,
317                        StyleFactory::known_fg_string("saddlebrown"),
318                    ),
319                    (
320                        Meaning::AlertWarn,
321                        StyleFactory::known_fg_string("darkorange"),
322                    ),
323                    (Meaning::AlertInfo, StyleFactory::known_fg_string("gold")),
324                    (
325                        Meaning::Annotation,
326                        StyleFactory::from_fg_color(Color::DarkGrey),
327                    ),
328                    (Meaning::Guidance, StyleFactory::known_fg_string("brown")),
329                ]),
330            ),
331            (
332                "marine",
333                HashMap::from([
334                    (
335                        Meaning::AlertError,
336                        StyleFactory::known_fg_string("yellowgreen"),
337                    ),
338                    (Meaning::AlertWarn, StyleFactory::known_fg_string("cyan")),
339                    (
340                        Meaning::AlertInfo,
341                        StyleFactory::known_fg_string("turquoise"),
342                    ),
343                    (
344                        Meaning::Annotation,
345                        StyleFactory::known_fg_string("steelblue"),
346                    ),
347                    (
348                        Meaning::Base,
349                        StyleFactory::known_fg_string("lightsteelblue"),
350                    ),
351                    (Meaning::Guidance, StyleFactory::known_fg_string("teal")),
352                ]),
353            ),
354        ])
355        .iter()
356        .map(|(name, theme)| (*name, Theme::from_map(name.to_string(), None, theme)))
357        .collect()
358    };
359}
360
361// To avoid themes being repeatedly loaded, we store them in a theme manager
362pub struct ThemeManager {
363    loaded_themes: HashMap<String, Theme>,
364    debug: bool,
365    override_theme_dir: Option<String>,
366}
367
368// Theme-loading logic
369impl ThemeManager {
370    pub fn new(debug: Option<bool>, theme_dir: Option<String>) -> Self {
371        Self {
372            loaded_themes: HashMap::new(),
373            debug: debug.unwrap_or(false),
374            override_theme_dir: match theme_dir {
375                Some(theme_dir) => Some(theme_dir),
376                None => std::env::var("ATUIN_THEME_DIR").ok(),
377            },
378        }
379    }
380
381    // Try to load a theme from a `{name}.toml` file in the theme directory. If an override is set
382    // for the theme dir (via ATUIN_THEME_DIR env) we should load the theme from there
383    pub fn load_theme_from_file(
384        &mut self,
385        name: &str,
386        max_depth: u8,
387    ) -> Result<&Theme, Box<dyn error::Error>> {
388        let mut theme_file = if let Some(p) = &self.override_theme_dir {
389            if p.is_empty() {
390                return Err(Box::new(Error::new(
391                    ErrorKind::NotFound,
392                    "Empty theme directory override and could not find theme elsewhere",
393                )));
394            }
395            PathBuf::from(p)
396        } else {
397            let config_dir = atuin_common::utils::config_dir();
398            let mut theme_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
399                PathBuf::from(p)
400            } else {
401                let mut theme_file = PathBuf::new();
402                theme_file.push(config_dir);
403                theme_file
404            };
405            theme_file.push("themes");
406            theme_file
407        };
408
409        let theme_toml = format!["{name}.toml"];
410        theme_file.push(theme_toml);
411
412        let mut config_builder = Config::builder();
413
414        config_builder = config_builder.add_source(ConfigFile::new(
415            theme_file.to_str().unwrap(),
416            FileFormat::Toml,
417        ));
418
419        let config = config_builder.build()?;
420        self.load_theme_from_config(name, config, max_depth)
421    }
422
423    pub fn load_theme_from_config(
424        &mut self,
425        name: &str,
426        config: Config,
427        max_depth: u8,
428    ) -> Result<&Theme, Box<dyn error::Error>> {
429        let debug = self.debug;
430        let theme_config: ThemeConfig = match config.try_deserialize() {
431            Ok(tc) => tc,
432            Err(e) => {
433                return Err(Box::new(Error::new(
434                    ErrorKind::InvalidInput,
435                    format!(
436                        "Failed to deserialize theme: {}",
437                        if debug {
438                            e.to_string()
439                        } else {
440                            "set theme debug on for more info".to_string()
441                        }
442                    ),
443                )));
444            }
445        };
446        let colors: HashMap<Meaning, String> = theme_config.colors;
447        let parent: Option<&Theme> = match theme_config.theme.parent {
448            Some(parent_name) => {
449                if max_depth == 0 {
450                    return Err(Box::new(Error::new(
451                        ErrorKind::InvalidInput,
452                        "Parent requested but we hit the recursion limit",
453                    )));
454                }
455                Some(self.load_theme(parent_name.as_str(), Some(max_depth - 1)))
456            }
457            None => Some(self.load_theme("default", Some(max_depth - 1))),
458        };
459
460        if debug && name != theme_config.theme.name {
461            log::warn!(
462                "Your theme config name is not the name of your loaded theme {} != {}",
463                name,
464                theme_config.theme.name
465            );
466        }
467
468        let theme = Theme::from_foreground_colors(theme_config.theme.name, parent, colors, debug);
469        let name = name.to_string();
470        self.loaded_themes.insert(name.clone(), theme);
471        let theme = self.loaded_themes.get(&name).unwrap();
472        Ok(theme)
473    }
474
475    // Check if the requested theme is loaded and, if not, then attempt to get it
476    // from the builtins or, if not there, from file
477    pub fn load_theme(&mut self, name: &str, max_depth: Option<u8>) -> &Theme {
478        if self.loaded_themes.contains_key(name) {
479            return self.loaded_themes.get(name).unwrap();
480        }
481        let built_ins = &BUILTIN_THEMES;
482        match built_ins.get(name) {
483            Some(theme) => theme,
484            None => match self.load_theme_from_file(name, max_depth.unwrap_or(DEFAULT_MAX_DEPTH)) {
485                Ok(theme) => theme,
486                Err(err) => {
487                    log::warn!("Could not load theme {name}: {err}");
488                    built_ins.get("(none)").unwrap()
489                }
490            },
491        }
492    }
493}
494
495#[cfg(test)]
496mod theme_tests {
497    use super::*;
498
499    #[test]
500    fn test_can_load_builtin_theme() {
501        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
502        let theme = manager.load_theme("autumn", None);
503        assert_eq!(
504            theme.as_style(Meaning::Guidance).foreground_color,
505            from_string("brown").ok()
506        );
507    }
508
509    #[test]
510    fn test_can_create_theme() {
511        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
512        let mytheme = Theme::new(
513            "mytheme".to_string(),
514            None,
515            HashMap::from([(
516                Meaning::AlertError,
517                StyleFactory::known_fg_string("yellowgreen"),
518            )]),
519        );
520        manager.loaded_themes.insert("mytheme".to_string(), mytheme);
521        let theme = manager.load_theme("mytheme", None);
522        assert_eq!(
523            theme.as_style(Meaning::AlertError).foreground_color,
524            from_string("yellowgreen").ok()
525        );
526    }
527
528    #[test]
529    fn test_can_fallback_when_meaning_missing() {
530        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
531
532        // We use title as an example of a meaning that is not defined
533        // even in the base theme.
534        assert!(!DEFAULT_THEME.styles.contains_key(&Meaning::Title));
535
536        let config = Config::builder()
537            .add_source(ConfigFile::from_str(
538                "
539        [theme]
540        name = \"title_theme\"
541
542        [colors]
543        Guidance = \"white\"
544        AlertInfo = \"zomp\"
545        ",
546                FileFormat::Toml,
547            ))
548            .build()
549            .unwrap();
550        let theme = manager
551            .load_theme_from_config("config_theme", config, 1)
552            .unwrap();
553
554        // Correctly picks overridden color.
555        assert_eq!(
556            theme.as_style(Meaning::Guidance).foreground_color,
557            from_string("white").ok()
558        );
559
560        // Does not fall back to any color.
561        assert_eq!(theme.as_style(Meaning::AlertInfo).foreground_color, None);
562
563        // Even for the base.
564        assert_eq!(theme.as_style(Meaning::Base).foreground_color, None);
565
566        // Falls back to red as meaning missing from theme, so picks base default.
567        assert_eq!(
568            theme.as_style(Meaning::AlertError).foreground_color,
569            Some(Color::DarkRed)
570        );
571
572        // Falls back to Important as Title not available.
573        assert_eq!(
574            theme.as_style(Meaning::Title).foreground_color,
575            theme.as_style(Meaning::Important).foreground_color,
576        );
577
578        let title_config = Config::builder()
579            .add_source(ConfigFile::from_str(
580                "
581        [theme]
582        name = \"title_theme\"
583
584        [colors]
585        Title = \"white\"
586        AlertInfo = \"zomp\"
587        ",
588                FileFormat::Toml,
589            ))
590            .build()
591            .unwrap();
592        let title_theme = manager
593            .load_theme_from_config("title_theme", title_config, 1)
594            .unwrap();
595
596        assert_eq!(
597            title_theme.as_style(Meaning::Title).foreground_color,
598            Some(Color::White)
599        );
600    }
601
602    #[test]
603    fn test_no_fallbacks_are_circular() {
604        let mytheme = Theme::new("mytheme".to_string(), None, HashMap::from([]));
605        MEANING_FALLBACKS
606            .iter()
607            .for_each(|pair| assert_eq!(mytheme.closest_meaning(pair.0), &Meaning::Base))
608    }
609
610    #[test]
611    fn test_can_get_colors_via_convenience_functions() {
612        let mut manager = ThemeManager::new(Some(true), Some("".to_string()));
613        let theme = manager.load_theme("default", None);
614        assert_eq!(theme.get_error().foreground_color.unwrap(), Color::DarkRed);
615        assert_eq!(
616            theme.get_warning().foreground_color.unwrap(),
617            Color::DarkYellow
618        );
619        assert_eq!(theme.get_info().foreground_color.unwrap(), Color::DarkGreen);
620        assert_eq!(theme.get_base().foreground_color, None);
621        assert_eq!(
622            theme.get_alert(log::Level::Error).foreground_color.unwrap(),
623            Color::DarkRed
624        )
625    }
626
627    #[test]
628    fn test_can_use_parent_theme_for_fallbacks() {
629        testing_logger::setup();
630
631        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
632
633        // First, we introduce a base theme
634        let solarized = Config::builder()
635            .add_source(ConfigFile::from_str(
636                "
637        [theme]
638        name = \"solarized\"
639
640        [colors]
641        Guidance = \"white\"
642        AlertInfo = \"pink\"
643        ",
644                FileFormat::Toml,
645            ))
646            .build()
647            .unwrap();
648        let solarized_theme = manager
649            .load_theme_from_config("solarized", solarized, 1)
650            .unwrap();
651
652        assert_eq!(
653            solarized_theme
654                .as_style(Meaning::AlertInfo)
655                .foreground_color,
656            from_string("pink").ok()
657        );
658
659        // Then we introduce a derived theme
660        let unsolarized = Config::builder()
661            .add_source(ConfigFile::from_str(
662                "
663        [theme]
664        name = \"unsolarized\"
665        parent = \"solarized\"
666
667        [colors]
668        AlertInfo = \"red\"
669        ",
670                FileFormat::Toml,
671            ))
672            .build()
673            .unwrap();
674        let unsolarized_theme = manager
675            .load_theme_from_config("unsolarized", unsolarized, 1)
676            .unwrap();
677
678        // It will take its own values
679        assert_eq!(
680            unsolarized_theme
681                .as_style(Meaning::AlertInfo)
682                .foreground_color,
683            from_string("red").ok()
684        );
685
686        // ...or fall back to the parent
687        assert_eq!(
688            unsolarized_theme
689                .as_style(Meaning::Guidance)
690                .foreground_color,
691            from_string("white").ok()
692        );
693
694        testing_logger::validate(|captured_logs| assert_eq!(captured_logs.len(), 0));
695
696        // If the parent is not found, we end up with the no theme colors or styling
697        // as this is considered a (soft) error state.
698        let nunsolarized = Config::builder()
699            .add_source(ConfigFile::from_str(
700                "
701        [theme]
702        name = \"nunsolarized\"
703        parent = \"nonsolarized\"
704
705        [colors]
706        AlertInfo = \"red\"
707        ",
708                FileFormat::Toml,
709            ))
710            .build()
711            .unwrap();
712        let nunsolarized_theme = manager
713            .load_theme_from_config("nunsolarized", nunsolarized, 1)
714            .unwrap();
715
716        assert_eq!(
717            nunsolarized_theme
718                .as_style(Meaning::Guidance)
719                .foreground_color,
720            None
721        );
722
723        testing_logger::validate(|captured_logs| {
724            assert_eq!(captured_logs.len(), 1);
725            assert_eq!(
726                captured_logs[0].body,
727                "Could not load theme nonsolarized: Empty theme directory override and could not find theme elsewhere"
728            );
729            assert_eq!(captured_logs[0].level, log::Level::Warn)
730        });
731    }
732
733    #[test]
734    fn test_can_debug_theme() {
735        testing_logger::setup();
736        [true, false].iter().for_each(|debug| {
737            let mut manager = ThemeManager::new(Some(*debug), Some("".to_string()));
738            let config = Config::builder()
739                .add_source(ConfigFile::from_str(
740                    "
741            [theme]
742            name = \"mytheme\"
743
744            [colors]
745            Guidance = \"white\"
746            AlertInfo = \"xinetic\"
747            ",
748                    FileFormat::Toml,
749                ))
750                .build()
751                .unwrap();
752            manager
753                .load_theme_from_config("config_theme", config, 1)
754                .unwrap();
755            testing_logger::validate(|captured_logs| {
756                if *debug {
757                    assert_eq!(captured_logs.len(), 2);
758                    assert_eq!(
759                        captured_logs[0].body,
760                        "Your theme config name is not the name of your loaded theme config_theme != mytheme"
761                    );
762                    assert_eq!(captured_logs[0].level, log::Level::Warn);
763                    assert_eq!(
764                        captured_logs[1].body,
765                        "Tried to load string as a color unsuccessfully: (AlertInfo=xinetic) No such color in palette"
766                    );
767                    assert_eq!(captured_logs[1].level, log::Level::Warn)
768                } else {
769                    assert_eq!(captured_logs.len(), 0)
770                }
771            })
772        })
773    }
774
775    #[test]
776    fn test_can_parse_color_strings_correctly() {
777        assert_eq!(
778            from_string("brown").unwrap(),
779            Color::Rgb {
780                r: 165,
781                g: 42,
782                b: 42
783            }
784        );
785
786        assert_eq!(from_string(""), Err("Empty string".into()));
787
788        ["manatee", "caput mortuum", "123456"]
789            .iter()
790            .for_each(|inp| {
791                assert_eq!(from_string(inp), Err("No such color in palette".into()));
792            });
793
794        assert_eq!(
795            from_string("#ff1122").unwrap(),
796            Color::Rgb {
797                r: 255,
798                g: 17,
799                b: 34
800            }
801        );
802        ["#1122", "#ffaa112", "#brown"].iter().for_each(|inp| {
803            assert_eq!(
804                from_string(inp),
805                Err("Could not parse 3 hex values from string".into())
806            );
807        });
808
809        assert_eq!(from_string("@dark_grey").unwrap(), Color::DarkGrey);
810        assert_eq!(
811            from_string("@rgb_(255,255,255)").unwrap(),
812            Color::Rgb {
813                r: 255,
814                g: 255,
815                b: 255
816            }
817        );
818        assert_eq!(from_string("@ansi_(255)").unwrap(), Color::AnsiValue(255));
819        ["@", "@DarkGray", "@Dark 4ay", "@ansi(256)"]
820            .iter()
821            .for_each(|inp| {
822                assert_eq!(
823                    from_string(inp),
824                    Err(format!(
825                        "Could not convert color name {inp} to Crossterm color"
826                    ))
827                );
828            });
829    }
830}