nu_cmd_extra/extra/formats/to/
html.rs

1use fancy_regex::Regex;
2use nu_cmd_base::formats::to::delimited::merge_descriptors;
3use nu_engine::command_prelude::*;
4use nu_protocol::{Config, DataSource, PipelineMetadata};
5use nu_utils::IgnoreCaseExt;
6use rust_embed::RustEmbed;
7use serde::{Deserialize, Serialize};
8use std::{collections::HashMap, error::Error, fmt::Write};
9
10#[derive(Serialize, Deserialize, Debug)]
11pub struct HtmlThemes {
12    themes: Vec<HtmlTheme>,
13}
14
15#[allow(non_snake_case)]
16#[derive(Serialize, Deserialize, Debug)]
17pub struct HtmlTheme {
18    name: String,
19    black: String,
20    red: String,
21    green: String,
22    yellow: String,
23    blue: String,
24    purple: String,
25    cyan: String,
26    white: String,
27    brightBlack: String,
28    brightRed: String,
29    brightGreen: String,
30    brightYellow: String,
31    brightBlue: String,
32    brightPurple: String,
33    brightCyan: String,
34    brightWhite: String,
35    background: String,
36    foreground: String,
37}
38
39impl Default for HtmlThemes {
40    fn default() -> Self {
41        HtmlThemes {
42            themes: vec![HtmlTheme::default()],
43        }
44    }
45}
46
47impl Default for HtmlTheme {
48    fn default() -> Self {
49        HtmlTheme {
50            name: "nu_default".to_string(),
51            black: "black".to_string(),
52            red: "red".to_string(),
53            green: "green".to_string(),
54            yellow: "#717100".to_string(),
55            blue: "blue".to_string(),
56            purple: "#c800c8".to_string(),
57            cyan: "#037979".to_string(),
58            white: "white".to_string(),
59            brightBlack: "black".to_string(),
60            brightRed: "red".to_string(),
61            brightGreen: "green".to_string(),
62            brightYellow: "#717100".to_string(),
63            brightBlue: "blue".to_string(),
64            brightPurple: "#c800c8".to_string(),
65            brightCyan: "#037979".to_string(),
66            brightWhite: "white".to_string(),
67            background: "white".to_string(),
68            foreground: "black".to_string(),
69        }
70    }
71}
72
73#[derive(RustEmbed)]
74#[folder = "assets/"]
75struct Assets;
76
77#[derive(Clone)]
78pub struct ToHtml;
79
80impl Command for ToHtml {
81    fn name(&self) -> &str {
82        "to html"
83    }
84
85    fn signature(&self) -> Signature {
86        Signature::build("to html")
87            .input_output_types(vec![(Type::Nothing, Type::Any), (Type::Any, Type::String)])
88            .allow_variants_without_examples(true)
89            .switch("html-color", "change ansi colors to html colors", Some('c'))
90            .switch("no-color", "remove all ansi colors in output", Some('n'))
91            .switch(
92                "dark",
93                "indicate your background color is a darker color",
94                Some('d'),
95            )
96            .switch(
97                "partial",
98                "only output the html for the content itself",
99                Some('p'),
100            )
101            .named(
102                "theme",
103                SyntaxShape::String,
104                "the name of the theme to use (github, blulocolight, ...); case-insensitive",
105                Some('t'),
106            )
107            .switch(
108                "list",
109                "produce a color table of all available themes",
110                Some('l'),
111            )
112            .switch("raw", "do not escape html tags", Some('r'))
113            .category(Category::Formats)
114    }
115
116    fn examples(&self) -> Vec<Example<'_>> {
117        vec![
118            Example {
119                description: "Outputs an HTML string representing the contents of this table",
120                example: "[[foo bar]; [1 2]] | to html",
121                result: Some(Value::test_string(
122                    r#"<html><style>body { background-color:white;color:black; }</style><body><table><thead><tr><th>foo</th><th>bar</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></body></html>"#,
123                )),
124            },
125            Example {
126                description: "Outputs an HTML string using a record of xml data",
127                example: r#"{tag: a attributes: { style: "color: red" } content: ["hello!"] } | to xml | to html --raw"#,
128                result: Some(Value::test_string(
129                    r#"<html><style>body { background-color:white;color:black; }</style><body><a style="color: red">hello!</a></body></html>"#,
130                )),
131            },
132            Example {
133                description: "Optionally, only output the html for the content itself",
134                example: "[[foo bar]; [1 2]] | to html --partial",
135                result: Some(Value::test_string(
136                    r#"<div style="background-color:white;color:black;"><table><thead><tr><th>foo</th><th>bar</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></div>"#,
137                )),
138            },
139            Example {
140                description: "Optionally, output the string with a dark background",
141                example: "[[foo bar]; [1 2]] | to html --dark",
142                result: Some(Value::test_string(
143                    r#"<html><style>body { background-color:black;color:white; }</style><body><table><thead><tr><th>foo</th><th>bar</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table></body></html>"#,
144                )),
145            },
146        ]
147    }
148
149    fn description(&self) -> &str {
150        "Convert table into simple HTML."
151    }
152
153    fn extra_description(&self) -> &str {
154        "Screenshots of the themes can be browsed here: https://github.com/mbadolato/iTerm2-Color-Schemes."
155    }
156
157    fn run(
158        &self,
159        engine_state: &EngineState,
160        stack: &mut Stack,
161        call: &Call,
162        input: PipelineData,
163    ) -> Result<PipelineData, ShellError> {
164        to_html(input, call, engine_state, stack)
165    }
166}
167
168fn get_theme_from_asset_file(
169    is_dark: bool,
170    theme: Option<&Spanned<String>>,
171) -> Result<HashMap<&'static str, String>, ShellError> {
172    let theme_name = match theme {
173        Some(s) => &s.item,
174        None => {
175            return Ok(convert_html_theme_to_hash_map(
176                is_dark,
177                &HtmlTheme::default(),
178            ));
179        }
180    };
181
182    let theme_span = theme.map(|s| s.span).unwrap_or(Span::unknown());
183
184    // 228 themes come from
185    // https://github.com/mbadolato/iTerm2-Color-Schemes/tree/master/windowsterminal
186    // we should find a hit on any name in there
187    let asset = get_html_themes("228_themes.json").unwrap_or_default();
188
189    // Find the theme by theme name
190    let th = asset
191        .themes
192        .into_iter()
193        .find(|n| n.name.eq_ignore_case(theme_name)); // case insensitive search
194
195    let th = match th {
196        Some(t) => t,
197        None => {
198            return Err(ShellError::TypeMismatch {
199                err_message: format!("Unknown HTML theme '{theme_name}'"),
200                span: theme_span,
201            });
202        }
203    };
204
205    Ok(convert_html_theme_to_hash_map(is_dark, &th))
206}
207
208fn convert_html_theme_to_hash_map(
209    is_dark: bool,
210    theme: &HtmlTheme,
211) -> HashMap<&'static str, String> {
212    let mut hm: HashMap<&str, String> = HashMap::with_capacity(18);
213
214    hm.insert("bold_black", theme.brightBlack[..].to_string());
215    hm.insert("bold_red", theme.brightRed[..].to_string());
216    hm.insert("bold_green", theme.brightGreen[..].to_string());
217    hm.insert("bold_yellow", theme.brightYellow[..].to_string());
218    hm.insert("bold_blue", theme.brightBlue[..].to_string());
219    hm.insert("bold_magenta", theme.brightPurple[..].to_string());
220    hm.insert("bold_cyan", theme.brightCyan[..].to_string());
221    hm.insert("bold_white", theme.brightWhite[..].to_string());
222
223    hm.insert("black", theme.black[..].to_string());
224    hm.insert("red", theme.red[..].to_string());
225    hm.insert("green", theme.green[..].to_string());
226    hm.insert("yellow", theme.yellow[..].to_string());
227    hm.insert("blue", theme.blue[..].to_string());
228    hm.insert("magenta", theme.purple[..].to_string());
229    hm.insert("cyan", theme.cyan[..].to_string());
230    hm.insert("white", theme.white[..].to_string());
231
232    // Try to make theme work with light or dark but
233    // flipping the foreground and background but leave
234    // the other colors the same.
235    if is_dark {
236        hm.insert("background", theme.black[..].to_string());
237        hm.insert("foreground", theme.white[..].to_string());
238    } else {
239        hm.insert("background", theme.white[..].to_string());
240        hm.insert("foreground", theme.black[..].to_string());
241    }
242
243    hm
244}
245
246fn get_html_themes(json_name: &str) -> Result<HtmlThemes, Box<dyn Error>> {
247    match Assets::get(json_name) {
248        Some(content) => Ok(nu_json::from_slice(&content.data)?),
249        None => Ok(HtmlThemes::default()),
250    }
251}
252
253fn to_html(
254    input: PipelineData,
255    call: &Call,
256    engine_state: &EngineState,
257    stack: &mut Stack,
258) -> Result<PipelineData, ShellError> {
259    let head = call.head;
260    let html_color = call.has_flag(engine_state, stack, "html-color")?;
261    let no_color = call.has_flag(engine_state, stack, "no-color")?;
262    let dark = call.has_flag(engine_state, stack, "dark")?;
263    let partial = call.has_flag(engine_state, stack, "partial")?;
264    let list = call.has_flag(engine_state, stack, "list")?;
265    let raw = call.has_flag(engine_state, stack, "raw")?;
266    let theme: Option<Spanned<String>> = call.get_flag(engine_state, stack, "theme")?;
267    let config = &stack.get_config(engine_state);
268
269    let vec_of_values = input.into_iter().collect::<Vec<Value>>();
270    let headers = merge_descriptors(&vec_of_values);
271    let headers = Some(headers)
272        .filter(|headers| !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()));
273    let mut output_string = String::new();
274    let mut regex_hm: HashMap<u32, (&str, String)> = HashMap::with_capacity(17);
275
276    if list {
277        // Being essentially a 'help' option, this can afford to be relatively unoptimised
278        return Ok(theme_demo(head));
279    }
280    let theme_span = match &theme {
281        Some(v) => v.span,
282        None => head,
283    };
284
285    let color_hm = match get_theme_from_asset_file(dark, theme.as_ref()) {
286        Ok(c) => c,
287        Err(e) => match e {
288            ShellError::TypeMismatch {
289                err_message,
290                span: _,
291            } => {
292                return Err(ShellError::TypeMismatch {
293                    err_message,
294                    span: theme_span,
295                });
296            }
297            _ => return Err(e),
298        },
299    };
300
301    // change the color of the page
302    if !partial {
303        write!(
304            &mut output_string,
305            r"<html><style>body {{ background-color:{};color:{}; }}</style><body>",
306            color_hm
307                .get("background")
308                .expect("Error getting background color"),
309            color_hm
310                .get("foreground")
311                .expect("Error getting foreground color")
312        )
313        .ok();
314    } else {
315        write!(
316            &mut output_string,
317            "<div style=\"background-color:{};color:{};\">",
318            color_hm
319                .get("background")
320                .expect("Error getting background color"),
321            color_hm
322                .get("foreground")
323                .expect("Error getting foreground color")
324        )
325        .ok();
326    }
327
328    let inner_value = match vec_of_values.len() {
329        0 => String::default(),
330        1 => match headers {
331            Some(headers) => html_table(vec_of_values, headers, raw, config),
332            None => {
333                let value = &vec_of_values[0];
334                html_value(value.clone(), raw, config)
335            }
336        },
337        _ => match headers {
338            Some(headers) => html_table(vec_of_values, headers, raw, config),
339            None => html_list(vec_of_values, raw, config),
340        },
341    };
342
343    output_string.push_str(&inner_value);
344
345    if !partial {
346        output_string.push_str("</body></html>");
347    } else {
348        output_string.push_str("</div>")
349    }
350
351    // Check to see if we want to remove all color or change ansi to html colors
352    if html_color {
353        setup_html_color_regexes(&mut regex_hm, &color_hm);
354        output_string = run_regexes(&regex_hm, &output_string);
355    } else if no_color {
356        setup_no_color_regexes(&mut regex_hm);
357        output_string = run_regexes(&regex_hm, &output_string);
358    }
359
360    let metadata = PipelineMetadata {
361        data_source: nu_protocol::DataSource::None,
362        content_type: Some(mime::TEXT_HTML_UTF_8.to_string()),
363        ..Default::default()
364    };
365
366    Ok(Value::string(output_string, head).into_pipeline_data_with_metadata(metadata))
367}
368
369fn theme_demo(span: Span) -> PipelineData {
370    // If asset doesn't work, make sure to return the default theme
371    let html_themes = get_html_themes("228_themes.json").unwrap_or_default();
372    let result: Vec<Value> = html_themes
373        .themes
374        .into_iter()
375        .map(|n| {
376            Value::record(
377                record! {
378                    "name" => Value::string(n.name, span),
379                    "black" => Value::string(n.black, span),
380                    "red" => Value::string(n.red, span),
381                    "green" => Value::string(n.green, span),
382                    "yellow" => Value::string(n.yellow, span),
383                    "blue" => Value::string(n.blue, span),
384                    "purple" => Value::string(n.purple, span),
385                    "cyan" => Value::string(n.cyan, span),
386                    "white" => Value::string(n.white, span),
387                    "brightBlack" => Value::string(n.brightBlack, span),
388                    "brightRed" => Value::string(n.brightRed, span),
389                    "brightGreen" => Value::string(n.brightGreen, span),
390                    "brightYellow" => Value::string(n.brightYellow, span),
391                    "brightBlue" => Value::string(n.brightBlue, span),
392                    "brightPurple" => Value::string(n.brightPurple, span),
393                    "brightCyan" => Value::string(n.brightCyan, span),
394                    "brightWhite" => Value::string(n.brightWhite, span),
395                    "background" => Value::string(n.background, span),
396                    "foreground" => Value::string(n.foreground, span),
397                },
398                span,
399            )
400        })
401        .collect();
402    Value::list(result, span).into_pipeline_data_with_metadata(PipelineMetadata {
403        data_source: DataSource::HtmlThemes,
404        ..Default::default()
405    })
406}
407
408fn html_list(list: Vec<Value>, raw: bool, config: &Config) -> String {
409    let mut output_string = String::new();
410    output_string.push_str("<ol>");
411    for value in list {
412        output_string.push_str("<li>");
413        output_string.push_str(&html_value(value, raw, config));
414        output_string.push_str("</li>");
415    }
416    output_string.push_str("</ol>");
417    output_string
418}
419
420fn html_table(table: Vec<Value>, headers: Vec<String>, raw: bool, config: &Config) -> String {
421    let mut output_string = String::new();
422
423    output_string.push_str("<table>");
424
425    output_string.push_str("<thead><tr>");
426    for header in &headers {
427        output_string.push_str("<th>");
428        output_string.push_str(&v_htmlescape::escape(header).to_string());
429        output_string.push_str("</th>");
430    }
431    output_string.push_str("</tr></thead><tbody>");
432
433    for row in table {
434        let span = row.span();
435        if let Value::Record { val: row, .. } = row {
436            output_string.push_str("<tr>");
437            for header in &headers {
438                let data = row
439                    .get(header)
440                    .cloned()
441                    .unwrap_or_else(|| Value::nothing(span));
442                output_string.push_str("<td>");
443                output_string.push_str(&html_value(data, raw, config));
444                output_string.push_str("</td>");
445            }
446            output_string.push_str("</tr>");
447        }
448    }
449    output_string.push_str("</tbody></table>");
450
451    output_string
452}
453
454fn html_value(value: Value, raw: bool, config: &Config) -> String {
455    let mut output_string = String::new();
456    match value {
457        Value::Binary { val, .. } => {
458            let output = nu_pretty_hex::pretty_hex(&val);
459            output_string.push_str("<pre>");
460            output_string.push_str(&output);
461            output_string.push_str("</pre>");
462        }
463        other => {
464            if raw {
465                output_string.push_str(
466                    &other
467                        .to_abbreviated_string(config)
468                        .to_string()
469                        .replace('\n', "<br>"),
470                )
471            } else {
472                output_string.push_str(
473                    &v_htmlescape::escape(&other.to_abbreviated_string(config))
474                        .to_string()
475                        .replace('\n', "<br>"),
476                )
477            }
478        }
479    }
480    output_string
481}
482
483fn setup_html_color_regexes(
484    hash: &mut HashMap<u32, (&'static str, String)>,
485    color_hm: &HashMap<&str, String>,
486) {
487    // All the bold colors
488    hash.insert(
489        0,
490        (
491            r"(?P<reset>\[0m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
492            // Reset the text color, normal weight font
493            format!(
494                r"<span style='color:{};font-weight:normal;'>$word</span>",
495                color_hm
496                    .get("foreground")
497                    .expect("Error getting reset text color")
498            ),
499        ),
500    );
501    hash.insert(
502        1,
503        (
504            // Bold Black
505            r"(?P<bb>\[1;30m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
506            format!(
507                r"<span style='color:{};font-weight:bold;'>$word</span>",
508                color_hm
509                    .get("foreground")
510                    .expect("Error getting bold black text color")
511            ),
512        ),
513    );
514    hash.insert(
515        2,
516        (
517            // Bold Red
518            r"(?P<br>\[1;31m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
519            format!(
520                r"<span style='color:{};font-weight:bold;'>$word</span>",
521                color_hm
522                    .get("bold_red")
523                    .expect("Error getting bold red text color"),
524            ),
525        ),
526    );
527    hash.insert(
528        3,
529        (
530            // Bold Green
531            r"(?P<bg>\[1;32m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
532            format!(
533                r"<span style='color:{};font-weight:bold;'>$word</span>",
534                color_hm
535                    .get("bold_green")
536                    .expect("Error getting bold green text color"),
537            ),
538        ),
539    );
540    hash.insert(
541        4,
542        (
543            // Bold Yellow
544            r"(?P<by>\[1;33m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
545            format!(
546                r"<span style='color:{};font-weight:bold;'>$word</span>",
547                color_hm
548                    .get("bold_yellow")
549                    .expect("Error getting bold yellow text color"),
550            ),
551        ),
552    );
553    hash.insert(
554        5,
555        (
556            // Bold Blue
557            r"(?P<bu>\[1;34m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
558            format!(
559                r"<span style='color:{};font-weight:bold;'>$word</span>",
560                color_hm
561                    .get("bold_blue")
562                    .expect("Error getting bold blue text color"),
563            ),
564        ),
565    );
566    hash.insert(
567        6,
568        (
569            // Bold Magenta
570            r"(?P<bm>\[1;35m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
571            format!(
572                r"<span style='color:{};font-weight:bold;'>$word</span>",
573                color_hm
574                    .get("bold_magenta")
575                    .expect("Error getting bold magenta text color"),
576            ),
577        ),
578    );
579    hash.insert(
580        7,
581        (
582            // Bold Cyan
583            r"(?P<bc>\[1;36m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
584            format!(
585                r"<span style='color:{};font-weight:bold;'>$word</span>",
586                color_hm
587                    .get("bold_cyan")
588                    .expect("Error getting bold cyan text color"),
589            ),
590        ),
591    );
592    hash.insert(
593        8,
594        (
595            // Bold White
596            // Let's change this to black since the html background
597            // is white. White on white = no bueno.
598            r"(?P<bw>\[1;37m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
599            format!(
600                r"<span style='color:{};font-weight:bold;'>$word</span>",
601                color_hm
602                    .get("foreground")
603                    .expect("Error getting bold bold white text color"),
604            ),
605        ),
606    );
607    // All the normal colors
608    hash.insert(
609        9,
610        (
611            // Black
612            r"(?P<b>\[30m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
613            format!(
614                r"<span style='color:{};'>$word</span>",
615                color_hm
616                    .get("foreground")
617                    .expect("Error getting black text color"),
618            ),
619        ),
620    );
621    hash.insert(
622        10,
623        (
624            // Red
625            r"(?P<r>\[31m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
626            format!(
627                r"<span style='color:{};'>$word</span>",
628                color_hm.get("red").expect("Error getting red text color"),
629            ),
630        ),
631    );
632    hash.insert(
633        11,
634        (
635            // Green
636            r"(?P<g>\[32m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
637            format!(
638                r"<span style='color:{};'>$word</span>",
639                color_hm
640                    .get("green")
641                    .expect("Error getting green text color"),
642            ),
643        ),
644    );
645    hash.insert(
646        12,
647        (
648            // Yellow
649            r"(?P<y>\[33m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
650            format!(
651                r"<span style='color:{};'>$word</span>",
652                color_hm
653                    .get("yellow")
654                    .expect("Error getting yellow text color"),
655            ),
656        ),
657    );
658    hash.insert(
659        13,
660        (
661            // Blue
662            r"(?P<u>\[34m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
663            format!(
664                r"<span style='color:{};'>$word</span>",
665                color_hm.get("blue").expect("Error getting blue text color"),
666            ),
667        ),
668    );
669    hash.insert(
670        14,
671        (
672            // Magenta
673            r"(?P<m>\[35m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
674            format!(
675                r"<span style='color:{};'>$word</span>",
676                color_hm
677                    .get("magenta")
678                    .expect("Error getting magenta text color"),
679            ),
680        ),
681    );
682    hash.insert(
683        15,
684        (
685            // Cyan
686            r"(?P<c>\[36m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
687            format!(
688                r"<span style='color:{};'>$word</span>",
689                color_hm.get("cyan").expect("Error getting cyan text color"),
690            ),
691        ),
692    );
693    hash.insert(
694        16,
695        (
696            // White
697            // Let's change this to black since the html background
698            // is white. White on white = no bueno.
699            r"(?P<w>\[37m)(?P<word>[[:alnum:][:space:][:punct:]]*)",
700            format!(
701                r"<span style='color:{};'>$word</span>",
702                color_hm
703                    .get("foreground")
704                    .expect("Error getting white text color"),
705            ),
706        ),
707    );
708}
709
710fn setup_no_color_regexes(hash: &mut HashMap<u32, (&'static str, String)>) {
711    // We can just use one regex here because we're just removing ansi sequences
712    // and not replacing them with html colors.
713    // attribution: https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
714    hash.insert(
715        0,
716        (
717            r"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])",
718            r"$name_group_doesnt_exist".to_string(),
719        ),
720    );
721}
722
723fn run_regexes(hash: &HashMap<u32, (&'static str, String)>, contents: &str) -> String {
724    let mut working_string = contents.to_owned();
725    let hash_count: u32 = hash.len() as u32;
726    for n in 0..hash_count {
727        let value = hash.get(&n).expect("error getting hash at index");
728        let re = Regex::new(value.0).expect("problem with color regex");
729        let after = re.replace_all(&working_string, &value.1[..]).to_string();
730        working_string = after;
731    }
732    working_string
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    #[test]
740    fn test_examples() {
741        use crate::test_examples_with_commands;
742        use nu_command::ToXml;
743
744        test_examples_with_commands(ToHtml {}, &[&ToXml])
745    }
746
747    #[test]
748    fn get_theme_from_asset_file_returns_default() {
749        let result = super::get_theme_from_asset_file(false, None);
750
751        assert!(result.is_ok(), "Expected Ok result for None theme");
752
753        let theme_map = result.unwrap();
754
755        assert_eq!(
756            theme_map.get("background").map(String::as_str),
757            Some("white"),
758            "Expected default background color to be white"
759        );
760
761        assert_eq!(
762            theme_map.get("foreground").map(String::as_str),
763            Some("black"),
764            "Expected default foreground color to be black"
765        );
766
767        assert!(
768            theme_map.contains_key("red"),
769            "Expected default theme to have a 'red' color"
770        );
771
772        assert!(
773            theme_map.contains_key("bold_green"),
774            "Expected default theme to have a 'bold_green' color"
775        );
776    }
777
778    #[test]
779    fn returns_a_valid_theme() {
780        let theme_name = "Dracula".to_string().into_spanned(Span::new(0, 7));
781        let result = super::get_theme_from_asset_file(false, Some(&theme_name));
782
783        assert!(result.is_ok(), "Expected Ok result for valid theme");
784        let theme_map = result.unwrap();
785        let required_keys = [
786            "background",
787            "foreground",
788            "red",
789            "green",
790            "blue",
791            "bold_red",
792            "bold_green",
793            "bold_blue",
794        ];
795
796        for key in required_keys {
797            assert!(
798                theme_map.contains_key(key),
799                "Expected theme to contain key '{key}'"
800            );
801        }
802    }
803
804    #[test]
805    fn fails_with_unknown_theme_name() {
806        let result = super::get_theme_from_asset_file(
807            false,
808            Some(&"doesnt-exist".to_string().into_spanned(Span::new(0, 13))),
809        );
810
811        assert!(result.is_err(), "Expected error for invalid theme name");
812
813        if let Err(err) = result {
814            assert!(
815                matches!(err, ShellError::TypeMismatch { .. }),
816                "Expected TypeMismatch error, got: {err:?}"
817            );
818
819            if let ShellError::TypeMismatch { err_message, span } = err {
820                assert!(
821                    err_message.contains("doesnt-exist"),
822                    "Error message should mention theme name, got: {err_message}"
823                );
824                assert_eq!(span.start, 0);
825                assert_eq!(span.end, 13);
826            }
827        }
828    }
829}