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 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#[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(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(_) => {} 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 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 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(¤t).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}