audiorouter_core/
config.rs1use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7pub const DEFAULT_SAMPLE_RATE: u32 = 48000;
8pub const DEFAULT_BUFFER_SIZE: u32 = 256;
9
10#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct Config {
13 #[serde(default)]
14 pub engine: EngineConfig,
15 #[serde(default)]
16 pub devices: Vec<DeviceConfig>,
17 #[serde(default)]
18 pub routes: Vec<RouteConfig>,
19}
20
21#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct EngineConfig {
24 #[serde(default = "default_sample_rate")]
25 pub sample_rate: u32,
26 #[serde(default = "default_buffer_size")]
27 pub buffer_size: u32,
28}
29
30impl Default for EngineConfig {
31 fn default() -> Self {
32 Self {
33 sample_rate: DEFAULT_SAMPLE_RATE,
34 buffer_size: DEFAULT_BUFFER_SIZE,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Serialize)]
41pub struct DeviceConfig {
42 pub name: String,
43 pub device: String,
44 pub limiter: bool,
45}
46
47impl<'de> Deserialize<'de> for DeviceConfig {
48 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
49 where
50 D: serde::Deserializer<'de>,
51 {
52 #[derive(Deserialize)]
53 struct RawDeviceConfig {
54 name: Option<String>,
55 device: String,
56 #[serde(default)]
57 limiter: bool,
58 }
59
60 let raw = RawDeviceConfig::deserialize(deserializer)?;
61 Ok(Self {
62 name: raw.name.unwrap_or_else(|| raw.device.clone()),
63 device: raw.device,
64 limiter: raw.limiter,
65 })
66 }
67}
68
69fn default_sample_rate() -> u32 {
70 DEFAULT_SAMPLE_RATE
71}
72
73fn default_buffer_size() -> u32 {
74 DEFAULT_BUFFER_SIZE
75}
76
77#[derive(Debug, Clone, Deserialize, Serialize)]
82pub struct RouteConfig {
83 pub from: String,
84 pub to: String,
85 pub from_channels: Vec<usize>,
86 pub to_channels: Vec<usize>,
87 #[serde(default)]
88 pub gain_db: f32,
89 #[serde(default)]
90 pub mute: bool,
91}
92
93pub fn default_config_path() -> anyhow::Result<PathBuf> {
108 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME")
110 && !xdg.is_empty()
111 {
112 return Ok(PathBuf::from(xdg).join("audiorouter").join("config.toml"));
113 }
114
115 if let Some(home) = dirs::home_dir() {
118 let xdg_fallback = home.join(".config").join("audiorouter").join("config.toml");
119 if xdg_fallback.exists() {
120 return Ok(xdg_fallback);
121 }
122 }
123
124 let config_dir =
126 dirs::config_dir().ok_or_else(|| anyhow::anyhow!("cannot determine config directory"))?;
127 Ok(config_dir.join("audiorouter").join("config.toml"))
128}
129
130pub fn resolve_config_path(config_arg: Option<&Path>) -> anyhow::Result<PathBuf> {
140 match config_arg {
141 Some(p) => {
142 if p.is_absolute() {
143 Ok(p.to_path_buf())
144 } else {
145 let cwd = std::env::current_dir()?;
146 Ok(cwd.join(p))
147 }
148 }
149 None => default_config_path(),
150 }
151}
152
153pub fn read_config(path: &Path) -> anyhow::Result<Config> {
160 let content = std::fs::read_to_string(path).map_err(|e| {
161 anyhow::anyhow!(
162 "cannot read config file {}: {e}\n\
163 Hint: Run 'audiorouter config-path' to see the expected location.",
164 path.display()
165 )
166 })?;
167
168 toml::from_str(&content)
169 .map_err(|e| anyhow::anyhow!("failed to parse config {}: {e}", path.display()))
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 const SAMPLE_CONFIG: &str = r#"
177[engine]
178sample_rate = 48000
179buffer_size = 256
180
181[[devices]]
182name = "vt4"
183device = "VT-4"
184
185[[devices]]
186name = "mic"
187device = "MacBook Pro Microphone"
188
189[[devices]]
190name = "blackhole"
191device = "BlackHole 2ch"
192limiter = true
193
194[[devices]]
195name = "speaker"
196device = "MacBook Pro Speakers"
197
198[[routes]]
199from = "vt4"
200to = "blackhole"
201from_channels = [3, 4]
202to_channels = [1, 2]
203gain_db = 0.0
204
205[[routes]]
206from = "mic"
207to = "blackhole"
208from_channels = [1, 1]
209to_channels = [1, 2]
210gain_db = -8.0
211
212[[routes]]
213from = "vt4"
214to = "speaker"
215from_channels = [3, 4]
216to_channels = [1, 2]
217gain_db = -12.0
218"#;
219
220 #[test]
221 fn parse_sample_config() {
222 let config: Config = toml::from_str(SAMPLE_CONFIG).unwrap();
223 assert_eq!(config.engine.sample_rate, 48000);
224 assert_eq!(config.engine.buffer_size, 256);
225 assert_eq!(config.devices.len(), 4);
226 assert_eq!(config.routes.len(), 3);
227
228 assert_eq!(config.devices[0].name, "vt4");
229 assert_eq!(config.devices[0].device, "VT-4");
230 assert!(!config.devices[0].limiter);
231
232 assert_eq!(config.devices[2].name, "blackhole");
233 assert!(config.devices[2].limiter);
234
235 assert_eq!(config.routes[0].from, "vt4");
236 assert_eq!(config.routes[0].to, "blackhole");
237 assert_eq!(config.routes[0].from_channels, vec![3, 4]);
238 assert_eq!(config.routes[0].to_channels, vec![1, 2]);
239
240 assert_eq!(config.routes[1].from_channels, vec![1, 1]);
242 assert_eq!(config.routes[1].to_channels, vec![1, 2]);
243 assert!((config.routes[1].gain_db - (-8.0)).abs() < 1e-6);
244 }
245
246 #[test]
247 fn default_mute_is_false() {
248 let config: Config = toml::from_str(
249 r#"
250[engine]
251sample_rate = 44100
252buffer_size = 128
253
254[[devices]]
255name = "a"
256device = "DevA"
257
258[[devices]]
259name = "b"
260device = "DevB"
261
262[[routes]]
263from = "a"
264to = "b"
265from_channels = [1]
266to_channels = [1]
267"#,
268 )
269 .unwrap();
270 assert!(!config.routes[0].mute);
271 assert!((config.routes[0].gain_db - 0.0).abs() < 1e-6);
272 }
273
274 #[test]
275 fn default_engine_is_used_when_engine_table_is_missing() {
276 let config: Config = toml::from_str(
277 r#"
278[[devices]]
279device = "Source"
280
281[[devices]]
282device = "Dest"
283
284[[routes]]
285from = "Source"
286to = "Dest"
287from_channels = [1]
288to_channels = [1]
289"#,
290 )
291 .unwrap();
292
293 assert_eq!(config.engine.sample_rate, DEFAULT_SAMPLE_RATE);
294 assert_eq!(config.engine.buffer_size, DEFAULT_BUFFER_SIZE);
295 }
296
297 #[test]
298 fn default_engine_fields_are_used_when_missing() {
299 let config: Config = toml::from_str(
300 r#"
301[engine]
302sample_rate = 44100
303
304[[devices]]
305device = "Source"
306
307[[devices]]
308device = "Dest"
309
310[[routes]]
311from = "Source"
312to = "Dest"
313from_channels = [1]
314to_channels = [1]
315"#,
316 )
317 .unwrap();
318
319 assert_eq!(config.engine.sample_rate, 44100);
320 assert_eq!(config.engine.buffer_size, DEFAULT_BUFFER_SIZE);
321 }
322
323 #[test]
324 fn device_name_defaults_to_device_string() {
325 let config: Config = toml::from_str(
326 r#"
327[engine]
328sample_rate = 48000
329buffer_size = 256
330
331[[devices]]
332device = "BlackHole 2ch"
333limiter = true
334"#,
335 )
336 .unwrap();
337
338 assert_eq!(config.devices[0].name, "BlackHole 2ch");
339 assert_eq!(config.devices[0].device, "BlackHole 2ch");
340 assert!(config.devices[0].limiter);
341 }
342
343 #[test]
344 fn xdg_config_path() {
345 unsafe {
347 std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg_test");
348 }
349 let path = default_config_path().unwrap();
350 assert_eq!(path, PathBuf::from("/tmp/xdg_test/audiorouter/config.toml"));
351 unsafe {
352 std::env::remove_var("XDG_CONFIG_HOME");
353 }
354 }
355
356 #[cfg(target_os = "linux")]
357 #[test]
358 fn empty_xdg_falls_back_to_home() {
359 unsafe {
360 std::env::set_var("XDG_CONFIG_HOME", "");
361 }
362 let path = default_config_path().unwrap();
363 assert!(path.ends_with(".config/audiorouter/config.toml"));
364 unsafe {
365 std::env::remove_var("XDG_CONFIG_HOME");
366 }
367 }
368
369 #[test]
370 fn absolute_config_path_returned_unchanged() {
371 let p = Path::new("/absolute/path/config.toml");
372 let resolved = resolve_config_path(Some(p)).unwrap();
373 assert_eq!(resolved, PathBuf::from("/absolute/path/config.toml"));
374 }
375
376 #[test]
377 fn relative_config_path_joined_with_cwd() {
378 let p = Path::new("relative.toml");
379 let resolved = resolve_config_path(Some(p)).unwrap();
380 let cwd = std::env::current_dir().unwrap();
381 assert_eq!(resolved, cwd.join("relative.toml"));
382 }
383
384 #[test]
385 fn read_missing_file_includes_hint() {
386 let result = read_config(Path::new("/nonexistent/audiorouter-test.toml"));
387 assert!(result.is_err());
388 let msg = format!("{}", result.unwrap_err());
389 assert!(msg.contains("config-path"));
390 }
391}