aethermapd 1.4.3

Privileged system daemon for aethermap
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
//! Integration tests for configuration hot-reload validation
//!
//! This test module verifies the hot-reload behavior of ConfigManager:
//!
//! - Valid configurations are reloaded atomically (validate-then-swap)
//! - Invalid configurations are rejected without affecting active config
//! - Both reload_remaps() and reload_device_profiles() are tested
//!
//! # Test Strategy
//!
//! These tests use temporary YAML files to verify the reload pattern:
//! 1. Load an initial valid configuration
//! 2. Verify the config is active
//! 3. Attempt to reload with invalid config (should fail)
//! 4. Verify original config is still active (atomic swap)
//! 5. Reload with valid config (should succeed)
//!
//! # Validate-Then-Swap Pattern
//!
//! The hot-reload implementation must validate the entire configuration
//! before applying any changes. This ensures the daemon never runs with
//! a partially-applied or invalid configuration.

use std::collections::HashMap;
use std::io::Write;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::runtime::Runtime;

use aethermapd::config::ConfigManager;
use aethermapd::remap_engine::RemapEngine;

/// Helper to create a ConfigManager with temporary directories
///
/// This allows tests to manipulate config files without affecting
/// the system configuration.
fn create_test_config_manager(temp_dir: &TempDir) -> ConfigManager {
    ConfigManager {
        config_path: temp_dir.path().join("config.yaml"),
        macros_path: temp_dir.path().join("macros.yaml"),
        cache_path: temp_dir.path().join("macros.bin"),
        profiles_dir: temp_dir.path().join("profiles"),
        remaps_path: temp_dir.path().join("remaps.yaml"),
        device_profiles_path: temp_dir.path().join("device_profiles.yaml"),
        layer_state_path: temp_dir.path().join("layer_state.yaml"),
        config: Arc::new(tokio::sync::RwLock::new(aethermapd::config::DaemonConfig::default())),
        macros: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
        profiles: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
        remaps: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
        device_profiles: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
    }
}

/// Helper to write a remaps YAML file
fn write_remaps_file(path: &std::path::Path, content: &str) -> std::io::Result<()> {
    let mut file = std::fs::File::create(path)?;
    file.write_all(content.as_bytes())
}

/// Test valid config reload with atomic swap
///
/// Verifies that loading a valid configuration updates the remaps
/// without leaving the system in an inconsistent state.
#[test]
fn test_valid_remap_reload() {
    let rt = Runtime::new().unwrap();
    let temp_dir = TempDir::new().unwrap();
    let manager = create_test_config_manager(&temp_dir);

    // Create initial valid remaps file
    let remaps_path = temp_dir.path().join("remaps.yaml");
    let initial_config = r#"
# Initial remapping configuration
capslock: leftctrl
"#;
    write_remaps_file(&remaps_path, initial_config).unwrap();

    // Load initial config
    rt.block_on(async {
        let result = manager.load_remaps().await;
        assert!(result.is_ok(), "Initial config load should succeed");

        // Verify remaps are loaded
        let remaps = manager.remaps.read().await;
        assert_eq!(remaps.len(), 1);
        assert_eq!(remaps.get("capslock"), Some(&"leftctrl".to_string()));
    });

    // Update config with new remaps (still valid)
    let updated_config = r#"
# Updated remapping configuration
capslock: leftctrl
a: b
"#;
    write_remaps_file(&remaps_path, updated_config).unwrap();

    // Reload config
    rt.block_on(async {
        let result = manager.load_remaps().await;
        assert!(result.is_ok(), "Updated config load should succeed");

        // Verify new remaps are loaded
        let remaps = manager.remaps.read().await;
        assert_eq!(remaps.len(), 2);
        assert_eq!(remaps.get("capslock"), Some(&"leftctrl".to_string()));
        assert_eq!(remaps.get("a"), Some(&"b".to_string()));
    });
}

