1use 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#[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#[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#[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 #[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 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 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(¤t).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}