use anyhow::Result;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct ObsConnCfg {
pub host: String,
pub port: u16,
pub password: Option<String>,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_field_names)] pub struct Config {
pub routes_dir: PathBuf,
pub default_bpm: f64,
pub default_ppqn: u32,
pub route_configs: HashMap<String, toml::Value>,
pub config_path: Option<PathBuf>,
pub default_connect_input: Option<String>,
pub default_connect_output: Option<String>,
pub osc_receive_port: Option<u16>,
pub osc_send_addr: Option<String>,
pub osc_heartbeat_interval: f64,
pub routes_dir_override: Option<PathBuf>,
pub state_dir: Option<PathBuf>,
pub obs: HashMap<String, ObsConnCfg>,
}
#[derive(Deserialize)]
struct RawConfig {
routes_dir: Option<PathBuf>,
#[serde(default = "default_bpm")]
default_bpm: f64,
#[serde(default = "default_ppqn")]
default_ppqn: u32,
default_connect_input: Option<String>,
default_connect_output: Option<String>,
osc_receive_port: Option<u16>,
osc_send_addr: Option<String>,
#[serde(default = "default_osc_heartbeat_interval")]
osc_heartbeat_interval: f64,
state_dir: Option<PathBuf>,
#[serde(default)]
obs: HashMap<String, ObsConnCfg>,
#[serde(flatten)]
route_configs: HashMap<String, toml::Value>,
}
impl RawConfig {
fn into_config(self, default_routes_dir: PathBuf, config_path: Option<PathBuf>) -> Config {
Config {
routes_dir: self.routes_dir.unwrap_or(default_routes_dir),
default_bpm: self.default_bpm,
default_ppqn: self.default_ppqn,
route_configs: self.route_configs,
config_path,
default_connect_input: self.default_connect_input,
default_connect_output: self.default_connect_output,
osc_receive_port: self.osc_receive_port,
osc_send_addr: self.osc_send_addr,
osc_heartbeat_interval: self.osc_heartbeat_interval,
routes_dir_override: None,
state_dir: self.state_dir,
obs: self.obs,
}
}
}
fn default_bpm() -> f64 {
120.0
}
fn default_ppqn() -> u32 {
24
}
fn default_osc_heartbeat_interval() -> f64 {
5.0
}
fn default_config(routes_dir: PathBuf) -> Config {
Config {
routes_dir,
default_bpm: default_bpm(),
default_ppqn: default_ppqn(),
route_configs: HashMap::new(),
config_path: None,
default_connect_input: None,
default_connect_output: None,
osc_receive_port: None,
osc_send_addr: None,
osc_heartbeat_interval: default_osc_heartbeat_interval(),
routes_dir_override: None,
state_dir: None,
obs: HashMap::new(),
}
}
fn user_config_dir() -> Option<PathBuf> {
let home = dirs::home_dir()?;
if !home.exists() || home == std::path::Path::new("/var/empty") {
return None;
}
dirs::config_dir().map(|d| d.join("midi-daemon"))
}
fn system_config_dir() -> PathBuf {
PathBuf::from("/etc/midi-daemon")
}
fn system_cache_dir() -> PathBuf {
PathBuf::from("/var/cache/midi-daemon")
}
#[allow(dead_code)]
pub fn default_config_path() -> PathBuf {
user_config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config/midi-daemon"))
.join("config.toml")
}
pub fn control_socket_path() -> PathBuf {
if let Ok(dir) = std::env::var("RUNTIME_DIRECTORY") {
return PathBuf::from(dir).join("control.sock");
}
if unsafe { libc::getuid() } == 0 {
return PathBuf::from("/run/midi-daemon/control.sock");
}
let system_sock = PathBuf::from("/run/midi-daemon/control.sock");
if system_sock.exists() {
return system_sock;
}
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
return PathBuf::from(dir).join("midi-daemon/control.sock");
}
if let Some(cache) = dirs::cache_dir()
&& cache.parent().is_some_and(std::path::Path::exists) {
return cache.join("midi-daemon/control.sock");
}
PathBuf::from("/tmp/midi-daemon.sock")
}
impl Config {
#[allow(clippy::unused_self)] pub fn cache_dir(&self) -> PathBuf {
if let Ok(dir) = std::env::var("CACHE_DIRECTORY") {
return PathBuf::from(dir);
}
if unsafe { libc::getuid() } == 0 {
return system_cache_dir();
}
let home = dirs::home_dir();
let home_usable = home
.as_deref()
.is_some_and(|h| h.exists() && h != std::path::Path::new("/var/empty"));
if !home_usable {
return system_cache_dir();
}
dirs::cache_dir().map_or_else(|| {
home.unwrap().join(".cache/midi-daemon")
}, |d| d.join("midi-daemon"))
}
pub fn lua_state_dir(&self) -> PathBuf {
self.state_dir
.clone()
.unwrap_or_else(|| self.cache_dir().join("lua-state"))
}
pub fn route_config(&self, name: &str) -> Option<&toml::Table> {
self.route_configs.get(name)?.as_table()
}
pub fn find_and_load_with_overrides(
config_override: Option<&Path>,
routes_override: Option<&Path>,
) -> Result<Self> {
let mut cfg = if let Some(path) = config_override {
let default_routes = path.parent().unwrap_or(path).join("routes.d");
tracing::info!("Loading config from --config: {}", path.display());
Self::load_file(path, default_routes)?
} else {
Self::find_and_load()?
};
if let Some(dir) = routes_override {
tracing::info!("Routes directory overridden by --routes: {}", dir.display());
cfg.routes_dir = dir.to_path_buf();
cfg.routes_dir_override = Some(dir.to_path_buf());
}
Ok(cfg)
}
pub fn find_and_load() -> Result<Self> {
if let Ok(val) = std::env::var("MIDI_DAEMON_CONFIG") {
let path = PathBuf::from(&val);
let default_routes = path
.parent()
.unwrap_or(path.as_path())
.join("routes.d");
tracing::info!("Loading config from $MIDI_DAEMON_CONFIG: {}", path.display());
return Self::load_file(&path, default_routes);
}
if let Some(dir) = user_config_dir() {
let path = dir.join("config.toml");
if path.exists() {
tracing::info!("Loading user config: {}", path.display());
return Self::load_file(&path, dir.join("routes.d"));
}
}
{
let dir = system_config_dir();
let path = dir.join("config.toml");
if path.exists() {
tracing::info!("Loading system config: {}", path.display());
return Self::load_file(&path, dir.join("routes.d"));
}
}
let (routes_dir, scope) = user_config_dir().map_or_else(|| (system_config_dir().join("routes.d"), "system"), |d| (d.join("routes.d"), "user"));
tracing::info!("No config file found, using {} defaults", scope);
Ok(default_config(routes_dir))
}
#[allow(dead_code)]
pub fn load(path: &Path) -> Result<Self> {
let default_routes = path.parent().unwrap_or(path).join("routes.d");
if path.exists() {
Self::load_file(path, default_routes)
} else {
tracing::info!("No config found at {}, using defaults", path.display());
let routes_dir = user_config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config/midi-daemon"))
.join("routes.d");
Ok(default_config(routes_dir))
}
}
pub fn reload(&self) -> Result<Self> {
let default_routes = self.routes_dir_override
.clone()
.unwrap_or_else(|| self.routes_dir.clone());
let mut new_cfg = match &self.config_path {
Some(path) => Self::load_file(path, default_routes)?,
None => return Ok(self.clone()),
};
if let Some(ref override_dir) = self.routes_dir_override {
new_cfg.routes_dir.clone_from(override_dir);
new_cfg.routes_dir_override = Some(override_dir.clone());
}
Ok(new_cfg)
}
fn load_file(path: &Path, default_routes_dir: PathBuf) -> Result<Self> {
let text = std::fs::read_to_string(path)?;
let raw: RawConfig = toml::from_str(&text)?;
Ok(raw.into_config(default_routes_dir, Some(path.to_path_buf())))
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)] mod tests {
use super::*;
use std::io::Write;
fn write_tmp(name: &str, content: &str) -> PathBuf {
let path = std::env::temp_dir().join(name);
let mut f = std::fs::File::create(&path).unwrap();
write!(f, "{content}").unwrap();
path
}
#[test]
fn default_bpm_is_120() {
assert_eq!(default_bpm(), 120.0);
}
#[test]
fn default_ppqn_is_24() {
assert_eq!(default_ppqn(), 24);
}
#[test]
fn missing_file_returns_defaults() {
let path = PathBuf::from("/tmp/midi_daemon_nonexistent_config_abc123.toml");
let _ = std::fs::remove_file(&path);
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.default_bpm, 120.0);
assert_eq!(cfg.default_ppqn, 24);
}
#[test]
fn load_full_config() {
let path = write_tmp(
"midi_daemon_test_full.toml",
"default_bpm = 140.0\ndefault_ppqn = 48\n",
);
let cfg = Config::load(&path).unwrap();
assert!((cfg.default_bpm - 140.0).abs() < 1e-9);
assert_eq!(cfg.default_ppqn, 48);
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_partial_config_fills_defaults() {
let path = write_tmp("midi_daemon_test_partial.toml", "default_bpm = 90.0\n");
let cfg = Config::load(&path).unwrap();
assert!((cfg.default_bpm - 90.0).abs() < 1e-9);
assert_eq!(cfg.default_ppqn, 24);
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_ppqn_only() {
let path = write_tmp("midi_daemon_test_ppqn.toml", "default_ppqn = 96\n");
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.default_bpm, 120.0);
assert_eq!(cfg.default_ppqn, 96);
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_custom_routes_dir() {
let path = write_tmp(
"midi_daemon_test_routes_dir.toml",
"routes_dir = \"/custom/routes\"\n",
);
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.routes_dir, PathBuf::from("/custom/routes"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn routes_dir_defaults_to_parent_of_config() {
let path = write_tmp("midi_daemon_test_routes_default.toml", "");
let cfg = Config::load(&path).unwrap();
let expected = path.parent().unwrap().join("routes.d");
assert_eq!(cfg.routes_dir, expected);
let _ = std::fs::remove_file(&path);
}
#[test]
fn invalid_toml_returns_error() {
let path = write_tmp("midi_daemon_test_invalid.toml", "not valid toml ][[\n");
let result = Config::load(&path);
assert!(result.is_err());
let _ = std::fs::remove_file(&path);
}
#[test]
fn default_config_path_ends_with_config_toml() {
let p = default_config_path();
assert_eq!(p.file_name().unwrap(), "config.toml");
}
#[test]
fn explicit_config_path_loads_correctly() {
let path = write_tmp("midi_daemon_test_explicit_config.toml", "default_bpm = 99.0\n");
let cfg = Config::find_and_load_with_overrides(Some(&path), None).unwrap();
assert!((cfg.default_bpm - 99.0).abs() < 1e-9);
assert_eq!(cfg.config_path, Some(path.clone()));
assert!(cfg.routes_dir_override.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn explicit_config_path_missing_returns_error() {
let path = PathBuf::from("/tmp/midi_daemon_nonexistent_explicit_xyz.toml");
let _ = std::fs::remove_file(&path);
let result = Config::find_and_load_with_overrides(Some(&path), None);
assert!(result.is_err());
}
#[test]
fn routes_override_wins_over_config_file() {
let path = write_tmp(
"midi_daemon_test_routes_override.toml",
"routes_dir = \"/config/routes\"\n",
);
let override_dir = PathBuf::from("/override/routes");
let cfg = Config::find_and_load_with_overrides(Some(&path), Some(&override_dir)).unwrap();
assert_eq!(cfg.routes_dir, override_dir);
assert_eq!(cfg.routes_dir_override, Some(override_dir));
let _ = std::fs::remove_file(&path);
}
#[test]
fn routes_override_survives_reload() {
let path = write_tmp(
"midi_daemon_test_routes_reload.toml",
"routes_dir = \"/config/routes\"\n",
);
let override_dir = PathBuf::from("/override/routes");
let cfg = Config::find_and_load_with_overrides(Some(&path), Some(&override_dir)).unwrap();
let reloaded = cfg.reload().unwrap();
assert_eq!(reloaded.routes_dir, override_dir);
assert_eq!(reloaded.routes_dir_override, Some(override_dir));
let _ = std::fs::remove_file(&path);
}
#[test]
fn no_overrides_leaves_routes_dir_override_none() {
let cfg = Config::find_and_load_with_overrides(None, None).unwrap();
assert!(cfg.routes_dir_override.is_none());
}
#[test]
fn routes_only_override_with_no_config_override() {
let override_dir = PathBuf::from("/cli/routes");
let cfg = Config::find_and_load_with_overrides(None, Some(&override_dir)).unwrap();
assert_eq!(cfg.routes_dir, override_dir);
assert_eq!(cfg.routes_dir_override, Some(override_dir));
}
#[test]
fn connect_fields_absent_returns_none() {
let path = write_tmp("midi_daemon_test_no_connect.toml", "default_bpm = 120.0\n");
let cfg = Config::load(&path).unwrap();
assert!(cfg.default_connect_input.is_none());
assert!(cfg.default_connect_output.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn connect_input_only_parses() {
let path = write_tmp(
"midi_daemon_test_connect_in.toml",
"default_connect_input = \".*KeyLab.*\"\n",
);
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.default_connect_input.as_deref(), Some(".*KeyLab.*"));
assert!(cfg.default_connect_output.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn connect_output_only_parses() {
let path = write_tmp(
"midi_daemon_test_connect_out.toml",
"default_connect_output = \".*Surge.*\"\n",
);
let cfg = Config::load(&path).unwrap();
assert!(cfg.default_connect_input.is_none());
assert_eq!(cfg.default_connect_output.as_deref(), Some(".*Surge.*"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn connect_both_fields_parse() {
let path = write_tmp(
"midi_daemon_test_connect_both.toml",
"default_connect_input = \".*KeyLab.*\"\ndefault_connect_output = \".*Surge.*\"\n",
);
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.default_connect_input.as_deref(), Some(".*KeyLab.*"));
assert_eq!(cfg.default_connect_output.as_deref(), Some(".*Surge.*"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn connect_fields_survive_missing_file_fallback() {
let path = PathBuf::from("/tmp/midi_daemon_nonexistent_connect_xyz.toml");
let _ = std::fs::remove_file(&path);
let cfg = Config::load(&path).unwrap();
assert!(cfg.default_connect_input.is_none());
assert!(cfg.default_connect_output.is_none());
}
#[test]
fn state_dir_absent_falls_back_to_cache_dir() {
let path = write_tmp("midi_daemon_test_no_state_dir.toml", "default_bpm = 120.0\n");
let cfg = Config::load(&path).unwrap();
assert!(cfg.state_dir.is_none());
assert_eq!(cfg.lua_state_dir(), cfg.cache_dir().join("lua-state"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn state_dir_configured_wins_over_cache_dir() {
let path = write_tmp(
"midi_daemon_test_state_dir.toml",
"state_dir = \"/custom/lua-state\"\n",
);
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.lua_state_dir(), PathBuf::from("/custom/lua-state"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn obs_section_absent_returns_empty_map() {
let path = write_tmp("midi_daemon_test_no_obs.toml", "default_bpm = 120.0\n");
let cfg = Config::load(&path).unwrap();
assert!(cfg.obs.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn single_obs_connection_parses() {
let path = write_tmp(
"midi_daemon_test_obs_single.toml",
"[obs.main]\nhost = \"127.0.0.1\"\nport = 4455\npassword = \"secret\"\n",
);
let cfg = Config::load(&path).unwrap();
let main = cfg.obs.get("main").unwrap();
assert_eq!(main.host, "127.0.0.1");
assert_eq!(main.port, 4455);
assert_eq!(main.password.as_deref(), Some("secret"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn obs_connection_without_password_parses() {
let path = write_tmp(
"midi_daemon_test_obs_no_pw.toml",
"[obs.main]\nhost = \"127.0.0.1\"\nport = 4455\n",
);
let cfg = Config::load(&path).unwrap();
assert!(cfg.obs.get("main").unwrap().password.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn multiple_obs_connections_parse_independently() {
let path = write_tmp(
"midi_daemon_test_obs_multi.toml",
"[obs.main]\nhost = \"127.0.0.1\"\nport = 4455\n\n[obs.backup]\nhost = \"192.168.1.50\"\nport = 4456\npassword = \"other\"\n",
);
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.obs.len(), 2);
assert_eq!(cfg.obs.get("main").unwrap().port, 4455);
assert_eq!(cfg.obs.get("backup").unwrap().password.as_deref(), Some("other"));
let _ = std::fs::remove_file(&path);
}
}