/// Test invalid config rejection preserves original config
///
/// Verifies the validate-then-swap pattern: when loading an invalid
/// configuration, the original configuration remains unchanged.
#[test]
fn test_invalid_remap_rejection() {
    let rt = Runtime::new().unwrap();
    let temp_dir = TempDir::new().unwrap();
    let manager = create_test_config_manager(&temp_dir);

    let remaps_path = temp_dir.path().join("remaps.yaml");

    // Create initial valid config
    let initial_config = r#"
capslock: leftctrl
a: b
"#;
    write_remaps_file(&remaps_path, initial_config).unwrap();

    rt.block_on(async {
        // Load initial config
        let result = manager.load_remaps().await;
        assert!(result.is_ok());

        // Verify initial state
        let remaps = manager.remaps.read().await;
        assert_eq!(remaps.len(), 2);
        drop(remaps);

        // Now write invalid config (bad key name)
        drop(write_remaps_file(
            &remaps_path,
            "invalid_key_xyz: leftctrl\n",
        ));

        // Attempt reload - should fail
        let result = manager.load_remaps().await;
        assert!(result.is_err(), "Invalid config should be rejected");

        // Verify original config is still active
        let remaps = manager.remaps.read().await;
        assert_eq!(remaps.len(), 2, "Original remaps should remain active");
        assert_eq!(
            remaps.get("capslock"),
            Some(&"leftctrl".to_string()),
            "Original remaps should be unchanged"
        );
    });
}

/// Test reload_remaps with RemapEngine
///
/// Verifies that reload_remaps properly updates the RemapEngine.
#[test]
fn test_reload_remaps_with_engine() {
    let rt = Runtime::new().unwrap();
    let temp_dir = TempDir::new().unwrap();
    let manager = create_test_config_manager(&temp_dir);

    let remaps_path = temp_dir.path().join("remaps.yaml");

    // Create initial config
    write_remaps_file(&remaps_path, "capslock: leftctrl\n").unwrap();

    rt.block_on(async {
        let engine = Arc::new(RemapEngine::new());

        // Load initial config
        let result = manager.reload_remaps(engine.clone()).await;
        assert!(result.is_ok(), "Initial reload should succeed");

        // Verify engine has the remapping
        assert_eq!(engine.remap_count().await, 1);

        // Update config
        write_remaps_file(&remaps_path, "capslock: leftctrl\na: b\n").unwrap();

        // Reload
        let result = manager.reload_remaps(engine.clone()).await;
        assert!(result.is_ok(), "Updated reload should succeed");

        // Verify engine has updated remappings
        assert_eq!(engine.remap_count().await, 2);
    });
}

/// Test reload_remaps rejects invalid keys
///
/// Verifies that reload_remaps validates key names and rejects
/// configurations with invalid key names.
#[test]
fn test_reload_remaps_validates_keys() {
    let rt = Runtime::new().unwrap();
    let temp_dir = TempDir::new().unwrap();
    let manager = create_test_config_manager(&temp_dir);

    let remaps_path = temp_dir.path().join("remaps.yaml");

    rt.block_on(async {
        let engine = Arc::new(RemapEngine::new());

        // Load valid initial config
        write_remaps_file(&remaps_path, "capslock: leftctrl\n").unwrap();
        let result = manager.reload_remaps(engine.clone()).await;
        assert!(result.is_ok());

        let initial_count = engine.remap_count().await;
        assert_eq!(initial_count, 1);

        // Try to load invalid config
        write_remaps_file(&remaps_path, "not_a_real_key: leftctrl\n").unwrap();
        let result = manager.reload_remaps(engine.clone()).await;

        assert!(result.is_err(), "Should reject invalid key name");

        // Verify engine still has original config
        let final_count = engine.remap_count().await;
        assert_eq!(
            final_count, initial_count,
            "Engine should keep original config on validation failure"
        );
    });
}

/// Helper to write device profiles YAML file
fn write_device_profiles_file(path: &std::path::Path, content: &str) -> std::io::Result<()> {
    let mut file = std::fs::File::create(path)?;
    file.write_all(content.as_bytes())
}

