[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:
2026-02-28 08:08:45 +01:00
parent 6a47fb00ad
commit 06312abe42
6 changed files with 185 additions and 11 deletions
+86 -8
View File
@@ -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<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>(&section_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<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)
.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<PathBuf>), 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::<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))
}
/// 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> {
let mut paths = vec![PathBuf::from(Self::config_filename())];
+1 -1
View File
@@ -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;