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