Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-app"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
license = "GPL-2.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dirs = "6"
|
||||
thiserror = "2"
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("Failed to read config file {0}: {1}")]
|
||||
ReadError(PathBuf, String),
|
||||
|
||||
#[error("Failed to parse config file {0}: {1}")]
|
||||
ParseError(PathBuf, String),
|
||||
}
|
||||
|
||||
/// Returns the default search paths for `trx-rs.toml`
|
||||
/// (current directory → XDG config → /etc).
|
||||
fn config_search_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 from a `trx-rs.toml` section.
|
||||
pub trait ConfigFile: Sized + Default + DeserializeOwned {
|
||||
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
|
||||
fn section_key() -> &'static str;
|
||||
|
||||
/// Load the section from a specific file path.
|
||||
///
|
||||
/// Returns an error if the file cannot be read, is not valid TOML, or
|
||||
/// does not contain the expected `[<section_key>]` header.
|
||||
fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
|
||||
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
|
||||
ConfigError::ParseError(
|
||||
path.to_path_buf(),
|
||||
format!("missing [{}] section", Self::section_key()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
|
||||
/// the first file that contains the expected section.
|
||||
///
|
||||
/// Returns `(config, path_where_found)` or `(Default::default(), None)`
|
||||
/// when no config file is found.
|
||||
fn load_from_default_paths() -> Result<(Self, Option<PathBuf>), ConfigError> {
|
||||
for path in config_search_paths() {
|
||||
if path.exists() {
|
||||
if let Some(cfg) = load_section_from_file::<Self>(&path, Self::section_key())? {
|
||||
return Ok((cfg, Some(path)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((Self::default(), None))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
pub mod config;
|
||||
pub mod logging;
|
||||
pub mod shared_config;
|
||||
pub mod util;
|
||||
|
||||
pub use config::{ConfigError, ConfigFile};
|
||||
pub use logging::init_logging;
|
||||
pub use shared_config::{validate_log_level, validate_tokens};
|
||||
pub use util::normalize_name;
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
/// Initialize logging with optional level from config.
|
||||
/// Falls back to INFO if level is None or invalid.
|
||||
pub fn init_logging(log_level: Option<&str>) {
|
||||
let level = log_level
|
||||
.and_then(|s| s.parse::<Level>().ok())
|
||||
.unwrap_or(Level::INFO);
|
||||
|
||||
FmtSubscriber::builder()
|
||||
.with_target(false)
|
||||
.with_max_level(level)
|
||||
.init();
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
//! Shared configuration validation helpers used by both `trx-server` and
|
||||
//! `trx-client`.
|
||||
//!
|
||||
//! # Non-shared structs
|
||||
//!
|
||||
//! `GeneralConfig` is defined separately in each binary because the fields
|
||||
//! differ:
|
||||
//!
|
||||
//! - **Server** `GeneralConfig`: `callsign`, `log_level`, `latitude`,
|
||||
//! `longitude`
|
||||
//! - **Client** `GeneralConfig`: `callsign`, `log_level`, `website_url`,
|
||||
//! `website_name`, `ais_vessel_url_base`
|
||||
//!
|
||||
//! Only `callsign` and `log_level` overlap. Merging into a single struct
|
||||
//! would either bloat both binaries with unused fields or require a trait
|
||||
//! abstraction that adds complexity without clear benefit.
|
||||
|
||||
/// Validate that a log level string is one of the accepted values.
|
||||
///
|
||||
/// Returns `Ok(())` when `level` is `None` (defaulting is handled elsewhere)
|
||||
/// or a recognised level name.
|
||||
pub fn validate_log_level(level: Option<&str>) -> Result<(), String> {
|
||||
if let Some(level) = level {
|
||||
match level {
|
||||
"trace" | "debug" | "info" | "warn" | "error" => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"[general].log_level '{}' is invalid (expected one of: trace, debug, info, warn, error)",
|
||||
level
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that a list of authentication tokens contains no empty entries.
|
||||
///
|
||||
/// `path` is a human-readable config path prefix used in the error message
|
||||
/// (e.g. `"[listen.auth].tokens"`).
|
||||
pub fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
|
||||
if tokens.iter().any(|t| t.trim().is_empty()) {
|
||||
return Err(format!("{path} must not contain empty tokens"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_log_level_none() {
|
||||
assert!(validate_log_level(None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_log_level_valid() {
|
||||
for level in &["trace", "debug", "info", "warn", "error"] {
|
||||
assert!(validate_log_level(Some(level)).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_log_level_invalid() {
|
||||
assert!(validate_log_level(Some("verbose")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_tokens_empty_list() {
|
||||
assert!(validate_tokens("[auth].tokens", &[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_tokens_valid() {
|
||||
let tokens = vec!["abc".to_string(), "def".to_string()];
|
||||
assert!(validate_tokens("[auth].tokens", &tokens).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_tokens_rejects_empty() {
|
||||
let tokens = vec!["abc".to_string(), "".to_string()];
|
||||
assert!(validate_tokens("[auth].tokens", &tokens).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_tokens_rejects_whitespace_only() {
|
||||
let tokens = vec![" ".to_string()];
|
||||
assert!(validate_tokens("[auth].tokens", &tokens).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/// Normalize a name to lowercase alphanumeric.
|
||||
pub fn normalize_name(name: &str) -> String {
|
||||
name.to_ascii_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_normalize_name() {
|
||||
assert_eq!(normalize_name("FT-817"), "ft817");
|
||||
assert_eq!(normalize_name("HTTP-JSON"), "httpjson");
|
||||
assert_eq!(normalize_name("foo_bar-baz"), "foobarbaz");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user