From 06312abe4227710fe2fc32cbb42ab48f3cb9cfb7 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 28 Feb 2026 08:08:45 +0100 Subject: [PATCH] [feat](trx-app): support combined trx-rs.toml config file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both trx-server and trx-client now look for a combined trx-rs.toml with [trx-server] and [trx-client] section headers respectively, falling back to per-binary config files as before. Search order per tier: combined trx-rs.toml → flat per-binary file, checked in CWD, ~/.config/trx-rs/, and /etc/trx-rs/. --print-config now outputs the config under the appropriate section header so the combined file can be generated directly. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- src/trx-app/src/config.rs | 94 +++++++++++++++++++++++++++++++++--- src/trx-app/src/lib.rs | 2 +- src/trx-client/src/config.rs | 52 ++++++++++++++++++++ src/trx-client/src/main.rs | 2 +- src/trx-server/src/config.rs | 44 +++++++++++++++++ src/trx-server/src/main.rs | 2 +- 6 files changed, 185 insertions(+), 11 deletions(-) diff --git a/src/trx-app/src/config.rs b/src/trx-app/src/config.rs index 5c0e318..51e07ec 100644 --- a/src/trx-app/src/config.rs +++ b/src/trx-app/src/config.rs @@ -15,33 +15,111 @@ pub enum ConfigError { ParseError(PathBuf, String), } +/// Returns search paths for the combined `trx-rs.toml` config file +/// (current directory → XDG config → /etc). +pub fn combined_config_paths() -> Vec { + let mut paths = vec![PathBuf::from("trx-rs.toml")]; + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("trx-rs").join("trx-rs.toml")); + } + paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml")); + paths +} + +/// Extract and deserialize a named section from a TOML file. +/// +/// Returns `Ok(Some(cfg))` when the section is present and parses cleanly, +/// `Ok(None)` when the section is absent, or `Err` on I/O / parse failure. +fn load_section_from_file( + path: &Path, + key: &str, +) -> Result, ConfigError> { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?; + + let table: toml::Table = toml::from_str(&content) + .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; + + let Some(section) = table.get(key) else { + return Ok(None); + }; + + // Re-serialize the section then parse as T so all serde defaults apply. + let section_toml = toml::to_string(section) + .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; + let cfg = toml::from_str::(§ion_toml) + .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?; + Ok(Some(cfg)) +} + /// Trait for loading configuration files with default paths. pub trait ConfigFile: Sized + Default + DeserializeOwned { /// Config filename (e.g., "server.toml" or "client.toml") fn config_filename() -> &'static str; - /// Load config from specific path + /// Section key inside a combined `trx-rs.toml` file, e.g. `"trx-server"`. + /// Return `None` (the default) to disable combined-file support. + fn combined_key() -> Option<&'static str> { + None + } + + /// Load config from a specific file path. + /// + /// If `combined_key()` is set and the file contains that section header, + /// only that section is deserialized. Otherwise the whole file is used, + /// preserving full backward compatibility with per-binary config files. fn load_from_file(path: &Path) -> Result { + if let Some(key) = Self::combined_key() { + // Peek at the file: if it contains our section, use that section. + if let Ok(Some(cfg)) = load_section_from_file::(path, key) { + return Ok(cfg); + } + } + let content = std::fs::read_to_string(path) .map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?; - toml::from_str(&content) .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string())) } /// Search default paths and load first found config. - /// Returns (config, path_where_found) or (Default::default(), None) if not found. + /// + /// Search order (for each location tier — CWD, XDG, /etc): + /// 1. `trx-rs.toml` with our section header (combined file) + /// 2. per-binary flat file (e.g. `trx-server.toml`) + /// + /// Returns `(config, path_where_found)` or `(Default::default(), None)`. fn load_from_default_paths() -> Result<(Self, Option), ConfigError> { - for path in Self::default_search_paths() { - if path.exists() { - let cfg = Self::load_from_file(&path)?; - return Ok((cfg, Some(path))); + let combined = combined_config_paths(); + let flat = Self::default_search_paths(); + + // Build interleaved list: (combined_path, flat_path) per tier. + let tiers = combined.len().max(flat.len()); + for i in 0..tiers { + // Combined file at this tier + if let Some(key) = Self::combined_key() { + if let Some(path) = combined.get(i) { + if path.exists() { + if let Some(cfg) = load_section_from_file::(path, key)? { + return Ok((cfg, Some(path.clone()))); + } + // Combined file present but our section absent → skip to flat. + } + } + } + // Flat file at this tier + if let Some(path) = flat.get(i) { + if path.exists() { + let cfg = Self::load_from_file(path)?; + return Ok((cfg, Some(path.clone()))); + } } } Ok((Self::default(), None)) } - /// Default search paths (current dir → XDG → /etc) + /// Default search paths for the per-binary flat config file + /// (current dir → XDG → /etc). fn default_search_paths() -> Vec { let mut paths = vec![PathBuf::from(Self::config_filename())]; diff --git a/src/trx-app/src/lib.rs b/src/trx-app/src/lib.rs index 931e981..96db6d9 100644 --- a/src/trx-app/src/lib.rs +++ b/src/trx-app/src/lib.rs @@ -7,7 +7,7 @@ pub mod logging; pub mod plugins; pub mod util; -pub use config::{ConfigError, ConfigFile}; +pub use config::{combined_config_paths, ConfigError, ConfigFile}; pub use logging::init_logging; pub use plugins::{load_backend_plugins, load_frontend_plugins}; pub use util::normalize_name; diff --git a/src/trx-client/src/config.rs b/src/trx-client/src/config.rs index 74308a0..a654aaf 100644 --- a/src/trx-client/src/config.rs +++ b/src/trx-client/src/config.rs @@ -411,6 +411,54 @@ impl ClientConfig { toml::to_string_pretty(&example).unwrap_or_default() } + + /// Generate an example configuration wrapped under the `[trx-client]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + pub fn example_combined_toml() -> String { + #[derive(serde::Serialize)] + struct Wrapper { + #[serde(rename = "trx-client")] + inner: ClientConfig, + } + let example = ClientConfig { + general: GeneralConfig { + callsign: Some("N0CALL".to_string()), + log_level: Some("info".to_string()), + }, + remote: RemoteConfig { + url: Some("192.168.1.100:9000".to_string()), + rig_id: Some("hf".to_string()), + auth: RemoteAuthConfig { + token: Some("my-token".to_string()), + }, + poll_interval_ms: 750, + }, + frontends: FrontendsConfig { + http: HttpFrontendConfig { + enabled: true, + listen: IpAddr::from([127, 0, 0, 1]), + port: 8080, + auth: HttpAuthConfig { + enabled: false, + rx_passphrase: Some("rx-passphrase-example".to_string()), + control_passphrase: Some("control-passphrase-example".to_string()), + tx_access_control_enabled: true, + session_ttl_min: 480, + cookie_secure: false, + cookie_same_site: CookieSameSite::Lax, + }, + }, + rigctl: RigctlFrontendConfig { + enabled: false, + listen: IpAddr::from([127, 0, 0, 1]), + port: 4532, + }, + http_json: HttpJsonFrontendConfig::default(), + audio: AudioClientConfig::default(), + }, + }; + toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() + } } fn validate_log_level(level: Option<&str>) -> Result<(), String> { @@ -476,6 +524,10 @@ impl ConfigFile for ClientConfig { "client.toml" } + fn combined_key() -> Option<&'static str> { + Some("trx-client") + } + fn default_search_paths() -> Vec { let mut paths = Vec::new(); paths.push(PathBuf::from("trx-client.toml")); diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index dcccb21..f0ee36e 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -131,7 +131,7 @@ async fn async_init() -> DynResult { let cli = Cli::parse(); if cli.print_config { - println!("{}", ClientConfig::example_toml()); + println!("{}", ClientConfig::example_combined_toml()); std::process::exit(0); } diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs index 888a33b..add5c78 100644 --- a/src/trx-server/src/config.rs +++ b/src/trx-server/src/config.rs @@ -691,6 +691,46 @@ impl ServerConfig { toml::to_string_pretty(&example).unwrap_or_default() } + + /// Generate an example configuration wrapped under the `[trx-server]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + pub fn example_combined_toml() -> String { + #[derive(serde::Serialize)] + struct Wrapper { + #[serde(rename = "trx-server")] + inner: ServerConfig, + } + let example = ServerConfig { + general: GeneralConfig { + callsign: Some("N0CALL".to_string()), + log_level: Some("info".to_string()), + latitude: Some(52.2297), + longitude: Some(21.0122), + }, + rig: RigConfig { + model: Some("ft817".to_string()), + initial_freq_hz: 144_300_000, + initial_mode: RigMode::USB, + access: AccessConfig { + access_type: Some("serial".to_string()), + port: Some("/dev/ttyUSB0".to_string()), + baud: Some(9600), + host: None, + tcp_port: None, + args: None, + }, + }, + behavior: BehaviorConfig::default(), + listen: ListenConfig::default(), + audio: AudioConfig::default(), + pskreporter: PskReporterConfig::default(), + aprsfi: AprsFiConfig::default(), + decode_logs: DecodeLogsConfig::default(), + sdr: SdrConfig::default(), + rigs: Vec::new(), + }; + toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() + } } fn validate_log_level(level: Option<&str>) -> Result<(), String> { @@ -789,6 +829,10 @@ impl ConfigFile for ServerConfig { "server.toml" } + fn combined_key() -> Option<&'static str> { + Some("trx-server") + } + fn default_search_paths() -> Vec { let mut paths = Vec::new(); paths.push(PathBuf::from("trx-server.toml")); diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 8fa149b..af7f67a 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -663,7 +663,7 @@ async fn main() -> DynResult<()> { let cli = Cli::parse(); if cli.print_config { - println!("{}", ServerConfig::example_toml()); + println!("{}", ServerConfig::example_combined_toml()); return Ok(()); }