1use std::ffi::OsStr;
4use std::fs;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10use toml::de::DeTable;
11
12mod diagnostics;
13mod editing;
14mod imports;
15mod themes;
16
17pub use diagnostics::{ConfigDiagnostic, ConfigProblem};
18pub use editing::{ConfigEditError, read_user_file, save_user_file};
19pub use themes::{
20 HexColor, TerminalColors, TerminalPalette, TerminalPalettes, ThemeConfig, ThemeName,
21};
22
23#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
24#[serde(default)]
25pub struct Config {
26 pub appearance: Appearance,
27 pub terminal: TerminalConfig,
28 pub theme: ThemeConfig,
29 pub editor: EditorConfig,
30 pub traces: TraceConfig,
31}
32
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34#[serde(default)]
35pub struct TraceConfig {
36 pub detail_budget_mb: u32,
38 pub retention_days: u32,
40}
41
42impl Default for TraceConfig {
43 fn default() -> Self {
44 Self {
45 detail_budget_mb: 512,
46 retention_days: 90,
47 }
48 }
49}
50
51#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
52#[serde(default)]
53pub struct EditorConfig {
54 pub autosave: bool,
55 pub autosave_delay_ms: AutosaveDelay,
56}
57
58impl Default for EditorConfig {
59 fn default() -> Self {
60 Self {
61 autosave: true,
62 autosave_delay_ms: AutosaveDelay::default(),
63 }
64 }
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
69#[serde(transparent)]
70pub struct AutosaveDelay(u32);
71
72impl Default for AutosaveDelay {
73 fn default() -> Self {
74 Self(500)
75 }
76}
77
78impl AutosaveDelay {
79 #[must_use]
80 pub const fn milliseconds(self) -> u32 {
81 self.0
82 }
83}
84
85impl<'de> Deserialize<'de> for AutosaveDelay {
86 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
87 let milliseconds = u32::deserialize(deserializer)?;
88 if milliseconds == 0 {
89 return Err(serde::de::Error::custom(
90 "autosave delay must be a positive whole number of milliseconds",
91 ));
92 }
93 Ok(Self(milliseconds))
94 }
95}
96
97#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
98#[serde(default)]
99pub struct TerminalConfig {
100 pub font_family: String,
102 pub font_size: FontSize,
103 pub cursor_style: CursorStyle,
104 pub cursor_blink: bool,
105 pub colors: TerminalColors,
106 #[serde(skip_deserializing)]
108 pub palettes: TerminalPalettes,
109}
110
111impl Default for TerminalConfig {
112 fn default() -> Self {
113 Self {
114 font_family: String::new(),
115 font_size: FontSize::default(),
116 cursor_style: CursorStyle::default(),
117 cursor_blink: true,
118 colors: TerminalColors::default(),
119 palettes: TerminalPalettes::default(),
120 }
121 }
122}
123
124#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
125#[serde(rename_all = "snake_case")]
126pub enum CursorStyle {
127 #[default]
128 Block,
129 Underline,
130 Bar,
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
135#[serde(transparent)]
136pub struct FontSize(f64);
137
138impl Eq for FontSize {}
140
141impl Default for FontSize {
142 fn default() -> Self {
143 Self(13.0)
144 }
145}
146
147impl FontSize {
148 #[must_use]
149 pub const fn points(self) -> f64 {
150 self.0
151 }
152}
153
154impl<'de> Deserialize<'de> for FontSize {
155 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
156 let points = f64::deserialize(deserializer)?;
157 if !(6.0..=72.0).contains(&points) {
158 return Err(serde::de::Error::custom(
159 "font size must be between 6 and 72 points",
160 ));
161 }
162 Ok(Self(points))
163 }
164}
165
166#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
167#[serde(default)]
168pub struct Appearance {
169 pub color_scheme: ColorScheme,
170}
171
172#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum ColorScheme {
175 #[default]
176 System,
177 Light,
178 Dark,
179}
180
181#[derive(Debug, Default)]
182pub struct LoadedConfig {
183 pub config: Config,
184 pub diagnostics: Vec<ConfigDiagnostic>,
185}
186
187impl Config {
188 #[must_use]
190 pub fn user_path() -> Option<PathBuf> {
191 user_config_path(
192 std::env::var_os("TWINE_CONFIG_PATH").as_deref(),
193 std::env::var_os("XDG_CONFIG_HOME").as_deref(),
194 std::env::home_dir().as_deref(),
195 )
196 }
197
198 #[must_use]
201 pub fn load_user() -> Self {
202 let Some(path) = Self::user_path() else {
203 tracing::error!("cannot find user config directory; using defaults");
204 return Self::default();
205 };
206 let loaded = Self::load(&path);
207 for diagnostic in &loaded.diagnostics {
208 if diagnostic.problem == ConfigProblem::UnknownKey {
209 tracing::warn!(%diagnostic, "configuration warning");
210 } else {
211 tracing::error!(%diagnostic, "configuration error");
212 }
213 }
214 loaded.config
215 }
216
217 #[must_use]
220 pub fn load(path: &Path) -> LoadedConfig {
221 let source = match read_or_create(path) {
222 Ok(source) => source,
223 Err(error) => {
224 return LoadedConfig {
225 diagnostics: vec![file_diagnostic(path, &error)],
226 ..LoadedConfig::default()
227 };
228 }
229 };
230 Self::load_source(path, &source)
231 }
232
233 pub(crate) fn load_source(path: &Path, source: &str) -> LoadedConfig {
235 let publication_failures = themes::publish(path);
236 let mut loaded = imports::load(path, source);
237 if loaded
238 .diagnostics
239 .iter()
240 .any(|diagnostic| diagnostic.problem != ConfigProblem::UnknownKey)
241 {
242 loaded.config = Self::default();
243 return loaded;
244 }
245 let (mut palettes, diagnostics) =
246 themes::load(path, &loaded.config.theme, &publication_failures);
247 loaded.diagnostics.extend(diagnostics);
248 if loaded
249 .diagnostics
250 .iter()
251 .any(|diagnostic| diagnostic.problem != ConfigProblem::UnknownKey)
252 {
253 loaded.config = Self::default();
254 } else {
255 palettes.apply(&loaded.config.terminal.colors);
256 loaded.config.terminal.palettes = palettes;
257 }
258 loaded
259 }
260
261 fn parse(path: &Path, source: &str) -> LoadedConfig {
262 let (config, mut diagnostics) = parse_document(path, source);
263 diagnostics.retain(|diagnostic| {
265 diagnostic.key != "import" || diagnostic.problem != ConfigProblem::UnknownKey
266 });
267 LoadedConfig {
268 config,
269 diagnostics,
270 }
271 }
272}
273
274fn user_config_path(
275 twine: Option<&OsStr>,
276 xdg: Option<&OsStr>,
277 home: Option<&Path>,
278) -> Option<PathBuf> {
279 if let Some(path) = twine {
280 return Some(Path::new(path))
281 .filter(|path| path.is_absolute())
282 .map(Path::to_owned);
283 }
284 xdg.map(Path::new)
285 .filter(|path| path.is_absolute())
286 .map(|path| path.join("twine/config.toml"))
287 .or_else(|| {
288 home.filter(|path| path.is_absolute())
289 .map(|home| home.join(".config/twine/config.toml"))
290 })
291}
292
293fn file_diagnostic(path: &Path, error: &impl std::fmt::Display) -> ConfigDiagnostic {
294 ConfigDiagnostic {
295 file: path.to_owned(),
296 line: 1,
297 key: "<document>".to_owned(),
298 problem: ConfigProblem::File(error.to_string()),
299 }
300}
301
302fn parse_document<T: serde::de::DeserializeOwned + Default>(
303 path: &Path,
304 source: &str,
305) -> (T, Vec<ConfigDiagnostic>) {
306 let (table, errors) = DeTable::parse_recoverable(source);
307 let locations = diagnostics::locations(table.get_ref());
308 let mut diagnostics = Vec::new();
309 for error in errors {
310 let offset = error.span().map_or(0, |span| span.start);
311 diagnostics.push(ConfigDiagnostic::at(
312 path,
313 source,
314 offset,
315 diagnostics::key_at(&locations, source, &error),
316 ConfigProblem::InvalidToml,
317 ));
318 }
319 if !diagnostics.is_empty() {
320 return (T::default(), diagnostics);
321 }
322
323 let mut track = serde_path_to_error::Track::new();
324 let deserializer =
325 serde_path_to_error::Deserializer::new(toml::Deserializer::from(table), &mut track);
326 let result = serde_ignored::deserialize(deserializer, |key| {
327 let segments = diagnostics::ignored_segments(&key);
328 let offset = locations
329 .iter()
330 .find(|location| location.segments == segments)
331 .map_or(0, |location| location.start);
332 diagnostics.push(ConfigDiagnostic::at(
333 path,
334 source,
335 offset,
336 diagnostics::display_key(&segments),
337 ConfigProblem::UnknownKey,
338 ));
339 });
340 let config = match result {
341 Ok(config) => config,
342 Err(error) => {
343 diagnostics.push(ConfigDiagnostic::at(
344 path,
345 source,
346 error.span().map_or(0, |span| span.start),
347 track.path().to_string(),
348 ConfigProblem::InvalidValue,
349 ));
350 T::default()
351 }
352 };
353 (config, diagnostics)
354}
355
356fn read_or_create(path: &Path) -> Result<String, ConfigFileError> {
357 match fs::read_to_string(path) {
358 Ok(source) => return Ok(source),
359 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
360 Err(error) => return Err(ConfigFileError::Read(error)),
361 }
362
363 write_default(path, &default_source()?)
364}
365
366fn default_source() -> Result<String, ConfigFileError> {
367 let mut defaults = toml::Value::try_from(Config::default())?;
368 defaults
369 .as_table_mut()
370 .expect("config is a table")
371 .retain(|key, _| matches!(key, "appearance" | "terminal" | "theme"));
372 defaults["terminal"]
373 .as_table_mut()
374 .expect("terminal is a table")
375 .retain(|key, _| matches!(key, "font_family" | "font_size"));
376 Ok(format!(
377 "# Twine configuration. Settings saved in Twine apply immediately.\n\
378 # More options: https://standing-desks.github.io/twine/documentation/guide/#configuration\n\n{}",
379 toml::to_string_pretty(&defaults)?
380 ))
381}
382
383fn write_default(path: &Path, source: &str) -> Result<String, ConfigFileError> {
384 if publish_default(path, source)? {
385 Ok(source.to_owned())
386 } else {
387 fs::read_to_string(path).map_err(ConfigFileError::Read)
388 }
389}
390
391fn publish_default(path: &Path, source: &str) -> Result<bool, ConfigFileError> {
392 let parent = path
393 .parent()
394 .filter(|parent| !parent.as_os_str().is_empty())
395 .unwrap_or(Path::new("."));
396 fs::create_dir_all(parent).map_err(ConfigFileError::Create)?;
397 let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(ConfigFileError::Create)?;
399 temporary
400 .write_all(source.as_bytes())
401 .map_err(ConfigFileError::Create)?;
402 temporary
403 .as_file()
404 .sync_all()
405 .map_err(ConfigFileError::Create)?;
406 match temporary.persist_noclobber(path) {
407 Ok(_) => Ok(true),
408 Err(error) if error.error.kind() == io::ErrorKind::AlreadyExists => Ok(false),
409 Err(error) => Err(ConfigFileError::Create(error.error)),
410 }
411}
412
413#[derive(Debug, Error)]
414enum ConfigFileError {
415 #[error("cannot read file ({0}); using defaults")]
416 Read(io::Error),
417 #[error("cannot create default file ({0}); using defaults")]
418 Create(io::Error),
419 #[error("cannot serialize default config; using defaults")]
420 Serialize(#[from] toml::ser::Error),
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 const PATH: &str = "/test/config.toml";
428
429 #[test]
430 fn xdg_config_path_uses_absolute_xdg_or_home_fallback() {
431 let home = Some(Path::new("/home/user"));
432 assert_eq!(
433 user_config_path(None, Some(OsStr::new("/xdg")), home),
434 Some(PathBuf::from("/xdg/twine/config.toml"))
435 );
436 for xdg in [None, Some(OsStr::new("")), Some(OsStr::new("relative"))] {
437 assert_eq!(
438 user_config_path(None, xdg, home),
439 Some(PathBuf::from("/home/user/.config/twine/config.toml"))
440 );
441 }
442 assert_eq!(
443 user_config_path(None, Some(OsStr::new("/xdg")), None),
444 Some(PathBuf::from("/xdg/twine/config.toml"))
445 );
446 assert_eq!(user_config_path(None, None, None), None);
447 }
448
449 #[test]
450 fn twine_config_override_is_independent_of_other_tools_config() {
451 let xdg = Some(OsStr::new("/user/config"));
452 let home = Some(Path::new("/home/user"));
453 assert_eq!(
454 user_config_path(Some(OsStr::new("/checkout/out/config.toml")), xdg, home),
455 Some(PathBuf::from("/checkout/out/config.toml"))
456 );
457 for twine in [Some(OsStr::new("")), Some(OsStr::new("relative"))] {
458 assert_eq!(user_config_path(twine, xdg, home), None);
459 }
460 }
461
462 #[test]
463 fn absent_fields_use_defaults() {
464 for source in [
465 "",
466 "# empty\n",
467 "[appearance]\n",
468 "[terminal]\n",
469 "[editor]\n",
470 ] {
471 let loaded = Config::parse(Path::new(PATH), source);
472 assert_eq!(loaded.config, Config::default());
473 assert_eq!(loaded.diagnostics, []);
474 }
475 }
476
477 #[test]
478 fn editor_settings_preserve_defaults_and_serialize_for_consumers() {
479 for source in [
480 "[editor]\nautosave = false\nautosave_delay_ms = 1500\n",
481 "editor.autosave = false\neditor.autosave_delay_ms = 1500\n",
482 ] {
483 let loaded = Config::parse(Path::new(PATH), source);
484 assert_eq!(loaded.diagnostics, []);
485 assert!(!loaded.config.editor.autosave);
486 assert_eq!(loaded.config.editor.autosave_delay_ms.milliseconds(), 1500);
487 let json = serde_json::to_value(&loaded.config).unwrap();
488 assert_eq!(json["editor"]["autosave"], false);
489 assert_eq!(json["editor"]["autosave_delay_ms"], 1500);
490 }
491 let disabled = Config::parse(Path::new(PATH), "editor.autosave = false\n");
492 assert_eq!(
493 disabled.config.editor.autosave_delay_ms,
494 AutosaveDelay::default()
495 );
496 let delay = Config::parse(Path::new(PATH), "editor.autosave_delay_ms = 1\n");
497 assert!(delay.config.editor.autosave);
498 assert_eq!(delay.config.editor.autosave_delay_ms.milliseconds(), 1);
499 }
500
501 #[test]
502 fn invalid_autosave_settings_report_the_key_and_use_defaults() {
503 for (key, values) in [
504 ("autosave", vec!["'yes'", "1", "[]"]),
505 (
506 "autosave_delay_ms",
507 vec!["0", "-1", "1.5", "4294967296", "'secret'", "true"],
508 ),
509 ] {
510 for value in values {
511 let source = format!("[editor]\n{key} = {value}\n");
512 let loaded = Config::parse(Path::new(PATH), &source);
513 assert_eq!(loaded.config, Config::default());
514 assert_eq!(loaded.diagnostics.len(), 1);
515 assert_eq!(loaded.diagnostics[0].key, format!("editor.{key}"));
516 assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
517 }
518 }
519 }
520
521 #[test]
522 fn terminal_settings_accept_fractional_sizes_and_toml_key_forms() {
523 for source in [
524 "[terminal]\nfont_family = 'JetBrains Mono'\nfont_size = 15.5\n",
525 "terminal.font_family = 'JetBrains Mono'\nterminal.font_size = 15.5\n",
526 "terminal = { font_family = 'JetBrains Mono', font_size = 15.5 }\n",
527 ] {
528 let loaded = Config::parse(Path::new(PATH), source);
529 assert_eq!(loaded.config.terminal.font_family, "JetBrains Mono");
530 assert_eq!(loaded.config.terminal.font_size, FontSize(15.5));
531 assert!(loaded.diagnostics.is_empty(), "{:?}", loaded.diagnostics);
532 }
533 for size in ["6", "13", "72", "6.0", "72.0"] {
534 let loaded = Config::parse(Path::new(PATH), &format!("terminal.font_size = {size}\n"));
535 assert_eq!(
536 loaded.config.terminal.font_size,
537 FontSize(size.parse::<f64>().unwrap())
538 );
539 assert_eq!(loaded.diagnostics, []);
540 }
541 }
542
543 #[test]
544 fn omitted_terminal_settings_preserve_the_other_setting() {
545 let family = Config::parse(Path::new(PATH), "terminal.font_family = 'Menlo'\n");
546 assert_eq!(family.config.terminal.font_family, "Menlo");
547 assert_eq!(family.config.terminal.font_size, FontSize::default());
548 assert_eq!(family.config.terminal.cursor_style, CursorStyle::default());
549 assert!(family.config.terminal.cursor_blink);
550 let size = Config::parse(Path::new(PATH), "terminal.font_size = 18\n");
551 assert_eq!(size.config.terminal.font_family, "");
552 assert_eq!(size.config.terminal.font_size, FontSize(18.0));
553 assert_eq!(size.config.terminal.cursor_style, CursorStyle::default());
554 assert!(size.config.terminal.cursor_blink);
555 assert_eq!(family.diagnostics, []);
556 assert_eq!(size.diagnostics, []);
557 let system = Config::parse(Path::new(PATH), "terminal.font_family = ''\n");
558 assert_eq!(system.config.terminal.font_family, "");
559 assert_eq!(system.config.terminal.font_size, FontSize::default());
560 assert_eq!(system.diagnostics, []);
561 }
562
563 #[test]
564 fn invalid_terminal_values_report_the_setting_and_use_complete_defaults() {
565 for (key, values) in [
566 ("font_family", vec!["42", "true", "[]", "{}"]),
567 (
568 "font_size",
569 vec![
570 "0", "-13", "5.99", "72.01", "nan", "inf", "-inf", "'secret'", "true", "[]",
571 "{}",
572 ],
573 ),
574 ] {
575 for value in values {
576 let source =
577 format!("[appearance]\ncolor_scheme = 'dark'\n[terminal]\n{key} = {value}\n");
578 let loaded = Config::parse(Path::new(PATH), &source);
579 assert_eq!(loaded.config, Config::default());
580 assert_eq!(loaded.diagnostics.len(), 1);
581 let diagnostic = &loaded.diagnostics[0];
582 assert_eq!(diagnostic.line, 4, "{source}");
583 assert_eq!(diagnostic.key, format!("terminal.{key}"));
584 assert_eq!(diagnostic.problem, ConfigProblem::InvalidValue);
585 assert!(!diagnostic.to_string().contains("secret"));
586 }
587 }
588 }
589
590 #[test]
591 fn accepts_all_cursor_styles() {
592 for (value, expected) in [
593 ("block", CursorStyle::Block),
594 ("underline", CursorStyle::Underline),
595 ("bar", CursorStyle::Bar),
596 ] {
597 let source = format!("terminal.cursor_style = '{value}'\n");
598 let loaded = Config::parse(Path::new(PATH), &source);
599 assert_eq!(loaded.config.terminal.cursor_style, expected);
600 assert!(loaded.diagnostics.is_empty(), "{:?}", loaded.diagnostics);
601 }
602 }
603
604 #[test]
605 fn invalid_cursor_style_reports_the_key_and_uses_defaults() {
606 for value in ["'rectangle'", "42", "true", "[]"] {
607 let source = format!("[terminal]\ncursor_style = {value}\n");
608 let loaded = Config::parse(Path::new(PATH), &source);
609 assert_eq!(loaded.config, Config::default());
610 assert_eq!(loaded.diagnostics.len(), 1);
611 assert_eq!(loaded.diagnostics[0].key, "terminal.cursor_style");
612 assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
613 }
614 }
615
616 #[test]
617 fn accepts_cursor_blink_values() {
618 for (value, expected) in [("true", true), ("false", false)] {
619 let source = format!("terminal.cursor_blink = {value}\n");
620 let loaded = Config::parse(Path::new(PATH), &source);
621 assert_eq!(loaded.config.terminal.cursor_blink, expected);
622 assert!(loaded.diagnostics.is_empty(), "{:?}", loaded.diagnostics);
623 }
624 }
625
626 #[test]
627 fn invalid_cursor_blink_reports_the_key_and_uses_defaults() {
628 for value in ["'yes'", "1", "[]"] {
629 let source = format!("[terminal]\ncursor_blink = {value}\n");
630 let loaded = Config::parse(Path::new(PATH), &source);
631 assert_eq!(loaded.config, Config::default());
632 assert_eq!(loaded.diagnostics.len(), 1);
633 assert_eq!(loaded.diagnostics[0].key, "terminal.cursor_blink");
634 assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidValue);
635 }
636 }
637
638 #[test]
639 fn accepts_all_color_schemes_and_toml_key_forms() {
640 for (value, expected) in [
641 ("system", ColorScheme::System),
642 ("light", ColorScheme::Light),
643 ("dark", ColorScheme::Dark),
644 ] {
645 for source in [
646 format!("[appearance]\ncolor_scheme = '{value}'\n"),
647 format!("appearance.color_scheme = '{value}'\n"),
648 format!("appearance = {{ color_scheme = '{value}' }}\n"),
649 format!("['appearance']\n'color_scheme' = '{value}'\n"),
650 ] {
651 let loaded = Config::parse(Path::new(PATH), &source);
652 assert_eq!(loaded.config.appearance.color_scheme, expected);
653 assert!(loaded.diagnostics.is_empty(), "{:?}", loaded.diagnostics);
654 }
655 }
656 }
657
658 #[test]
659 fn invalid_values_report_file_line_and_key_without_leaking_values() {
660 for value in ["'secret-invalid-value'", "42", "true", "[]", "{}"] {
661 let source = format!("# comment\n[appearance]\ncolor_scheme = {value}\n");
662 let loaded = Config::parse(Path::new(PATH), &source);
663 assert_eq!(loaded.config, Config::default());
664 assert_eq!(loaded.diagnostics.len(), 1);
665 let diagnostic = &loaded.diagnostics[0];
666 assert_eq!(diagnostic.file, Path::new(PATH));
667 assert_eq!(diagnostic.line, 3);
668 assert_eq!(diagnostic.key, "appearance.color_scheme");
669 assert_eq!(diagnostic.problem, ConfigProblem::InvalidValue);
670 assert!(!diagnostic.to_string().contains("secret-invalid-value"));
671 }
672 }
673
674 #[test]
675 fn invalid_table_type_falls_back() {
676 let loaded = Config::parse(Path::new(PATH), "appearance = 42\n");
677 assert_eq!(loaded.config, Config::default());
678 assert_eq!(loaded.diagnostics[0].key, "appearance");
679 assert_eq!(loaded.diagnostics[0].line, 1);
680 }
681
682 #[test]
683 fn malformed_toml_reports_location_and_uses_defaults() {
684 for value in ["@", "'unterminated", "[1,", ""] {
685 let source = format!("[appearance]\ncolor_scheme = {value}\n");
686 let loaded = Config::parse(Path::new(PATH), &source);
687 assert_eq!(loaded.config, Config::default());
688 assert_ne!(loaded.diagnostics, []);
689 let diagnostic = &loaded.diagnostics[0];
690 assert_eq!(diagnostic.file, Path::new(PATH));
691 assert!(diagnostic.line >= 2);
692 assert_eq!(diagnostic.key, "appearance.color_scheme", "{source}");
693 assert_eq!(diagnostic.problem, ConfigProblem::InvalidToml);
694 }
695 }
696
697 #[test]
698 fn unknown_keys_warn_and_preserve_valid_values() {
699 let loaded = Config::parse(
700 Path::new(PATH),
701 "extra = 'secret'\n[appearance]\ncolor_scheme = 'dark'\ntypo = true\n[future]\nsetting = 42\n",
702 );
703 assert_eq!(loaded.config.appearance.color_scheme, ColorScheme::Dark);
704 let warnings: Vec<_> = loaded
705 .diagnostics
706 .iter()
707 .map(|diagnostic| {
708 assert_eq!(diagnostic.problem, ConfigProblem::UnknownKey);
709 assert!(!diagnostic.to_string().contains("secret"));
710 (diagnostic.key.as_str(), diagnostic.line)
711 })
712 .collect();
713 assert!(warnings.contains(&("extra", 1)));
714 assert!(warnings.contains(&("appearance.typo", 4)));
715 assert!(warnings.contains(&("future", 5)));
716 assert_eq!(warnings.len(), 3);
717 }
718
719 #[test]
720 fn duplicate_keys_report_the_duplicate_location() {
721 for key in ["color_scheme", "'color_scheme'", "\"color_scheme\""] {
722 let source = format!("[appearance]\ncolor_scheme = 'light'\n{key} = 'secret'\n");
723 let loaded = Config::parse(Path::new(PATH), &source);
724 assert_eq!(loaded.config, Config::default());
725 assert_eq!(loaded.diagnostics[0].line, 3);
726 assert_eq!(loaded.diagnostics[0].key, "appearance.color_scheme");
727 assert_eq!(loaded.diagnostics[0].problem, ConfigProblem::InvalidToml);
728 assert!(!loaded.diagnostics[0].to_string().contains("secret"));
729 }
730 }
731
732 #[test]
733 fn quoted_keys_with_dots_do_not_collide_with_nested_paths() {
734 let loaded = Config::parse(
735 Path::new(PATH),
736 "\"appearance.typo\" = 1\n[appearance]\ntypo = 2\n",
737 );
738 assert_eq!(loaded.diagnostics.len(), 2);
739 assert!(
740 loaded
741 .diagnostics
742 .iter()
743 .any(|diagnostic| diagnostic.key == "\"appearance.typo\"" && diagnostic.line == 1)
744 );
745 assert!(
746 loaded
747 .diagnostics
748 .iter()
749 .any(|diagnostic| diagnostic.key == "appearance.typo" && diagnostic.line == 3)
750 );
751 }
752
753 #[test]
754 fn duplicate_leaf_does_not_claim_an_ambiguous_parent() {
755 let loaded = Config::parse(
756 Path::new(PATH),
757 "appearance.color_scheme = 'light'\nfuture.color_scheme = 'system'\nappearance.color_scheme = 'dark'\n",
758 );
759 assert_eq!(loaded.config, Config::default());
760 assert_eq!(loaded.diagnostics[0].line, 3);
761 assert_eq!(loaded.diagnostics[0].key, "color_scheme");
762 }
763
764 #[test]
765 fn first_load_creates_basic_settings_and_preserves_edits() {
766 let directory = tempfile::tempdir().unwrap();
767 let path = directory.path().join("twine/config.toml");
768 let loaded = Config::load(&path);
769 assert_eq!(loaded.config, Config::default());
770 assert_eq!(loaded.diagnostics, []);
771 let source = fs::read_to_string(&path).unwrap();
772 assert!(source.contains("color_scheme = \"system\""));
773 assert!(source.contains("font_family = \"\""));
774 assert!(source.contains("font_size = 13.0"));
775 assert!(source.contains("[theme]"));
776 assert!(source.contains("light = \"twine_light\""));
777 assert!(source.contains("dark = \"twine_dark\""));
778 assert!(
779 source.contains(
780 "https://standing-desks.github.io/twine/documentation/guide/#configuration"
781 )
782 );
783 assert_eq!(
784 source.lines().filter(|line| line.starts_with('#')).count(),
785 2
786 );
787 assert!(source.lines().count() <= 16);
788 assert!(!source.contains("import"));
789 assert!(!source.contains("[terminal.colors]"));
790 assert!(!source.contains("[editor]"));
791 assert!(!source.contains("[traces]"));
792 assert_eq!(Config::parse(&path, &source).config, Config::default());
793 assert_eq!(Config::parse(&path, &source).diagnostics, []);
794
795 let edited = "[appearance]\ncolor_scheme = 'light'\n";
796 fs::write(&path, edited).unwrap();
797 assert_eq!(
798 Config::load(&path).config.appearance.color_scheme,
799 ColorScheme::Light
800 );
801 assert_eq!(fs::read_to_string(&path).unwrap(), edited);
802
803 fs::write(&path, "invalid = @").unwrap();
804 assert_eq!(Config::load(&path).config, Config::default());
805 assert_eq!(fs::read_to_string(&path).unwrap(), "invalid = @");
806 }
807
808 #[test]
809 fn io_errors_and_invalid_utf8_fall_back_without_overwriting() {
810 let directory = tempfile::tempdir().unwrap();
811 let path = directory.path().join("config.toml");
812 fs::write(&path, [0xff, 0xfe]).unwrap();
813 for path in [&path, &path.join("config.toml"), directory.path()] {
814 let loaded = Config::load(path);
815 assert_eq!(loaded.config, Config::default());
816 assert!(matches!(
817 loaded.diagnostics[0].problem,
818 ConfigProblem::File(_)
819 ));
820 }
821 assert_eq!(fs::read(&path).unwrap(), [0xff, 0xfe]);
822 }
823
824 #[test]
825 fn unreadable_config_does_not_publish_sibling_theme_files() {
826 let directory = tempfile::tempdir().unwrap();
827 let path = directory.path().join("config.toml");
828 fs::create_dir(&path).unwrap();
829 let loaded = Config::load(&path);
830 assert_eq!(loaded.config, Config::default());
831 assert!(matches!(
832 loaded.diagnostics[0].problem,
833 ConfigProblem::File(_)
834 ));
835 assert!(!directory.path().join("themes").exists());
836 }
837
838 #[test]
839 fn concurrent_first_loads_publish_one_complete_file() {
840 let directory = tempfile::tempdir().unwrap();
841 let path = directory.path().join("config.toml");
842 std::thread::scope(|scope| {
843 for _ in 0..8 {
844 scope.spawn(|| {
845 let loaded = Config::load(&path);
846 assert_eq!(loaded.config, Config::default());
847 assert_eq!(loaded.diagnostics, []);
848 });
849 }
850 });
851 assert!(fs::read_to_string(path).unwrap().contains("color_scheme"));
852 }
853}