Skip to main content

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