1use std::{
10 collections::BTreeMap,
11 fs, io,
12 path::{Path, PathBuf},
13};
14
15use atomic_write_file::AtomicWriteFile;
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19mod device;
20mod settings;
21
22pub use device::{DeviceConfig, DeviceIdentity};
23pub use settings::{
24 AppSettings, Appearance, AssetSourcePreference, DEFAULT_THUMBWHEEL_SENSITIVITY, GestureOwner,
25 Lighting, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY,
26 SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, ScrollResolution, SmartShift,
27 WheelMode,
28};
29
30use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for};
31use crate::paths::{self, PathsError};
32
33pub const SCHEMA_VERSION: u32 = 3;
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Config {
51 pub schema_version: u32,
55 #[serde(default, skip_serializing_if = "AppSettings::is_default")]
57 pub app_settings: AppSettings,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub selected_device: Option<String>,
63 #[serde(default)]
67 pub devices: BTreeMap<String, DeviceConfig>,
68}
69
70impl Default for Config {
71 fn default() -> Self {
72 Self {
73 schema_version: SCHEMA_VERSION,
74 app_settings: AppSettings::default(),
75 selected_device: None,
76 devices: BTreeMap::new(),
77 }
78 }
79}
80
81#[derive(Debug, Error)]
84pub enum ConfigError {
85 #[error("could not resolve config path")]
88 Path(#[from] PathsError),
89 #[error("could not read config at {path}")]
91 Read {
92 path: PathBuf,
94 #[source]
96 source: io::Error,
97 },
98 #[error("could not parse config at {path}")]
100 Parse {
101 path: PathBuf,
103 #[source]
105 source: toml::de::Error,
106 },
107 #[error("could not write config at {path}")]
109 Write {
110 path: PathBuf,
112 #[source]
114 source: io::Error,
115 },
116 #[error("could not serialize config")]
120 Serialize(#[from] toml::ser::Error),
121 #[error("config at {path} has unsupported schema_version {found}")]
125 UnsupportedSchemaVersion {
126 path: PathBuf,
128 found: u32,
130 },
131}
132
133#[allow(
134 clippy::result_large_err,
135 reason = "Config I/O keeps rich parse/write context and is not a hot path"
136)]
137impl Config {
138 pub fn load_or_default() -> Result<Self, ConfigError> {
141 Self::load_from_path(&paths::config_path()?)
142 }
143
144 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
147 match fs::read_to_string(path) {
148 Ok(text) => {
149 let mut config: Self =
150 toml::from_str(&text).map_err(|source| ConfigError::Parse {
151 path: path.to_path_buf(),
152 source,
153 })?;
154 if config.schema_version > SCHEMA_VERSION {
160 return Err(ConfigError::UnsupportedSchemaVersion {
161 path: path.to_path_buf(),
162 found: config.schema_version,
163 });
164 }
165 config.schema_version = SCHEMA_VERSION;
169 Ok(config)
170 }
171 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
172 Err(source) => Err(ConfigError::Read {
173 path: path.to_path_buf(),
174 source,
175 }),
176 }
177 }
178
179 pub fn save_atomic(&self) -> Result<(), ConfigError> {
183 self.save_to_path(&paths::config_path()?)
184 }
185
186 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
188 if let Some(parent) = path.parent() {
189 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
190 path: path.to_path_buf(),
191 source,
192 })?;
193 }
194 let body = toml::to_string_pretty(self)?;
195 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
196 path: path.to_path_buf(),
197 source,
198 })
199 }
200
201 #[must_use]
204 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
205 self.devices
206 .get(device_key)
207 .map(|d| d.bindings.clone())
208 .unwrap_or_default()
209 }
210
211 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
216 self.devices
217 .entry(device_key.to_string())
218 .or_default()
219 .bindings
220 .insert(button, binding);
221 }
222
223 #[must_use]
228 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
229 match self
230 .devices
231 .get(device_key)
232 .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
233 {
234 Some(Binding::Gesture(map)) => map.clone(),
235 _ => BTreeMap::new(),
236 }
237 }
238
239 pub fn set_gesture_direction(
249 &mut self,
250 device_key: &str,
251 button: ButtonId,
252 direction: GestureDirection,
253 action: Action,
254 ) {
255 if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
256 map.insert(direction, action);
257 }
258 }
259
260 fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
268 let entry = self
269 .devices
270 .entry(device_key.to_string())
271 .or_default()
272 .bindings
273 .entry(button)
274 .or_insert_with(|| default_binding_for(button));
275 entry.upgrade_to_gesture();
276 entry
277 }
278
279 #[must_use]
288 pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
289 let Some(device) = self.devices.get(device_key) else {
290 return Some(ButtonId::GestureButton);
292 };
293 match device.gesture_owner {
294 Some(GestureOwner::Off) => None,
295 Some(GestureOwner::Button(id)) => Some(id),
296 None => Self::infer_gesture_owner(&device.bindings),
297 }
298 }
299
300 fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
305 if let Some((id, _)) = bindings
307 .iter()
308 .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
309 {
310 return Some(*id);
311 }
312 if matches!(
314 bindings.get(&ButtonId::GestureButton),
315 Some(Binding::Single(_))
316 ) {
317 return None;
318 }
319 Some(ButtonId::GestureButton)
321 }
322
323 pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
336 self.devices
337 .entry(device_key.to_string())
338 .or_default()
339 .gesture_owner = Some(GestureOwner::Button(button));
340 self.ensure_gesture_binding(device_key, button)
341 .fill_gesture_defaults();
342 }
343
344 pub fn disable_gestures(&mut self, device_key: &str) {
348 self.devices
349 .entry(device_key.to_string())
350 .or_default()
351 .gesture_owner = Some(GestureOwner::Off);
352 }
353
354 #[must_use]
362 pub fn effective_bindings(
363 &self,
364 device_key: &str,
365 bundle_id: Option<&str>,
366 ) -> BTreeMap<ButtonId, Binding> {
367 let Some(device) = self.devices.get(device_key) else {
368 return BTreeMap::new();
369 };
370 let mut out = device.bindings.clone();
371 if let Some(bid) = bundle_id
372 && let Some(overlay) = device.per_app_bindings.get(bid)
373 {
374 for (k, v) in overlay {
375 out.insert(*k, Binding::Single(v.clone()));
376 }
377 }
378 out
379 }
380
381 pub fn set_per_app_binding(
385 &mut self,
386 device_key: &str,
387 bundle_id: &str,
388 button: ButtonId,
389 action: Option<Action>,
390 ) {
391 let entry = self
392 .devices
393 .entry(device_key.to_string())
394 .or_default()
395 .per_app_bindings
396 .entry(bundle_id.to_string())
397 .or_default();
398 match action {
399 Some(a) => {
400 entry.insert(button, a);
401 }
402 None => {
403 entry.remove(&button);
404 }
405 }
406 if let Some(d) = self.devices.get_mut(device_key) {
407 d.per_app_bindings.retain(|_, m| !m.is_empty());
408 }
409 }
410
411 #[must_use]
413 pub fn selected_device(&self) -> Option<&str> {
414 self.selected_device.as_deref()
415 }
416
417 pub fn set_selected_device(&mut self, key: Option<String>) {
420 self.selected_device = key;
421 }
422
423 #[must_use]
426 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
427 self.devices
428 .get(device_key)
429 .map(|d| d.dpi_presets.clone())
430 .unwrap_or_default()
431 }
432
433 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
437 self.devices
438 .entry(device_key.to_string())
439 .or_default()
440 .dpi_presets = presets;
441 }
442
443 #[must_use]
447 pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
448 self.devices
449 .get(device_key)
450 .and_then(|d| d.identity.as_ref())
451 }
452
453 pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
456 self.devices
457 .entry(device_key.to_string())
458 .or_default()
459 .identity = Some(identity);
460 }
461
462 #[must_use]
467 pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
468 self.devices.get(device_key).is_some_and(|d| {
469 d.per_app_bindings
470 .get(app)
471 .is_some_and(|overlay| !overlay.is_empty())
472 })
473 }
474
475 pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
479 self.devices
480 .iter()
481 .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
482 }
483
484 #[must_use]
486 pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
487 self.devices
488 .get(device_key)
489 .and_then(|d| d.lighting.clone())
490 }
491
492 pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
494 self.devices
495 .entry(device_key.to_string())
496 .or_default()
497 .lighting = Some(lighting);
498 }
499
500 #[must_use]
502 pub fn dpi(&self, device_key: &str) -> Option<u32> {
503 self.devices.get(device_key).and_then(|d| d.dpi)
504 }
505
506 pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
509 self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
510 }
511
512 #[must_use]
514 pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
515 self.devices.get(device_key).and_then(|d| d.smartshift)
516 }
517
518 pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
521 self.devices
522 .entry(device_key.to_string())
523 .or_default()
524 .smartshift = Some(smartshift);
525 }
526
527 #[must_use]
530 pub fn invert_scroll(&self, device_key: &str) -> bool {
531 self.devices
532 .get(device_key)
533 .is_some_and(|d| d.invert_scroll)
534 }
535
536 pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
539 self.devices
540 .entry(device_key.to_string())
541 .or_default()
542 .invert_scroll = invert;
543 }
544
545 #[must_use]
548 pub fn scroll_resolution(&self, device_key: &str) -> Option<ScrollResolution> {
549 self.devices
550 .get(device_key)
551 .and_then(|device| device.scroll_resolution)
552 }
553
554 pub fn set_scroll_resolution(
557 &mut self,
558 device_key: &str,
559 resolution: Option<ScrollResolution>,
560 ) {
561 self.devices
562 .entry(device_key.to_string())
563 .or_default()
564 .scroll_resolution = resolution;
565 }
566}
567
568fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
571 #[cfg_attr(
572 not(unix),
573 expect(unused_mut, reason = "only the unix path mutates the options")
574 )]
575 let mut options = AtomicWriteFile::options();
576 #[cfg(unix)]
577 {
578 use atomic_write_file::unix::OpenOptionsExt as _;
579 use std::os::unix::fs::OpenOptionsExt as _;
580 options.preserve_mode(false).mode(0o600);
582 }
583 let mut file = options.open(path)?;
584 io::Write::write_all(&mut file, bytes)?;
585 file.commit()
586}
587
588#[cfg(test)]
589#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
590mod tests {
591 use std::assert_matches;
592
593 use super::*;
594 use crate::binding::{default_binding, default_gesture_binding};
595
596 fn write_and_read(config: &Config) -> Config {
597 let dir = tempfile::tempdir().expect("tempdir");
598 let path = dir.path().join("config.toml");
599 config.save_to_path(&path).expect("save");
600 Config::load_from_path(&path).expect("load")
601 }
602
603 #[test]
604 fn missing_file_yields_default() {
605 let dir = tempfile::tempdir().expect("tempdir");
606 let path = dir.path().join("nonexistent.toml");
607 let cfg = Config::load_from_path(&path).expect("load");
608 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
609 assert!(cfg.devices.is_empty());
610 }
611
612 #[test]
613 fn lighting_roundtrips_per_device() {
614 let mut cfg = Config::default();
615 cfg.set_lighting(
616 "g513",
617 Lighting {
618 enabled: true,
619 color: "00aabb".parse().expect("valid hex"),
620 brightness: 75,
621 },
622 );
623 let restored = write_and_read(&cfg);
624 assert_eq!(
625 restored.lighting("g513"),
626 Some(Lighting {
627 enabled: true,
628 color: "00aabb".parse().expect("valid hex"),
629 brightness: 75,
630 })
631 );
632 assert_eq!(restored.lighting("absent"), None);
633 }
634
635 #[test]
636 fn unparseable_lighting_color_falls_back_to_white() {
637 let cfg: Config = toml::from_str(
638 r#"
639 schema_version = 3
640 [devices.g513.lighting]
641 enabled = true
642 color = "red"
643 brightness = 50
644 "#,
645 )
646 .expect("config with a bad color still loads");
647 assert_eq!(
648 cfg.lighting("g513").map(|l| l.color),
649 Some(crate::color::Rgb::WHITE)
650 );
651 }
652
653 #[test]
654 fn hash_prefixed_lighting_color_migrates_to_canonical_hex() {
655 let dir = tempfile::tempdir().expect("tempdir");
656 let path = dir.path().join("config.toml");
657 fs::write(
658 &path,
659 r##"
660 schema_version = 3
661 [devices.g513.lighting]
662 enabled = true
663 color = "#ff0000"
664 brightness = 50
665 "##,
666 )
667 .expect("write config");
668
669 let cfg = Config::load_from_path(&path).expect("load hash-prefixed color");
670 assert_eq!(
671 cfg.lighting("g513").map(|lighting| lighting.color),
672 Some(crate::color::Rgb::new(0xff, 0x00, 0x00))
673 );
674
675 cfg.save_to_path(&path).expect("save canonical color");
676 let saved = fs::read_to_string(path).expect("read saved config");
677 assert!(saved.contains("color = \"ff0000\""));
678 assert!(!saved.contains("color = \"#"));
679 }
680
681 #[test]
682 fn dpi_roundtrips_per_device() {
683 let mut cfg = Config::default();
684 cfg.set_dpi("2b042", 1600);
685 let restored = write_and_read(&cfg);
686 assert_eq!(restored.dpi("2b042"), Some(1600));
687 assert_eq!(restored.dpi("absent"), None);
688 }
689
690 #[test]
691 fn smartshift_roundtrips_per_device() {
692 let mut cfg = Config::default();
693 cfg.set_smartshift(
694 "2b042",
695 SmartShift {
696 mode: WheelMode::Ratchet,
697 auto_disengage: 16,
698 tunable_torque: 30,
699 },
700 );
701 let restored = write_and_read(&cfg);
702 assert_eq!(
703 restored.smartshift("2b042"),
704 Some(SmartShift {
705 mode: WheelMode::Ratchet,
706 auto_disengage: 16,
707 tunable_torque: 30,
708 })
709 );
710 assert_eq!(restored.smartshift("absent"), None);
711 }
712
713 #[test]
714 fn invert_scroll_roundtrips_per_device() {
715 let mut cfg = Config::default();
716 assert!(!cfg.invert_scroll("2b042"));
718 cfg.set_invert_scroll("2b042", true);
719 let restored = write_and_read(&cfg);
720 assert!(restored.invert_scroll("2b042"));
721 assert!(!restored.invert_scroll("absent"));
722 }
723
724 #[test]
725 fn default_invert_scroll_is_omitted_from_toml() {
726 let mut cfg = Config::default();
729 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
730 cfg.set_invert_scroll("2b042", false);
731 let body = toml::to_string_pretty(&cfg).expect("serialize");
732 assert!(
733 !body.contains("invert_scroll"),
734 "default invert_scroll should be omitted: {body}"
735 );
736 }
737
738 #[test]
739 fn scroll_resolution_roundtrips_all_three_states() {
740 let mut cfg = Config::default();
741 assert_eq!(cfg.scroll_resolution("mouse"), None);
742
743 cfg.set_scroll_resolution("mouse", Some(ScrollResolution::Low));
744 let low = write_and_read(&cfg);
745 assert_eq!(low.scroll_resolution("mouse"), Some(ScrollResolution::Low));
746
747 cfg.set_scroll_resolution("mouse", Some(ScrollResolution::High));
748 let high = write_and_read(&cfg);
749 assert_eq!(
750 high.scroll_resolution("mouse"),
751 Some(ScrollResolution::High)
752 );
753
754 cfg.set_scroll_resolution("mouse", None);
755 let unmanaged = write_and_read(&cfg);
756 assert_eq!(unmanaged.scroll_resolution("mouse"), None);
757 }
758
759 #[test]
760 fn unset_scroll_resolution_is_omitted_from_toml() {
761 let mut cfg = Config::default();
762 cfg.set_binding("mouse", ButtonId::Back, Binding::Single(Action::Copy));
763 cfg.set_scroll_resolution("mouse", Some(ScrollResolution::Low));
764 cfg.set_scroll_resolution("mouse", None);
765
766 let body = toml::to_string_pretty(&cfg).expect("serialize");
767 assert!(
768 !body.contains("scroll_resolution"),
769 "unset scroll resolution should be omitted: {body}"
770 );
771 }
772
773 #[test]
774 fn config_without_scroll_resolution_loads_as_unmanaged() {
775 let dir = tempfile::tempdir().expect("tempdir");
776 let path = dir.path().join("config.toml");
777 fs::write(
778 &path,
779 r"
780 schema_version = 3
781 [devices.mouse]
782 invert_scroll = true
783 ",
784 )
785 .expect("write config");
786
787 let cfg = Config::load_from_path(&path).expect("load existing config");
788 assert_eq!(cfg.scroll_resolution("mouse"), None);
789 assert!(cfg.invert_scroll("mouse"));
790 }
791
792 #[test]
793 fn bindings_roundtrip_per_device() {
794 let mut cfg = Config::default();
795 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
796 cfg.set_binding(
797 "2b042",
798 ButtonId::DpiToggle,
799 Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
800 modifiers: crate::binding::KeyCombo::MOD_CMD,
801 key_code: 0x23, display: "⌘P".into(),
803 })),
804 );
805 cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
806
807 let parsed = write_and_read(&cfg);
808
809 let a = parsed.bindings_for("2b042");
811 assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
812 assert_eq!(
813 a.get(&ButtonId::DpiToggle),
814 Some(&Binding::Single(Action::CustomShortcut(
815 crate::binding::KeyCombo {
816 modifiers: crate::binding::KeyCombo::MOD_CMD,
817 key_code: 0x23,
818 display: "⌘P".into(),
819 }
820 )))
821 );
822
823 let b = parsed.bindings_for("4082d");
824 assert_eq!(
825 b.get(&ButtonId::Back),
826 Some(&Binding::Single(Action::Paste))
827 );
828 assert_eq!(b.len(), 1, "device b should only see its own bindings");
829
830 assert!(parsed.bindings_for("deadbeef").is_empty());
832 }
833
834 #[test]
835 fn human_readable_toml_layout() {
836 let mut cfg = Config::default();
837 cfg.set_binding(
838 "2b042",
839 ButtonId::Back,
840 Binding::Single(Action::BrowserBack),
841 );
842 let body = toml::to_string_pretty(&cfg).expect("serialize");
843
844 assert!(body.contains("schema_version = 3"), "got: {body}");
848 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
849 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
852 }
853
854 #[test]
855 fn dpi_presets_roundtrip_per_device() {
856 let mut cfg = Config::default();
857 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
858 cfg.set_dpi_presets("4082d", vec![400, 1600]);
859
860 let parsed = write_and_read(&cfg);
861
862 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
863 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
864 assert!(parsed.dpi_presets("unknown").is_empty());
865 }
866
867 #[test]
868 fn empty_dpi_presets_skip_serialization() {
869 let mut cfg = Config::default();
870 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
872 cfg.set_dpi_presets("2b042", vec![800]);
873 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
876 assert!(
877 !body.contains("dpi_presets"),
878 "empty dpi_presets should be omitted: {body}"
879 );
880 }
881
882 #[test]
883 fn device_identity_roundtrips_and_is_iterable() {
884 use crate::device::{Capabilities, DeviceKind};
885
886 let mut cfg = Config::default();
887 let mouse = DeviceIdentity {
888 display_name: "MX Master 3S".to_string(),
889 model_info: None,
890 codename: None,
891 kind: DeviceKind::Mouse,
892 capabilities: Capabilities {
893 buttons: true,
894 pointer: true,
895 lighting: false,
896 scroll_inversion: false,
897 hires_wheel: true,
898 },
899 };
900 cfg.set_device_identity("2b034", mouse.clone());
901 cfg.set_binding(
903 "2b034",
904 ButtonId::Back,
905 Binding::Single(Action::BrowserBack),
906 );
907
908 let parsed = write_and_read(&cfg);
909 assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
910 assert_eq!(parsed.device_identity("absent"), None);
911 assert_eq!(
912 parsed.bindings_for("2b034").get(&ButtonId::Back),
913 Some(&Binding::Single(Action::BrowserBack)),
914 "identity must coexist with bindings on the same device block"
915 );
916 assert_eq!(
917 parsed.known_identities().collect::<Vec<_>>(),
918 vec![("2b034", &mouse)]
919 );
920 }
921
922 #[test]
923 fn selected_device_roundtrips() {
924 let mut cfg = Config::default();
925 assert_eq!(cfg.selected_device(), None);
926 cfg.set_selected_device(Some("2b042".into()));
927 let parsed = write_and_read(&cfg);
928 assert_eq!(parsed.selected_device(), Some("2b042"));
929 }
930
931 #[test]
932 fn per_app_overlay_takes_precedence() {
933 let mut cfg = Config::default();
934 cfg.set_binding(
935 "2b042",
936 ButtonId::Back,
937 Binding::Single(Action::BrowserBack),
938 );
939 cfg.set_binding(
940 "2b042",
941 ButtonId::Forward,
942 Binding::Single(Action::BrowserForward),
943 );
944 cfg.set_per_app_binding(
945 "2b042",
946 "com.microsoft.VSCode",
947 ButtonId::Back,
948 Some(Action::Undo),
949 );
950
951 let global = cfg.effective_bindings("2b042", None);
953 assert_eq!(
954 global.get(&ButtonId::Back),
955 Some(&Binding::Single(Action::BrowserBack))
956 );
957 assert_eq!(
958 global.get(&ButtonId::Forward),
959 Some(&Binding::Single(Action::BrowserForward))
960 );
961
962 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
964 assert_eq!(
965 vscode.get(&ButtonId::Back),
966 Some(&Binding::Single(Action::Undo))
967 );
968 assert_eq!(
969 vscode.get(&ButtonId::Forward),
970 Some(&Binding::Single(Action::BrowserForward))
971 );
972
973 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
975 assert_eq!(
976 other.get(&ButtonId::Back),
977 Some(&Binding::Single(Action::BrowserBack))
978 );
979 }
980
981 #[test]
982 fn per_app_binding_removal_prunes_empty_app() {
983 let mut cfg = Config::default();
984 cfg.set_per_app_binding(
985 "2b042",
986 "com.example.App",
987 ButtonId::Back,
988 Some(Action::Copy),
989 );
990 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
991 assert!(
992 cfg.devices["2b042"].per_app_bindings.is_empty(),
993 "removing last override should prune the app entry"
994 );
995 }
996
997 #[test]
998 fn app_settings_default_omits_block() {
999 let cfg = Config::default();
1000 let body = toml::to_string_pretty(&cfg).expect("serialize");
1001 assert!(
1002 !body.contains("app_settings"),
1003 "default app_settings should be omitted: {body}"
1004 );
1005 }
1006
1007 #[test]
1008 fn app_settings_launch_at_login_roundtrips() {
1009 let mut cfg = Config::default();
1010 cfg.app_settings.launch_at_login = true;
1011 let parsed = write_and_read(&cfg);
1012 assert!(parsed.app_settings.launch_at_login);
1013 }
1014
1015 #[test]
1016 fn asset_source_preference_roundtrips() {
1017 let mut cfg = Config::default();
1018 cfg.app_settings.asset_source = AssetSourcePreference::OpenLogi;
1019
1020 let body = toml::to_string_pretty(&cfg).expect("serialize");
1021 let parsed = write_and_read(&cfg);
1022
1023 assert!(body.contains("asset_source = \"openlogi\""));
1024 assert_eq!(
1025 parsed.app_settings.asset_source,
1026 AssetSourcePreference::OpenLogi
1027 );
1028 }
1029
1030 #[test]
1031 fn config_without_asset_source_keeps_automatic_selection() {
1032 let parsed: Config = toml::from_str(
1033 r"
1034 schema_version = 3
1035 [app_settings]
1036 auto_download_assets = false
1037 ",
1038 )
1039 .expect("config predating the asset-source setting loads");
1040
1041 assert_eq!(
1042 parsed.app_settings.asset_source,
1043 AssetSourcePreference::Automatic
1044 );
1045 }
1046
1047 #[test]
1048 fn cleared_selected_device_omits_field() {
1049 let mut cfg = Config::default();
1050 cfg.set_selected_device(Some("2b042".into()));
1051 cfg.set_selected_device(None);
1052 let body = toml::to_string_pretty(&cfg).expect("serialize");
1053 assert!(
1054 !body.contains("selected_device"),
1055 "cleared selection should not appear: {body}"
1056 );
1057 }
1058
1059 #[test]
1060 fn empty_device_block_is_skipped_in_output() {
1061 let mut cfg = Config::default();
1064 cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1065 cfg.devices
1066 .get_mut("2b042")
1067 .expect("entry")
1068 .bindings
1069 .clear();
1070 let body = toml::to_string_pretty(&cfg).expect("serialize");
1071 assert!(
1072 !body.contains("Back"),
1073 "cleared bindings should not appear: {body}"
1074 );
1075 }
1076
1077 #[test]
1078 fn migrates_v1_button_and_gesture_bindings() {
1079 let v1 = "\
1081schema_version = 1
1082
1083[devices.2b042.button_bindings]
1084Back = \"BrowserBack\"
1085
1086[devices.2b042.gesture_bindings]
1087Up = \"Copy\"
1088Click = \"Paste\"
1089";
1090 let dir = tempfile::tempdir().expect("tempdir");
1091 let path = dir.path().join("config.toml");
1092 fs::write(&path, v1).expect("write");
1093
1094 let cfg = Config::load_from_path(&path).expect("load v1");
1096 let bindings = cfg.bindings_for("2b042");
1097 assert_eq!(
1098 bindings.get(&ButtonId::Back),
1099 Some(&Binding::Single(Action::BrowserBack))
1100 );
1101 let mut gesture = BTreeMap::new();
1102 gesture.insert(GestureDirection::Up, Action::Copy);
1103 gesture.insert(GestureDirection::Click, Action::Paste);
1104 assert_eq!(
1105 bindings.get(&ButtonId::GestureButton),
1106 Some(&Binding::Gesture(gesture))
1107 );
1108
1109 let body = toml::to_string_pretty(&cfg).expect("serialize");
1112 assert!(body.contains("schema_version = 3"), "got: {body}");
1113 assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1114 assert!(!body.contains("button_bindings"), "got: {body}");
1115 assert!(!body.contains("gesture_bindings"), "got: {body}");
1116 }
1117
1118 #[test]
1119 fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1120 let v1 = "\
1125schema_version = 1
1126
1127[devices.2b042.button_bindings]
1128GestureButton = \"MissionControl\"
1129
1130[devices.2b042.gesture_bindings]
1131Up = \"Copy\"
1132Down = \"Paste\"
1133";
1134 let dir = tempfile::tempdir().expect("tempdir");
1135 let path = dir.path().join("config.toml");
1136 fs::write(&path, v1).expect("write");
1137
1138 let cfg = Config::load_from_path(&path).expect("load v1");
1139 let mut gesture = BTreeMap::new();
1140 gesture.insert(GestureDirection::Up, Action::Copy);
1141 gesture.insert(GestureDirection::Down, Action::Paste);
1142 assert_eq!(
1143 cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1144 Some(&Binding::Gesture(gesture)),
1145 "gesture map must win over the legacy single GestureButton entry"
1146 );
1147 }
1148
1149 #[test]
1150 fn migration_drops_vestigial_lone_gesture_button_single() {
1151 let v1 = "\
1158schema_version = 1
1159
1160[devices.2b042.button_bindings]
1161GestureButton = \"MissionControl\"
1162Back = \"BrowserBack\"
1163";
1164 let dir = tempfile::tempdir().expect("tempdir");
1165 let path = dir.path().join("config.toml");
1166 fs::write(&path, v1).expect("write");
1167
1168 let bindings = Config::load_from_path(&path)
1169 .expect("load v1")
1170 .bindings_for("2b042");
1171 assert_eq!(
1173 bindings.get(&ButtonId::Back),
1174 Some(&Binding::Single(Action::BrowserBack))
1175 );
1176 assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1179 }
1180
1181 #[test]
1182 fn rejects_newer_schema_version_but_accepts_v1() {
1183 let dir = tempfile::tempdir().expect("tempdir");
1186 let path = dir.path().join("config.toml");
1187 fs::write(&path, "schema_version = 99\n").expect("write");
1188 assert_matches!(
1189 Config::load_from_path(&path).expect_err("v99 should fail"),
1190 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1191 );
1192
1193 fs::write(&path, "schema_version = 1\n").expect("write");
1194 assert!(
1195 Config::load_from_path(&path).is_ok(),
1196 "v1 should still load"
1197 );
1198 }
1199
1200 #[test]
1201 fn set_gesture_direction_upgrades_single_to_gesture() {
1202 let mut cfg = Config::default();
1203 cfg.set_binding(
1205 "2b042",
1206 ButtonId::Back,
1207 Binding::Single(Action::BrowserBack),
1208 );
1209 cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1210
1211 match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1212 Some(Binding::Gesture(map)) => {
1213 assert_eq!(
1215 map.get(&GestureDirection::Click),
1216 Some(&Action::BrowserBack)
1217 );
1218 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1219 }
1220 other => panic!("expected Gesture after upgrade, got {other:?}"),
1221 }
1222 }
1223
1224 #[test]
1225 fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1226 let mut cfg = Config::default();
1230 cfg.set_gesture_direction(
1231 "2b042",
1232 ButtonId::GestureButton,
1233 GestureDirection::Up,
1234 Action::Copy,
1235 );
1236
1237 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1238 Some(Binding::Gesture(map)) => {
1239 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1240 assert_eq!(
1241 map.get(&GestureDirection::Click),
1242 Some(&crate::binding::default_gesture_binding(
1243 GestureDirection::Click
1244 )),
1245 "a fresh gesture button must seed a Click from its default"
1246 );
1247 }
1248 other => panic!("expected Gesture, got {other:?}"),
1249 }
1250 }
1251
1252 #[test]
1253 fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() {
1254 let mut cfg = Config::default();
1255 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1257
1258 cfg.set_gesture_direction(
1260 "2b042",
1261 ButtonId::GestureButton,
1262 GestureDirection::Up,
1263 Action::MissionControl,
1264 );
1265 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1266
1267 cfg.set_binding(
1269 "2b042",
1270 ButtonId::Forward,
1271 Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1272 );
1273 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1274
1275 let mut off = Config::default();
1277 off.disable_gestures("2b042");
1278 assert_eq!(off.gesture_owner("2b042"), None);
1279 }
1280
1281 #[test]
1282 fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1283 let mut cfg = Config::default();
1284 cfg.set_gesture_direction(
1286 "2b042",
1287 ButtonId::GestureButton,
1288 GestureDirection::Up,
1289 Action::Copy,
1290 );
1291 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1292
1293 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1296 cfg.set_gesture_owner("2b042", ButtonId::Back);
1297 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1298
1299 let bindings = cfg.bindings_for("2b042");
1300 match bindings.get(&ButtonId::Back) {
1303 Some(Binding::Gesture(map)) => {
1304 assert_eq!(
1305 map.get(&GestureDirection::Click),
1306 Some(&Action::BrowserBack)
1307 );
1308 assert_eq!(
1309 map.get(&GestureDirection::Up),
1310 Some(&default_gesture_binding(GestureDirection::Up)),
1311 "a promoted button gets full default arms"
1312 );
1313 }
1314 other => panic!("expected Back to be a gesture binding, got {other:?}"),
1315 }
1316 match bindings.get(&ButtonId::GestureButton) {
1318 Some(Binding::Gesture(map)) => {
1319 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1320 }
1321 other => panic!("expected the HID++ gesture button map preserved, got {other:?}"),
1322 }
1323
1324 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1327 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1328 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1329 Some(Binding::Gesture(map)) => {
1330 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1331 }
1332 other => panic!("expected preserved gesture map, got {other:?}"),
1333 }
1334 }
1335
1336 #[test]
1337 fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1338 let mut cfg = Config::default();
1339 cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1341 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1342 Some(Binding::Gesture(map)) => {
1343 for dir in GestureDirection::ALL {
1344 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1345 }
1346 }
1347 other => panic!("expected full default gesture map, got {other:?}"),
1348 }
1349
1350 cfg.set_gesture_owner("2b042", ButtonId::Forward);
1354 match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1355 Some(Binding::Gesture(map)) => {
1356 assert_eq!(
1357 map.get(&GestureDirection::Click),
1358 Some(&default_binding(ButtonId::Forward))
1359 );
1360 for dir in [
1361 GestureDirection::Up,
1362 GestureDirection::Down,
1363 GestureDirection::Left,
1364 GestureDirection::Right,
1365 ] {
1366 assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1367 }
1368 }
1369 other => panic!("expected full gesture map for Forward, got {other:?}"),
1370 }
1371 }
1372
1373 #[test]
1374 fn disable_gestures_turns_off_without_destroying_maps() {
1375 let mut cfg = Config::default();
1376 cfg.set_gesture_direction(
1377 "2b042",
1378 ButtonId::GestureButton,
1379 GestureDirection::Up,
1380 Action::Copy,
1381 );
1382 cfg.disable_gestures("2b042");
1383 assert_eq!(cfg.gesture_owner("2b042"), None);
1386 match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1387 Some(Binding::Gesture(map)) => {
1388 assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1389 }
1390 other => panic!("expected the gesture map preserved while off, got {other:?}"),
1391 }
1392 }
1393
1394 #[test]
1395 fn gesture_owner_field_roundtrips_as_a_scalar() {
1396 let mut cfg = Config::default();
1397 cfg.set_gesture_owner("2b042", ButtonId::Back); cfg.disable_gestures("4082d"); let parsed = write_and_read(&cfg);
1401 assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1402 assert_eq!(parsed.gesture_owner("4082d"), None);
1403
1404 let body = toml::to_string_pretty(&cfg).expect("serialize");
1407 assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1408 assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1409 }
1410
1411 #[test]
1412 fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1413 let toml = "\
1417schema_version = 2
1418
1419[devices.2b042]
1420gesture_owner = \"bogus\"
1421
1422[devices.2b042.bindings]
1423Back = \"Copy\"
1424";
1425 let dir = tempfile::tempdir().expect("tempdir");
1426 let path = dir.path().join("config.toml");
1427 fs::write(&path, toml).expect("write");
1428
1429 let cfg =
1430 Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1431 assert_eq!(
1433 cfg.bindings_for("2b042").get(&ButtonId::Back),
1434 Some(&Binding::Single(Action::Copy))
1435 );
1436 assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1438 }
1439}