use crate::error::{ConfigError, Result, ScopeError};
use crate::market::HealthThresholds;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub chains: ChainsConfig,
pub output: OutputConfig,
#[serde(alias = "portfolio")]
pub address_book: AddressBookConfig,
pub monitor: MonitorConfig,
pub ghola: GholaConfig,
pub web: WebConfig,
pub market: MarketConfig,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ChainsConfig {
pub ethereum_rpc: Option<String>,
pub bsc_rpc: Option<String>,
#[doc(hidden)]
pub aegis_rpc: Option<String>,
pub solana_rpc: Option<String>,
pub tron_api: Option<String>,
pub api_keys: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputConfig {
pub format: OutputFormat,
pub color: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AddressBookConfig {
pub data_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct GholaConfig {
pub enabled: bool,
pub stealth: bool,
pub buffer_size: u32,
}
impl Default for GholaConfig {
fn default() -> Self {
Self {
enabled: false,
stealth: false,
buffer_size: 4096,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct MarketConfig {
pub health: HealthThresholds,
#[serde(skip_serializing_if = "Option::is_none")]
pub venue_catalog_url: Option<String>,
}
impl MarketConfig {
pub fn catalog_url(&self) -> &str {
self.venue_catalog_url
.as_deref()
.filter(|u| !u.trim().is_empty())
.unwrap_or(crate::market::catalog::DEFAULT_CATALOG_URL)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct WebConfig {
pub auth_token: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
#[default]
Table,
Json,
Csv,
#[value(name = "markdown")]
Markdown,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
format: OutputFormat::Table,
color: true,
}
}
}
impl Config {
pub fn load(path: Option<&Path>) -> Result<Self> {
let config_path = path
.map(PathBuf::from)
.or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
.unwrap_or_else(Self::default_path);
if !config_path.exists() {
tracing::debug!(
path = %config_path.display(),
"No config file found, using defaults"
);
return Ok(Self::default());
}
tracing::debug!(path = %config_path.display(), "Loading configuration");
let contents = std::fs::read_to_string(&config_path).map_err(|e| {
ScopeError::Config(ConfigError::Read {
path: config_path.clone(),
source: e,
})
})?;
let config: Config =
serde_saphyr::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
Ok(config)
}
pub fn default_path() -> PathBuf {
crate::paths::config_file()
}
pub fn config_path() -> Option<PathBuf> {
Some(crate::paths::config_file())
}
pub fn default_data_dir() -> PathBuf {
crate::paths::data_dir()
}
pub fn data_dir(&self) -> PathBuf {
match &self.address_book.data_dir {
Some(dir) => expand_home(dir, crate::paths::user_home()),
None => Self::default_data_dir(),
}
}
}
fn expand_home(dir: &Path, home: Option<PathBuf>) -> PathBuf {
match (dir.strip_prefix("~"), home) {
(Ok(rest), Some(home)) => home.join(rest),
_ => dir.to_path_buf(),
}
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OutputFormat::Table => write!(f, "table"),
OutputFormat::Json => write!(f, "json"),
OutputFormat::Csv => write!(f, "csv"),
OutputFormat::Markdown => write!(f, "markdown"),
}
}
}
pub const DEFAULT_MONITOR_REFRESH_SECS: u64 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum LayoutPreset {
#[default]
Dashboard,
ChartFocus,
Feed,
Compact,
Exchange,
}
impl LayoutPreset {
pub fn next(&self) -> Self {
match self {
LayoutPreset::Dashboard => LayoutPreset::ChartFocus,
LayoutPreset::ChartFocus => LayoutPreset::Feed,
LayoutPreset::Feed => LayoutPreset::Compact,
LayoutPreset::Compact => LayoutPreset::Exchange,
LayoutPreset::Exchange => LayoutPreset::Dashboard,
}
}
pub fn prev(&self) -> Self {
match self {
LayoutPreset::Dashboard => LayoutPreset::Exchange,
LayoutPreset::ChartFocus => LayoutPreset::Dashboard,
LayoutPreset::Feed => LayoutPreset::ChartFocus,
LayoutPreset::Compact => LayoutPreset::Feed,
LayoutPreset::Exchange => LayoutPreset::Compact,
}
}
pub fn label(&self) -> &'static str {
match self {
LayoutPreset::Dashboard => "Dashboard",
LayoutPreset::ChartFocus => "Chart",
LayoutPreset::Feed => "Feed",
LayoutPreset::Compact => "Compact",
LayoutPreset::Exchange => "Exchange",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct WidgetVisibility {
pub price_chart: bool,
pub volume_chart: bool,
pub buy_sell_pressure: bool,
pub metrics_panel: bool,
pub activity_log: bool,
pub holder_count: bool,
pub liquidity_depth: bool,
}
impl Default for WidgetVisibility {
fn default() -> Self {
Self {
price_chart: true,
volume_chart: true,
buy_sell_pressure: true,
metrics_panel: true,
activity_log: true,
holder_count: true,
liquidity_depth: true,
}
}
}
impl WidgetVisibility {
pub fn visible_count(&self) -> usize {
[
self.price_chart,
self.volume_chart,
self.buy_sell_pressure,
self.metrics_panel,
self.activity_log,
]
.iter()
.filter(|&&v| v)
.count()
}
pub fn toggle_by_index(&mut self, index: usize) {
match index {
1 => self.price_chart = !self.price_chart,
2 => self.volume_chart = !self.volume_chart,
3 => self.buy_sell_pressure = !self.buy_sell_pressure,
4 => self.metrics_panel = !self.metrics_panel,
5 => self.activity_log = !self.activity_log,
_ => {}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum ScaleMode {
#[default]
Linear,
Log,
}
impl ScaleMode {
pub fn toggle(&self) -> Self {
match self {
ScaleMode::Linear => ScaleMode::Log,
ScaleMode::Log => ScaleMode::Linear,
}
}
pub fn label(&self) -> &'static str {
match self {
ScaleMode::Linear => "Lin",
ScaleMode::Log => "Log",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum ColorScheme {
#[default]
GreenRed,
BlueOrange,
Monochrome,
}
impl ColorScheme {
pub fn next(&self) -> Self {
match self {
ColorScheme::GreenRed => ColorScheme::BlueOrange,
ColorScheme::BlueOrange => ColorScheme::Monochrome,
ColorScheme::Monochrome => ColorScheme::GreenRed,
}
}
pub fn label(&self) -> &'static str {
match self {
ColorScheme::GreenRed => "green-red",
ColorScheme::BlueOrange => "blue-orange",
ColorScheme::Monochrome => "mono",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct AlertConfig {
pub price_min: Option<f64>,
pub price_max: Option<f64>,
pub whale_min_usd: Option<f64>,
pub volume_spike_threshold_pct: Option<f64>,
}
impl Default for AlertConfig {
#[allow(clippy::derivable_impls)]
fn default() -> Self {
Self {
price_min: None,
price_max: None,
whale_min_usd: None,
volume_spike_threshold_pct: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ExportConfig {
pub path: Option<String>,
}
impl Default for ExportConfig {
#[allow(clippy::derivable_impls)]
fn default() -> Self {
Self { path: None }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct MonitorConfig {
pub layout: LayoutPreset,
pub refresh_seconds: u64,
pub widgets: WidgetVisibility,
pub scale: ScaleMode,
pub color_scheme: ColorScheme,
pub alerts: AlertConfig,
pub export: ExportConfig,
pub auto_pause_on_input: bool,
#[serde(default)]
pub venue: Option<String>,
}
impl Default for MonitorConfig {
fn default() -> Self {
Self {
layout: LayoutPreset::Dashboard,
refresh_seconds: DEFAULT_MONITOR_REFRESH_SECS,
widgets: WidgetVisibility::default(),
scale: ScaleMode::Linear,
color_scheme: ColorScheme::GreenRed,
alerts: AlertConfig::default(),
export: ExportConfig::default(),
auto_pause_on_input: false,
venue: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_a_leading_tilde_in_data_dir_is_the_home_folder() {
let home = Some(PathBuf::from("/home/u"));
assert_eq!(
expand_home(Path::new("~/.scope-bca/data"), home.clone()),
PathBuf::from("/home/u").join(".scope-bca/data")
);
assert_eq!(
expand_home(Path::new("~"), home.clone()),
PathBuf::from("/home/u")
);
assert_eq!(
expand_home(Path::new("~x/d"), home.clone()),
PathBuf::from("~x/d")
);
assert_eq!(
expand_home(Path::new("/a/~/d"), home),
PathBuf::from("/a/~/d")
);
assert_eq!(expand_home(Path::new("~/d"), None), PathBuf::from("~/d"));
}
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.chains.api_keys.is_empty());
assert!(config.chains.ethereum_rpc.is_none());
assert!(config.chains.bsc_rpc.is_none());
assert!(config.chains.aegis_rpc.is_none());
assert!(config.chains.solana_rpc.is_none());
assert!(config.chains.tron_api.is_none());
assert_eq!(config.output.format, OutputFormat::Table);
assert!(config.output.color);
assert!(config.address_book.data_dir.is_none());
}
#[test]
fn test_load_from_yaml_full() {
let yaml = r#"
chains:
ethereum_rpc: "https://example.com/rpc"
bsc_rpc: "https://bsc-dataseed.binance.org"
solana_rpc: "https://api.mainnet-beta.solana.com"
tron_api: "https://api.trongrid.io"
api_keys:
etherscan: "test-api-key"
polygonscan: "another-key"
bscscan: "bsc-key"
solscan: "sol-key"
tronscan: "tron-key"
output:
format: json
color: false
address_book:
data_dir: "/custom/data"
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
let config = Config::load(Some(file.path())).unwrap();
assert_eq!(
config.chains.ethereum_rpc,
Some("https://example.com/rpc".into())
);
assert_eq!(
config.chains.bsc_rpc,
Some("https://bsc-dataseed.binance.org".into())
);
assert_eq!(
config.chains.solana_rpc,
Some("https://api.mainnet-beta.solana.com".into())
);
assert_eq!(
config.chains.tron_api,
Some("https://api.trongrid.io".into())
);
assert_eq!(
config.chains.api_keys.get("etherscan"),
Some(&"test-api-key".into())
);
assert_eq!(
config.chains.api_keys.get("polygonscan"),
Some(&"another-key".into())
);
assert_eq!(
config.chains.api_keys.get("bscscan"),
Some(&"bsc-key".into())
);
assert_eq!(
config.chains.api_keys.get("solscan"),
Some(&"sol-key".into())
);
assert_eq!(
config.chains.api_keys.get("tronscan"),
Some(&"tron-key".into())
);
assert_eq!(config.output.format, OutputFormat::Json);
assert!(!config.output.color);
assert_eq!(
config.address_book.data_dir,
Some(PathBuf::from("/custom/data"))
);
}
#[test]
fn test_load_partial_yaml_uses_defaults() {
let yaml = r#"
chains:
ethereum_rpc: "https://partial.example.com"
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
let config = Config::load(Some(file.path())).unwrap();
assert_eq!(
config.chains.ethereum_rpc,
Some("https://partial.example.com".into())
);
assert!(config.chains.api_keys.is_empty());
assert_eq!(config.output.format, OutputFormat::Table);
assert!(config.output.color);
assert_eq!(config.market.health, HealthThresholds::default());
}
#[test]
fn test_load_market_health_overrides_only_given_keys() {
let yaml = r#"
market:
health:
min_levels: 20
max_spread_pct: 1.5
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
let config = Config::load(Some(file.path())).unwrap();
let health = &config.market.health;
assert_eq!(health.min_levels, 20);
assert_eq!(health.max_spread_pct, 1.5);
let defaults = HealthThresholds::default();
assert_eq!(health.min_top3_depth, defaults.min_top3_depth);
assert_eq!(health.min_top10_depth, defaults.min_top10_depth);
assert_eq!(health.min_depth, defaults.min_depth);
}
#[test]
fn test_load_missing_file_returns_defaults() {
let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
assert_eq!(config, Config::default());
}
#[test]
fn test_load_invalid_yaml_returns_error() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"invalid: yaml: : content: [").unwrap();
let result = Config::load(Some(file.path()));
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
}
#[test]
fn test_load_empty_file_returns_defaults() {
let file = NamedTempFile::new().unwrap();
let config = Config::load(Some(file.path())).unwrap();
assert_eq!(config, Config::default());
}
#[test]
fn test_output_format_serialization() {
let json_format = OutputFormat::Json;
let serialized = serde_saphyr::to_string(&json_format).unwrap();
assert!(serialized.contains("json"));
let deserialized: OutputFormat = serde_saphyr::from_str("csv").unwrap();
assert_eq!(deserialized, OutputFormat::Csv);
}
#[test]
fn test_output_format_display() {
assert_eq!(OutputFormat::Table.to_string(), "table");
assert_eq!(OutputFormat::Json.to_string(), "json");
assert_eq!(OutputFormat::Csv.to_string(), "csv");
assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
}
#[test]
fn test_default_path_is_absolute_or_relative() {
assert_eq!(Config::default_path(), crate::paths::config_file());
assert!(Config::default_path().ends_with(".scope-bca/config.yaml"));
}
#[test]
fn test_default_data_dir() {
let data_dir = Config::default_data_dir();
assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
}
#[test]
fn test_data_dir_uses_config_value() {
let config = Config {
address_book: AddressBookConfig {
data_dir: Some(PathBuf::from("/custom/path")),
},
..Default::default()
};
assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
}
#[test]
fn test_data_dir_falls_back_to_default() {
let config = Config::default();
assert_eq!(config.data_dir(), Config::default_data_dir());
}
#[test]
fn test_config_clone_and_eq() {
let config1 = Config::default();
let config2 = config1.clone();
assert_eq!(config1, config2);
}
#[test]
fn test_config_path_returns_some() {
let path = Config::config_path();
assert!(path.is_some());
assert!(path.unwrap().to_string_lossy().contains("scope"));
}
#[test]
fn test_config_debug() {
let config = Config::default();
let debug = format!("{:?}", config);
assert!(debug.contains("Config"));
assert!(debug.contains("ChainsConfig"));
}
#[test]
fn test_output_config_default() {
let output = OutputConfig::default();
assert_eq!(output.format, OutputFormat::Table);
assert!(output.color);
}
#[test]
fn test_config_serialization_roundtrip() {
let mut config = Config::default();
config
.chains
.api_keys
.insert("etherscan".to_string(), "test_key".to_string());
config.output.format = OutputFormat::Json;
config.output.color = false;
config.address_book.data_dir = Some(PathBuf::from("/custom"));
let yaml = serde_saphyr::to_string(&config).unwrap();
let deserialized: Config = serde_saphyr::from_str(&yaml).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_chains_config_with_multiple_api_keys() {
let mut api_keys = HashMap::new();
api_keys.insert("etherscan".into(), "key1".into());
api_keys.insert("polygonscan".into(), "key2".into());
api_keys.insert("bscscan".into(), "key3".into());
let chains = ChainsConfig {
ethereum_rpc: Some("https://rpc.example.com".into()),
api_keys,
..Default::default()
};
assert_eq!(chains.api_keys.len(), 3);
assert!(chains.api_keys.contains_key("etherscan"));
}
#[test]
fn test_load_via_scope_config_env_var() {
let yaml = r#"
chains:
ethereum_rpc: "https://env-test.example.com"
output:
format: csv
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
let path_str = file.path().to_string_lossy().to_string();
unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
let config = Config::load(None).unwrap();
assert_eq!(
config.chains.ethereum_rpc,
Some("https://env-test.example.com".into())
);
assert_eq!(config.output.format, OutputFormat::Csv);
unsafe { std::env::remove_var("SCOPE_CONFIG") };
}
#[test]
fn test_output_format_default() {
let fmt = OutputFormat::default();
assert_eq!(fmt, OutputFormat::Table);
}
#[test]
fn test_address_book_config_default() {
let port = AddressBookConfig::default();
assert!(port.data_dir.is_none());
}
#[test]
fn test_chains_config_default() {
let chains = ChainsConfig::default();
assert!(chains.ethereum_rpc.is_none());
assert!(chains.bsc_rpc.is_none());
assert!(chains.aegis_rpc.is_none());
assert!(chains.solana_rpc.is_none());
assert!(chains.tron_api.is_none());
assert!(chains.api_keys.is_empty());
}
#[test]
fn test_load_unreadable_file_returns_error() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.yaml");
std::fs::create_dir_all(&config_path).unwrap();
let result = Config::load(Some(&config_path));
assert!(result.is_err());
}
#[test]
fn test_default_path_returns_valid_path() {
let path = Config::default_path();
assert!(path.to_str().unwrap().contains("scope"));
}
#[test]
fn test_ghola_config_default() {
let ghola = GholaConfig::default();
assert!(!ghola.enabled);
assert!(!ghola.stealth);
assert_eq!(ghola.buffer_size, 4096);
}
#[test]
fn test_config_default_ghola_disabled() {
let config = Config::default();
assert!(!config.ghola.enabled);
assert!(!config.ghola.stealth);
assert_eq!(config.ghola.buffer_size, 4096);
}
#[test]
fn test_load_ghola_enabled() {
let yaml = r#"
ghola:
enabled: true
stealth: true
buffer_size: 8192
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
let config = Config::load(Some(file.path())).unwrap();
assert!(config.ghola.enabled);
assert!(config.ghola.stealth);
assert_eq!(config.ghola.buffer_size, 8192);
}
#[test]
fn test_load_ghola_partial() {
let yaml = r#"
ghola:
enabled: true
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
let config = Config::load(Some(file.path())).unwrap();
assert!(config.ghola.enabled);
assert!(!config.ghola.stealth); assert_eq!(config.ghola.buffer_size, 4096); }
#[test]
fn test_load_ghola_absent_uses_defaults() {
let yaml = r#"
chains:
ethereum_rpc: "https://example.com"
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
let config = Config::load(Some(file.path())).unwrap();
assert!(!config.ghola.enabled);
assert!(!config.ghola.stealth);
assert_eq!(config.ghola.buffer_size, 4096);
}
#[test]
fn test_load_ghola_custom_buffer_size() {
let yaml = r#"
ghola:
enabled: false
buffer_size: 16384
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
let config = Config::load(Some(file.path())).unwrap();
assert!(!config.ghola.enabled);
assert_eq!(config.ghola.buffer_size, 16384);
}
#[test]
fn test_ghola_config_serialization_roundtrip() {
let mut config = Config::default();
config.ghola.enabled = true;
config.ghola.stealth = true;
config.ghola.buffer_size = 8192;
let yaml = serde_saphyr::to_string(&config).unwrap();
let deserialized: Config = serde_saphyr::from_str(&yaml).unwrap();
assert_eq!(config.ghola, deserialized.ghola);
}
#[test]
fn test_the_venue_catalog_url_defaults_unless_set() {
let mut market = MarketConfig::default();
assert_eq!(
market.catalog_url(),
crate::market::catalog::DEFAULT_CATALOG_URL
);
market.venue_catalog_url = Some(" ".into());
assert_eq!(
market.catalog_url(),
crate::market::catalog::DEFAULT_CATALOG_URL
);
market.venue_catalog_url = Some("https://example.com/cat".into());
assert_eq!(market.catalog_url(), "https://example.com/cat");
let read: Config =
serde_saphyr::from_str("market:\n venue_catalog_url: https://x/c\n").unwrap();
assert_eq!(read.market.catalog_url(), "https://x/c");
}
}