/// Test valid device profile reload
///
/// Verifies that reload_device_profiles loads valid configurations.
#[test]
fn test_valid_device_profile_reload() {
    let rt = Runtime::new().unwrap();
    let temp_dir = TempDir::new().unwrap();
    let manager = create_test_config_manager(&temp_dir);

    let profiles_path = temp_dir.path().join("device_profiles.yaml");

    // Create valid device profiles config
    let config = r#"
devices:
  "1532:0220":
    device_id: "1532:0220"
    profiles:
      gaming:
        name: "gaming"
        remaps:
          - from: capslock
            to: leftctrl
      work:
        name: "work"
        remaps:
          - from: a
            to: b
"#;
    write_device_profiles_file(&profiles_path, config).unwrap();

    rt.block_on(async {
        let result = manager.reload_device_profiles().await;
        assert!(result.is_ok(), "Valid device profiles should load successfully");

        // Verify profiles are loaded
        let devices = manager.list_profile_devices().await;
        assert_eq!(devices.len(), 1);
        assert!(devices.contains(&"1532:0220".to_string()));

        // Verify profiles for device
        let profiles = manager.list_device_profiles("1532:0220").await;
        assert_eq!(profiles.len(), 2);
        assert!(profiles.contains(&"gaming".to_string()));
        assert!(profiles.contains(&"work".to_string()));
    });
}

/// Test invalid device profile rejection
///
/// Verifies that reload_device_profiles rejects configurations
/// with invalid key names and preserves the original config.
#[test]
fn test_invalid_device_profile_rejection() {
    let rt = Runtime::new().unwrap();
    let temp_dir = TempDir::new().unwrap();
    let manager = create_test_config_manager(&temp_dir);

    let profiles_path = temp_dir.path().join("device_profiles.yaml");

    rt.block_on(async {
        // Load valid initial config
        let valid_config = r#"
devices:
  "1532:0220":
    device_id: "1532:0220"
    profiles:
      gaming:
        name: "gaming"
        remaps:
          - from: capslock
            to: leftctrl
"#;
        write_device_profiles_file(&profiles_path, valid_config).unwrap();

        let result = manager.reload_device_profiles().await;
        assert!(result.is_ok());

        // Verify initial state
        let devices = manager.list_profile_devices().await;
        assert_eq!(devices.len(), 1);
        let profiles = manager.list_device_profiles("1532:0220").await;
        assert_eq!(profiles.len(), 1);

        // Try to load invalid config (bad key name)
        let invalid_config = r#"
devices:
  "1532:0220":
    device_id: "1532:0220"
    profiles:
      bad_profile:
        name: "bad_profile"
        remaps:
          - from: invalid_key_name_xyz
            to: leftctrl
"#;
        write_device_profiles_file(&profiles_path, invalid_config).unwrap();

        let result = manager.reload_device_profiles().await;
        assert!(result.is_err(), "Should reject invalid key name");

        // Verify original config is still active
        let devices = manager.list_profile_devices().await;
        assert_eq!(
            devices.len(),
            1,
            "Original device list should remain"
        );
        let profiles = manager.list_device_profiles("1532:0220").await;
        assert_eq!(
            profiles.len(),
            1,
            "Original profiles should remain"
        );
    });
}

