Files
foxhunt/bin/fxt/src/config.rs
jgrusewski 08d070bb95 refactor: rename JWT issuer foxhunt-api-gateway → foxhunt-api, drop compat aliases
- JWT issuer now foxhunt-api across all 16 files (services, tests, config, docker-compose)
- Remove serde alias api_gateway_url from FxtConfig (no backwards compat)
- Remove api_gateway CLI alias from e2e orchestrator
- All services must deploy simultaneously for JWT validation to match

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:22:04 +01:00

283 lines
8.0 KiB
Rust

//! Configuration file support for FXT
//!
//! This module provides configuration file management for FXT client settings.
//! Configuration is loaded from `~/.foxhunt/config.toml` and can be overridden
//! by CLI arguments (following standard CLI precedence).
//!
//! ## Precedence
//! CLI arguments > Config file > Defaults
//!
//! ## Example Config File
//! ```toml
//! # ~/.foxhunt/config.toml
//! api_url = "https://api.fxhnt.ai"
//! log_level = "info"
//! token_storage = "keyring"
//! ```
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// IBKR-specific connection settings
#[derive(Debug, Serialize, Deserialize)]
pub struct IbkrConfig {
/// IB Gateway host
#[serde(default = "default_ibkr_host")]
pub host: String,
/// IB Gateway socat port (4004 = paper, 4003 = live)
#[serde(default = "default_ibkr_port")]
pub port: u16,
/// TWS client ID (unique per connection)
#[serde(default = "default_ibkr_client_id")]
pub client_id: i32,
}
fn default_ibkr_host() -> String {
"127.0.0.1".to_owned()
}
fn default_ibkr_port() -> u16 {
4004
}
// Default IBKR client ID (99 = non-production default)
fn default_ibkr_client_id() -> i32 {
99
}
impl Default for IbkrConfig {
fn default() -> Self {
Self {
host: default_ibkr_host(),
port: default_ibkr_port(),
client_id: default_ibkr_client_id(),
}
}
}
/// Broker connectivity settings
#[derive(Debug, Serialize, Deserialize)]
pub struct BrokerConfig {
/// Broker Gateway gRPC URL
#[serde(default = "default_broker_gateway_url")]
pub gateway_url: String,
/// IBKR-specific settings
#[serde(default)]
pub ibkr: IbkrConfig,
}
fn default_broker_gateway_url() -> String {
"http://localhost:50056".to_owned()
}
impl Default for BrokerConfig {
fn default() -> Self {
Self {
gateway_url: default_broker_gateway_url(),
ibkr: IbkrConfig::default(),
}
}
}
/// FXT configuration structure
///
/// Contains all configurable settings for the FXT client application.
/// Settings can be loaded from `~/.foxhunt/config.toml` and overridden
/// by CLI arguments or environment variables.
#[derive(Debug, Serialize, Deserialize)]
pub struct FxtConfig {
/// Unified API URL (default: <https://api.fxhnt.ai>)
#[serde(default = "default_api_url")]
pub api_url: String,
/// Log level (default: info)
#[serde(default = "default_log_level")]
pub log_level: String,
/// Token storage backend (default: keyring)
#[serde(default = "default_token_storage")]
pub token_storage: String,
/// JWT secret for token signing (must match API Gateway's JWT_SECRET)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwt_secret: Option<String>,
/// Broker connectivity settings
#[serde(default)]
pub broker: BrokerConfig,
}
fn default_api_url() -> String {
"https://api.fxhnt.ai".to_owned()
}
fn default_log_level() -> String {
"info".to_owned()
}
fn default_token_storage() -> String {
"keyring".to_owned()
}
impl Default for FxtConfig {
fn default() -> Self {
Self {
api_url: default_api_url(),
log_level: default_log_level(),
token_storage: default_token_storage(),
jwt_secret: None,
broker: BrokerConfig::default(),
}
}
}
impl FxtConfig {
/// Load config from ~/.foxhunt/config.toml
///
/// Returns default configuration if file doesn't exist.
/// Returns error if file exists but is malformed.
///
/// # Examples
/// ```no_run
/// use fxt::config::FxtConfig;
///
/// let config = FxtConfig::load().unwrap();
/// println!("API URL: {}", config.api_url);
/// ```
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
if !config_path.exists() {
// Return defaults if no config file
return Ok(Self::default());
}
let content =
std::fs::read_to_string(&config_path).context("Failed to read config file")?;
let config: Self = toml::from_str(&content).context("Failed to parse config file")?;
Ok(config)
}
/// Save config to ~/.foxhunt/config.toml
///
/// Creates parent directory if it doesn't exist.
///
/// # Examples
/// ```no_run
/// use fxt::config::FxtConfig;
///
/// let config = FxtConfig {
/// api_url: "http://localhost:50051".to_string(),
/// log_level: "debug".to_string(),
/// token_storage: "keyring".to_string(),
/// jwt_secret: None,
/// broker: BrokerConfig::default(),
/// };
/// config.save().unwrap();
/// ```
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
// Create parent directory if needed
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
std::fs::write(&config_path, content).context("Failed to write config file")?;
Ok(())
}
/// Get config file path: ~/.foxhunt/config.toml
///
/// # Errors
/// Returns error if home directory cannot be determined
fn config_path() -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?;
Ok(home.join(".foxhunt").join("config.toml"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
// Test that Default trait provides correct values
let config = FxtConfig::default();
assert_eq!(config.api_url, "https://api.fxhnt.ai");
assert_eq!(config.log_level, "info");
assert_eq!(config.token_storage, "keyring");
}
#[test]
fn test_config_serialization() {
let config = FxtConfig {
api_url: "http://example.com:50051".to_owned(),
log_level: "debug".to_owned(),
token_storage: "file".to_owned(),
jwt_secret: None,
broker: BrokerConfig::default(),
};
let toml_str = toml::to_string(&config).unwrap();
let parsed: FxtConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config.api_url, parsed.api_url);
assert_eq!(config.log_level, parsed.log_level);
assert_eq!(config.token_storage, parsed.token_storage);
}
#[test]
fn test_load_config_succeeds() {
// load() should always succeed: returns file contents if present, defaults otherwise.
// Default-value assertions live in test_serde_defaults and test_default_config.
let result = FxtConfig::load();
assert!(result.is_ok());
}
#[test]
fn test_serde_defaults() {
// Test that empty TOML string deserializes to defaults
let empty_toml = "";
let config: FxtConfig = toml::from_str(empty_toml).unwrap();
assert_eq!(config.api_url, "https://api.fxhnt.ai");
assert_eq!(config.log_level, "info");
assert_eq!(config.token_storage, "keyring");
}
#[test]
fn test_config_with_broker_section() {
let toml_str = r#"
api_url = "http://localhost:50051"
[broker]
gateway_url = "http://broker-gw:50060"
[broker.ibkr]
host = "10.0.0.5"
port = 4002
client_id = 42
"#;
let config: FxtConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.broker.gateway_url, "http://broker-gw:50060");
assert_eq!(config.broker.ibkr.host, "10.0.0.5");
assert_eq!(config.broker.ibkr.port, 4002);
assert_eq!(config.broker.ibkr.client_id, 42);
}
#[test]
fn test_config_broker_defaults() {
let config: FxtConfig = toml::from_str("").unwrap();
assert_eq!(config.broker.gateway_url, "http://localhost:50056");
assert_eq!(config.broker.ibkr.host, "127.0.0.1");
assert_eq!(config.broker.ibkr.port, 4004);
assert_eq!(config.broker.ibkr.client_id, 99);
}
}