Skip to main content

audiorouter_core/
config.rs

1//! Config structs, path resolution, and TOML parsing.
2
3use 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/// Top-level config structure.
11#[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/// `[engine]` — sample rate and buffer size.
22#[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/// `[[devices]]` — a named device alias.
40#[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/// `[[routes]]` — a channel mapping from one device to another.
78///
79/// Channel numbers are stored as `usize` but represent **1-based physical
80/// channel numbers**.
81#[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
93/// Resolve the default config path.
94///
95/// Resolution order:
96/// 1. `$XDG_CONFIG_HOME/audiorouter/config.toml` — if `XDG_CONFIG_HOME` is set and non-empty
97/// 2. `~/.config/audiorouter/config.toml` — if the file already exists there (XDG fallback,
98///    honoured on all platforms so dotfile-managed configs work on macOS/Windows too)
99/// 3. Platform-native config directory:
100///    - Linux/BSD: `~/.config/audiorouter/config.toml`
101///    - macOS:     `~/Library/Application Support/audiorouter/config.toml`
102///    - Windows:   `%APPDATA%\audiorouter\config.toml`
103///
104/// # Errors
105///
106/// Returns an error if the home/config directory cannot be determined.
107pub fn default_config_path() -> anyhow::Result<PathBuf> {
108    // 1. Explicit XDG override.
109    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    // 2. XDG fallback: ~/.config — honoured on all platforms so users who manage
116    //    dotfiles with stow/chezmoi/etc. on macOS or Windows don't need to move files.
117    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    // 3. Platform-native directory.
125    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
130/// Resolve a config path from the optional positional `CONFIG` argument or
131/// the default path.
132///
133/// Does **not** call `canonicalize()` so that `config-path` works even
134/// when the file does not exist.
135///
136/// # Errors
137///
138/// Returns an error if the default path cannot be determined.
139pub 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
153/// Read and parse a TOML config file.
154///
155/// # Errors
156///
157/// Returns an error whose message includes the path (and a hint if the file
158/// does not exist) when reading or parsing fails.
159pub 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        // mono-to-stereo route
241        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        // SAFETY: setenv is safe in single-threaded tests.
346        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}