1use std::{
11 collections::BTreeMap,
12 fs, io,
13 path::{Path, PathBuf},
14};
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use crate::binding::{Action, ButtonId, GestureDirection};
20use crate::paths::{self, PathsError};
21
22pub const SCHEMA_VERSION: u32 = 1;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Config {
30 pub schema_version: u32,
31 #[serde(default, skip_serializing_if = "AppSettings::is_default")]
33 pub app_settings: AppSettings,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub selected_device: Option<String>,
39 #[serde(default)]
40 pub devices: BTreeMap<String, DeviceConfig>,
41}
42
43impl Default for Config {
44 fn default() -> Self {
45 Self {
46 schema_version: SCHEMA_VERSION,
47 app_settings: AppSettings::default(),
48 selected_device: None,
49 devices: BTreeMap::new(),
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[allow(
60 clippy::struct_excessive_bools,
61 reason = "independent on/off user preferences, not a state machine"
62)]
63pub struct AppSettings {
64 #[serde(default)]
70 pub launch_at_login: bool,
71 #[serde(default)]
77 pub check_for_updates: bool,
78 #[serde(default)]
83 pub update_prompt_seen: bool,
84 #[serde(default = "default_true")]
89 pub show_in_menu_bar: bool,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub language: Option<String>,
97 #[serde(default = "default_thumbwheel_sensitivity")]
104 pub thumbwheel_sensitivity: i32,
105}
106
107pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
111pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
113pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
115
116impl AppSettings {
117 #[must_use]
120 pub fn is_default(&self) -> bool {
121 self == &Self::default()
122 }
123}
124
125impl Default for AppSettings {
126 fn default() -> Self {
127 Self {
128 launch_at_login: false,
129 check_for_updates: false,
130 update_prompt_seen: false,
131 show_in_menu_bar: true,
132 language: None,
133 thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
134 }
135 }
136}
137
138fn default_true() -> bool {
141 true
142}
143
144const fn default_thumbwheel_sensitivity() -> i32 {
147 DEFAULT_THUMBWHEEL_SENSITIVITY
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct Lighting {
154 #[serde(default = "default_lighting_enabled")]
155 pub enabled: bool,
156 #[serde(default = "default_lighting_color")]
158 pub color: String,
159 #[serde(
161 default = "default_lighting_brightness",
162 deserialize_with = "deserialize_brightness"
163 )]
164 pub brightness: u8,
165}
166
167impl Default for Lighting {
168 fn default() -> Self {
169 Self {
170 enabled: default_lighting_enabled(),
171 color: default_lighting_color(),
172 brightness: default_lighting_brightness(),
173 }
174 }
175}
176
177fn default_lighting_enabled() -> bool {
178 true
179}
180
181fn default_lighting_color() -> String {
182 "ffffff".to_string()
183}
184
185fn default_lighting_brightness() -> u8 {
186 100
187}
188
189fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
193where
194 D: serde::Deserializer<'de>,
195{
196 Ok(u8::deserialize(deserializer)?.min(100))
197}
198
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct DeviceConfig {
202 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
203 pub button_bindings: BTreeMap<ButtonId, Action>,
204 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
209 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
210 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
215 pub gesture_bindings: BTreeMap<GestureDirection, Action>,
216 #[serde(default, skip_serializing_if = "Vec::is_empty")]
221 pub dpi_presets: Vec<u32>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub lighting: Option<Lighting>,
226}
227
228#[derive(Debug, Error)]
229pub enum ConfigError {
230 #[error("could not resolve config path")]
231 Path(#[from] PathsError),
232 #[error("could not read config at {path}")]
233 Read {
234 path: PathBuf,
235 #[source]
236 source: io::Error,
237 },
238 #[error("could not parse config at {path}")]
239 Parse {
240 path: PathBuf,
241 #[source]
242 source: toml::de::Error,
243 },
244 #[error("could not write config at {path}")]
245 Write {
246 path: PathBuf,
247 #[source]
248 source: io::Error,
249 },
250 #[error("could not serialize config")]
251 Serialize(#[from] toml::ser::Error),
252 #[error("config at {path} has unsupported schema_version {found}")]
253 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
254}
255
256#[allow(
257 clippy::result_large_err,
258 reason = "Config I/O keeps rich parse/write context and is not a hot path"
259)]
260impl Config {
261 pub fn load_or_default() -> Result<Self, ConfigError> {
264 Self::load_from_path(&paths::config_path()?)
265 }
266
267 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
270 match fs::read_to_string(path) {
271 Ok(text) => {
272 let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
273 path: path.to_path_buf(),
274 source,
275 })?;
276 if config.schema_version != SCHEMA_VERSION {
277 return Err(ConfigError::UnsupportedSchemaVersion {
278 path: path.to_path_buf(),
279 found: config.schema_version,
280 });
281 }
282 Ok(config)
283 }
284 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
285 Err(source) => Err(ConfigError::Read {
286 path: path.to_path_buf(),
287 source,
288 }),
289 }
290 }
291
292 pub fn save_atomic(&self) -> Result<(), ConfigError> {
296 self.save_to_path(&paths::config_path()?)
297 }
298
299 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
301 if let Some(parent) = path.parent() {
302 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
303 path: path.to_path_buf(),
304 source,
305 })?;
306 }
307 let body = toml::to_string_pretty(self)?;
308 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
309 path: path.to_path_buf(),
310 source,
311 })
312 }
313
314 #[must_use]
317 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Action> {
318 self.devices
319 .get(device_key)
320 .map(|d| d.button_bindings.clone())
321 .unwrap_or_default()
322 }
323
324 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, action: Action) {
327 self.devices
328 .entry(device_key.to_string())
329 .or_default()
330 .button_bindings
331 .insert(button, action);
332 }
333
334 #[must_use]
337 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
338 self.devices
339 .get(device_key)
340 .map(|d| d.gesture_bindings.clone())
341 .unwrap_or_default()
342 }
343
344 pub fn set_gesture_binding(
346 &mut self,
347 device_key: &str,
348 direction: GestureDirection,
349 action: Action,
350 ) {
351 self.devices
352 .entry(device_key.to_string())
353 .or_default()
354 .gesture_bindings
355 .insert(direction, action);
356 }
357
358 #[must_use]
365 pub fn effective_bindings(
366 &self,
367 device_key: &str,
368 bundle_id: Option<&str>,
369 ) -> BTreeMap<ButtonId, Action> {
370 let Some(device) = self.devices.get(device_key) else {
371 return BTreeMap::new();
372 };
373 let mut out = device.button_bindings.clone();
374 if let Some(bid) = bundle_id {
375 if let Some(overlay) = device.per_app_bindings.get(bid) {
376 for (k, v) in overlay {
377 out.insert(*k, v.clone());
378 }
379 }
380 }
381 out
382 }
383
384 pub fn set_per_app_binding(
388 &mut self,
389 device_key: &str,
390 bundle_id: &str,
391 button: ButtonId,
392 action: Option<Action>,
393 ) {
394 let entry = self
395 .devices
396 .entry(device_key.to_string())
397 .or_default()
398 .per_app_bindings
399 .entry(bundle_id.to_string())
400 .or_default();
401 match action {
402 Some(a) => {
403 entry.insert(button, a);
404 }
405 None => {
406 entry.remove(&button);
407 }
408 }
409 if let Some(d) = self.devices.get_mut(device_key) {
410 d.per_app_bindings.retain(|_, m| !m.is_empty());
411 }
412 }
413
414 #[must_use]
416 pub fn selected_device(&self) -> Option<&str> {
417 self.selected_device.as_deref()
418 }
419
420 pub fn set_selected_device(&mut self, key: Option<String>) {
423 self.selected_device = key;
424 }
425
426 #[must_use]
429 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
430 self.devices
431 .get(device_key)
432 .map(|d| d.dpi_presets.clone())
433 .unwrap_or_default()
434 }
435
436 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
440 self.devices
441 .entry(device_key.to_string())
442 .or_default()
443 .dpi_presets = presets;
444 }
445
446 #[must_use]
448 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
449 self.devices
450 .get(device_key)
451 .and_then(|d| d.lighting.clone())
452 }
453
454 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
456 self.devices
457 .entry(device_key.to_string())
458 .or_default()
459 .lighting = Some(lighting);
460 }
461}
462
463fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
464 let tmp = path.with_extension("toml.tmp");
465 {
466 #[cfg(unix)]
467 {
468 use std::os::unix::fs::OpenOptionsExt;
469 let mut f = fs::OpenOptions::new()
470 .write(true)
471 .create(true)
472 .truncate(true)
473 .mode(0o600)
474 .open(&tmp)?;
475 io::Write::write_all(&mut f, bytes)?;
476 f.sync_all()?;
477 }
478 #[cfg(not(unix))]
479 {
480 let mut f = fs::OpenOptions::new()
481 .write(true)
482 .create(true)
483 .truncate(true)
484 .open(&tmp)?;
485 io::Write::write_all(&mut f, bytes)?;
486 f.sync_all()?;
487 }
488 }
489 fs::rename(&tmp, path)
490}
491
492#[cfg(test)]
493#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
494mod tests {
495 use super::*;
496
497 fn write_and_read(config: &Config) -> Config {
498 let dir = tempfile::tempdir().expect("tempdir");
499 let path = dir.path().join("config.toml");
500 config.save_to_path(&path).expect("save");
501 Config::load_from_path(&path).expect("load")
502 }
503
504 #[test]
505 fn missing_file_yields_default() {
506 let dir = tempfile::tempdir().expect("tempdir");
507 let path = dir.path().join("nonexistent.toml");
508 let cfg = Config::load_from_path(&path).expect("load");
509 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
510 assert!(cfg.devices.is_empty());
511 }
512
513 #[test]
514 fn lighting_roundtrips_per_device() {
515 let mut cfg = Config::default();
516 cfg.set_lighting(
517 "g513",
518 Lighting {
519 enabled: true,
520 color: "00aabb".to_string(),
521 brightness: 75,
522 },
523 );
524 let restored = write_and_read(&cfg);
525 assert_eq!(
526 restored.lighting("g513"),
527 Some(Lighting {
528 enabled: true,
529 color: "00aabb".to_string(),
530 brightness: 75,
531 })
532 );
533 assert_eq!(restored.lighting("absent"), None);
534 }
535
536 #[test]
537 fn bindings_roundtrip_per_device() {
538 let mut cfg = Config::default();
539 cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
540 cfg.set_binding(
541 "2b042",
542 ButtonId::DpiToggle,
543 Action::CustomShortcut(crate::binding::KeyCombo {
544 modifiers: crate::binding::KeyCombo::MOD_CMD,
545 key_code: 0x23, display: "⌘P".into(),
547 }),
548 );
549 cfg.set_binding("4082d", ButtonId::Back, Action::Paste);
550
551 let parsed = write_and_read(&cfg);
552
553 let a = parsed.bindings_for("2b042");
555 assert_eq!(a.get(&ButtonId::Back), Some(&Action::Copy));
556 assert_eq!(
557 a.get(&ButtonId::DpiToggle),
558 Some(&Action::CustomShortcut(crate::binding::KeyCombo {
559 modifiers: crate::binding::KeyCombo::MOD_CMD,
560 key_code: 0x23,
561 display: "⌘P".into(),
562 }))
563 );
564
565 let b = parsed.bindings_for("4082d");
566 assert_eq!(b.get(&ButtonId::Back), Some(&Action::Paste));
567 assert_eq!(b.len(), 1, "device b should only see its own bindings");
568
569 assert!(parsed.bindings_for("deadbeef").is_empty());
571 }
572
573 #[test]
574 fn human_readable_toml_layout() {
575 let mut cfg = Config::default();
576 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
577 let body = toml::to_string_pretty(&cfg).expect("serialize");
578
579 assert!(body.contains("schema_version = 1"), "got: {body}");
583 assert!(
584 body.contains("[devices.2b042.button_bindings]"),
585 "got: {body}"
586 );
587 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
588 }
589
590 #[test]
591 fn rejects_unknown_schema_version() {
592 let dir = tempfile::tempdir().expect("tempdir");
593 let path = dir.path().join("config.toml");
594 fs::write(&path, "schema_version = 99\n").expect("write");
595 let err = Config::load_from_path(&path).expect_err("should fail");
596 assert!(matches!(
597 err,
598 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
599 ));
600 }
601
602 #[test]
603 fn dpi_presets_roundtrip_per_device() {
604 let mut cfg = Config::default();
605 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
606 cfg.set_dpi_presets("4082d", vec![400, 1600]);
607
608 let parsed = write_and_read(&cfg);
609
610 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
611 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
612 assert!(parsed.dpi_presets("unknown").is_empty());
613 }
614
615 #[test]
616 fn empty_dpi_presets_skip_serialization() {
617 let mut cfg = Config::default();
618 cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
620 cfg.set_dpi_presets("2b042", vec![800]);
621 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
624 assert!(
625 !body.contains("dpi_presets"),
626 "empty dpi_presets should be omitted: {body}"
627 );
628 }
629
630 #[test]
631 fn selected_device_roundtrips() {
632 let mut cfg = Config::default();
633 assert_eq!(cfg.selected_device(), None);
634 cfg.set_selected_device(Some("2b042".into()));
635 let parsed = write_and_read(&cfg);
636 assert_eq!(parsed.selected_device(), Some("2b042"));
637 }
638
639 #[test]
640 fn per_app_overlay_takes_precedence() {
641 let mut cfg = Config::default();
642 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
643 cfg.set_binding("2b042", ButtonId::Forward, Action::BrowserForward);
644 cfg.set_per_app_binding(
645 "2b042",
646 "com.microsoft.VSCode",
647 ButtonId::Back,
648 Some(Action::Undo),
649 );
650
651 let global = cfg.effective_bindings("2b042", None);
653 assert_eq!(global.get(&ButtonId::Back), Some(&Action::BrowserBack));
654 assert_eq!(
655 global.get(&ButtonId::Forward),
656 Some(&Action::BrowserForward)
657 );
658
659 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
661 assert_eq!(vscode.get(&ButtonId::Back), Some(&Action::Undo));
662 assert_eq!(
663 vscode.get(&ButtonId::Forward),
664 Some(&Action::BrowserForward)
665 );
666
667 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
669 assert_eq!(other.get(&ButtonId::Back), Some(&Action::BrowserBack));
670 }
671
672 #[test]
673 fn per_app_binding_removal_prunes_empty_app() {
674 let mut cfg = Config::default();
675 cfg.set_per_app_binding(
676 "2b042",
677 "com.example.App",
678 ButtonId::Back,
679 Some(Action::Copy),
680 );
681 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
682 assert!(
683 cfg.devices["2b042"].per_app_bindings.is_empty(),
684 "removing last override should prune the app entry"
685 );
686 }
687
688 #[test]
689 fn app_settings_default_omits_block() {
690 let cfg = Config::default();
691 let body = toml::to_string_pretty(&cfg).expect("serialize");
692 assert!(
693 !body.contains("app_settings"),
694 "default app_settings should be omitted: {body}"
695 );
696 }
697
698 #[test]
699 fn app_settings_launch_at_login_roundtrips() {
700 let mut cfg = Config::default();
701 cfg.app_settings.launch_at_login = true;
702 let parsed = write_and_read(&cfg);
703 assert!(parsed.app_settings.launch_at_login);
704 }
705
706 #[test]
707 fn cleared_selected_device_omits_field() {
708 let mut cfg = Config::default();
709 cfg.set_selected_device(Some("2b042".into()));
710 cfg.set_selected_device(None);
711 let body = toml::to_string_pretty(&cfg).expect("serialize");
712 assert!(
713 !body.contains("selected_device"),
714 "cleared selection should not appear: {body}"
715 );
716 }
717
718 #[test]
719 fn empty_device_block_is_skipped_in_output() {
720 let mut cfg = Config::default();
723 cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
724 cfg.devices
725 .get_mut("2b042")
726 .expect("entry")
727 .button_bindings
728 .clear();
729 let body = toml::to_string_pretty(&cfg).expect("serialize");
730 assert!(
731 !body.contains("Back"),
732 "cleared bindings should not appear: {body}"
733 );
734 }
735}