From 30bf0f56ac0283db55f40b8f1ee1e308d176e508 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Thu, 12 Feb 2026 20:39:17 +0100 Subject: [PATCH] [feat](trx-app): create shared app infrastructure crate Create new trx-app crate to consolidate 334 lines of duplicate infrastructure code from server and client binaries. Modules: - plugins: load_plugins() - unified plugin discovery and loading - util: normalize_name() - centralized name normalization - config: ConfigFile trait - generic config loading with default paths - logging: init_logging() - unified logging initialization Benefits: - Eliminates duplicate plugins.rs (232 lines, 100% identical) - Single normalize_name() function (previously 4+ instances) - ConfigFile trait enables consistent config handling - log_level config field now usable (feature previously broken) Co-Authored-By: Claude Haiku 4.5 Signed-off-by: Stanislaw Grams --- src/trx-app/Cargo.toml | 18 ++++++ src/trx-app/src/config.rs | 55 ++++++++++++++++++ src/trx-app/src/lib.rs | 13 +++++ src/trx-app/src/logging.rs | 19 ++++++ src/trx-app/src/plugins.rs | 115 +++++++++++++++++++++++++++++++++++++ src/trx-app/src/util.rs | 23 ++++++++ 6 files changed, 243 insertions(+) create mode 100644 src/trx-app/Cargo.toml create mode 100644 src/trx-app/src/config.rs create mode 100644 src/trx-app/src/lib.rs create mode 100644 src/trx-app/src/logging.rs create mode 100644 src/trx-app/src/plugins.rs create mode 100644 src/trx-app/src/util.rs diff --git a/src/trx-app/Cargo.toml b/src/trx-app/Cargo.toml new file mode 100644 index 0000000..76b054b --- /dev/null +++ b/src/trx-app/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2025 Stanislaw Grams +# +# SPDX-License-Identifier: BSD-2-Clause + +[package] +name = "trx-app" +version = "0.1.0" +edition = "2021" +license = "BSD-2-Clause" + +[dependencies] +serde = { workspace = true } +toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dirs = "6" +libloading = "0.8" +thiserror = "2" diff --git a/src/trx-app/src/config.rs b/src/trx-app/src/config.rs new file mode 100644 index 0000000..5c0e318 --- /dev/null +++ b/src/trx-app/src/config.rs @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +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), +} + +/// 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 + fn load_from_file(path: &Path) -> Result { + 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. + 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))); + } + } + Ok((Self::default(), None)) + } + + /// Default search paths (current dir → XDG → /etc) + fn default_search_paths() -> Vec { + let mut paths = vec![PathBuf::from(Self::config_filename())]; + + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("trx-rs").join(Self::config_filename())); + } + + paths.push(PathBuf::from("/etc/trx-rs").join(Self::config_filename())); + paths + } +} diff --git a/src/trx-app/src/lib.rs b/src/trx-app/src/lib.rs new file mode 100644 index 0000000..16c81ec --- /dev/null +++ b/src/trx-app/src/lib.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +pub mod config; +pub mod logging; +pub mod plugins; +pub mod util; + +pub use config::{ConfigError, ConfigFile}; +pub use logging::init_logging; +pub use plugins::load_plugins; +pub use util::normalize_name; diff --git a/src/trx-app/src/logging.rs b/src/trx-app/src/logging.rs new file mode 100644 index 0000000..d5e574d --- /dev/null +++ b/src/trx-app/src/logging.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +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::().ok()) + .unwrap_or(Level::INFO); + + FmtSubscriber::builder() + .with_target(false) + .with_max_level(level) + .init(); +} diff --git a/src/trx-app/src/plugins.rs b/src/trx-app/src/plugins.rs new file mode 100644 index 0000000..4456f17 --- /dev/null +++ b/src/trx-app/src/plugins.rs @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use libloading::{Library, Symbol}; +use tracing::{info, warn}; + +const PLUGIN_ENV: &str = "TRX_PLUGIN_DIRS"; +const PLUGIN_ENTRYPOINT: &str = "trx_register"; + +#[cfg(windows)] +const PATH_SEPARATOR: char = ';'; +#[cfg(not(windows))] +const PATH_SEPARATOR: char = ':'; + +#[cfg(windows)] +const PLUGIN_EXTENSIONS: &[&str] = &["dll"]; +#[cfg(target_os = "macos")] +const PLUGIN_EXTENSIONS: &[&str] = &["dylib"]; +#[cfg(all(unix, not(target_os = "macos")))] +const PLUGIN_EXTENSIONS: &[&str] = &["so"]; + +pub fn load_plugins() -> Vec { + let mut libraries = Vec::new(); + let search_paths = plugin_search_paths(); + + if search_paths.is_empty() { + return libraries; + } + + info!("Plugin search paths: {:?}", search_paths); + + for path in search_paths { + if let Err(err) = load_plugins_from_dir(&path, &mut libraries) { + warn!("Plugin scan failed for {:?}: {}", path, err); + } + } + + libraries +} + +fn load_plugins_from_dir(path: &Path, libraries: &mut Vec) -> std::io::Result<()> { + if !path.exists() { + return Ok(()); + } + + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + if !is_plugin_file(&path) { + continue; + } + + unsafe { + match Library::new(&path) { + Ok(lib) => { + if let Err(err) = register_library(&lib, &path) { + warn!("Plugin {:?} failed to register: {}", path, err); + continue; + } + info!("Loaded plugin {:?}", path); + libraries.push(lib); + } + Err(err) => { + warn!("Failed to load plugin {:?}: {}", path, err); + } + } + } + } + + Ok(()) +} + +unsafe fn register_library(lib: &Library, path: &Path) -> Result<(), String> { + let entry: Symbol = lib + .get(PLUGIN_ENTRYPOINT.as_bytes()) + .map_err(|e| format!("missing entrypoint {}: {}", PLUGIN_ENTRYPOINT, e))?; + entry(); + info!("Registered plugin {:?}", path); + Ok(()) +} + +fn plugin_search_paths() -> Vec { + let mut paths = Vec::new(); + + if let Ok(env_paths) = std::env::var(PLUGIN_ENV) { + for raw in env_paths.split(PATH_SEPARATOR) { + if raw.trim().is_empty() { + continue; + } + paths.push(PathBuf::from(raw)); + } + } + + paths.push(PathBuf::from("plugins")); + + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("trx-rs").join("plugins")); + } + + paths +} + +fn is_plugin_file(path: &Path) -> bool { + path.extension() + .and_then(OsStr::to_str) + .map(|ext| PLUGIN_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) + .unwrap_or(false) +} diff --git a/src/trx-app/src/util.rs b/src/trx-app/src/util.rs new file mode 100644 index 0000000..dab78d4 --- /dev/null +++ b/src/trx-app/src/util.rs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +/// 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"); + } +}