Skip to main content

twine_core/
config.rs

1//! User configuration. Serde defaults supply omitted settings; the starter file shows basic choices.
2
3use 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    /// Budget for full detail files; compact trace metadata remains in SQLite.
37    pub detail_budget_mb: u32,
38    /// Closed, unpinned workflows expire after this many days; zero disables age expiry.
39    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/// A positive autosave debounce in whole milliseconds.
68#[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    /// An installed font family. Empty selects the platform's internal terminal font.
101    pub font_family: String,
102    pub font_size: FontSize,
103    pub cursor_style: CursorStyle,
104    pub cursor_blink: bool,
105    pub colors: TerminalColors,
106    /// Resolved colors for consumers; never accepted as a user setting.
107    #[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/// A terminal font size in points, limited to 6 through 72.
134#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
135#[serde(transparent)]
136pub struct FontSize(f64);
137
138// Deserialization and the default only construct finite, positive values, so equality is reflexive.
139impl 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    /// The same user config path used by loading and the settings editor.
189    #[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    /// Loads `TWINE_CONFIG_PATH`, otherwise `$XDG_CONFIG_HOME/twine/config.toml` or
199    /// `~/.config/twine/config.toml`. The override must be an absolute file path.
200    #[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    /// Loads a file, creating a small starter file if missing. Existing files are never
218    /// overwritten. Any read, syntax, or validation failure returns the complete defaults.
219    #[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    /// Resolves a draft using the same imports, themes, and validation as a saved config.
234    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        // The document loader handles this reserved, top-level directive.
264        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    // Publish a complete file atomically without replacing a config created by another launch.
398    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}