[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 <noreply@anthropic.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
#
|
||||||
|
# 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"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// 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<Self, ConfigError> {
|
||||||
|
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<PathBuf>), 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<PathBuf> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// 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;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// 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::<Level>().ok())
|
||||||
|
.unwrap_or(Level::INFO);
|
||||||
|
|
||||||
|
FmtSubscriber::builder()
|
||||||
|
.with_target(false)
|
||||||
|
.with_max_level(level)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// 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<Library> {
|
||||||
|
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<Library>) -> 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<unsafe extern "C" fn()> = 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<PathBuf> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user