use std::{ collections::BTreeMap, env, path::{Path, PathBuf}, }; use anyhow::bail; use serde::{Deserialize, Serialize}; use tokio::fs; use tracing_subscriber::EnvFilter; /// The complete daemon configuration loaded from TOML. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct TvctlConfig { /// Runtime daemon settings. pub daemon: DaemonConfig, /// Discovery scan behavior. pub discovery: DiscoveryConfig, /// Default-device settings. pub devices: DeviceConfig, /// Remote input behavior. pub remote: RemoteConfig, /// Developer tooling toggles. pub dev: DevConfig, } impl TvctlConfig { /// Load configuration from the default XDG path or return defaults when absent. pub async fn load() -> anyhow::Result { Self::load_from_path(&default_config_path()).await } /// Load configuration from a specific path or return defaults when absent. pub async fn load_from_path(path: &Path) -> anyhow::Result { match fs::read_to_string(path).await { Ok(contents) => Ok(toml::from_str::(&contents)?), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), Err(error) => Err(error.into()), } } /// Persist configuration to a specific path, creating parent directories first. pub async fn save_to_path(&self, path: &Path) -> anyhow::Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).await?; } let contents = toml::to_string_pretty(self)?; fs::write(path, contents).await?; Ok(()) } /// Return the config as stable flattened key/value pairs. pub fn entries(&self) -> BTreeMap<&'static str, String> { BTreeMap::from([ ("daemon.socket", self.daemon.socket.clone()), ("daemon.http_enabled", self.daemon.http_enabled.to_string()), ("daemon.http_port", self.daemon.http_port.to_string()), ("daemon.http_host", self.daemon.http_host.clone()), ("daemon.log_level", self.daemon.log_level.clone()), ( "discovery.auto_discover", self.discovery.auto_discover.to_string(), ), ( "discovery.interval_secs", self.discovery.interval_secs.to_string(), ), ( "discovery.timeout_secs", self.discovery.timeout_secs.to_string(), ), ("devices.default", self.devices.default.clone()), ("remote.roku_key_mode", self.remote.roku_key_mode.clone()), ( "remote.roku_press_duration_ms", self.remote.roku_press_duration_ms.to_string(), ), ("dev.enabled", self.dev.enabled.to_string()), ("dev.roku_username", self.dev.roku_username.clone()), ("dev.roku_password", self.dev.roku_password.clone()), ]) } /// Return one flattened config value by stable key. pub fn get_value(&self, key: &str) -> Option { match key { "daemon.socket" => Some(self.daemon.socket.clone()), "daemon.http_enabled" => Some(self.daemon.http_enabled.to_string()), "daemon.http_port" => Some(self.daemon.http_port.to_string()), "daemon.http_host" => Some(self.daemon.http_host.clone()), "daemon.log_level" => Some(self.daemon.log_level.clone()), "discovery.auto_discover" => Some(self.discovery.auto_discover.to_string()), "discovery.interval_secs" => Some(self.discovery.interval_secs.to_string()), "discovery.timeout_secs" => Some(self.discovery.timeout_secs.to_string()), "devices.default" => Some(self.devices.default.clone()), "remote.roku_key_mode" => Some(self.remote.roku_key_mode.clone()), "remote.roku_press_duration_ms" => Some(self.remote.roku_press_duration_ms.to_string()), "dev.enabled" => Some(self.dev.enabled.to_string()), "dev.roku_username" => Some(self.dev.roku_username.clone()), "dev.roku_password" => Some(self.dev.roku_password.clone()), _ => None, } } /// Set one flattened config value by stable key. pub fn set_value(&mut self, key: &str, value: &str) -> anyhow::Result<()> { match key { "daemon.socket" => self.daemon.socket = value.to_string(), "daemon.http_enabled" => self.daemon.http_enabled = parse_value(key, value)?, "daemon.http_port" => self.daemon.http_port = parse_value(key, value)?, "daemon.http_host" => self.daemon.http_host = value.to_string(), "daemon.log_level" => { validate_log_level(value)?; self.daemon.log_level = value.to_string(); } "discovery.auto_discover" => self.discovery.auto_discover = parse_value(key, value)?, "discovery.interval_secs" => self.discovery.interval_secs = parse_value(key, value)?, "discovery.timeout_secs" => self.discovery.timeout_secs = parse_value(key, value)?, "devices.default" => self.devices.default = value.to_string(), "remote.roku_key_mode" => self.remote.roku_key_mode = value.to_string(), "remote.roku_press_duration_ms" => { self.remote.roku_press_duration_ms = parse_value(key, value)? } "dev.enabled" => self.dev.enabled = parse_value(key, value)?, "dev.roku_username" => self.dev.roku_username = value.to_string(), "dev.roku_password" => self.dev.roku_password = value.to_string(), other => bail!("unknown config key '{other}'"), } Ok(()) } } /// Runtime daemon settings. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct DaemonConfig { /// Unix socket path for CLI communication. pub socket: String, /// Whether the HTTP API is enabled. pub http_enabled: bool, /// Loopback HTTP port. pub http_port: u16, /// Loopback host or bind address. pub http_host: String, /// Logging level. pub log_level: String, } impl Default for DaemonConfig { fn default() -> Self { Self { socket: default_socket_path().to_string_lossy().into_owned(), http_enabled: true, http_port: 7272, http_host: "127.0.0.1".to_string(), log_level: "info".to_string(), } } } /// Discovery scan behavior. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct DiscoveryConfig { /// Whether discovery runs automatically on daemon start. pub auto_discover: bool, /// How often discovery repeats, in seconds. pub interval_secs: u64, /// Per-device timeout in seconds. pub timeout_secs: u64, } impl Default for DiscoveryConfig { fn default() -> Self { Self { auto_discover: true, interval_secs: 300, timeout_secs: 5, } } } /// Default-device settings. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(default)] pub struct DeviceConfig { /// The default device name or UUID. pub default: String, } /// Remote input behavior. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct RemoteConfig { /// Roku key delivery mode: `keypress` or `keydown_up`. pub roku_key_mode: String, /// How long a Roku key stays pressed before `keyup`, in milliseconds. pub roku_press_duration_ms: u64, } impl Default for RemoteConfig { fn default() -> Self { Self { roku_key_mode: "keypress".to_string(), roku_press_duration_ms: 75, } } } /// Developer tooling toggles. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct DevConfig { /// Whether developer tooling is enabled. pub enabled: bool, /// Optional Roku developer username override. pub roku_username: String, /// Optional Roku developer password override. pub roku_password: String, } impl Default for DevConfig { fn default() -> Self { Self { enabled: true, roku_username: String::new(), roku_password: String::new(), } } } /// Canonical runtime paths used by the daemon. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimePaths { /// The configuration file path. pub config_file: PathBuf, /// The data directory root. pub data_dir: PathBuf, /// The device registry path. pub devices_file: PathBuf, /// The platform app cache directory. pub cache_dir: PathBuf, /// The file containing the currently active daemon socket path. pub active_socket_file: PathBuf, /// The runtime socket path. pub socket_file: PathBuf, } impl RuntimePaths { /// Build the canonical path set from XDG defaults. pub fn detect() -> Self { let config_file = default_config_path(); let data_dir = default_data_dir(); let cache_dir = data_dir.join("cache"); Self { config_file, devices_file: data_dir.join("devices.json"), active_socket_file: data_dir.join("active_socket"), socket_file: default_socket_path(), data_dir, cache_dir, } } } /// Return the default config file path. pub fn default_config_path() -> PathBuf { default_config_dir().join("config.toml") } /// Return the default config directory path. pub fn default_config_dir() -> PathBuf { if let Ok(path) = env::var("XDG_CONFIG_HOME") { return PathBuf::from(path).join("tvctl"); } home_dir().join(".config/tvctl") } /// Return the user-level systemd unit directory path. pub fn systemd_user_dir() -> PathBuf { if let Ok(path) = env::var("XDG_CONFIG_HOME") { return PathBuf::from(path).join("systemd/user"); } home_dir().join(".config/systemd/user") } /// Return the canonical tvctld user service unit path. pub fn systemd_unit_path() -> PathBuf { systemd_user_dir().join("tvctld.service") } /// Return the default data directory path. pub fn default_data_dir() -> PathBuf { if let Ok(path) = env::var("XDG_DATA_HOME") { return PathBuf::from(path).join("tvctl"); } home_dir().join(".local/share/tvctl") } /// Return the default runtime socket path. pub fn default_socket_path() -> PathBuf { let runtime_dir = env::var("XDG_RUNTIME_DIR") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(format!("/run/user/{}", current_uid()))); runtime_dir.join("tvctl.sock") } fn home_dir() -> PathBuf { env::var("HOME") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("/tmp")) } fn current_uid() -> u32 { // SAFETY: geteuid reads process state and has no side effects. unsafe { libc::geteuid() } } fn parse_value(key: &str, value: &str) -> anyhow::Result where T: std::str::FromStr, T::Err: std::fmt::Display, { value .parse::() .map_err(|error| anyhow::anyhow!("invalid value '{value}' for {key}: {error}")) } fn validate_log_level(value: &str) -> anyhow::Result<()> { EnvFilter::try_new(value).map_err(|error| { anyhow::anyhow!("invalid value '{value}' for daemon.log_level: {error}") })?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn missing_config_uses_defaults() { let temp_dir = tempfile::tempdir().expect("temp dir should exist"); let config = TvctlConfig::load_from_path(&temp_dir.path().join("missing.toml")) .await .expect("default config should load"); assert_eq!(config, TvctlConfig::default()); } #[tokio::test] async fn config_round_trips() { let temp_dir = tempfile::tempdir().expect("temp dir should exist"); let path = temp_dir.path().join("config.toml"); let config = TvctlConfig { devices: DeviceConfig { default: "living-room".to_string(), }, dev: DevConfig { enabled: true, roku_username: "rokudev".to_string(), roku_password: "secret".to_string(), }, ..TvctlConfig::default() }; config .save_to_path(&path) .await .expect("config should save"); let loaded = TvctlConfig::load_from_path(&path) .await .expect("config should load"); assert_eq!(loaded, config); } #[test] fn rejects_invalid_log_level_values() { let mut config = TvctlConfig::default(); let error = config .set_value("daemon.log_level", "[") .expect_err("invalid log level should fail"); assert!( error .to_string() .contains("invalid value '[' for daemon.log_level"), "unexpected error: {error}" ); } }