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