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}
98
99impl AppSettings {
100 #[must_use]
103 pub fn is_default(&self) -> bool {
104 self == &Self::default()
105 }
106}
107
108impl Default for AppSettings {
109 fn default() -> Self {
110 Self {
111 launch_at_login: false,
112 check_for_updates: false,
113 update_prompt_seen: false,
114 show_in_menu_bar: true,
115 language: None,
116 }
117 }
118}
119
120fn default_true() -> bool {
123 true
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct Lighting {
130 #[serde(default = "default_lighting_enabled")]
131 pub enabled: bool,
132 #[serde(default = "default_lighting_color")]
134 pub color: String,
135 #[serde(
137 default = "default_lighting_brightness",
138 deserialize_with = "deserialize_brightness"
139 )]
140 pub brightness: u8,
141}
142
143impl Default for Lighting {
144 fn default() -> Self {
145 Self {
146 enabled: default_lighting_enabled(),
147 color: default_lighting_color(),
148 brightness: default_lighting_brightness(),
149 }
150 }
151}
152
153fn default_lighting_enabled() -> bool {
154 true
155}
156
157fn default_lighting_color() -> String {
158 "ffffff".to_string()
159}
160
161fn default_lighting_brightness() -> u8 {
162 100
163}
164
165fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
169where
170 D: serde::Deserializer<'de>,
171{
172 Ok(u8::deserialize(deserializer)?.min(100))
173}
174
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
177pub struct DeviceConfig {
178 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
179 pub button_bindings: BTreeMap<ButtonId, Action>,
180 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
185 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
186 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
191 pub gesture_bindings: BTreeMap<GestureDirection, Action>,
192 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 pub dpi_presets: Vec<u32>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub lighting: Option<Lighting>,
202}
203
204#[derive(Debug, Error)]
205pub enum ConfigError {
206 #[error("could not resolve config path")]
207 Path(#[from] PathsError),
208 #[error("could not read config at {path}")]
209 Read {
210 path: PathBuf,
211 #[source]
212 source: io::Error,
213 },
214 #[error("could not parse config at {path}")]
215 Parse {
216 path: PathBuf,
217 #[source]
218 source: toml::de::Error,
219 },
220 #[error("could not write config at {path}")]
221 Write {
222 path: PathBuf,
223 #[source]
224 source: io::Error,
225 },
226 #[error("could not serialize config")]
227 Serialize(#[from] toml::ser::Error),
228 #[error("config at {path} has unsupported schema_version {found}")]
229 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
230}
231
232impl Config {
233 pub fn load_or_default() -> Result<Self, ConfigError> {
236 Self::load_from_path(&paths::config_path()?)
237 }
238
239 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
242 match fs::read_to_string(path) {
243 Ok(text) => {
244 let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
245 path: path.to_path_buf(),
246 source,
247 })?;
248 if config.schema_version != SCHEMA_VERSION {
249 return Err(ConfigError::UnsupportedSchemaVersion {
250 path: path.to_path_buf(),
251 found: config.schema_version,
252 });
253 }
254 Ok(config)
255 }
256 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
257 Err(source) => Err(ConfigError::Read {
258 path: path.to_path_buf(),
259 source,
260 }),
261 }
262 }
263
264 pub fn save_atomic(&self) -> Result<(), ConfigError> {
268 self.save_to_path(&paths::config_path()?)
269 }
270
271 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
273 if let Some(parent) = path.parent() {
274 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
275 path: path.to_path_buf(),
276 source,
277 })?;
278 }
279 let body = toml::to_string_pretty(self)?;
280 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
281 path: path.to_path_buf(),
282 source,
283 })
284 }
285
286 #[must_use]
289 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Action> {
290 self.devices
291 .get(device_key)
292 .map(|d| d.button_bindings.clone())
293 .unwrap_or_default()
294 }
295
296 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, action: Action) {
299 self.devices
300 .entry(device_key.to_string())
301 .or_default()
302 .button_bindings
303 .insert(button, action);
304 }
305
306 #[must_use]
309 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
310 self.devices
311 .get(device_key)
312 .map(|d| d.gesture_bindings.clone())
313 .unwrap_or_default()
314 }
315
316 pub fn set_gesture_binding(
318 &mut self,
319 device_key: &str,
320 direction: GestureDirection,
321 action: Action,
322 ) {
323 self.devices
324 .entry(device_key.to_string())
325 .or_default()
326 .gesture_bindings
327 .insert(direction, action);
328 }
329
330 #[must_use]
337 pub fn effective_bindings(
338 &self,
339 device_key: &str,
340 bundle_id: Option<&str>,
341 ) -> BTreeMap<ButtonId, Action> {
342 let Some(device) = self.devices.get(device_key) else {
343 return BTreeMap::new();
344 };
345 let mut out = device.button_bindings.clone();
346 if let Some(bid) = bundle_id {
347 if let Some(overlay) = device.per_app_bindings.get(bid) {
348 for (k, v) in overlay {
349 out.insert(*k, v.clone());
350 }
351 }
352 }
353 out
354 }
355
356 pub fn set_per_app_binding(
360 &mut self,
361 device_key: &str,
362 bundle_id: &str,
363 button: ButtonId,
364 action: Option<Action>,
365 ) {
366 let entry = self
367 .devices
368 .entry(device_key.to_string())
369 .or_default()
370 .per_app_bindings
371 .entry(bundle_id.to_string())
372 .or_default();
373 match action {
374 Some(a) => {
375 entry.insert(button, a);
376 }
377 None => {
378 entry.remove(&button);
379 }
380 }
381 if let Some(d) = self.devices.get_mut(device_key) {
382 d.per_app_bindings.retain(|_, m| !m.is_empty());
383 }
384 }
385
386 #[must_use]
388 pub fn selected_device(&self) -> Option<&str> {
389 self.selected_device.as_deref()
390 }
391
392 pub fn set_selected_device(&mut self, key: Option<String>) {
395 self.selected_device = key;
396 }
397
398 #[must_use]
401 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
402 self.devices
403 .get(device_key)
404 .map(|d| d.dpi_presets.clone())
405 .unwrap_or_default()
406 }
407
408 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
412 self.devices
413 .entry(device_key.to_string())
414 .or_default()
415 .dpi_presets = presets;
416 }
417
418 #[must_use]
420 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
421 self.devices
422 .get(device_key)
423 .and_then(|d| d.lighting.clone())
424 }
425
426 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
428 self.devices
429 .entry(device_key.to_string())
430 .or_default()
431 .lighting = Some(lighting);
432 }
433}
434
435fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
436 let tmp = path.with_extension("toml.tmp");
437 {
438 #[cfg(unix)]
439 {
440 use std::os::unix::fs::OpenOptionsExt;
441 let mut f = fs::OpenOptions::new()
442 .write(true)
443 .create(true)
444 .truncate(true)
445 .mode(0o600)
446 .open(&tmp)?;
447 io::Write::write_all(&mut f, bytes)?;
448 f.sync_all()?;
449 }
450 #[cfg(not(unix))]
451 {
452 let mut f = fs::OpenOptions::new()
453 .write(true)
454 .create(true)
455 .truncate(true)
456 .open(&tmp)?;
457 io::Write::write_all(&mut f, bytes)?;
458 f.sync_all()?;
459 }
460 }
461 fs::rename(&tmp, path)
462}
463
464#[cfg(test)]
465#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
466mod tests {
467 use super::*;
468
469 fn write_and_read(config: &Config) -> Config {
470 let dir = tempfile::tempdir().expect("tempdir");
471 let path = dir.path().join("config.toml");
472 config.save_to_path(&path).expect("save");
473 Config::load_from_path(&path).expect("load")
474 }
475
476 #[test]
477 fn missing_file_yields_default() {
478 let dir = tempfile::tempdir().expect("tempdir");
479 let path = dir.path().join("nonexistent.toml");
480 let cfg = Config::load_from_path(&path).expect("load");
481 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
482 assert!(cfg.devices.is_empty());
483 }
484
485 #[test]
486 fn lighting_roundtrips_per_device() {
487 let mut cfg = Config::default();
488 cfg.set_lighting(
489 "g513",
490 Lighting {
491 enabled: true,
492 color: "00aabb".to_string(),
493 brightness: 75,
494 },
495 );
496 let restored = write_and_read(&cfg);
497 assert_eq!(
498 restored.lighting("g513"),
499 Some(Lighting {
500 enabled: true,
501 color: "00aabb".to_string(),
502 brightness: 75,
503 })
504 );
505 assert_eq!(restored.lighting("absent"), None);
506 }
507
508 #[test]
509 fn bindings_roundtrip_per_device() {
510 let mut cfg = Config::default();
511 cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
512 cfg.set_binding(
513 "2b042",
514 ButtonId::DpiToggle,
515 Action::CustomShortcut(crate::binding::KeyCombo {
516 modifiers: crate::binding::KeyCombo::MOD_CMD,
517 key_code: 0x23, display: "⌘P".into(),
519 }),
520 );
521 cfg.set_binding("4082d", ButtonId::Back, Action::Paste);
522
523 let parsed = write_and_read(&cfg);
524
525 let a = parsed.bindings_for("2b042");
527 assert_eq!(a.get(&ButtonId::Back), Some(&Action::Copy));
528 assert_eq!(
529 a.get(&ButtonId::DpiToggle),
530 Some(&Action::CustomShortcut(crate::binding::KeyCombo {
531 modifiers: crate::binding::KeyCombo::MOD_CMD,
532 key_code: 0x23,
533 display: "⌘P".into(),
534 }))
535 );
536
537 let b = parsed.bindings_for("4082d");
538 assert_eq!(b.get(&ButtonId::Back), Some(&Action::Paste));
539 assert_eq!(b.len(), 1, "device b should only see its own bindings");
540
541 assert!(parsed.bindings_for("deadbeef").is_empty());
543 }
544
545 #[test]
546 fn human_readable_toml_layout() {
547 let mut cfg = Config::default();
548 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
549 let body = toml::to_string_pretty(&cfg).expect("serialize");
550
551 assert!(body.contains("schema_version = 1"), "got: {body}");
555 assert!(
556 body.contains("[devices.2b042.button_bindings]"),
557 "got: {body}"
558 );
559 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
560 }
561
562 #[test]
563 fn rejects_unknown_schema_version() {
564 let dir = tempfile::tempdir().expect("tempdir");
565 let path = dir.path().join("config.toml");
566 fs::write(&path, "schema_version = 99\n").expect("write");
567 let err = Config::load_from_path(&path).expect_err("should fail");
568 assert!(matches!(
569 err,
570 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
571 ));
572 }
573
574 #[test]
575 fn dpi_presets_roundtrip_per_device() {
576 let mut cfg = Config::default();
577 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
578 cfg.set_dpi_presets("4082d", vec![400, 1600]);
579
580 let parsed = write_and_read(&cfg);
581
582 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
583 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
584 assert!(parsed.dpi_presets("unknown").is_empty());
585 }
586
587 #[test]
588 fn empty_dpi_presets_skip_serialization() {
589 let mut cfg = Config::default();
590 cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
592 cfg.set_dpi_presets("2b042", vec![800]);
593 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
596 assert!(
597 !body.contains("dpi_presets"),
598 "empty dpi_presets should be omitted: {body}"
599 );
600 }
601
602 #[test]
603 fn selected_device_roundtrips() {
604 let mut cfg = Config::default();
605 assert_eq!(cfg.selected_device(), None);
606 cfg.set_selected_device(Some("2b042".into()));
607 let parsed = write_and_read(&cfg);
608 assert_eq!(parsed.selected_device(), Some("2b042"));
609 }
610
611 #[test]
612 fn per_app_overlay_takes_precedence() {
613 let mut cfg = Config::default();
614 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
615 cfg.set_binding("2b042", ButtonId::Forward, Action::BrowserForward);
616 cfg.set_per_app_binding(
617 "2b042",
618 "com.microsoft.VSCode",
619 ButtonId::Back,
620 Some(Action::Undo),
621 );
622
623 let global = cfg.effective_bindings("2b042", None);
625 assert_eq!(global.get(&ButtonId::Back), Some(&Action::BrowserBack));
626 assert_eq!(
627 global.get(&ButtonId::Forward),
628 Some(&Action::BrowserForward)
629 );
630
631 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
633 assert_eq!(vscode.get(&ButtonId::Back), Some(&Action::Undo));
634 assert_eq!(
635 vscode.get(&ButtonId::Forward),
636 Some(&Action::BrowserForward)
637 );
638
639 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
641 assert_eq!(other.get(&ButtonId::Back), Some(&Action::BrowserBack));
642 }
643
644 #[test]
645 fn per_app_binding_removal_prunes_empty_app() {
646 let mut cfg = Config::default();
647 cfg.set_per_app_binding(
648 "2b042",
649 "com.example.App",
650 ButtonId::Back,
651 Some(Action::Copy),
652 );
653 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
654 assert!(
655 cfg.devices["2b042"].per_app_bindings.is_empty(),
656 "removing last override should prune the app entry"
657 );
658 }
659
660 #[test]
661 fn app_settings_default_omits_block() {
662 let cfg = Config::default();
663 let body = toml::to_string_pretty(&cfg).expect("serialize");
664 assert!(
665 !body.contains("app_settings"),
666 "default app_settings should be omitted: {body}"
667 );
668 }
669
670 #[test]
671 fn app_settings_launch_at_login_roundtrips() {
672 let mut cfg = Config::default();
673 cfg.app_settings.launch_at_login = true;
674 let parsed = write_and_read(&cfg);
675 assert!(parsed.app_settings.launch_at_login);
676 }
677
678 #[test]
679 fn cleared_selected_device_omits_field() {
680 let mut cfg = Config::default();
681 cfg.set_selected_device(Some("2b042".into()));
682 cfg.set_selected_device(None);
683 let body = toml::to_string_pretty(&cfg).expect("serialize");
684 assert!(
685 !body.contains("selected_device"),
686 "cleared selection should not appear: {body}"
687 );
688 }
689
690 #[test]
691 fn empty_device_block_is_skipped_in_output() {
692 let mut cfg = Config::default();
695 cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
696 cfg.devices
697 .get_mut("2b042")
698 .expect("entry")
699 .button_bindings
700 .clear();
701 let body = toml::to_string_pretty(&cfg).expect("serialize");
702 assert!(
703 !body.contains("Back"),
704 "cleared bindings should not appear: {body}"
705 );
706 }
707}