Skip to main content

handshake_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 HANDSHAKE_LIGHT: &str = include_str!("themes/handshake_light.toml");
16const HANDSHAKE_DARK: &str = include_str!("themes/handshake_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("handshake_light".to_owned()),
29            dark: ThemeName("handshake_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(HANDSHAKE_LIGHT),
233            dark: embedded_palette(HANDSHAKE_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
263fn compatibility_theme(name: &str) -> String {
264    format!("# Compatibility for existing Twine theme selections and imports.\nimport = '{name}'\n")
265}
266
267fn default_source(
268    directory: &Path,
269    name: &str,
270    source: &str,
271    twine: &str,
272    silica: &str,
273    silica_source: &str,
274) -> Result<String, ConfigDiagnostic> {
275    let legacy = directory.join("themes").join(twine);
276    match fs::read_to_string(&legacy) {
277        Ok(content) if content != compatibility_theme(name) => return Ok(content),
278        Ok(_) => {} // Our generated compatibility import must never become a self-import.
279        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
280        Err(error) => {
281            return Err(file_diagnostic(
282                &legacy,
283                &super::ConfigFileError::Read(error),
284            ));
285        }
286    }
287    let legacy = directory.join("themes").join(silica);
288    match fs::read_to_string(&legacy) {
289        // Accept untouched Silica from either release's generated comments, without changing its file.
290        Ok(legacy)
291            if legacy == silica_source || legacy == silica_source.replace("Handshake", "Twine") =>
292        {
293            Ok(source.to_owned())
294        }
295        Ok(customized) => Ok(customized),
296        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(source.to_owned()),
297        Err(error) => Err(file_diagnostic(
298            &legacy,
299            &super::ConfigFileError::Read(error),
300        )),
301    }
302}
303
304pub(super) fn publish(config_path: &Path) -> Vec<(&'static str, ConfigDiagnostic)> {
305    let directory = config_path.parent().unwrap_or(Path::new("."));
306    bundled::publish(directory);
307    let mut failures = Vec::new();
308    for (name, source, twine, silica, silica_source) in [
309        (
310            "handshake_light.toml",
311            HANDSHAKE_LIGHT,
312            "twine_light.toml",
313            "silica_light.toml",
314            SILICA_LIGHT,
315        ),
316        (
317            "handshake_dark.toml",
318            HANDSHAKE_DARK,
319            "twine_dark.toml",
320            "silica_dark.toml",
321            SILICA_DARK,
322        ),
323    ] {
324        let path = directory.join("themes").join(name);
325        let result = match fs::symlink_metadata(&path) {
326            Ok(_) => Ok(()),
327            Err(error) if error.kind() == io::ErrorKind::NotFound => {
328                default_source(directory, name, source, twine, silica, silica_source).and_then(
329                    |source| {
330                        write_default(&path, &source)
331                            .map(|_| ())
332                            .map_err(|error| file_diagnostic(&path, &error))
333                    },
334                )
335            }
336            Err(error) => Err(file_diagnostic(&path, &super::ConfigFileError::Read(error))),
337        };
338        if let Err(diagnostic) = result {
339            tracing::warn!(%diagnostic, "cannot publish default theme file");
340            failures.push((name, diagnostic));
341            continue;
342        }
343        // These are specific supported legacy inputs, not aliases for arbitrary renamed APIs.
344        let alias = directory.join("themes").join(twine);
345        if let Err(error) = super::publish_default(&alias, &compatibility_theme(name)) {
346            failures.push((twine, file_diagnostic(&alias, &error)));
347        }
348    }
349    failures
350}
351
352pub(super) fn load(
353    config_path: &Path,
354    selection: &ThemeConfig,
355    publication_failures: &[(&str, ConfigDiagnostic)],
356) -> (TerminalPalettes, Vec<ConfigDiagnostic>) {
357    let directory = config_path.parent().unwrap_or(Path::new("."));
358    let mut palettes = TerminalPalettes::default();
359    let mut diagnostics = Vec::new();
360    for (name, palette) in [
361        (&selection.light, &mut palettes.light),
362        (&selection.dark, &mut palettes.dark),
363    ] {
364        let filename = format!("{}.toml", name.as_str());
365        if let Some((_, diagnostic)) = publication_failures
366            .iter()
367            .find(|(failed, _)| *failed == filename)
368        {
369            diagnostics.push(diagnostic.clone());
370            continue;
371        }
372        let path = directory.join("themes").join(filename);
373        let source = match fs::read_to_string(&path) {
374            Ok(source) => source,
375            Err(error) => {
376                diagnostics.push(file_diagnostic(&path, &super::ConfigFileError::Read(error)));
377                continue;
378            }
379        };
380        let loaded = imports::load(&path, &source);
381        diagnostics.extend(loaded.diagnostics);
382        palette.apply(&loaded.config.terminal.colors);
383    }
384    (palettes, diagnostics)
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::config::{Config, ConfigProblem};
391
392    #[test]
393    fn selectors_resolve_independent_palettes_and_theme_files_import_colors_only() {
394        let directory = tempfile::tempdir().unwrap();
395        let config = directory.path().join("config.toml");
396        let themes = directory.path().join("themes");
397        fs::create_dir(&themes).unwrap();
398        fs::write(
399            themes.join("my light.toml"),
400            "[terminal.colors]\nbackground = '#112233'\nblue = '#445566'\n",
401        )
402        .unwrap();
403        fs::write(
404            themes.join("base.toml"),
405            "[terminal]\nfont_size = 22\n[terminal.colors]\nbackground = '#778899'\n",
406        )
407        .unwrap();
408        fs::write(
409            themes.join("my_dark.toml"),
410            "import = 'base.toml'\n[terminal.colors]\nblue = '#abcdef'\n",
411        )
412        .unwrap();
413        fs::write(&config, "[theme]\nlight = 'my light'\ndark = 'my_dark'\n").unwrap();
414        let loaded = Config::load(&config);
415        assert_eq!(loaded.diagnostics, []);
416        assert_eq!(
417            loaded.config.terminal.font_size,
418            super::super::FontSize::default()
419        );
420        assert_eq!(
421            loaded.config.terminal.palettes.light.background.as_str(),
422            "#112233"
423        );
424        assert_eq!(
425            loaded.config.terminal.palettes.light.ansi[4].as_str(),
426            "#445566"
427        );
428        assert_eq!(
429            loaded.config.terminal.palettes.light.foreground,
430            TerminalPalettes::default().light.foreground
431        );
432        assert_eq!(
433            loaded.config.terminal.palettes.dark.background.as_str(),
434            "#778899"
435        );
436        assert_eq!(
437            loaded.config.terminal.palettes.dark.ansi[4].as_str(),
438            "#abcdef"
439        );
440    }
441
442    #[test]
443    fn omitted_selectors_keep_the_corresponding_default() {
444        let directory = tempfile::tempdir().unwrap();
445        let config = directory.path().join("config.toml");
446        for mode in ["light", "dark"] {
447            fs::write(&config, format!("[theme]\n{mode} = 'nord'\n")).unwrap();
448            let loaded = Config::load(&config);
449            assert_eq!(loaded.diagnostics, []);
450            let (selected, omitted, default) = if mode == "light" {
451                (
452                    &loaded.config.terminal.palettes.light,
453                    &loaded.config.terminal.palettes.dark,
454                    TerminalPalettes::default().dark,
455                )
456            } else {
457                (
458                    &loaded.config.terminal.palettes.dark,
459                    &loaded.config.terminal.palettes.light,
460                    TerminalPalettes::default().light,
461                )
462            };
463            assert_eq!(selected.background.as_str(), "#2e3440");
464            assert_eq!(*omitted, default);
465        }
466    }
467
468    #[test]
469    fn global_imports_and_inline_colors_override_selected_palettes() {
470        let directory = tempfile::tempdir().unwrap();
471        let config = directory.path().join("config.toml");
472        let themes = directory.path().join("themes");
473        fs::create_dir(&themes).unwrap();
474        fs::write(
475            themes.join("light.toml"),
476            "[terminal.colors]\nbackground = '#112233'\nblue = '#123456'\n",
477        )
478        .unwrap();
479        fs::write(
480            themes.join("dark.toml"),
481            "[terminal.colors]\nbackground = '#445566'\nblue = '#654321'\n",
482        )
483        .unwrap();
484        let nested = directory.path().join("nested");
485        fs::create_dir(&nested).unwrap();
486        fs::write(nested.join("shared.toml"), "[theme]\nlight = 'light'\n[terminal.colors]\nbackground = '#abcdef'\nred = '#111111'\n").unwrap();
487        fs::write(&config, "import = 'nested/shared.toml'\n[theme]\ndark = 'dark'\n[terminal.colors]\nred = '#222222'\n").unwrap();
488        let loaded = Config::load(&config);
489        assert_eq!(loaded.diagnostics, []);
490        for palette in [
491            &loaded.config.terminal.palettes.light,
492            &loaded.config.terminal.palettes.dark,
493        ] {
494            assert_eq!(palette.background.as_str(), "#abcdef");
495            assert_eq!(palette.ansi[1].as_str(), "#222222");
496        }
497        assert_eq!(
498            loaded.config.terminal.palettes.light.ansi[4].as_str(),
499            "#123456"
500        );
501        assert_eq!(
502            loaded.config.terminal.palettes.dark.ansi[4].as_str(),
503            "#654321"
504        );
505    }
506
507    #[test]
508    fn selected_theme_errors_report_the_file_and_preserve_its_contents() {
509        let directory = tempfile::tempdir().unwrap();
510        let config = directory.path().join("config.toml");
511        fs::write(
512            &config,
513            "terminal.font_size = 20\n[theme]\ndark = 'missing'\n",
514        )
515        .unwrap();
516        let theme = directory.path().join("themes/missing.toml");
517        let missing = Config::load(&config);
518        assert_eq!(missing.config, Config::default());
519        assert_eq!(missing.diagnostics[0].file, theme);
520        assert!(matches!(
521            missing.diagnostics[0].problem,
522            ConfigProblem::File(_)
523        ));
524        assert!(!theme.exists());
525        let invalid = "[terminal.colors]\nforeground = 'secret'\n";
526        fs::write(&theme, invalid).unwrap();
527        let loaded = Config::load(&config);
528        assert_eq!(loaded.config, Config::default());
529        assert_eq!(loaded.diagnostics[0].file, theme);
530        assert_eq!(loaded.diagnostics[0].line, 2);
531        assert_eq!(loaded.diagnostics[0].key, "terminal.colors.foreground");
532        assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
533        assert!(!loaded.diagnostics[0].to_string().contains("secret"));
534        assert_eq!(fs::read_to_string(&theme).unwrap(), invalid);
535    }
536
537    #[test]
538    fn invalid_selector_names_and_types_report_the_appearance_key() {
539        for mode in ["light", "dark"] {
540            for value in [
541                "''",
542                "' '",
543                "'../escape'",
544                "'/absolute'",
545                "'a\\b'",
546                "'.'",
547                "'..'",
548                "1",
549                "[]",
550            ] {
551                let source = format!("[theme]\n{mode} = {value}\n");
552                let loaded = Config::parse(Path::new("/test/config.toml"), &source);
553                assert_eq!(loaded.config, Config::default());
554                assert_eq!(loaded.diagnostics.len(), 1, "{value}");
555                assert_eq!(loaded.diagnostics[0].key, format!("theme.{mode}"));
556                assert_eq!(loaded.diagnostics[0].line, 2);
557                assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
558            }
559        }
560    }
561
562    #[test]
563    fn invalid_unused_default_files_do_not_override_a_selected_theme() {
564        let directory = tempfile::tempdir().unwrap();
565        let config = directory.path().join("config.toml");
566        let themes = directory.path().join("themes");
567        fs::create_dir(&themes).unwrap();
568        fs::write(themes.join("handshake_dark.toml"), "invalid = @").unwrap();
569        fs::write(&config, "[theme]\ndark = 'nord'\n").unwrap();
570        let loaded = Config::load(&config);
571        assert_eq!(loaded.diagnostics, []);
572        assert_eq!(
573            loaded.config.terminal.palettes.dark.background.as_str(),
574            "#2e3440"
575        );
576        assert_eq!(
577            fs::read_to_string(themes.join("handshake_dark.toml")).unwrap(),
578            "invalid = @"
579        );
580    }
581
582    #[test]
583    fn named_colors_override_only_their_ansi_slots_in_both_appearances() {
584        let directory = tempfile::tempdir().unwrap();
585        let config = directory.path().join("config.toml");
586        for (slot, name) in [
587            "black",
588            "red",
589            "green",
590            "yellow",
591            "blue",
592            "magenta",
593            "cyan",
594            "white",
595            "brblack",
596            "brred",
597            "brgreen",
598            "bryellow",
599            "brblue",
600            "brmagenta",
601            "brcyan",
602            "brwhite",
603        ]
604        .iter()
605        .enumerate()
606        {
607            fs::write(&config, format!("[terminal.colors]\n{name} = '#123456'\n")).unwrap();
608            let loaded = Config::load(&config);
609            assert_eq!(loaded.diagnostics, []);
610            let mut expected = TerminalPalettes::default();
611            expected.light.ansi[slot] = HexColor("#123456".into());
612            expected.dark.ansi[slot] = HexColor("#123456".into());
613            assert_eq!(loaded.config.terminal.palettes, expected, "{name}");
614        }
615    }
616
617    #[test]
618    fn named_colors_merge_across_imports_and_report_invalid_values_by_name() {
619        let directory = tempfile::tempdir().unwrap();
620        let config = directory.path().join("config.toml");
621        fs::write(
622            directory.path().join("colors.toml"),
623            "[terminal.colors]\nblack = '#111111'\nblue = '#222222'\nbrmagenta = '#333333'\n",
624        )
625        .unwrap();
626        fs::write(
627            &config,
628            "import = 'colors.toml'\n[terminal.colors]\nblue = '#444444'\n",
629        )
630        .unwrap();
631        let loaded = Config::load(&config);
632        assert_eq!(loaded.diagnostics, []);
633        for palette in [
634            &loaded.config.terminal.palettes.light,
635            &loaded.config.terminal.palettes.dark,
636        ] {
637            assert_eq!(palette.ansi[0].as_str(), "#111111");
638            assert_eq!(palette.ansi[4].as_str(), "#444444");
639            assert_eq!(palette.ansi[13].as_str(), "#333333");
640        }
641        fs::write(&config, "[terminal.colors]\nbrblue = 'secret'\n").unwrap();
642        let loaded = Config::load(&config);
643        assert_eq!(loaded.config, Config::default());
644        assert_eq!(loaded.diagnostics[0].key, "terminal.colors.brblue");
645        assert_eq!(loaded.diagnostics[0].line, 2);
646        assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
647        assert!(!loaded.diagnostics[0].to_string().contains("secret"));
648    }
649
650    #[test]
651    fn legacy_array_remains_readable_and_named_keys_override_it() {
652        let directory = tempfile::tempdir().unwrap();
653        let config = directory.path().join("config.toml");
654        fs::write(
655            &config,
656            format!(
657                "[terminal.colors]\nansi = [{}]\nblue = '#abcdef'\n",
658                vec!["'#112233'"; 16].join(",")
659            ),
660        )
661        .unwrap();
662        let loaded = Config::load(&config);
663        assert_eq!(loaded.diagnostics, []);
664        assert_eq!(
665            loaded.config.terminal.palettes.dark.ansi[0].as_str(),
666            "#112233"
667        );
668        assert_eq!(
669            loaded.config.terminal.palettes.dark.ansi[4].as_str(),
670            "#abcdef"
671        );
672        let serialized = toml::to_string(&loaded.config.terminal.colors).unwrap();
673        assert!(serialized.contains("blue ="));
674        assert!(!serialized.contains("ansi ="));
675    }
676
677    #[test]
678    fn mixed_formats_preserve_import_order_and_local_precedence() {
679        let directory = tempfile::tempdir().unwrap();
680        let config = directory.path().join("config.toml");
681        fs::write(
682            directory.path().join("named.toml"),
683            "[terminal.colors]\nblue = '#abcdef'\n",
684        )
685        .unwrap();
686        let legacy = format!(
687            "[terminal.colors]\nansi = [{}]\n",
688            vec!["'#112233'"; 16].join(",")
689        );
690        fs::write(directory.path().join("legacy.toml"), &legacy).unwrap();
691        for (source, expected) in [
692            (format!("import = 'named.toml'\n{legacy}"), "#112233"),
693            ("import = ['named.toml', 'legacy.toml']\n".into(), "#112233"),
694            ("import = ['legacy.toml', 'named.toml']\n".into(), "#abcdef"),
695        ] {
696            fs::write(&config, source).unwrap();
697            let loaded = Config::load(&config);
698            assert_eq!(loaded.diagnostics, []);
699            assert_eq!(
700                loaded.config.terminal.palettes.dark.ansi[4].as_str(),
701                expected
702            );
703        }
704    }
705
706    #[test]
707    fn seeds_both_palettes_and_preserves_user_edits() {
708        let directory = tempfile::tempdir().unwrap();
709        let config = directory.path().join("config.toml");
710        let first = Config::load(&config);
711        assert_eq!(first.config, Config::default());
712        assert_eq!(first.diagnostics, []);
713        let dark = directory.path().join("themes/handshake_dark.toml");
714        assert_eq!(fs::read_to_string(&dark).unwrap(), HANDSHAKE_DARK);
715        assert_eq!(
716            fs::read_to_string(directory.path().join("themes/handshake_light.toml")).unwrap(),
717            HANDSHAKE_LIGHT
718        );
719        fs::write(&dark, "[terminal.colors]\nbackground = '#112233'").unwrap();
720        let edited = Config::load(&config);
721        assert_eq!(
722            edited.config.terminal.palettes.dark.background.as_str(),
723            "#112233"
724        );
725        assert_eq!(
726            edited.config.terminal.palettes.light,
727            TerminalPalettes::default().light
728        );
729        assert_eq!(
730            fs::read_to_string(&dark).unwrap(),
731            "[terminal.colors]\nbackground = '#112233'"
732        );
733    }
734
735    #[test]
736    fn untouched_legacy_defaults_adopt_handshake_without_overwriting_silica() {
737        let directory = tempfile::tempdir().unwrap();
738        let themes = directory.path().join("themes");
739        fs::create_dir(&themes).unwrap();
740        fs::write(themes.join("silica_light.toml"), SILICA_LIGHT).unwrap();
741        fs::write(themes.join("silica_dark.toml"), SILICA_DARK).unwrap();
742        let loaded = Config::load(&directory.path().join("config.toml"));
743        assert_eq!(loaded.diagnostics, []);
744        assert_eq!(loaded.config.terminal.palettes, TerminalPalettes::default());
745        assert_eq!(
746            fs::read_to_string(themes.join("silica_light.toml")).unwrap(),
747            SILICA_LIGHT
748        );
749        assert_eq!(
750            fs::read_to_string(themes.join("silica_dark.toml")).unwrap(),
751            SILICA_DARK
752        );
753    }
754
755    #[test]
756    fn customized_legacy_defaults_are_carried_forward_only_on_first_load() {
757        let directory = tempfile::tempdir().unwrap();
758        let themes = directory.path().join("themes");
759        fs::create_dir(&themes).unwrap();
760        let customized = "[terminal.colors]\nbackground = '#112233'\n";
761        fs::write(themes.join("silica_dark.toml"), customized).unwrap();
762        let config = directory.path().join("config.toml");
763        let first = Config::load(&config);
764        assert_eq!(first.diagnostics, []);
765        assert_eq!(
766            first.config.terminal.palettes.dark.background.as_str(),
767            "#112233"
768        );
769        assert_eq!(
770            first.config.terminal.palettes.light,
771            TerminalPalettes::default().light
772        );
773        assert_eq!(
774            fs::read_to_string(themes.join("silica_dark.toml")).unwrap(),
775            customized
776        );
777        assert_eq!(
778            fs::read_to_string(themes.join("handshake_dark.toml")).unwrap(),
779            customized
780        );
781
782        fs::write(
783            themes.join("silica_dark.toml"),
784            "[terminal.colors]\nbackground = '#445566'\n",
785        )
786        .unwrap();
787        let again = Config::load(&config);
788        assert_eq!(again.diagnostics, []);
789        assert_eq!(
790            again.config.terminal.palettes,
791            first.config.terminal.palettes
792        );
793        assert_eq!(
794            fs::read_to_string(themes.join("handshake_dark.toml")).unwrap(),
795            customized
796        );
797    }
798
799    #[test]
800    fn unreadable_legacy_defaults_report_the_file_and_retry_after_repair() {
801        let directory = tempfile::tempdir().unwrap();
802        let themes = directory.path().join("themes");
803        fs::create_dir(&themes).unwrap();
804        let legacy = themes.join("silica_dark.toml");
805        let current = themes.join("handshake_dark.toml");
806        fs::write(&legacy, [0xff, 0xfe]).unwrap();
807        let config = directory.path().join("config.toml");
808        let first = Config::load(&config);
809        assert_eq!(first.config, Config::default());
810        assert_eq!(first.diagnostics.len(), 1);
811        assert_eq!(first.diagnostics[0].file, legacy);
812        assert!(matches!(
813            first.diagnostics[0].problem,
814            ConfigProblem::File(_)
815        ));
816        assert!(!current.exists());
817        assert_eq!(fs::read(&legacy).unwrap(), [0xff, 0xfe]);
818        let repaired = "[terminal.colors]\nbackground = '#112233'\n";
819        fs::write(&legacy, repaired).unwrap();
820        let again = Config::load(&config);
821        assert_eq!(again.diagnostics, []);
822        assert_eq!(
823            again.config.terminal.palettes.dark.background.as_str(),
824            "#112233"
825        );
826        assert_eq!(fs::read_to_string(&current).unwrap(), repaired);
827    }
828
829    #[test]
830    fn failed_unused_migration_does_not_block_a_selected_theme() {
831        let directory = tempfile::tempdir().unwrap();
832        let themes = directory.path().join("themes");
833        fs::create_dir(&themes).unwrap();
834        fs::write(themes.join("silica_dark.toml"), [0xff, 0xfe]).unwrap();
835        let config = directory.path().join("config.toml");
836        fs::write(&config, "[theme]\ndark = 'nord'\n").unwrap();
837        let loaded = Config::load(&config);
838        assert_eq!(loaded.diagnostics, []);
839        assert_eq!(
840            loaded.config.terminal.palettes.dark.background.as_str(),
841            "#2e3440"
842        );
843        assert!(!themes.join("handshake_dark.toml").exists());
844    }
845
846    #[test]
847    fn existing_handshake_defaults_take_precedence_over_legacy_customizations() {
848        let directory = tempfile::tempdir().unwrap();
849        let themes = directory.path().join("themes");
850        fs::create_dir(&themes).unwrap();
851        let current = "[terminal.colors]\nbackground = '#778899'\n";
852        fs::write(themes.join("handshake_dark.toml"), current).unwrap();
853        fs::write(
854            themes.join("silica_dark.toml"),
855            "[terminal.colors]\nbackground = '#112233'\n",
856        )
857        .unwrap();
858        let loaded = Config::load(&directory.path().join("config.toml"));
859        assert_eq!(loaded.diagnostics, []);
860        assert_eq!(
861            loaded.config.terminal.palettes.dark.background.as_str(),
862            "#778899"
863        );
864        assert_eq!(
865            fs::read_to_string(themes.join("handshake_dark.toml")).unwrap(),
866            current
867        );
868    }
869
870    #[test]
871    fn imported_colors_and_inline_overrides_apply_to_both_appearances() {
872        let directory = tempfile::tempdir().unwrap();
873        let config = directory.path().join("config.toml");
874        let theme = directory.path().join("custom.toml");
875        fs::write(&theme, "[terminal.colors]\nbackground = '#123456'\nforeground = '#ABCDEF'\ncursor = '#abcdef'\nselection = '#654321'\n").unwrap();
876        for import in ["custom.toml".to_owned(), theme.display().to_string()] {
877            fs::write(
878                &config,
879                format!("import = '{import}'\n[terminal.colors]\nforeground = '#ffffff'\n"),
880            )
881            .unwrap();
882            let loaded = Config::load(&config);
883            assert_eq!(loaded.diagnostics, []);
884            for palette in [
885                &loaded.config.terminal.palettes.light,
886                &loaded.config.terminal.palettes.dark,
887            ] {
888                assert_eq!(palette.background.as_str(), "#123456");
889                assert_eq!(palette.foreground.as_str(), "#ffffff");
890                assert_eq!(palette.cursor.as_str(), "#abcdef");
891                assert_eq!(palette.selection.as_str(), "#654321");
892            }
893        }
894    }
895
896    #[test]
897    fn full_palette_import_pins_the_same_colors_for_light_and_dark() {
898        let directory = tempfile::tempdir().unwrap();
899        let config = directory.path().join("config.toml");
900        fs::write(&config, "import = 'themes/handshake_dark.toml'\n").unwrap();
901        let loaded = Config::load(&config);
902        assert_eq!(loaded.diagnostics, []);
903        assert_eq!(
904            loaded.config.terminal.palettes.light,
905            TerminalPalettes::default().dark
906        );
907        assert_eq!(
908            loaded.config.terminal.palettes.light,
909            loaded.config.terminal.palettes.dark
910        );
911    }
912
913    #[test]
914    fn invalid_imports_report_the_theme_file_and_fall_back_without_overwriting() {
915        let directory = tempfile::tempdir().unwrap();
916        let config = directory.path().join("config.toml");
917        fs::write(&config, "import = 'custom.toml'\nterminal.font_size = 18\n").unwrap();
918        let theme = directory.path().join("custom.toml");
919        let missing = Config::load(&config);
920        assert_eq!(missing.config, Config::default());
921        assert_eq!(missing.diagnostics[0].file, theme);
922        assert!(!theme.exists());
923        for (source, problem) in [
924            (
925                "[terminal.colors]\nforeground = 'secret'",
926                ConfigProblem::InvalidValue,
927            ),
928            (
929                "[terminal.colors]\nforeground = @",
930                ConfigProblem::InvalidToml,
931            ),
932        ] {
933            fs::write(&theme, source).unwrap();
934            let loaded = Config::load(&config);
935            assert_eq!(loaded.config, Config::default());
936            assert_eq!(loaded.diagnostics[0].file, theme);
937            assert_eq!(loaded.diagnostics[0].line, 2);
938            assert_eq!(loaded.diagnostics[0].key, "terminal.colors.foreground");
939            assert_eq!(loaded.diagnostics[0].problem, problem);
940            assert!(!loaded.diagnostics[0].to_string().contains("secret"));
941            assert_eq!(fs::read_to_string(&theme).unwrap(), source);
942        }
943    }
944
945    #[test]
946    fn invalid_inline_colors_and_ansi_lengths_report_the_key() {
947        let directory = tempfile::tempdir().unwrap();
948        let config = directory.path().join("config.toml");
949        for value in ["'secret'", "'#123'", "'#gg0000'", "42", "'#12345678'"] {
950            fs::write(
951                &config,
952                format!("[terminal.colors]\nbackground = {value}\n"),
953            )
954            .unwrap();
955            let loaded = Config::load(&config);
956            assert_eq!(loaded.config, Config::default());
957            assert_eq!(loaded.diagnostics[0].key, "terminal.colors.background");
958            assert_eq!(loaded.diagnostics[0].line, 2);
959        }
960        for count in [0, 15, 17] {
961            fs::write(
962                &config,
963                format!(
964                    "terminal.colors.ansi = [{}]",
965                    vec!["'#112233'"; count].join(",")
966                ),
967            )
968            .unwrap();
969            let loaded = Config::load(&config);
970            assert_eq!(loaded.config, Config::default());
971            assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
972        }
973    }
974
975    #[test]
976    fn customized_twine_files_are_carried_once_and_legacy_selectors_keep_their_files() {
977        let root = tempfile::tempdir().unwrap();
978        let themes = root.path().join("themes");
979        fs::create_dir(&themes).unwrap();
980        let legacy = themes.join("twine_dark.toml");
981        let custom = "[terminal.colors]\nbackground = '#123456'\n";
982        fs::write(&legacy, custom).unwrap();
983        let path = root.path().join("config.toml");
984        let loaded = Config::load(&path);
985        assert_eq!(loaded.diagnostics, []);
986        assert_eq!(
987            loaded.config.terminal.palettes.dark.background.as_str(),
988            "#123456"
989        );
990        assert_eq!(
991            fs::read_to_string(themes.join("handshake_dark.toml")).unwrap(),
992            custom
993        );
994        fs::write(&legacy, "[terminal.colors]\nbackground = '#654321'\n").unwrap();
995        assert_eq!(
996            Config::load(&path)
997                .config
998                .terminal
999                .palettes
1000                .dark
1001                .background
1002                .as_str(),
1003            "#123456"
1004        );
1005        fs::write(&path, "[theme]\ndark = 'twine_dark'\n").unwrap();
1006        assert_eq!(
1007            Config::load(&path)
1008                .config
1009                .terminal
1010                .palettes
1011                .dark
1012                .background
1013                .as_str(),
1014            "#654321"
1015        );
1016    }
1017
1018    #[test]
1019    fn legacy_imports_and_selectors_resolve_and_missing_new_defaults_do_not_self_import() {
1020        let root = tempfile::tempdir().unwrap();
1021        let path = root.path().join("config.toml");
1022        let first = Config::load(&path);
1023        assert_eq!(first.config.theme.dark.as_str(), "handshake_dark");
1024        fs::write(
1025            &path,
1026            "import = 'themes/twine_dark.toml'\n[theme]\ndark = 'twine_dark'\n",
1027        )
1028        .unwrap();
1029        let selected = Config::load(&path);
1030        assert_eq!(selected.diagnostics, []);
1031        assert_eq!(
1032            selected.config.terminal.palettes.dark,
1033            TerminalPalettes::default().dark
1034        );
1035        fs::remove_file(root.path().join("themes/handshake_dark.toml")).unwrap();
1036        let repaired = Config::load(&path);
1037        assert_eq!(repaired.diagnostics, []);
1038        assert_eq!(
1039            repaired.config.terminal.palettes.dark,
1040            TerminalPalettes::default().dark
1041        );
1042    }
1043
1044    #[test]
1045    fn untouched_silica_from_before_the_rename_uses_current_defaults_without_rewriting_it() {
1046        let root = tempfile::tempdir().unwrap();
1047        let themes = root.path().join("themes");
1048        fs::create_dir(&themes).unwrap();
1049        let old = SILICA_DARK.replace("Handshake", "Twine");
1050        fs::write(themes.join("silica_dark.toml"), &old).unwrap();
1051        let loaded = Config::load(&root.path().join("config.toml"));
1052        assert_eq!(loaded.diagnostics, []);
1053        assert_eq!(loaded.config.terminal.palettes, TerminalPalettes::default());
1054        assert_eq!(
1055            fs::read_to_string(themes.join("silica_dark.toml")).unwrap(),
1056            old
1057        );
1058    }
1059}