Skip to main content

atuin_client/
theme.rs

1use config::{Config, File as ConfigFile, FileFormat};
2use log;
3use palette::named;
4use serde::{Deserialize, Serialize};
5use serde_json;
6use std::collections::HashMap;
7use std::error;
8use std::io::{Error, ErrorKind};
9use std::path::PathBuf;
10use std::sync::LazyLock;
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
244static ALERT_TYPES: LazyLock<HashMap<log::Level, Meaning>> = LazyLock::new(|| {
245    HashMap::from([
246        (log::Level::Info, Meaning::AlertInfo),
247        (log::Level::Warn, Meaning::AlertWarn),
248        (log::Level::Error, Meaning::AlertError),
249    ])
250});
251
252static MEANING_FALLBACKS: LazyLock<HashMap<Meaning, Meaning>> = LazyLock::new(|| {
253    HashMap::from([
254        (Meaning::Guidance, Meaning::AlertInfo),
255        (Meaning::Annotation, Meaning::AlertInfo),
256        (Meaning::Title, Meaning::Important),
257    ])
258});
259
260static DEFAULT_THEME: LazyLock<Theme> = LazyLock::new(|| {
261    Theme::new(
262        "default".to_string(),
263        None,
264        HashMap::from([
265            (
266                Meaning::AlertError,
267                StyleFactory::from_fg_color(Color::DarkRed),
268            ),
269            (
270                Meaning::AlertWarn,
271                StyleFactory::from_fg_color(Color::DarkYellow),
272            ),
273            (
274                Meaning::AlertInfo,
275                StyleFactory::from_fg_color(Color::DarkGreen),
276            ),
277            (
278                Meaning::Annotation,
279                StyleFactory::from_fg_color(Color::DarkGrey),
280            ),
281            (
282                Meaning::Guidance,
283                StyleFactory::from_fg_color(Color::DarkBlue),
284            ),
285            (
286                Meaning::Important,
287                StyleFactory::from_fg_color_and_attributes(
288                    Color::White,
289                    Attributes::from(Attribute::Bold),
290                ),
291            ),
292            (Meaning::Muted, StyleFactory::from_fg_color(Color::Grey)),
293            (Meaning::Base, ContentStyle::default()),
294        ]),
295    )
296});
297
298static BUILTIN_THEMES: LazyLock<HashMap<&'static str, Theme>> = LazyLock::new(|| {
299    HashMap::from([
300        ("default", HashMap::new()),
301        (
302            "(none)",
303            HashMap::from([
304                (Meaning::AlertError, ContentStyle::default()),
305                (Meaning::AlertWarn, ContentStyle::default()),
306                (Meaning::AlertInfo, ContentStyle::default()),
307                (Meaning::Annotation, ContentStyle::default()),
308                (Meaning::Guidance, ContentStyle::default()),
309                (Meaning::Important, ContentStyle::default()),
310                (Meaning::Muted, ContentStyle::default()),
311                (Meaning::Base, ContentStyle::default()),
312            ]),
313        ),
314        (
315            "autumn",
316            HashMap::from([
317                (
318                    Meaning::AlertError,
319                    StyleFactory::known_fg_string("saddlebrown"),
320                ),
321                (
322                    Meaning::AlertWarn,
323                    StyleFactory::known_fg_string("darkorange"),
324                ),
325                (Meaning::AlertInfo, StyleFactory::known_fg_string("gold")),
326                (
327                    Meaning::Annotation,
328                    StyleFactory::from_fg_color(Color::DarkGrey),
329                ),
330                (Meaning::Guidance, StyleFactory::known_fg_string("brown")),
331            ]),
332        ),
333        (
334            "marine",
335            HashMap::from([
336                (
337                    Meaning::AlertError,
338                    StyleFactory::known_fg_string("yellowgreen"),
339                ),
340                (Meaning::AlertWarn, StyleFactory::known_fg_string("cyan")),
341                (
342                    Meaning::AlertInfo,
343                    StyleFactory::known_fg_string("turquoise"),
344                ),
345                (
346                    Meaning::Annotation,
347                    StyleFactory::known_fg_string("steelblue"),
348                ),
349                (
350                    Meaning::Base,
351                    StyleFactory::known_fg_string("lightsteelblue"),
352                ),
353                (Meaning::Guidance, StyleFactory::known_fg_string("teal")),
354            ]),
355        ),
356    ])
357    .iter()
358    .map(|(name, theme)| (*name, Theme::from_map(name.to_string(), None, theme)))
359    .collect()
360});
361
362// To avoid themes being repeatedly loaded, we store them in a theme manager
363pub struct ThemeManager {
364    loaded_themes: HashMap<String, Theme>,
365    debug: bool,
366    override_theme_dir: Option<String>,
367}
368
369// Theme-loading logic
370impl ThemeManager {
371    pub fn new(debug: Option<bool>, theme_dir: Option<String>) -> Self {
372        Self {
373            loaded_themes: HashMap::new(),
374            debug: debug.unwrap_or(false),
375            override_theme_dir: match theme_dir {
376                Some(theme_dir) => Some(theme_dir),
377                None => std::env::var("ATUIN_THEME_DIR").ok(),
378            },
379        }
380    }
381
382    // Try to load a theme from a `{name}.toml` file in the theme directory. If an override is set
383    // for the theme dir (via ATUIN_THEME_DIR env) we should load the theme from there
384    pub fn load_theme_from_file(
385        &mut self,
386        name: &str,
387        max_depth: u8,
388    ) -> Result<&Theme, Box<dyn error::Error>> {
389        let mut theme_file = if let Some(p) = &self.override_theme_dir {
390            if p.is_empty() {
391                return Err(Box::new(Error::new(
392                    ErrorKind::NotFound,
393                    "Empty theme directory override and could not find theme elsewhere",
394                )));
395            }
396            PathBuf::from(p)
397        } else {
398            let config_dir = atuin_common::utils::config_dir();
399            let mut theme_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
400                PathBuf::from(p)
401            } else {
402                let mut theme_file = PathBuf::new();
403                theme_file.push(config_dir);
404                theme_file
405            };
406            theme_file.push("themes");
407            theme_file
408        };
409
410        let theme_toml = format!["{name}.toml"];
411        theme_file.push(theme_toml);
412
413        let mut config_builder = Config::builder();
414
415        config_builder = config_builder.add_source(ConfigFile::new(
416            theme_file.to_str().unwrap(),
417            FileFormat::Toml,
418        ));
419
420        let config = config_builder.build()?;
421        self.load_theme_from_config(name, config, max_depth)
422    }
423
424    pub fn load_theme_from_config(
425        &mut self,
426        name: &str,
427        config: Config,
428        max_depth: u8,
429    ) -> Result<&Theme, Box<dyn error::Error>> {
430        let debug = self.debug;
431        let theme_config: ThemeConfig = match config.try_deserialize() {
432            Ok(tc) => tc,
433            Err(e) => {
434                return Err(Box::new(Error::new(
435                    ErrorKind::InvalidInput,
436                    format!(
437                        "Failed to deserialize theme: {}",
438                        if debug {
439                            e.to_string()
440                        } else {
441                            "set theme debug on for more info".to_string()
442                        }
443                    ),
444                )));
445            }
446        };
447        let colors: HashMap<Meaning, String> = theme_config.colors;
448        let parent: Option<&Theme> = match theme_config.theme.parent {
449            Some(parent_name) => {
450                if max_depth == 0 {
451                    return Err(Box::new(Error::new(
452                        ErrorKind::InvalidInput,
453                        "Parent requested but we hit the recursion limit",
454                    )));
455                }
456                Some(self.load_theme(parent_name.as_str(), Some(max_depth - 1)))
457            }
458            None => Some(self.load_theme("default", Some(max_depth - 1))),
459        };
460
461        if debug && name != theme_config.theme.name {
462            log::warn!(
463                "Your theme config name is not the name of your loaded theme {} != {}",
464                name,
465                theme_config.theme.name
466            );
467        }
468
469        let theme = Theme::from_foreground_colors(theme_config.theme.name, parent, colors, debug);
470        let name = name.to_string();
471        self.loaded_themes.insert(name.clone(), theme);
472        let theme = self.loaded_themes.get(&name).unwrap();
473        Ok(theme)
474    }
475
476    // Check if the requested theme is loaded and, if not, then attempt to get it
477    // from the builtins or, if not there, from file
478    pub fn load_theme(&mut self, name: &str, max_depth: Option<u8>) -> &Theme {
479        if self.loaded_themes.contains_key(name) {
480            return self.loaded_themes.get(name).unwrap();
481        }
482        let built_ins = &BUILTIN_THEMES;
483        match built_ins.get(name) {
484            Some(theme) => theme,
485            None => match self.load_theme_from_file(name, max_depth.unwrap_or(DEFAULT_MAX_DEPTH)) {
486                Ok(theme) => theme,
487                Err(err) => {
488                    log::warn!("Could not load theme {name}: {err}");
489                    built_ins.get("(none)").unwrap()
490                }
491            },
492        }
493    }
494}
495
496#[cfg(test)]
497mod theme_tests {
498    use super::*;
499
500    #[test]
501    fn test_can_load_builtin_theme() {
502        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
503        let theme = manager.load_theme("autumn", None);
504        assert_eq!(
505            theme.as_style(Meaning::Guidance).foreground_color,
506            from_string("brown").ok()
507        );
508    }
509
510    #[test]
511    fn test_can_create_theme() {
512        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
513        let mytheme = Theme::new(
514            "mytheme".to_string(),
515            None,
516            HashMap::from([(
517                Meaning::AlertError,
518                StyleFactory::known_fg_string("yellowgreen"),
519            )]),
520        );
521        manager.loaded_themes.insert("mytheme".to_string(), mytheme);
522        let theme = manager.load_theme("mytheme", None);
523        assert_eq!(
524            theme.as_style(Meaning::AlertError).foreground_color,
525            from_string("yellowgreen").ok()
526        );
527    }
528
529    #[test]
530    fn test_can_fallback_when_meaning_missing() {
531        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
532
533        // We use title as an example of a meaning that is not defined
534        // even in the base theme.
535        assert!(!DEFAULT_THEME.styles.contains_key(&Meaning::Title));
536
537        let config = Config::builder()
538            .add_source(ConfigFile::from_str(
539                "
540        [theme]
541        name = \"title_theme\"
542
543        [colors]
544        Guidance = \"white\"
545        AlertInfo = \"zomp\"
546        ",
547                FileFormat::Toml,
548            ))
549            .build()
550            .unwrap();
551        let theme = manager
552            .load_theme_from_config("config_theme", config, 1)
553            .unwrap();
554
555        // Correctly picks overridden color.
556        assert_eq!(
557            theme.as_style(Meaning::Guidance).foreground_color,
558            from_string("white").ok()
559        );
560
561        // Does not fall back to any color.
562        assert_eq!(theme.as_style(Meaning::AlertInfo).foreground_color, None);
563
564        // Even for the base.
565        assert_eq!(theme.as_style(Meaning::Base).foreground_color, None);
566
567        // Falls back to red as meaning missing from theme, so picks base default.
568        assert_eq!(
569            theme.as_style(Meaning::AlertError).foreground_color,
570            Some(Color::DarkRed)
571        );
572
573        // Falls back to Important as Title not available.
574        assert_eq!(
575            theme.as_style(Meaning::Title).foreground_color,
576            theme.as_style(Meaning::Important).foreground_color,
577        );
578
579        let title_config = Config::builder()
580            .add_source(ConfigFile::from_str(
581                "
582        [theme]
583        name = \"title_theme\"
584
585        [colors]
586        Title = \"white\"
587        AlertInfo = \"zomp\"
588        ",
589                FileFormat::Toml,
590            ))
591            .build()
592            .unwrap();
593        let title_theme = manager
594            .load_theme_from_config("title_theme", title_config, 1)
595            .unwrap();
596
597        assert_eq!(
598            title_theme.as_style(Meaning::Title).foreground_color,
599            Some(Color::White)
600        );
601    }
602
603    #[test]
604    fn test_no_fallbacks_are_circular() {
605        let mytheme = Theme::new("mytheme".to_string(), None, HashMap::from([]));
606        MEANING_FALLBACKS
607            .iter()
608            .for_each(|pair| assert_eq!(mytheme.closest_meaning(pair.0), &Meaning::Base))
609    }
610
611    #[test]
612    fn test_can_get_colors_via_convenience_functions() {
613        let mut manager = ThemeManager::new(Some(true), Some("".to_string()));
614        let theme = manager.load_theme("default", None);
615        assert_eq!(theme.get_error().foreground_color.unwrap(), Color::DarkRed);
616        assert_eq!(
617            theme.get_warning().foreground_color.unwrap(),
618            Color::DarkYellow
619        );
620        assert_eq!(theme.get_info().foreground_color.unwrap(), Color::DarkGreen);
621        assert_eq!(theme.get_base().foreground_color, None);
622        assert_eq!(
623            theme.get_alert(log::Level::Error).foreground_color.unwrap(),
624            Color::DarkRed
625        )
626    }
627
628    #[test]
629    fn test_can_use_parent_theme_for_fallbacks() {
630        testing_logger::setup();
631
632        let mut manager = ThemeManager::new(Some(false), Some("".to_string()));
633
634        // First, we introduce a base theme
635        let solarized = Config::builder()
636            .add_source(ConfigFile::from_str(
637                "
638        [theme]
639        name = \"solarized\"
640
641        [colors]
642        Guidance = \"white\"
643        AlertInfo = \"pink\"
644        ",
645                FileFormat::Toml,
646            ))
647            .build()
648            .unwrap();
649        let solarized_theme = manager
650            .load_theme_from_config("solarized", solarized, 1)
651            .unwrap();
652
653        assert_eq!(
654            solarized_theme
655                .as_style(Meaning::AlertInfo)
656                .foreground_color,
657            from_string("pink").ok()
658        );
659
660        // Then we introduce a derived theme
661        let unsolarized = Config::builder()
662            .add_source(ConfigFile::from_str(
663                "
664        [theme]
665        name = \"unsolarized\"
666        parent = \"solarized\"
667
668        [colors]
669        AlertInfo = \"red\"
670        ",
671                FileFormat::Toml,
672            ))
673            .build()
674            .unwrap();
675        let unsolarized_theme = manager
676            .load_theme_from_config("unsolarized", unsolarized, 1)
677            .unwrap();
678
679        // It will take its own values
680        assert_eq!(
681            unsolarized_theme
682                .as_style(Meaning::AlertInfo)
683                .foreground_color,
684            from_string("red").ok()
685        );
686
687        // ...or fall back to the parent
688        assert_eq!(
689            unsolarized_theme
690                .as_style(Meaning::Guidance)
691                .foreground_color,
692            from_string("white").ok()
693        );
694
695        testing_logger::validate(|captured_logs| assert_eq!(captured_logs.len(), 0));
696
697        // If the parent is not found, we end up with the no theme colors or styling
698        // as this is considered a (soft) error state.
699        let nunsolarized = Config::builder()
700            .add_source(ConfigFile::from_str(
701                "
702        [theme]
703        name = \"nunsolarized\"
704        parent = \"nonsolarized\"
705
706        [colors]
707        AlertInfo = \"red\"
708        ",
709                FileFormat::Toml,
710            ))
711            .build()
712            .unwrap();
713        let nunsolarized_theme = manager
714            .load_theme_from_config("nunsolarized", nunsolarized, 1)
715            .unwrap();
716
717        assert_eq!(
718            nunsolarized_theme
719                .as_style(Meaning::Guidance)
720                .foreground_color,
721            None
722        );
723
724        testing_logger::validate(|captured_logs| {
725            assert_eq!(captured_logs.len(), 1);
726            assert_eq!(
727                captured_logs[0].body,
728                "Could not load theme nonsolarized: Empty theme directory override and could not find theme elsewhere"
729            );
730            assert_eq!(captured_logs[0].level, log::Level::Warn)
731        });
732    }
733
734    #[test]
735    fn test_can_debug_theme() {
736        testing_logger::setup();
737        [true, false].iter().for_each(|debug| {
738            let mut manager = ThemeManager::new(Some(*debug), Some("".to_string()));
739            let config = Config::builder()
740                .add_source(ConfigFile::from_str(
741                    "
742            [theme]
743            name = \"mytheme\"
744
745            [colors]
746            Guidance = \"white\"
747            AlertInfo = \"xinetic\"
748            ",
749                    FileFormat::Toml,
750                ))
751                .build()
752                .unwrap();
753            manager
754                .load_theme_from_config("config_theme", config, 1)
755                .unwrap();
756            testing_logger::validate(|captured_logs| {
757                if *debug {
758                    assert_eq!(captured_logs.len(), 2);
759                    assert_eq!(
760                        captured_logs[0].body,
761                        "Your theme config name is not the name of your loaded theme config_theme != mytheme"
762                    );
763                    assert_eq!(captured_logs[0].level, log::Level::Warn);
764                    assert_eq!(
765                        captured_logs[1].body,
766                        "Tried to load string as a color unsuccessfully: (AlertInfo=xinetic) No such color in palette"
767                    );
768                    assert_eq!(captured_logs[1].level, log::Level::Warn)
769                } else {
770                    assert_eq!(captured_logs.len(), 0)
771                }
772            })
773        })
774    }
775
776    #[test]
777    fn test_can_parse_color_strings_correctly() {
778        assert_eq!(
779            from_string("brown").unwrap(),
780            Color::Rgb {
781                r: 165,
782                g: 42,
783                b: 42
784            }
785        );
786
787        assert_eq!(from_string(""), Err("Empty string".into()));
788
789        ["manatee", "caput mortuum", "123456"]
790            .iter()
791            .for_each(|inp| {
792                assert_eq!(from_string(inp), Err("No such color in palette".into()));
793            });
794
795        assert_eq!(
796            from_string("#ff1122").unwrap(),
797            Color::Rgb {
798                r: 255,
799                g: 17,
800                b: 34
801            }
802        );
803        ["#1122", "#ffaa112", "#brown"].iter().for_each(|inp| {
804            assert_eq!(
805                from_string(inp),
806                Err("Could not parse 3 hex values from string".into())
807            );
808        });
809
810        assert_eq!(from_string("@dark_grey").unwrap(), Color::DarkGrey);
811        assert_eq!(
812            from_string("@rgb_(255,255,255)").unwrap(),
813            Color::Rgb {
814                r: 255,
815                g: 255,
816                b: 255
817            }
818        );
819        assert_eq!(from_string("@ansi_(255)").unwrap(), Color::AnsiValue(255));
820        ["@", "@DarkGray", "@Dark 4ay", "@ansi(256)"]
821            .iter()
822            .for_each(|inp| {
823                assert_eq!(
824                    from_string(inp),
825                    Err(format!(
826                        "Could not convert color name {inp} to Crossterm color"
827                    ))
828                );
829            });
830    }
831}