Skip to main content

twine_core/config/
themes.rs

1//! Terminal palettes are resolved in core so every terminal consumer uses the same colors.
2
3use std::fs;
4use std::io;
5use std::path::Path;
6
7use serde::{Deserialize, Serialize};
8
9use super::{ConfigDiagnostic, file_diagnostic, imports, write_default};
10
11mod bundled;
12
13const SILICA_LIGHT: &str = include_str!("themes/silica_light.toml");
14const SILICA_DARK: &str = include_str!("themes/silica_dark.toml");
15const TWINE_LIGHT: &str = include_str!("themes/twine_light.toml");
16const TWINE_DARK: &str = include_str!("themes/twine_dark.toml");
17
18#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
19#[serde(default)]
20pub struct ThemeConfig {
21    pub light: ThemeName,
22    pub dark: ThemeName,
23}
24
25impl Default for ThemeConfig {
26    fn default() -> Self {
27        Self {
28            light: ThemeName("twine_light".to_owned()),
29            dark: ThemeName("twine_dark".to_owned()),
30        }
31    }
32}
33
34/// A theme file's basename, resolved beneath the user config's themes directory.
35#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
36#[serde(transparent)]
37pub struct ThemeName(String);
38
39impl ThemeName {
40    #[must_use]
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44}
45
46impl<'de> Deserialize<'de> for ThemeName {
47    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
48        let name = String::deserialize(deserializer)?;
49        if name.trim().is_empty()
50            || matches!(name.as_str(), "." | "..")
51            || name.contains(['/', '\\', '\0'])
52        {
53            return Err(serde::de::Error::custom(
54                "theme name must be a single file basename",
55            ));
56        }
57        Ok(Self(name))
58    }
59}
60
61pub(super) const ANSI_COLOR_NAMES: [&str; 16] = [
62    "black",
63    "red",
64    "green",
65    "yellow",
66    "blue",
67    "magenta",
68    "cyan",
69    "white",
70    "brblack",
71    "brred",
72    "brgreen",
73    "bryellow",
74    "brblue",
75    "brmagenta",
76    "brcyan",
77    "brwhite",
78];
79
80/// An opaque sRGB color written as `#RRGGBB`.
81#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
82#[serde(transparent)]
83pub struct HexColor(String);
84
85impl HexColor {
86    #[must_use]
87    pub fn as_str(&self) -> &str {
88        &self.0
89    }
90}
91
92impl<'de> Deserialize<'de> for HexColor {
93    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
94        let value = String::deserialize(deserializer)?;
95        if value.len() != 7
96            || !value.starts_with('#')
97            || !value.as_bytes()[1..].iter().all(u8::is_ascii_hexdigit)
98        {
99            return Err(serde::de::Error::custom("color must be #RRGGBB"));
100        }
101        Ok(Self(value))
102    }
103}
104
105/// Terminal color overrides, supplied inline or through a global config import.
106#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
107#[serde(default)]
108pub struct TerminalColors {
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub background: Option<HexColor>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub foreground: Option<HexColor>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub cursor: Option<HexColor>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub selection: Option<HexColor>,
117    /// Legacy array input remains readable; new files use the individual color keys below.
118    #[serde(skip_serializing, deserialize_with = "deserialize_ansi")]
119    pub ansi: Option<[HexColor; 16]>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub black: Option<HexColor>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub red: Option<HexColor>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub green: Option<HexColor>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub yellow: Option<HexColor>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub blue: Option<HexColor>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub magenta: Option<HexColor>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub cyan: Option<HexColor>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub white: Option<HexColor>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub brblack: Option<HexColor>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub brred: Option<HexColor>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub brgreen: Option<HexColor>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub bryellow: Option<HexColor>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub brblue: Option<HexColor>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub brmagenta: Option<HexColor>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub brcyan: Option<HexColor>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub brwhite: Option<HexColor>,
152}
153
154impl TerminalColors {
155    fn named_ansi(&self) -> [Option<&HexColor>; 16] {
156        [
157            self.black.as_ref(),
158            self.red.as_ref(),
159            self.green.as_ref(),
160            self.yellow.as_ref(),
161            self.blue.as_ref(),
162            self.magenta.as_ref(),
163            self.cyan.as_ref(),
164            self.white.as_ref(),
165            self.brblack.as_ref(),
166            self.brred.as_ref(),
167            self.brgreen.as_ref(),
168            self.bryellow.as_ref(),
169            self.brblue.as_ref(),
170            self.brmagenta.as_ref(),
171            self.brcyan.as_ref(),
172            self.brwhite.as_ref(),
173        ]
174    }
175}
176
177fn deserialize_ansi<'de, D: serde::Deserializer<'de>>(
178    deserializer: D,
179) -> Result<Option<[HexColor; 16]>, D::Error> {
180    Option::<Vec<HexColor>>::deserialize(deserializer)?
181        .map(|colors| {
182            colors.try_into().map_err(|_| {
183                serde::de::Error::custom("ANSI palette must contain exactly 16 colors")
184            })
185        })
186        .transpose()
187}
188
189#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
190pub struct TerminalPalette {
191    pub background: HexColor,
192    pub foreground: HexColor,
193    pub cursor: HexColor,
194    pub selection: HexColor,
195    pub ansi: [HexColor; 16],
196}
197
198impl TerminalPalette {
199    fn apply(&mut self, colors: &TerminalColors) {
200        if let Some(color) = &colors.background {
201            self.background = color.clone();
202        }
203        if let Some(color) = &colors.foreground {
204            self.foreground = color.clone();
205        }
206        if let Some(color) = &colors.cursor {
207            self.cursor = color.clone();
208        }
209        if let Some(color) = &colors.selection {
210            self.selection = color.clone();
211        }
212        if let Some(colors) = &colors.ansi {
213            self.ansi.clone_from(colors);
214        }
215        for (slot, color) in self.ansi.iter_mut().zip(colors.named_ansi()) {
216            if let Some(color) = color {
217                *slot = color.clone();
218            }
219        }
220    }
221}
222
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
224pub struct TerminalPalettes {
225    pub light: TerminalPalette,
226    pub dark: TerminalPalette,
227}
228
229impl Default for TerminalPalettes {
230    fn default() -> Self {
231        Self {
232            light: embedded_palette(TWINE_LIGHT),
233            dark: embedded_palette(TWINE_DARK),
234        }
235    }
236}
237
238impl TerminalPalettes {
239    pub(super) fn apply(&mut self, colors: &TerminalColors) {
240        self.light.apply(colors);
241        self.dark.apply(colors);
242    }
243}
244
245fn embedded_palette(source: &str) -> TerminalPalette {
246    let table: toml::Table = toml::from_str(source).expect("valid embedded theme document");
247    let colors: TerminalColors = table["terminal"]["colors"]
248        .clone()
249        .try_into()
250        .expect("valid embedded terminal palette");
251    let ansi = colors
252        .named_ansi()
253        .map(|color| color.expect("complete embedded ANSI palette").clone());
254    TerminalPalette {
255        background: colors.background.expect("embedded background"),
256        foreground: colors.foreground.expect("embedded foreground"),
257        cursor: colors.cursor.expect("embedded cursor"),
258        selection: colors.selection.expect("embedded selection"),
259        ansi,
260    }
261}
262
263pub(super) fn publish(config_path: &Path) -> Vec<(&'static str, ConfigDiagnostic)> {
264    let directory = config_path.parent().unwrap_or(Path::new("."));
265    bundled::publish(directory);
266    let mut failures = Vec::new();
267    // Publish defaults before resolving either global imports or appearance selectors.
268    for (name, source, legacy_name, legacy_source) in [
269        (
270            "twine_light.toml",
271            TWINE_LIGHT,
272            "silica_light.toml",
273            SILICA_LIGHT,
274        ),
275        (
276            "twine_dark.toml",
277            TWINE_DARK,
278            "silica_dark.toml",
279            SILICA_DARK,
280        ),
281    ] {
282        let path = directory.join("themes").join(name);
283        let result = match fs::symlink_metadata(&path) {
284            Ok(_) => continue,
285            Err(error) if error.kind() == io::ErrorKind::NotFound => {
286                // Carry customized defaults forward once; untouched Silica files stay optional.
287                let legacy_path = directory.join("themes").join(legacy_name);
288                let source = match fs::read_to_string(&legacy_path) {
289                    Ok(legacy) => {
290                        if legacy == legacy_source {
291                            source.to_owned()
292                        } else {
293                            legacy
294                        }
295                    }
296                    Err(error) if error.kind() == io::ErrorKind::NotFound => source.to_owned(),
297                    Err(error) => {
298                        let diagnostic =
299                            file_diagnostic(&legacy_path, &super::ConfigFileError::Read(error));
300                        tracing::warn!(%diagnostic, "cannot migrate default theme file");
301                        failures.push((name, diagnostic));
302                        continue;
303                    }
304                };
305                write_default(&path, &source).map(|_| ())
306            }
307            Err(error) => Err(super::ConfigFileError::Read(error)),
308        };
309        if let Err(error) = result {
310            let diagnostic = file_diagnostic(&path, &error);
311            tracing::warn!(%diagnostic, "cannot publish default theme file");
312            failures.push((name, diagnostic));
313        }
314    }
315    failures
316}
317
318pub(super) fn load(
319    config_path: &Path,
320    selection: &ThemeConfig,
321    publication_failures: &[(&str, ConfigDiagnostic)],
322) -> (TerminalPalettes, Vec<ConfigDiagnostic>) {
323    let directory = config_path.parent().unwrap_or(Path::new("."));
324    let mut palettes = TerminalPalettes::default();
325    let mut diagnostics = Vec::new();
326    for (name, palette) in [
327        (&selection.light, &mut palettes.light),
328        (&selection.dark, &mut palettes.dark),
329    ] {
330        let filename = format!("{}.toml", name.as_str());
331        if let Some((_, diagnostic)) = publication_failures
332            .iter()
333            .find(|(failed, _)| *failed == filename)
334        {
335            diagnostics.push(diagnostic.clone());
336            continue;
337        }
338        let path = directory.join("themes").join(filename);
339        let source = match fs::read_to_string(&path) {
340            Ok(source) => source,
341            Err(error) => {
342                diagnostics.push(file_diagnostic(&path, &super::ConfigFileError::Read(error)));
343                continue;
344            }
345        };
346        let loaded = imports::load(&path, &source);
347        diagnostics.extend(loaded.diagnostics);
348        palette.apply(&loaded.config.terminal.colors);
349    }
350    (palettes, diagnostics)
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::config::{Config, ConfigProblem};
357
358    #[test]
359    fn selectors_resolve_independent_palettes_and_theme_files_import_colors_only() {
360        let directory = tempfile::tempdir().unwrap();
361        let config = directory.path().join("config.toml");
362        let themes = directory.path().join("themes");
363        fs::create_dir(&themes).unwrap();
364        fs::write(
365            themes.join("my light.toml"),
366            "[terminal.colors]\nbackground = '#112233'\nblue = '#445566'\n",
367        )
368        .unwrap();
369        fs::write(
370            themes.join("base.toml"),
371            "[terminal]\nfont_size = 22\n[terminal.colors]\nbackground = '#778899'\n",
372        )
373        .unwrap();
374        fs::write(
375            themes.join("my_dark.toml"),
376            "import = 'base.toml'\n[terminal.colors]\nblue = '#abcdef'\n",
377        )
378        .unwrap();
379        fs::write(&config, "[theme]\nlight = 'my light'\ndark = 'my_dark'\n").unwrap();
380        let loaded = Config::load(&config);
381        assert_eq!(loaded.diagnostics, []);
382        assert_eq!(
383            loaded.config.terminal.font_size,
384            super::super::FontSize::default()
385        );
386        assert_eq!(
387            loaded.config.terminal.palettes.light.background.as_str(),
388            "#112233"
389        );
390        assert_eq!(
391            loaded.config.terminal.palettes.light.ansi[4].as_str(),
392            "#445566"
393        );
394        assert_eq!(
395            loaded.config.terminal.palettes.light.foreground,
396            TerminalPalettes::default().light.foreground
397        );
398        assert_eq!(
399            loaded.config.terminal.palettes.dark.background.as_str(),
400            "#778899"
401        );
402        assert_eq!(
403            loaded.config.terminal.palettes.dark.ansi[4].as_str(),
404            "#abcdef"
405        );
406    }
407
408    #[test]
409    fn omitted_selectors_keep_the_corresponding_default() {
410        let directory = tempfile::tempdir().unwrap();
411        let config = directory.path().join("config.toml");
412        for mode in ["light", "dark"] {
413            fs::write(&config, format!("[theme]\n{mode} = 'nord'\n")).unwrap();
414            let loaded = Config::load(&config);
415            assert_eq!(loaded.diagnostics, []);
416            let (selected, omitted, default) = if mode == "light" {
417                (
418                    &loaded.config.terminal.palettes.light,
419                    &loaded.config.terminal.palettes.dark,
420                    TerminalPalettes::default().dark,
421                )
422            } else {
423                (
424                    &loaded.config.terminal.palettes.dark,
425                    &loaded.config.terminal.palettes.light,
426                    TerminalPalettes::default().light,
427                )
428            };
429            assert_eq!(selected.background.as_str(), "#2e3440");
430            assert_eq!(*omitted, default);
431        }
432    }
433
434    #[test]
435    fn global_imports_and_inline_colors_override_selected_palettes() {
436        let directory = tempfile::tempdir().unwrap();
437        let config = directory.path().join("config.toml");
438        let themes = directory.path().join("themes");
439        fs::create_dir(&themes).unwrap();
440        fs::write(
441            themes.join("light.toml"),
442            "[terminal.colors]\nbackground = '#112233'\nblue = '#123456'\n",
443        )
444        .unwrap();
445        fs::write(
446            themes.join("dark.toml"),
447            "[terminal.colors]\nbackground = '#445566'\nblue = '#654321'\n",
448        )
449        .unwrap();
450        let nested = directory.path().join("nested");
451        fs::create_dir(&nested).unwrap();
452        fs::write(nested.join("shared.toml"), "[theme]\nlight = 'light'\n[terminal.colors]\nbackground = '#abcdef'\nred = '#111111'\n").unwrap();
453        fs::write(&config, "import = 'nested/shared.toml'\n[theme]\ndark = 'dark'\n[terminal.colors]\nred = '#222222'\n").unwrap();
454        let loaded = Config::load(&config);
455        assert_eq!(loaded.diagnostics, []);
456        for palette in [
457            &loaded.config.terminal.palettes.light,
458            &loaded.config.terminal.palettes.dark,
459        ] {
460            assert_eq!(palette.background.as_str(), "#abcdef");
461            assert_eq!(palette.ansi[1].as_str(), "#222222");
462        }
463        assert_eq!(
464            loaded.config.terminal.palettes.light.ansi[4].as_str(),
465            "#123456"
466        );
467        assert_eq!(
468            loaded.config.terminal.palettes.dark.ansi[4].as_str(),
469            "#654321"
470        );
471    }
472
473    #[test]
474    fn selected_theme_errors_report_the_file_and_preserve_its_contents() {
475        let directory = tempfile::tempdir().unwrap();
476        let config = directory.path().join("config.toml");
477        fs::write(
478            &config,
479            "terminal.font_size = 20\n[theme]\ndark = 'missing'\n",
480        )
481        .unwrap();
482        let theme = directory.path().join("themes/missing.toml");
483        let missing = Config::load(&config);
484        assert_eq!(missing.config, Config::default());
485        assert_eq!(missing.diagnostics[0].file, theme);
486        assert!(matches!(
487            missing.diagnostics[0].problem,
488            ConfigProblem::File(_)
489        ));
490        assert!(!theme.exists());
491        let invalid = "[terminal.colors]\nforeground = 'secret'\n";
492        fs::write(&theme, invalid).unwrap();
493        let loaded = Config::load(&config);
494        assert_eq!(loaded.config, Config::default());
495        assert_eq!(loaded.diagnostics[0].file, theme);
496        assert_eq!(loaded.diagnostics[0].line, 2);
497        assert_eq!(loaded.diagnostics[0].key, "terminal.colors.foreground");
498        assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
499        assert!(!loaded.diagnostics[0].to_string().contains("secret"));
500        assert_eq!(fs::read_to_string(&theme).unwrap(), invalid);
501    }
502
503    #[test]
504    fn invalid_selector_names_and_types_report_the_appearance_key() {
505        for mode in ["light", "dark"] {
506            for value in [
507                "''",
508                "' '",
509                "'../escape'",
510                "'/absolute'",
511                "'a\\b'",
512                "'.'",
513                "'..'",
514                "1",
515                "[]",
516            ] {
517                let source = format!("[theme]\n{mode} = {value}\n");
518                let loaded = Config::parse(Path::new("/test/config.toml"), &source);
519                assert_eq!(loaded.config, Config::default());
520                assert_eq!(loaded.diagnostics.len(), 1, "{value}");
521                assert_eq!(loaded.diagnostics[0].key, format!("theme.{mode}"));
522                assert_eq!(loaded.diagnostics[0].line, 2);
523                assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
524            }
525        }
526    }
527
528    #[test]
529    fn invalid_unused_default_files_do_not_override_a_selected_theme() {
530        let directory = tempfile::tempdir().unwrap();
531        let config = directory.path().join("config.toml");
532        let themes = directory.path().join("themes");
533        fs::create_dir(&themes).unwrap();
534        fs::write(themes.join("twine_dark.toml"), "invalid = @").unwrap();
535        fs::write(&config, "[theme]\ndark = 'nord'\n").unwrap();
536        let loaded = Config::load(&config);
537        assert_eq!(loaded.diagnostics, []);
538        assert_eq!(
539            loaded.config.terminal.palettes.dark.background.as_str(),
540            "#2e3440"
541        );
542        assert_eq!(
543            fs::read_to_string(themes.join("twine_dark.toml")).unwrap(),
544            "invalid = @"
545        );
546    }
547
548    #[test]
549    fn named_colors_override_only_their_ansi_slots_in_both_appearances() {
550        let directory = tempfile::tempdir().unwrap();
551        let config = directory.path().join("config.toml");
552        for (slot, name) in [
553            "black",
554            "red",
555            "green",
556            "yellow",
557            "blue",
558            "magenta",
559            "cyan",
560            "white",
561            "brblack",
562            "brred",
563            "brgreen",
564            "bryellow",
565            "brblue",
566            "brmagenta",
567            "brcyan",
568            "brwhite",
569        ]
570        .iter()
571        .enumerate()
572        {
573            fs::write(&config, format!("[terminal.colors]\n{name} = '#123456'\n")).unwrap();
574            let loaded = Config::load(&config);
575            assert_eq!(loaded.diagnostics, []);
576            let mut expected = TerminalPalettes::default();
577            expected.light.ansi[slot] = HexColor("#123456".into());
578            expected.dark.ansi[slot] = HexColor("#123456".into());
579            assert_eq!(loaded.config.terminal.palettes, expected, "{name}");
580        }
581    }
582
583    #[test]
584    fn named_colors_merge_across_imports_and_report_invalid_values_by_name() {
585        let directory = tempfile::tempdir().unwrap();
586        let config = directory.path().join("config.toml");
587        fs::write(
588            directory.path().join("colors.toml"),
589            "[terminal.colors]\nblack = '#111111'\nblue = '#222222'\nbrmagenta = '#333333'\n",
590        )
591        .unwrap();
592        fs::write(
593            &config,
594            "import = 'colors.toml'\n[terminal.colors]\nblue = '#444444'\n",
595        )
596        .unwrap();
597        let loaded = Config::load(&config);
598        assert_eq!(loaded.diagnostics, []);
599        for palette in [
600            &loaded.config.terminal.palettes.light,
601            &loaded.config.terminal.palettes.dark,
602        ] {
603            assert_eq!(palette.ansi[0].as_str(), "#111111");
604            assert_eq!(palette.ansi[4].as_str(), "#444444");
605            assert_eq!(palette.ansi[13].as_str(), "#333333");
606        }
607        fs::write(&config, "[terminal.colors]\nbrblue = 'secret'\n").unwrap();
608        let loaded = Config::load(&config);
609        assert_eq!(loaded.config, Config::default());
610        assert_eq!(loaded.diagnostics[0].key, "terminal.colors.brblue");
611        assert_eq!(loaded.diagnostics[0].line, 2);
612        assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
613        assert!(!loaded.diagnostics[0].to_string().contains("secret"));
614    }
615
616    #[test]
617    fn legacy_array_remains_readable_and_named_keys_override_it() {
618        let directory = tempfile::tempdir().unwrap();
619        let config = directory.path().join("config.toml");
620        fs::write(
621            &config,
622            format!(
623                "[terminal.colors]\nansi = [{}]\nblue = '#abcdef'\n",
624                vec!["'#112233'"; 16].join(",")
625            ),
626        )
627        .unwrap();
628        let loaded = Config::load(&config);
629        assert_eq!(loaded.diagnostics, []);
630        assert_eq!(
631            loaded.config.terminal.palettes.dark.ansi[0].as_str(),
632            "#112233"
633        );
634        assert_eq!(
635            loaded.config.terminal.palettes.dark.ansi[4].as_str(),
636            "#abcdef"
637        );
638        let serialized = toml::to_string(&loaded.config.terminal.colors).unwrap();
639        assert!(serialized.contains("blue ="));
640        assert!(!serialized.contains("ansi ="));
641    }
642
643    #[test]
644    fn mixed_formats_preserve_import_order_and_local_precedence() {
645        let directory = tempfile::tempdir().unwrap();
646        let config = directory.path().join("config.toml");
647        fs::write(
648            directory.path().join("named.toml"),
649            "[terminal.colors]\nblue = '#abcdef'\n",
650        )
651        .unwrap();
652        let legacy = format!(
653            "[terminal.colors]\nansi = [{}]\n",
654            vec!["'#112233'"; 16].join(",")
655        );
656        fs::write(directory.path().join("legacy.toml"), &legacy).unwrap();
657        for (source, expected) in [
658            (format!("import = 'named.toml'\n{legacy}"), "#112233"),
659            ("import = ['named.toml', 'legacy.toml']\n".into(), "#112233"),
660            ("import = ['legacy.toml', 'named.toml']\n".into(), "#abcdef"),
661        ] {
662            fs::write(&config, source).unwrap();
663            let loaded = Config::load(&config);
664            assert_eq!(loaded.diagnostics, []);
665            assert_eq!(
666                loaded.config.terminal.palettes.dark.ansi[4].as_str(),
667                expected
668            );
669        }
670    }
671
672    #[test]
673    fn seeds_both_palettes_and_preserves_user_edits() {
674        let directory = tempfile::tempdir().unwrap();
675        let config = directory.path().join("config.toml");
676        let first = Config::load(&config);
677        assert_eq!(first.config, Config::default());
678        assert_eq!(first.diagnostics, []);
679        let dark = directory.path().join("themes/twine_dark.toml");
680        assert_eq!(fs::read_to_string(&dark).unwrap(), TWINE_DARK);
681        assert_eq!(
682            fs::read_to_string(directory.path().join("themes/twine_light.toml")).unwrap(),
683            TWINE_LIGHT
684        );
685        fs::write(&dark, "[terminal.colors]\nbackground = '#112233'").unwrap();
686        let edited = Config::load(&config);
687        assert_eq!(
688            edited.config.terminal.palettes.dark.background.as_str(),
689            "#112233"
690        );
691        assert_eq!(
692            edited.config.terminal.palettes.light,
693            TerminalPalettes::default().light
694        );
695        assert_eq!(
696            fs::read_to_string(&dark).unwrap(),
697            "[terminal.colors]\nbackground = '#112233'"
698        );
699    }
700
701    #[test]
702    fn untouched_legacy_defaults_adopt_twine_without_overwriting_silica() {
703        let directory = tempfile::tempdir().unwrap();
704        let themes = directory.path().join("themes");
705        fs::create_dir(&themes).unwrap();
706        fs::write(themes.join("silica_light.toml"), SILICA_LIGHT).unwrap();
707        fs::write(themes.join("silica_dark.toml"), SILICA_DARK).unwrap();
708        let loaded = Config::load(&directory.path().join("config.toml"));
709        assert_eq!(loaded.diagnostics, []);
710        assert_eq!(loaded.config.terminal.palettes, TerminalPalettes::default());
711        assert_eq!(
712            fs::read_to_string(themes.join("silica_light.toml")).unwrap(),
713            SILICA_LIGHT
714        );
715        assert_eq!(
716            fs::read_to_string(themes.join("silica_dark.toml")).unwrap(),
717            SILICA_DARK
718        );
719    }
720
721    #[test]
722    fn customized_legacy_defaults_are_carried_forward_only_on_first_load() {
723        let directory = tempfile::tempdir().unwrap();
724        let themes = directory.path().join("themes");
725        fs::create_dir(&themes).unwrap();
726        let customized = "[terminal.colors]\nbackground = '#112233'\n";
727        fs::write(themes.join("silica_dark.toml"), customized).unwrap();
728        let config = directory.path().join("config.toml");
729        let first = Config::load(&config);
730        assert_eq!(first.diagnostics, []);
731        assert_eq!(
732            first.config.terminal.palettes.dark.background.as_str(),
733            "#112233"
734        );
735        assert_eq!(
736            first.config.terminal.palettes.light,
737            TerminalPalettes::default().light
738        );
739        assert_eq!(
740            fs::read_to_string(themes.join("silica_dark.toml")).unwrap(),
741            customized
742        );
743        assert_eq!(
744            fs::read_to_string(themes.join("twine_dark.toml")).unwrap(),
745            customized
746        );
747
748        fs::write(
749            themes.join("silica_dark.toml"),
750            "[terminal.colors]\nbackground = '#445566'\n",
751        )
752        .unwrap();
753        let again = Config::load(&config);
754        assert_eq!(again.diagnostics, []);
755        assert_eq!(
756            again.config.terminal.palettes,
757            first.config.terminal.palettes
758        );
759        assert_eq!(
760            fs::read_to_string(themes.join("twine_dark.toml")).unwrap(),
761            customized
762        );
763    }
764
765    #[test]
766    fn unreadable_legacy_defaults_report_the_file_and_retry_after_repair() {
767        let directory = tempfile::tempdir().unwrap();
768        let themes = directory.path().join("themes");
769        fs::create_dir(&themes).unwrap();
770        let legacy = themes.join("silica_dark.toml");
771        let current = themes.join("twine_dark.toml");
772        fs::write(&legacy, [0xff, 0xfe]).unwrap();
773        let config = directory.path().join("config.toml");
774        let first = Config::load(&config);
775        assert_eq!(first.config, Config::default());
776        assert_eq!(first.diagnostics.len(), 1);
777        assert_eq!(first.diagnostics[0].file, legacy);
778        assert!(matches!(
779            first.diagnostics[0].problem,
780            ConfigProblem::File(_)
781        ));
782        assert!(!current.exists());
783        assert_eq!(fs::read(&legacy).unwrap(), [0xff, 0xfe]);
784        let repaired = "[terminal.colors]\nbackground = '#112233'\n";
785        fs::write(&legacy, repaired).unwrap();
786        let again = Config::load(&config);
787        assert_eq!(again.diagnostics, []);
788        assert_eq!(
789            again.config.terminal.palettes.dark.background.as_str(),
790            "#112233"
791        );
792        assert_eq!(fs::read_to_string(&current).unwrap(), repaired);
793    }
794
795    #[test]
796    fn failed_unused_migration_does_not_block_a_selected_theme() {
797        let directory = tempfile::tempdir().unwrap();
798        let themes = directory.path().join("themes");
799        fs::create_dir(&themes).unwrap();
800        fs::write(themes.join("silica_dark.toml"), [0xff, 0xfe]).unwrap();
801        let config = directory.path().join("config.toml");
802        fs::write(&config, "[theme]\ndark = 'nord'\n").unwrap();
803        let loaded = Config::load(&config);
804        assert_eq!(loaded.diagnostics, []);
805        assert_eq!(
806            loaded.config.terminal.palettes.dark.background.as_str(),
807            "#2e3440"
808        );
809        assert!(!themes.join("twine_dark.toml").exists());
810    }
811
812    #[test]
813    fn existing_twine_defaults_take_precedence_over_legacy_customizations() {
814        let directory = tempfile::tempdir().unwrap();
815        let themes = directory.path().join("themes");
816        fs::create_dir(&themes).unwrap();
817        let current = "[terminal.colors]\nbackground = '#778899'\n";
818        fs::write(themes.join("twine_dark.toml"), current).unwrap();
819        fs::write(
820            themes.join("silica_dark.toml"),
821            "[terminal.colors]\nbackground = '#112233'\n",
822        )
823        .unwrap();
824        let loaded = Config::load(&directory.path().join("config.toml"));
825        assert_eq!(loaded.diagnostics, []);
826        assert_eq!(
827            loaded.config.terminal.palettes.dark.background.as_str(),
828            "#778899"
829        );
830        assert_eq!(
831            fs::read_to_string(themes.join("twine_dark.toml")).unwrap(),
832            current
833        );
834    }
835
836    #[test]
837    fn imported_colors_and_inline_overrides_apply_to_both_appearances() {
838        let directory = tempfile::tempdir().unwrap();
839        let config = directory.path().join("config.toml");
840        let theme = directory.path().join("custom.toml");
841        fs::write(&theme, "[terminal.colors]\nbackground = '#123456'\nforeground = '#ABCDEF'\ncursor = '#abcdef'\nselection = '#654321'\n").unwrap();
842        for import in ["custom.toml".to_owned(), theme.display().to_string()] {
843            fs::write(
844                &config,
845                format!("import = '{import}'\n[terminal.colors]\nforeground = '#ffffff'\n"),
846            )
847            .unwrap();
848            let loaded = Config::load(&config);
849            assert_eq!(loaded.diagnostics, []);
850            for palette in [
851                &loaded.config.terminal.palettes.light,
852                &loaded.config.terminal.palettes.dark,
853            ] {
854                assert_eq!(palette.background.as_str(), "#123456");
855                assert_eq!(palette.foreground.as_str(), "#ffffff");
856                assert_eq!(palette.cursor.as_str(), "#abcdef");
857                assert_eq!(palette.selection.as_str(), "#654321");
858            }
859        }
860    }
861
862    #[test]
863    fn full_palette_import_pins_the_same_colors_for_light_and_dark() {
864        let directory = tempfile::tempdir().unwrap();
865        let config = directory.path().join("config.toml");
866        fs::write(&config, "import = 'themes/twine_dark.toml'\n").unwrap();
867        let loaded = Config::load(&config);
868        assert_eq!(loaded.diagnostics, []);
869        assert_eq!(
870            loaded.config.terminal.palettes.light,
871            TerminalPalettes::default().dark
872        );
873        assert_eq!(
874            loaded.config.terminal.palettes.light,
875            loaded.config.terminal.palettes.dark
876        );
877    }
878
879    #[test]
880    fn invalid_imports_report_the_theme_file_and_fall_back_without_overwriting() {
881        let directory = tempfile::tempdir().unwrap();
882        let config = directory.path().join("config.toml");
883        fs::write(&config, "import = 'custom.toml'\nterminal.font_size = 18\n").unwrap();
884        let theme = directory.path().join("custom.toml");
885        let missing = Config::load(&config);
886        assert_eq!(missing.config, Config::default());
887        assert_eq!(missing.diagnostics[0].file, theme);
888        assert!(!theme.exists());
889        for (source, problem) in [
890            (
891                "[terminal.colors]\nforeground = 'secret'",
892                ConfigProblem::InvalidValue,
893            ),
894            (
895                "[terminal.colors]\nforeground = @",
896                ConfigProblem::InvalidToml,
897            ),
898        ] {
899            fs::write(&theme, source).unwrap();
900            let loaded = Config::load(&config);
901            assert_eq!(loaded.config, Config::default());
902            assert_eq!(loaded.diagnostics[0].file, theme);
903            assert_eq!(loaded.diagnostics[0].line, 2);
904            assert_eq!(loaded.diagnostics[0].key, "terminal.colors.foreground");
905            assert_eq!(loaded.diagnostics[0].problem, problem);
906            assert!(!loaded.diagnostics[0].to_string().contains("secret"));
907            assert_eq!(fs::read_to_string(&theme).unwrap(), source);
908        }
909    }
910
911    #[test]
912    fn invalid_inline_colors_and_ansi_lengths_report_the_key() {
913        let directory = tempfile::tempdir().unwrap();
914        let config = directory.path().join("config.toml");
915        for value in ["'secret'", "'#123'", "'#gg0000'", "42", "'#12345678'"] {
916            fs::write(
917                &config,
918                format!("[terminal.colors]\nbackground = {value}\n"),
919            )
920            .unwrap();
921            let loaded = Config::load(&config);
922            assert_eq!(loaded.config, Config::default());
923            assert_eq!(loaded.diagnostics[0].key, "terminal.colors.background");
924            assert_eq!(loaded.diagnostics[0].line, 2);
925        }
926        for count in [0, 15, 17] {
927            fs::write(
928                &config,
929                format!(
930                    "terminal.colors.ansi = [{}]",
931                    vec!["'#112233'"; count].join(",")
932                ),
933            )
934            .unwrap();
935            let loaded = Config::load(&config);
936            assert_eq!(loaded.config, Config::default());
937            assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
938        }
939    }
940}