/// Test atomic swap for reload_device_profiles
///
/// Verifies that on validation failure, the config is not partially applied.
#[test]
fn test_device_profile_atomic_swap() {
    let rt = Runtime::new().unwrap();
    let temp_dir = TempDir::new().unwrap();
    let manager = create_test_config_manager(&temp_dir);

    let profiles_path = temp_dir.path().join("device_profiles.yaml");

    rt.block_on(async {
        // Load config with multiple devices
        let multi_device_config = r#"
devices:
  "1532:0220":
    device_id: "1532:0220"
    profiles:
      gaming:
        name: "gaming"
        remaps:
          - from: capslock
            to: leftctrl
  "046d:c52b":
    device_id: "046d:c52b"
    profiles:
      default:
        name: "default"
        remaps:
          - from: a
            to: b
"#;
        write_device_profiles_file(&profiles_path, multi_device_config).unwrap();

        let result = manager.reload_device_profiles().await;
        assert!(result.is_ok());

        // Verify both devices loaded
        let devices = manager.list_profile_devices().await;
        assert_eq!(devices.len(), 2);

        // Try to load config where one device has invalid keys
        let partial_invalid_config = r#"
devices:
  "1532:0220":
    device_id: "1532:0220"
    profiles:
      gaming:
        name: "gaming"
        remaps:
          - from: capslock
            to: leftctrl
  "046d:c52b":
    device_id: "046d:c52b"
    profiles:
      bad_profile:
        name: "bad_profile"
        remaps:
          - from: invalid_key_xyz
            to: leftctrl
"#;
        write_device_profiles_file(&profiles_path, partial_invalid_config).unwrap();

        let result = manager.reload_device_profiles().await;
        assert!(result.is_err(), "Should reject config with invalid keys");

        // Verify original config is fully intact (not partially replaced)
        let devices = manager.list_profile_devices().await;
        assert_eq!(
            devices.len(),
            2,
            "Should keep both original devices"
        );
        assert!(devices.contains(&"1532:0220".to_string()));
        assert!(devices.contains(&"046d:c52b".to_string()));
    });
}

/// Test empty config handling during reload
///
/// Verifies that reloading with an empty config clears the
/// configuration without errors.
#[test]
fn test_empty_remap_reload() {
    let rt = Runtime::new().unwrap();
    let temp_dir = TempDir::new().unwrap();
    let manager = create_test_config_manager(&temp_dir);

    let remaps_path = temp_dir.path().join("remaps.yaml");

    rt.block_on(async {
        // Load initial config
        write_remaps_file(&remaps_path, "capslock: leftctrl\n").unwrap();
        let result = manager.load_remaps().await;
        assert!(result.is_ok());

        let remaps = manager.remaps.read().await;
        assert_eq!(remaps.len(), 1);
        drop(remaps);

        // Reload with empty config
        write_remaps_file(&remaps_path, "{}\n").unwrap();
        let result = manager.load_remaps().await;
        assert!(result.is_ok(), "Empty config should be valid");

        // Verify config is cleared
        let remaps = manager.remaps.read().await;
        assert_eq!(remaps.len(), 0, "Empty config should clear remaps");
    });
}

/// Test concurrent config reload safety
///
/// Verifies that multiple concurrent reload operations don't
/// cause race conditions (basic smoke test).
#[test]
fn test_concurrent_reload_safety() {
    let rt = Runtime::new().unwrap();
    let temp_dir = TempDir::new().unwrap();
    let manager = Arc::new(create_test_config_manager(&temp_dir));

    let remaps_path = temp_dir.path().join("remaps.yaml");

    rt.block_on(async {
        // Write valid config
        write_remaps_file(&remaps_path, "capslock: leftctrl\na: b\n").unwrap();

        // Spawn multiple concurrent reloads
        let manager1 = manager.clone();
        let manager2 = manager.clone();
        let manager3 = manager.clone();

        let task1 = tokio::spawn(async move {
            manager1.load_remaps().await
        });
        let task2 = tokio::spawn(async move {
            manager2.load_remaps().await
        });
        let task3 = tokio::spawn(async move {
            manager3.load_remaps().await
        });

        // All should succeed
        let results = vec![
            task1.await.unwrap(),
            task2.await.unwrap(),
            task3.await.unwrap(),
        ];

        for result in results {
            assert!(result.is_ok(), "Concurrent reloads should succeed");
        }

        // Final state should be consistent
        let remaps = manager.remaps.read().await;
        assert_eq!(remaps.len(), 2);
    });
}