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, Default, Serialize, Deserialize)]
128pub struct DeviceConfig {
129 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
130 pub button_bindings: BTreeMap<ButtonId, Action>,
131 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
136 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
137 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
142 pub gesture_bindings: BTreeMap<GestureDirection, Action>,
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub dpi_presets: Vec<u32>,
149}
150
151#[derive(Debug, Error)]
152pub enum ConfigError {
153 #[error("could not resolve config path")]
154 Path(#[from] PathsError),
155 #[error("could not read config at {path}")]
156 Read {
157 path: PathBuf,
158 #[source]
159 source: io::Error,
160 },
161 #[error("could not parse config at {path}")]
162 Parse {
163 path: PathBuf,
164 #[source]
165 source: toml::de::Error,
166 },
167 #[error("could not write config at {path}")]
168 Write {
169 path: PathBuf,
170 #[source]
171 source: io::Error,
172 },
173 #[error("could not serialize config")]
174 Serialize(#[from] toml::ser::Error),
175 #[error("config at {path} has unsupported schema_version {found}")]
176 UnsupportedSchemaVersion { path: PathBuf, found: u32 },
177}
178
179impl Config {
180 pub fn load_or_default() -> Result<Self, ConfigError> {
183 Self::load_from_path(&paths::config_path()?)
184 }
185
186 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
189 match fs::read_to_string(path) {
190 Ok(text) => {
191 let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
192 path: path.to_path_buf(),
193 source,
194 })?;
195 if config.schema_version != SCHEMA_VERSION {
196 return Err(ConfigError::UnsupportedSchemaVersion {
197 path: path.to_path_buf(),
198 found: config.schema_version,
199 });
200 }
201 Ok(config)
202 }
203 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
204 Err(source) => Err(ConfigError::Read {
205 path: path.to_path_buf(),
206 source,
207 }),
208 }
209 }
210
211 pub fn save_atomic(&self) -> Result<(), ConfigError> {
215 self.save_to_path(&paths::config_path()?)
216 }
217
218 pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
220 if let Some(parent) = path.parent() {
221 fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
222 path: path.to_path_buf(),
223 source,
224 })?;
225 }
226 let body = toml::to_string_pretty(self)?;
227 write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
228 path: path.to_path_buf(),
229 source,
230 })
231 }
232
233 #[must_use]
236 pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Action> {
237 self.devices
238 .get(device_key)
239 .map(|d| d.button_bindings.clone())
240 .unwrap_or_default()
241 }
242
243 pub fn set_binding(&mut self, device_key: &str, button: ButtonId, action: Action) {
246 self.devices
247 .entry(device_key.to_string())
248 .or_default()
249 .button_bindings
250 .insert(button, action);
251 }
252
253 #[must_use]
256 pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
257 self.devices
258 .get(device_key)
259 .map(|d| d.gesture_bindings.clone())
260 .unwrap_or_default()
261 }
262
263 pub fn set_gesture_binding(
265 &mut self,
266 device_key: &str,
267 direction: GestureDirection,
268 action: Action,
269 ) {
270 self.devices
271 .entry(device_key.to_string())
272 .or_default()
273 .gesture_bindings
274 .insert(direction, action);
275 }
276
277 #[must_use]
284 pub fn effective_bindings(
285 &self,
286 device_key: &str,
287 bundle_id: Option<&str>,
288 ) -> BTreeMap<ButtonId, Action> {
289 let Some(device) = self.devices.get(device_key) else {
290 return BTreeMap::new();
291 };
292 let mut out = device.button_bindings.clone();
293 if let Some(bid) = bundle_id {
294 if let Some(overlay) = device.per_app_bindings.get(bid) {
295 for (k, v) in overlay {
296 out.insert(*k, v.clone());
297 }
298 }
299 }
300 out
301 }
302
303 pub fn set_per_app_binding(
307 &mut self,
308 device_key: &str,
309 bundle_id: &str,
310 button: ButtonId,
311 action: Option<Action>,
312 ) {
313 let entry = self
314 .devices
315 .entry(device_key.to_string())
316 .or_default()
317 .per_app_bindings
318 .entry(bundle_id.to_string())
319 .or_default();
320 match action {
321 Some(a) => {
322 entry.insert(button, a);
323 }
324 None => {
325 entry.remove(&button);
326 }
327 }
328 if let Some(d) = self.devices.get_mut(device_key) {
329 d.per_app_bindings.retain(|_, m| !m.is_empty());
330 }
331 }
332
333 #[must_use]
335 pub fn selected_device(&self) -> Option<&str> {
336 self.selected_device.as_deref()
337 }
338
339 pub fn set_selected_device(&mut self, key: Option<String>) {
342 self.selected_device = key;
343 }
344
345 #[must_use]
348 pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
349 self.devices
350 .get(device_key)
351 .map(|d| d.dpi_presets.clone())
352 .unwrap_or_default()
353 }
354
355 pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
359 self.devices
360 .entry(device_key.to_string())
361 .or_default()
362 .dpi_presets = presets;
363 }
364}
365
366fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
367 let tmp = path.with_extension("toml.tmp");
368 {
369 #[cfg(unix)]
370 {
371 use std::os::unix::fs::OpenOptionsExt;
372 let mut f = fs::OpenOptions::new()
373 .write(true)
374 .create(true)
375 .truncate(true)
376 .mode(0o600)
377 .open(&tmp)?;
378 io::Write::write_all(&mut f, bytes)?;
379 f.sync_all()?;
380 }
381 #[cfg(not(unix))]
382 {
383 let mut f = fs::OpenOptions::new()
384 .write(true)
385 .create(true)
386 .truncate(true)
387 .open(&tmp)?;
388 io::Write::write_all(&mut f, bytes)?;
389 f.sync_all()?;
390 }
391 }
392 fs::rename(&tmp, path)
393}
394
395#[cfg(test)]
396#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
397mod tests {
398 use super::*;
399
400 fn write_and_read(config: &Config) -> Config {
401 let dir = tempfile::tempdir().expect("tempdir");
402 let path = dir.path().join("config.toml");
403 config.save_to_path(&path).expect("save");
404 Config::load_from_path(&path).expect("load")
405 }
406
407 #[test]
408 fn missing_file_yields_default() {
409 let dir = tempfile::tempdir().expect("tempdir");
410 let path = dir.path().join("nonexistent.toml");
411 let cfg = Config::load_from_path(&path).expect("load");
412 assert_eq!(cfg.schema_version, SCHEMA_VERSION);
413 assert!(cfg.devices.is_empty());
414 }
415
416 #[test]
417 fn bindings_roundtrip_per_device() {
418 let mut cfg = Config::default();
419 cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
420 cfg.set_binding(
421 "2b042",
422 ButtonId::DpiToggle,
423 Action::CustomShortcut(crate::binding::KeyCombo {
424 modifiers: crate::binding::KeyCombo::MOD_CMD,
425 key_code: 0x23, display: "⌘P".into(),
427 }),
428 );
429 cfg.set_binding("4082d", ButtonId::Back, Action::Paste);
430
431 let parsed = write_and_read(&cfg);
432
433 let a = parsed.bindings_for("2b042");
435 assert_eq!(a.get(&ButtonId::Back), Some(&Action::Copy));
436 assert_eq!(
437 a.get(&ButtonId::DpiToggle),
438 Some(&Action::CustomShortcut(crate::binding::KeyCombo {
439 modifiers: crate::binding::KeyCombo::MOD_CMD,
440 key_code: 0x23,
441 display: "⌘P".into(),
442 }))
443 );
444
445 let b = parsed.bindings_for("4082d");
446 assert_eq!(b.get(&ButtonId::Back), Some(&Action::Paste));
447 assert_eq!(b.len(), 1, "device b should only see its own bindings");
448
449 assert!(parsed.bindings_for("deadbeef").is_empty());
451 }
452
453 #[test]
454 fn human_readable_toml_layout() {
455 let mut cfg = Config::default();
456 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
457 let body = toml::to_string_pretty(&cfg).expect("serialize");
458
459 assert!(body.contains("schema_version = 1"), "got: {body}");
463 assert!(
464 body.contains("[devices.2b042.button_bindings]"),
465 "got: {body}"
466 );
467 assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
468 }
469
470 #[test]
471 fn rejects_unknown_schema_version() {
472 let dir = tempfile::tempdir().expect("tempdir");
473 let path = dir.path().join("config.toml");
474 fs::write(&path, "schema_version = 99\n").expect("write");
475 let err = Config::load_from_path(&path).expect_err("should fail");
476 assert!(matches!(
477 err,
478 ConfigError::UnsupportedSchemaVersion { found: 99, .. }
479 ));
480 }
481
482 #[test]
483 fn dpi_presets_roundtrip_per_device() {
484 let mut cfg = Config::default();
485 cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
486 cfg.set_dpi_presets("4082d", vec![400, 1600]);
487
488 let parsed = write_and_read(&cfg);
489
490 assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
491 assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
492 assert!(parsed.dpi_presets("unknown").is_empty());
493 }
494
495 #[test]
496 fn empty_dpi_presets_skip_serialization() {
497 let mut cfg = Config::default();
498 cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
500 cfg.set_dpi_presets("2b042", vec![800]);
501 cfg.set_dpi_presets("2b042", vec![]); let body = toml::to_string_pretty(&cfg).expect("serialize");
504 assert!(
505 !body.contains("dpi_presets"),
506 "empty dpi_presets should be omitted: {body}"
507 );
508 }
509
510 #[test]
511 fn selected_device_roundtrips() {
512 let mut cfg = Config::default();
513 assert_eq!(cfg.selected_device(), None);
514 cfg.set_selected_device(Some("2b042".into()));
515 let parsed = write_and_read(&cfg);
516 assert_eq!(parsed.selected_device(), Some("2b042"));
517 }
518
519 #[test]
520 fn per_app_overlay_takes_precedence() {
521 let mut cfg = Config::default();
522 cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
523 cfg.set_binding("2b042", ButtonId::Forward, Action::BrowserForward);
524 cfg.set_per_app_binding(
525 "2b042",
526 "com.microsoft.VSCode",
527 ButtonId::Back,
528 Some(Action::Undo),
529 );
530
531 let global = cfg.effective_bindings("2b042", None);
533 assert_eq!(global.get(&ButtonId::Back), Some(&Action::BrowserBack));
534 assert_eq!(
535 global.get(&ButtonId::Forward),
536 Some(&Action::BrowserForward)
537 );
538
539 let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
541 assert_eq!(vscode.get(&ButtonId::Back), Some(&Action::Undo));
542 assert_eq!(
543 vscode.get(&ButtonId::Forward),
544 Some(&Action::BrowserForward)
545 );
546
547 let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
549 assert_eq!(other.get(&ButtonId::Back), Some(&Action::BrowserBack));
550 }
551
552 #[test]
553 fn per_app_binding_removal_prunes_empty_app() {
554 let mut cfg = Config::default();
555 cfg.set_per_app_binding(
556 "2b042",
557 "com.example.App",
558 ButtonId::Back,
559 Some(Action::Copy),
560 );
561 cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
562 assert!(
563 cfg.devices["2b042"].per_app_bindings.is_empty(),
564 "removing last override should prune the app entry"
565 );
566 }
567
568 #[test]
569 fn app_settings_default_omits_block() {
570 let cfg = Config::default();
571 let body = toml::to_string_pretty(&cfg).expect("serialize");
572 assert!(
573 !body.contains("app_settings"),
574 "default app_settings should be omitted: {body}"
575 );
576 }
577
578 #[test]
579 fn app_settings_launch_at_login_roundtrips() {
580 let mut cfg = Config::default();
581 cfg.app_settings.launch_at_login = true;
582 let parsed = write_and_read(&cfg);
583 assert!(parsed.app_settings.launch_at_login);
584 }
585
586 #[test]
587 fn cleared_selected_device_omits_field() {
588 let mut cfg = Config::default();
589 cfg.set_selected_device(Some("2b042".into()));
590 cfg.set_selected_device(None);
591 let body = toml::to_string_pretty(&cfg).expect("serialize");
592 assert!(
593 !body.contains("selected_device"),
594 "cleared selection should not appear: {body}"
595 );
596 }
597
598 #[test]
599 fn empty_device_block_is_skipped_in_output() {
600 let mut cfg = Config::default();
603 cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
604 cfg.devices
605 .get_mut("2b042")
606 .expect("entry")
607 .button_bindings
608 .clear();
609 let body = toml::to_string_pretty(&cfg).expect("serialize");
610 assert!(
611 !body.contains("Back"),
612 "cleared bindings should not appear: {body}"
613 );
614 }
615}