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