[feat](trx-app): support combined trx-rs.toml config file
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 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -15,33 +15,111 @@ pub enum ConfigError {
|
|||||||
ParseError(PathBuf, String),
|
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<PathBuf> {
|
||||||
|
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<T: DeserializeOwned>(
|
||||||
|
path: &Path,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Option<T>, 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::<T>(§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.
|
/// Trait for loading configuration files with default paths.
|
||||||
pub trait ConfigFile: Sized + Default + DeserializeOwned {
|
pub trait ConfigFile: Sized + Default + DeserializeOwned {
|
||||||
/// Config filename (e.g., "server.toml" or "client.toml")
|
/// Config filename (e.g., "server.toml" or "client.toml")
|
||||||
fn config_filename() -> &'static str;
|
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<Self, ConfigError> {
|
fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
|
||||||
|
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::<Self>(path, key) {
|
||||||
|
return Ok(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let content = std::fs::read_to_string(path)
|
let content = std::fs::read_to_string(path)
|
||||||
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
|
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
|
||||||
|
|
||||||
toml::from_str(&content)
|
toml::from_str(&content)
|
||||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))
|
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search default paths and load first found config.
|
/// 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<PathBuf>), ConfigError> {
|
fn load_from_default_paths() -> Result<(Self, Option<PathBuf>), ConfigError> {
|
||||||
for path in Self::default_search_paths() {
|
let combined = combined_config_paths();
|
||||||
if path.exists() {
|
let flat = Self::default_search_paths();
|
||||||
let cfg = Self::load_from_file(&path)?;
|
|
||||||
return Ok((cfg, Some(path)));
|
// 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::<Self>(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))
|
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<PathBuf> {
|
fn default_search_paths() -> Vec<PathBuf> {
|
||||||
let mut paths = vec![PathBuf::from(Self::config_filename())];
|
let mut paths = vec![PathBuf::from(Self::config_filename())];
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub mod logging;
|
|||||||
pub mod plugins;
|
pub mod plugins;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use config::{ConfigError, ConfigFile};
|
pub use config::{combined_config_paths, ConfigError, ConfigFile};
|
||||||
pub use logging::init_logging;
|
pub use logging::init_logging;
|
||||||
pub use plugins::{load_backend_plugins, load_frontend_plugins};
|
pub use plugins::{load_backend_plugins, load_frontend_plugins};
|
||||||
pub use util::normalize_name;
|
pub use util::normalize_name;
|
||||||
|
|||||||
@@ -411,6 +411,54 @@ impl ClientConfig {
|
|||||||
|
|
||||||
toml::to_string_pretty(&example).unwrap_or_default()
|
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> {
|
fn validate_log_level(level: Option<&str>) -> Result<(), String> {
|
||||||
@@ -476,6 +524,10 @@ impl ConfigFile for ClientConfig {
|
|||||||
"client.toml"
|
"client.toml"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn combined_key() -> Option<&'static str> {
|
||||||
|
Some("trx-client")
|
||||||
|
}
|
||||||
|
|
||||||
fn default_search_paths() -> Vec<PathBuf> {
|
fn default_search_paths() -> Vec<PathBuf> {
|
||||||
let mut paths = Vec::new();
|
let mut paths = Vec::new();
|
||||||
paths.push(PathBuf::from("trx-client.toml"));
|
paths.push(PathBuf::from("trx-client.toml"));
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
if cli.print_config {
|
if cli.print_config {
|
||||||
println!("{}", ClientConfig::example_toml());
|
println!("{}", ClientConfig::example_combined_toml());
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -691,6 +691,46 @@ impl ServerConfig {
|
|||||||
|
|
||||||
toml::to_string_pretty(&example).unwrap_or_default()
|
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> {
|
fn validate_log_level(level: Option<&str>) -> Result<(), String> {
|
||||||
@@ -789,6 +829,10 @@ impl ConfigFile for ServerConfig {
|
|||||||
"server.toml"
|
"server.toml"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn combined_key() -> Option<&'static str> {
|
||||||
|
Some("trx-server")
|
||||||
|
}
|
||||||
|
|
||||||
fn default_search_paths() -> Vec<PathBuf> {
|
fn default_search_paths() -> Vec<PathBuf> {
|
||||||
let mut paths = Vec::new();
|
let mut paths = Vec::new();
|
||||||
paths.push(PathBuf::from("trx-server.toml"));
|
paths.push(PathBuf::from("trx-server.toml"));
|
||||||
|
|||||||
@@ -663,7 +663,7 @@ async fn main() -> DynResult<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
if cli.print_config {
|
if cli.print_config {
|
||||||
println!("{}", ServerConfig::example_toml());
|
println!("{}", ServerConfig::example_combined_toml());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user