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 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 pub detail_budget_mb: u32,
46 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#[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 pub font_family: String,
110 pub font_size: FontSize,
111 pub cursor_style: CursorStyle,
112 pub cursor_blink: bool,
113 pub colors: TerminalColors,
114 #[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#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
143#[serde(transparent)]
144pub struct FontSize(f64);
145
146impl 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 #[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 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 #[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 #[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 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 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 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 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}