Skip to main content

audiorouter_core/
validate.rs

1//! Pure config validation and device role inference.
2//!
3//! This module does not touch CPAL. All validation here is about internal
4//! config consistency — device aliases, channel numbers, route references,
5//! and role/channel-count inference.
6
7use std::collections::HashMap;
8
9use serde::Serialize;
10
11use crate::config::{Config, DeviceConfig, RouteConfig};
12
13/// A device with its inferred input/output roles and required channel counts.
14#[derive(Debug, Clone, Serialize)]
15pub struct ResolvedDeviceRole {
16    pub name: String,
17    pub device: String,
18    pub limiter: bool,
19    pub needs_input: bool,
20    pub needs_output: bool,
21    pub required_input_channels: usize,
22    pub required_output_channels: usize,
23}
24
25/// A route that has passed pure validation.
26#[derive(Debug, Clone, Serialize)]
27pub struct ValidatedRoute {
28    pub from: String,
29    pub to: String,
30    pub from_channels: Vec<usize>,
31    pub to_channels: Vec<usize>,
32    pub gain_db: f32,
33    pub mute: bool,
34}
35
36/// The output of successful validation.
37#[derive(Debug, Clone, Serialize)]
38pub struct ValidatedConfig {
39    pub config: Config,
40    pub devices: Vec<ResolvedDeviceRole>,
41    pub routes: Vec<ValidatedRoute>,
42    pub warnings: Vec<String>,
43}
44
45impl ValidatedConfig {
46    /// Returns the resolved device role for the given alias.
47    pub fn device_by_name(&self, name: &str) -> Option<&ResolvedDeviceRole> {
48        self.devices.iter().find(|d| d.name == name)
49    }
50}
51
52/// Run pure config validation, returning a [`ValidatedConfig`] or a list of
53/// error messages.
54///
55/// This function does not touch CPAL. It validates internal consistency only.
56pub fn validate_config(mut config: Config) -> Result<ValidatedConfig, Vec<String>> {
57    let mut errors = Vec::new();
58    let mut warnings = Vec::new();
59
60    // --- engine ---
61    if config.engine.sample_rate == 0 {
62        errors.push("engine.sample_rate must be positive".to_string());
63    }
64    if config.engine.buffer_size == 0 {
65        errors.push("engine.buffer_size must be positive".to_string());
66    }
67
68    add_implicit_route_devices(&mut config.devices, &config.routes);
69
70    let mut name_map: HashMap<&str, &DeviceConfig> = HashMap::new();
71    for dev in &config.devices {
72        if dev.name.is_empty() {
73            errors.push("device name must be non-empty".to_string());
74        }
75        if dev.device.is_empty() {
76            errors.push(format!(
77                "device \"{}\" has an empty 'device' field (audio device name)",
78                dev.name
79            ));
80        }
81        if let Some(_existing) = name_map.get(dev.name.as_str()) {
82            errors.push(format!(
83                "duplicate device name \"{}\"; names must be unique",
84                dev.name
85            ));
86        } else {
87            name_map.insert(dev.name.as_str(), dev);
88        }
89    }
90
91    // --- routes ---
92    for (i, route) in config.routes.iter().enumerate() {
93        validate_route(i, route, &name_map, &mut errors);
94    }
95
96    // Return early if there are structural errors.
97    if !errors.is_empty() {
98        return Err(errors);
99    }
100
101    // --- role inference ---
102    let mut roles: HashMap<String, ResolvedDeviceRole> = config
103        .devices
104        .iter()
105        .map(|d| {
106            (
107                d.name.clone(),
108                ResolvedDeviceRole {
109                    name: d.name.clone(),
110                    device: d.device.clone(),
111                    limiter: d.limiter,
112                    needs_input: false,
113                    needs_output: false,
114                    required_input_channels: 0,
115                    required_output_channels: 0,
116                },
117            )
118        })
119        .collect();
120
121    for route in &config.routes {
122        if let Some(role) = roles.get_mut(&route.from) {
123            role.needs_input = true;
124            for &ch in &route.from_channels {
125                if ch > role.required_input_channels {
126                    role.required_input_channels = ch;
127                }
128            }
129        }
130        if let Some(role) = roles.get_mut(&route.to) {
131            role.needs_output = true;
132            for &ch in &route.to_channels {
133                if ch > role.required_output_channels {
134                    role.required_output_channels = ch;
135                }
136            }
137        }
138    }
139
140    // --- warnings ---
141    if !config.engine.sample_rate_in_recommended_range() {
142        warnings.push(format!(
143            "engine.sample_rate {} is outside the recommended range (44100 or 48000)",
144            config.engine.sample_rate
145        ));
146    }
147    if !config.engine.buffer_size_in_recommended_range() {
148        warnings.push(format!(
149            "engine.buffer_size {} is outside the recommended range (64..=2048)",
150            config.engine.buffer_size
151        ));
152    }
153
154    let devices: Vec<ResolvedDeviceRole> = config
155        .devices
156        .iter()
157        .map(|d| roles.get(&d.name).cloned().unwrap())
158        .collect();
159
160    let routes: Vec<ValidatedRoute> = config
161        .routes
162        .iter()
163        .map(|r| ValidatedRoute {
164            from: r.from.clone(),
165            to: r.to.clone(),
166            from_channels: r.from_channels.clone(),
167            to_channels: r.to_channels.clone(),
168            gain_db: r.gain_db,
169            mute: r.mute,
170        })
171        .collect();
172
173    Ok(ValidatedConfig {
174        config,
175        devices,
176        routes,
177        warnings,
178    })
179}
180
181fn add_implicit_route_devices(devices: &mut Vec<DeviceConfig>, routes: &[RouteConfig]) {
182    let mut known: std::collections::HashSet<String> =
183        devices.iter().map(|device| device.name.clone()).collect();
184
185    for route_device in routes.iter().flat_map(|route| [&route.from, &route.to]) {
186        if known.insert(route_device.clone()) {
187            devices.push(DeviceConfig {
188                name: route_device.clone(),
189                device: route_device.clone(),
190                limiter: false,
191            });
192        }
193    }
194}
195
196fn validate_route(
197    i: usize,
198    route: &RouteConfig,
199    name_map: &HashMap<&str, &DeviceConfig>,
200    errors: &mut Vec<String>,
201) {
202    let known: Vec<&str> = name_map.keys().copied().collect();
203
204    if !name_map.contains_key(route.from.as_str()) {
205        errors.push(format!(
206            "route[{i}].from references unknown device alias \"{}\"; known devices: {}",
207            route.from,
208            known.join(", ")
209        ));
210    }
211    if !name_map.contains_key(route.to.as_str()) {
212        errors.push(format!(
213            "route[{i}].to references unknown device alias \"{}\"; known devices: {}",
214            route.to,
215            known.join(", ")
216        ));
217    }
218
219    if route.from == route.to {
220        errors.push(format!(
221            "route[{i}].from and route[{i}].to are both \"{}\"; same-device routes are rejected in v0.1 to prevent feedback",
222            route.from
223        ));
224    }
225
226    if route.from_channels.is_empty() {
227        errors.push(format!("route[{i}].from_channels is empty",));
228    }
229    if route.to_channels.is_empty() {
230        errors.push(format!("route[{i}].to_channels is empty",));
231    }
232
233    if route.from_channels.len() != route.to_channels.len() {
234        errors.push(format!(
235            "route[{i}] maps from_channels length {} to to_channels length {}; lengths must match. \
236             Use from_channels = [1, 1] for mono-to-stereo.",
237            route.from_channels.len(),
238            route.to_channels.len()
239        ));
240    }
241
242    for &ch in &route.from_channels {
243        if ch == 0 {
244            errors.push(format!(
245                "route[{i}].from_channels contains invalid channel 0; channels are 1-based"
246            ));
247        }
248    }
249    for &ch in &route.to_channels {
250        if ch == 0 {
251            errors.push(format!(
252                "route[{i}].to_channels contains invalid channel 0; channels are 1-based"
253            ));
254        }
255    }
256
257    if !route.gain_db.is_finite() {
258        errors.push(format!(
259            "route[{i}].gain_db is not a finite number (NaN or infinity rejected)"
260        ));
261    }
262}
263
264// ─── helpers on EngineConfig for recommended-range warnings ───────────────
265
266trait EngineConfigExt {
267    fn sample_rate_in_recommended_range(&self) -> bool;
268    fn buffer_size_in_recommended_range(&self) -> bool;
269}
270
271impl EngineConfigExt for crate::config::EngineConfig {
272    fn sample_rate_in_recommended_range(&self) -> bool {
273        matches!(self.sample_rate, 44100 | 48000)
274    }
275
276    fn buffer_size_in_recommended_range(&self) -> bool {
277        (64..=2048).contains(&self.buffer_size)
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn make_config() -> Config {
286        toml::from_str(
287            r#"
288[engine]
289sample_rate = 48000
290buffer_size = 256
291
292[[devices]]
293name = "src"
294device = "Source"
295
296[[devices]]
297name = "dst"
298device = "Dest"
299
300[[routes]]
301from = "src"
302to = "dst"
303from_channels = [1, 2]
304to_channels = [1, 2]
305"#,
306        )
307        .unwrap()
308    }
309
310    #[test]
311    fn valid_config_passes() {
312        let config = make_config();
313        let result = validate_config(config).unwrap();
314        assert_eq!(result.devices.len(), 2);
315        assert_eq!(result.routes.len(), 1);
316    }
317
318    #[test]
319    fn duplicate_device_names_fail() {
320        let mut config = make_config();
321        config.devices[1].name = "src".to_string();
322        let result = validate_config(config);
323        assert!(result.is_err());
324        let errors = result.unwrap_err();
325        assert!(errors.iter().any(|e| e.contains("duplicate")));
326    }
327
328    #[test]
329    fn mismatched_channel_lengths_fail() {
330        let mut config = make_config();
331        config.routes[0].to_channels = vec![1, 2, 3];
332        let result = validate_config(config);
333        assert!(result.is_err());
334        let errors = result.unwrap_err();
335        assert!(errors.iter().any(|e| e.contains("lengths must match")));
336    }
337
338    #[test]
339    fn zero_channel_fails() {
340        let mut config = make_config();
341        config.routes[0].from_channels = vec![0, 2];
342        let result = validate_config(config);
343        assert!(result.is_err());
344        let errors = result.unwrap_err();
345        assert!(errors.iter().any(|e| e.contains("invalid channel 0")));
346    }
347
348    #[test]
349    fn same_device_route_fails() {
350        let mut config = make_config();
351        config.routes[0].to = "src".to_string();
352        let result = validate_config(config);
353        assert!(result.is_err());
354        let errors = result.unwrap_err();
355        assert!(errors.iter().any(|e| e.contains("same-device routes")));
356    }
357
358    #[test]
359    fn nan_gain_fails() {
360        let mut config = make_config();
361        config.routes[0].gain_db = f32::NAN;
362        let result = validate_config(config);
363        assert!(result.is_err());
364        let errors = result.unwrap_err();
365        assert!(errors.iter().any(|e| e.contains("not a finite number")));
366    }
367
368    #[test]
369    fn inf_gain_fails() {
370        let mut config = make_config();
371        config.routes[0].gain_db = f32::INFINITY;
372        let result = validate_config(config);
373        assert!(result.is_err());
374    }
375
376    #[test]
377    fn role_inference_from_only() {
378        let config = make_config();
379        let result = validate_config(config).unwrap();
380        let src = result.device_by_name("src").unwrap();
381        let dst = result.device_by_name("dst").unwrap();
382        assert!(src.needs_input);
383        assert!(!src.needs_output);
384        assert!(!dst.needs_input);
385        assert!(dst.needs_output);
386    }
387
388    #[test]
389    fn role_inference_both() {
390        let config: Config = toml::from_str(
391            r#"
392[engine]
393sample_rate = 48000
394buffer_size = 256
395
396[[devices]]
397name = "a"
398device = "DevA"
399
400[[devices]]
401name = "b"
402device = "DevB"
403
404[[routes]]
405from = "a"
406to = "b"
407from_channels = [1]
408to_channels = [1]
409
410[[routes]]
411from = "b"
412to = "a"
413from_channels = [1]
414to_channels = [1]
415"#,
416        )
417        .unwrap();
418        let result = validate_config(config).unwrap();
419        let a = result.device_by_name("a").unwrap();
420        let b = result.device_by_name("b").unwrap();
421        assert!(a.needs_input && a.needs_output);
422        assert!(b.needs_input && b.needs_output);
423    }
424
425    #[test]
426    fn required_channel_counts() {
427        let config: Config = toml::from_str(
428            r#"
429[engine]
430sample_rate = 48000
431buffer_size = 256
432
433[[devices]]
434name = "src"
435device = "Source"
436
437[[devices]]
438name = "dst"
439device = "Dest"
440
441[[routes]]
442from = "src"
443to = "dst"
444from_channels = [3, 4]
445to_channels = [1, 2]
446"#,
447        )
448        .unwrap();
449        let result = validate_config(config).unwrap();
450        let src = result.device_by_name("src").unwrap();
451        let dst = result.device_by_name("dst").unwrap();
452        assert_eq!(src.required_input_channels, 4);
453        assert_eq!(dst.required_output_channels, 2);
454    }
455
456    #[test]
457    fn no_warning_for_unused_device() {
458        // Devices not referenced by any route are allowed without warning.
459        let config: Config = toml::from_str(
460            r#"
461[engine]
462sample_rate = 48000
463buffer_size = 256
464
465[[devices]]
466name = "src"
467device = "Source"
468
469[[devices]]
470name = "dst"
471device = "Dest"
472
473[[devices]]
474name = "unused"
475device = "Unused"
476
477[[routes]]
478from = "src"
479to = "dst"
480from_channels = [1]
481to_channels = [1]
482"#,
483        )
484        .unwrap();
485        let result = validate_config(config).unwrap();
486        assert!(
487            !result
488                .warnings
489                .iter()
490                .any(|w| w.contains("not used by any route"))
491        );
492    }
493
494    #[test]
495    fn missing_devices_are_inferred_from_routes() {
496        let config: Config = toml::from_str(
497            r#"
498[engine]
499sample_rate = 48000
500buffer_size = 256
501
502[[routes]]
503from = "Source"
504to = "Dest"
505from_channels = [1]
506to_channels = [1]
507"#,
508        )
509        .unwrap();
510        let result = validate_config(config).unwrap();
511
512        assert_eq!(result.config.devices.len(), 2);
513        assert_eq!(result.devices.len(), 2);
514
515        let source = result.device_by_name("Source").unwrap();
516        assert_eq!(source.device, "Source");
517        assert!(source.needs_input);
518        assert!(!source.needs_output);
519
520        let dest = result.device_by_name("Dest").unwrap();
521        assert_eq!(dest.device, "Dest");
522        assert!(!dest.needs_input);
523        assert!(dest.needs_output);
524    }
525
526    #[test]
527    fn missing_route_devices_are_added_to_explicit_devices() {
528        let config: Config = toml::from_str(
529            r#"
530[engine]
531sample_rate = 48000
532buffer_size = 256
533
534[[devices]]
535name = "out"
536device = "BlackHole 2ch"
537limiter = true
538
539[[routes]]
540from = "VT-4"
541to = "out"
542from_channels = [3, 4]
543to_channels = [1, 2]
544"#,
545        )
546        .unwrap();
547        let result = validate_config(config).unwrap();
548
549        assert_eq!(result.config.devices.len(), 2);
550        let input = result.device_by_name("VT-4").unwrap();
551        assert_eq!(input.device, "VT-4");
552        assert!(input.needs_input);
553
554        let output = result.device_by_name("out").unwrap();
555        assert_eq!(output.device, "BlackHole 2ch");
556        assert!(output.limiter);
557        assert!(output.needs_output);
558    }
559
560    #[test]
561    fn empty_routes_passes() {
562        let config: Config = toml::from_str(
563            r#"
564[engine]
565sample_rate = 48000
566buffer_size = 256
567
568[[devices]]
569name = "a"
570device = "DevA"
571"#,
572        )
573        .unwrap();
574        let result = validate_config(config).unwrap();
575        assert!(result.routes.is_empty());
576        assert_eq!(result.devices.len(), 1);
577    }
578
579    #[test]
580    fn zero_sample_rate_fails() {
581        let mut config = make_config();
582        config.engine.sample_rate = 0;
583        assert!(validate_config(config).is_err());
584    }
585
586    #[test]
587    fn zero_buffer_size_fails() {
588        let mut config = make_config();
589        config.engine.buffer_size = 0;
590        assert!(validate_config(config).is_err());
591    }
592}