1use std::collections::HashMap;
8
9use serde::Serialize;
10
11use crate::config::{Config, DeviceConfig, RouteConfig};
12
13#[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#[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#[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 pub fn device_by_name(&self, name: &str) -> Option<&ResolvedDeviceRole> {
48 self.devices.iter().find(|d| d.name == name)
49 }
50}
51
52pub fn validate_config(mut config: Config) -> Result<ValidatedConfig, Vec<String>> {
57 let mut errors = Vec::new();
58 let mut warnings = Vec::new();
59
60 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 for (i, route) in config.routes.iter().enumerate() {
93 validate_route(i, route, &name_map, &mut errors);
94 }
95
96 if !errors.is_empty() {
98 return Err(errors);
99 }
100
101 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 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
264trait 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 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}