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