Skip to main content

scope/
config.rs

1//! # Configuration Management
2//!
3//! This module handles loading, merging, and validating configuration
4//! from multiple sources with the following priority (highest to lowest):
5//!
6//! 1. CLI arguments
7//! 2. Environment variables (`SCOPE_*` prefix)
8//! 3. User config file (`~/.scope-bca/config.yaml`)
9//! 4. Built-in defaults
10//!
11//! ## Configuration File Format
12//!
13//! ```yaml
14//! chains:
15//!   # EVM-compatible chains
16//!   ethereum_rpc: "https://mainnet.infura.io/v3/YOUR_KEY"
17//!   bsc_rpc: "https://bsc-dataseed.binance.org"
18//!
19//!   # Non-EVM chains
20//!   solana_rpc: "https://api.mainnet-beta.solana.com"
21//!   tron_api: "https://api.trongrid.io"
22//!
23//!   api_keys:
24//!     etherscan: "YOUR_API_KEY"
25//!     bscscan: "YOUR_API_KEY"
26//!     solscan: "YOUR_API_KEY"
27//!     tronscan: "YOUR_API_KEY"
28//!
29//! output:
30//!   format: table  # table, json, csv, markdown
31//!   color: true
32//!
33//! address_book:
34//!   data_dir: "/Users/me/scope-address-book"  # optional; absolute path
35//!
36//! ghola:
37//!   enabled: false        # route HTTP through Ghola sidecar
38//!   stealth: false        # apply temporal drift + ghost signing
39//!   buffer_size: 4096     # read buffer for large response headers
40//!
41//! market:
42//!   health:               # order book health thresholds; omitted keys keep defaults
43//!     min_levels: 10      # valid levels (price > 0, qty > 0) per side
44//!     max_spread_pct: 3.0 # best bid/ask spread, % of mid
45//!     min_top3_depth: 300
46//!     min_top10_depth: 2000
47//!     min_depth: 3000     # total in-band depth per side
48//!     peg_target: 1.0
49//!     peg_range: 0.001
50//!     min_bid_ask_ratio: 0.2
51//!     max_bid_ask_ratio: 5.0
52//!     depth_window_pct: 2.0       # depth/volume rule: depth within ±2% of mid
53//!     min_depth_volume_pct: 5.0   # below 5% of 24h volume fails
54//!     max_depth_volume_pct: 10.0  # above 10% of 24h volume warns
55//! ```
56//!
57//! ## Error Handling
58//!
59//! Configuration errors are wrapped in [`ScopeError::Config`] and include
60//! context about which source caused the failure.
61
62use crate::error::{ConfigError, Result, ScopeError};
63use crate::market::HealthThresholds;
64use serde::{Deserialize, Serialize};
65use std::collections::HashMap;
66use std::path::{Path, PathBuf};
67
68/// Application configuration.
69///
70/// Contains all settings for blockchain clients, output formatting,
71/// and address book management. Use [`Config::load`] to load from file
72/// or [`Config::default`] for sensible defaults.
73///
74/// # Examples
75///
76/// ```rust
77/// use scope::Config;
78///
79/// // Load from default location or custom path
80/// let config = Config::load(None).unwrap_or_default();
81/// println!("Output format: {:?}", config.output.format);
82/// ```
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
84#[serde(default)]
85pub struct Config {
86    /// Blockchain chain client configuration.
87    pub chains: ChainsConfig,
88
89    /// Output formatting configuration.
90    pub output: OutputConfig,
91
92    /// Address book configuration (label → address mapping storage).
93    #[serde(alias = "portfolio")]
94    pub address_book: AddressBookConfig,
95
96    /// Monitor TUI configuration (layout, widgets, refresh rate).
97    pub monitor: MonitorConfig,
98
99    /// Ghola sidecar configuration for stealth HTTP transport.
100    pub ghola: GholaConfig,
101
102    /// Web server configuration.
103    pub web: WebConfig,
104
105    /// Market analysis configuration (order book health thresholds).
106    pub market: MarketConfig,
107}
108
109/// Blockchain client configuration.
110///
111/// Contains RPC endpoints and API keys for various blockchain networks.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
113#[serde(default)]
114pub struct ChainsConfig {
115    // =========================================================================
116    // EVM-Compatible Chains
117    // =========================================================================
118    /// Ethereum JSON-RPC endpoint URL.
119    ///
120    /// Example: `https://mainnet.infura.io/v3/YOUR_PROJECT_ID`
121    pub ethereum_rpc: Option<String>,
122
123    /// BSC (BNB Smart Chain) JSON-RPC endpoint URL.
124    ///
125    /// Example: `https://bsc-dataseed.binance.org`
126    pub bsc_rpc: Option<String>,
127
128    /// Custom EVM chain JSON-RPC endpoint (reserved for internal use).
129    #[doc(hidden)]
130    pub aegis_rpc: Option<String>,
131
132    // =========================================================================
133    // Non-EVM Chains
134    // =========================================================================
135    /// Solana JSON-RPC endpoint URL.
136    ///
137    /// Example: `https://api.mainnet-beta.solana.com`
138    pub solana_rpc: Option<String>,
139
140    /// Tron API endpoint URL (TronGrid).
141    ///
142    /// Example: `https://api.trongrid.io`
143    pub tron_api: Option<String>,
144
145    // =========================================================================
146    // API Keys
147    // =========================================================================
148    /// API keys for block explorer services.
149    ///
150    /// Keys are service names (e.g., "etherscan", "polygonscan", "bscscan",
151    /// "solscan", "tronscan").
152    pub api_keys: HashMap<String, String>,
153}
154
155/// Output formatting configuration.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[serde(default)]
158pub struct OutputConfig {
159    /// Output format for analysis results.
160    pub format: OutputFormat,
161
162    /// Whether to use colored output in the terminal.
163    pub color: bool,
164}
165
166/// Address book configuration.
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
168#[serde(default)]
169pub struct AddressBookConfig {
170    /// Directory for storing address book data.
171    ///
172    /// Defaults to `~/.scope-bca/data` (see [`crate::paths`]). Use an absolute
173    /// path: `~` is not expanded.
174    pub data_dir: Option<PathBuf>,
175}
176
177/// Ghola sidecar configuration.
178///
179/// Controls whether HTTP requests are routed through the
180/// [Ghola](https://github.com/robot-accomplice/ghola) sidecar for
181/// stealth analysis (temporal drift, ghost signing, chain-aware headers).
182///
183/// Ghola is an external Go binary; all fields default to `false` / sensible
184/// values so existing installations see no behavior change.
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186#[serde(default)]
187pub struct GholaConfig {
188    /// When `true`, route HTTP requests through the Ghola sidecar
189    /// (`127.0.0.1:18789`) instead of using `reqwest` directly.
190    pub enabled: bool,
191
192    /// When `true` (and `enabled` is `true`), the sidecar applies
193    /// temporal drift and ghost signing to every outgoing request.
194    pub stealth: bool,
195
196    /// Read buffer size (in bytes) for handling large response headers.
197    /// Passed to the Ghola sidecar which uses it as `fasthttp.Client.ReadBufferSize`.
198    /// Default: 4096. Increase for APIs that return very large headers.
199    pub buffer_size: u32,
200}
201
202impl Default for GholaConfig {
203    fn default() -> Self {
204        Self {
205            enabled: false,
206            stealth: false,
207            buffer_size: 4096,
208        }
209    }
210}
211
212/// Market analysis configuration.
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
214#[serde(default)]
215pub struct MarketConfig {
216    /// Order book health thresholds. Keys left out keep the built-in default.
217    pub health: HealthThresholds,
218    /// Where `scope venues update` fetches the venue catalog from (a folder
219    /// URL holding `index.json`). Unset: the scope repository's catalog.
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub venue_catalog_url: Option<String>,
222}
223
224impl MarketConfig {
225    /// The venue catalog's URL: the configured one, else the default.
226    pub fn catalog_url(&self) -> &str {
227        self.venue_catalog_url
228            .as_deref()
229            .filter(|u| !u.trim().is_empty())
230            .unwrap_or(crate::market::catalog::DEFAULT_CATALOG_URL)
231    }
232}
233
234/// Web server configuration.
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
236#[serde(default)]
237pub struct WebConfig {
238    /// Optional bearer token for API authentication.
239    /// When set, all `/api/*` endpoints require `Authorization: Bearer <token>`.
240    /// When not set, no authentication is required (localhost-only use case).
241    pub auth_token: Option<String>,
242}
243
244/// Available output formats for analysis results.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
246#[serde(rename_all = "lowercase")]
247pub enum OutputFormat {
248    /// Human-readable table format (default).
249    #[default]
250    Table,
251
252    /// JSON format for programmatic consumption.
253    Json,
254
255    /// CSV format for spreadsheet import.
256    Csv,
257
258    /// Markdown format for agent parsing (console output).
259    #[value(name = "markdown")]
260    Markdown,
261}
262
263impl Default for OutputConfig {
264    fn default() -> Self {
265        Self {
266            format: OutputFormat::Table,
267            color: true,
268        }
269    }
270}
271
272impl Config {
273    /// Loads configuration from a file path or the default location.
274    ///
275    /// # Arguments
276    ///
277    /// * `path` - Optional path to a configuration file. If `None`, looks
278    ///   for config at `~/.scope-bca/config.yaml`.
279    ///
280    /// # Returns
281    ///
282    /// Returns the loaded configuration, or defaults if no config file exists.
283    ///
284    /// # Errors
285    ///
286    /// Returns [`ScopeError::Config`] if the file exists but cannot be read
287    /// or contains invalid YAML.
288    ///
289    /// # Examples
290    ///
291    /// ```rust
292    /// use scope::Config;
293    /// use std::path::Path;
294    ///
295    /// // Load from default location
296    /// let config = Config::load(None).unwrap_or_default();
297    ///
298    /// // Load from custom path
299    /// let config = Config::load(Some(Path::new("/custom/config.yaml")));
300    /// ```
301    pub fn load(path: Option<&Path>) -> Result<Self> {
302        // Determine config path: CLI arg > env var > default location
303        let config_path = path
304            .map(PathBuf::from)
305            .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
306            .unwrap_or_else(Self::default_path);
307
308        // Return defaults if no config file exists
309        // This allows first-run without manual setup
310        if !config_path.exists() {
311            tracing::debug!(
312                path = %config_path.display(),
313                "No config file found, using defaults"
314            );
315            return Ok(Self::default());
316        }
317
318        tracing::debug!(path = %config_path.display(), "Loading configuration");
319
320        let contents = std::fs::read_to_string(&config_path).map_err(|e| {
321            ScopeError::Config(ConfigError::Read {
322                path: config_path.clone(),
323                source: e,
324            })
325        })?;
326
327        let config: Config =
328            serde_saphyr::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
329
330        Ok(config)
331    }
332
333    /// Returns the default configuration file path: `<home>/config.yaml`
334    /// (see [`crate::paths`]).
335    pub fn default_path() -> PathBuf {
336        crate::paths::config_file()
337    }
338
339    /// Returns the configuration file path that `setup` and `scope web`
340    /// write: `<home>/config.yaml`.
341    pub fn config_path() -> Option<PathBuf> {
342        Some(crate::paths::config_file())
343    }
344
345    /// Returns the default data directory for address book storage:
346    /// `<home>/data`.
347    pub fn default_data_dir() -> PathBuf {
348        crate::paths::data_dir()
349    }
350
351    /// Returns the effective data directory, using config or default. A
352    /// leading `~` in the config is the user's home folder.
353    pub fn data_dir(&self) -> PathBuf {
354        match &self.address_book.data_dir {
355            Some(dir) => expand_home(dir, crate::paths::user_home()),
356            None => Self::default_data_dir(),
357        }
358    }
359}
360
361/// `dir` with a leading `~` as `home`. YAML has no shell to expand it, so
362/// `data_dir: "~/x"` made a folder named `~` (#106).
363fn expand_home(dir: &Path, home: Option<PathBuf>) -> PathBuf {
364    match (dir.strip_prefix("~"), home) {
365        (Ok(rest), Some(home)) => home.join(rest),
366        _ => dir.to_path_buf(),
367    }
368}
369
370impl std::fmt::Display for OutputFormat {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        match self {
373            OutputFormat::Table => write!(f, "table"),
374            OutputFormat::Json => write!(f, "json"),
375            OutputFormat::Csv => write!(f, "csv"),
376            OutputFormat::Markdown => write!(f, "markdown"),
377        }
378    }
379}
380
381// ============================================================================
382// Monitor Configuration Types
383// ============================================================================
384//
385// These types are serialized as part of the main Config struct, so they must
386// live in scope-core.  The TUI-only types (DataPoint, OhlcCandle, ChartMode,
387// TimePeriod, ColorPalette, etc.) remain in scope-cli's `cli::monitor::config`.
388
389/// Default refresh interval in seconds.
390pub const DEFAULT_MONITOR_REFRESH_SECS: u64 = 5;
391
392/// Layout preset for the monitor TUI.
393///
394/// Controls which widgets are shown and how they are arranged.
395/// Can be switched at runtime with keybindings or set via config.
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
397#[serde(rename_all = "kebab-case")]
398pub enum LayoutPreset {
399    /// Balanced 2x2 grid with all widgets visible.
400    #[default]
401    Dashboard,
402    /// Price chart takes ~85% of the screen; minimal stats overlay.
403    ChartFocus,
404    /// Transaction/activity feed prioritized; small price ticker.
405    Feed,
406    /// Minimal single-column sparkline view for small terminals.
407    Compact,
408    /// Exchange-style view: order book + chart + market info.
409    Exchange,
410}
411
412impl LayoutPreset {
413    /// Cycles to the next layout preset.
414    pub fn next(&self) -> Self {
415        match self {
416            LayoutPreset::Dashboard => LayoutPreset::ChartFocus,
417            LayoutPreset::ChartFocus => LayoutPreset::Feed,
418            LayoutPreset::Feed => LayoutPreset::Compact,
419            LayoutPreset::Compact => LayoutPreset::Exchange,
420            LayoutPreset::Exchange => LayoutPreset::Dashboard,
421        }
422    }
423
424    /// Cycles to the previous layout preset.
425    pub fn prev(&self) -> Self {
426        match self {
427            LayoutPreset::Dashboard => LayoutPreset::Exchange,
428            LayoutPreset::ChartFocus => LayoutPreset::Dashboard,
429            LayoutPreset::Feed => LayoutPreset::ChartFocus,
430            LayoutPreset::Compact => LayoutPreset::Feed,
431            LayoutPreset::Exchange => LayoutPreset::Compact,
432        }
433    }
434
435    /// Returns a display label for this preset.
436    pub fn label(&self) -> &'static str {
437        match self {
438            LayoutPreset::Dashboard => "Dashboard",
439            LayoutPreset::ChartFocus => "Chart",
440            LayoutPreset::Feed => "Feed",
441            LayoutPreset::Compact => "Compact",
442            LayoutPreset::Exchange => "Exchange",
443        }
444    }
445}
446
447/// Controls which widgets are visible in the monitor.
448///
449/// Individual widgets can be toggled on/off via keybindings or config.
450/// The layout functions use these flags to decide which `Rect` areas to allocate.
451#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
452#[serde(default)]
453pub struct WidgetVisibility {
454    /// Show the price chart (line or candlestick).
455    pub price_chart: bool,
456    /// Show the volume bar chart.
457    pub volume_chart: bool,
458    /// Show the buy/sell pressure gauge and activity log.
459    pub buy_sell_pressure: bool,
460    /// Show the metrics panel (sparkline + key metrics table).
461    pub metrics_panel: bool,
462    /// Show the activity log feed.
463    pub activity_log: bool,
464    /// Show the holder count in the metrics panel.
465    pub holder_count: bool,
466    /// Show the per-pair liquidity depth.
467    pub liquidity_depth: bool,
468}
469
470impl Default for WidgetVisibility {
471    fn default() -> Self {
472        Self {
473            price_chart: true,
474            volume_chart: true,
475            buy_sell_pressure: true,
476            metrics_panel: true,
477            activity_log: true,
478            holder_count: true,
479            liquidity_depth: true,
480        }
481    }
482}
483
484impl WidgetVisibility {
485    /// Returns the number of visible widgets.
486    pub fn visible_count(&self) -> usize {
487        [
488            self.price_chart,
489            self.volume_chart,
490            self.buy_sell_pressure,
491            self.metrics_panel,
492            self.activity_log,
493        ]
494        .iter()
495        .filter(|&&v| v)
496        .count()
497    }
498
499    /// Toggles a widget by index (1-based: 1=price_chart, 2=volume, 3=buy_sell, 4=metrics, 5=log).
500    pub fn toggle_by_index(&mut self, index: usize) {
501        match index {
502            1 => self.price_chart = !self.price_chart,
503            2 => self.volume_chart = !self.volume_chart,
504            3 => self.buy_sell_pressure = !self.buy_sell_pressure,
505            4 => self.metrics_panel = !self.metrics_panel,
506            5 => self.activity_log = !self.activity_log,
507            _ => {}
508        }
509    }
510}
511
512/// Y-axis scaling mode for price charts.
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
514#[serde(rename_all = "kebab-case")]
515pub enum ScaleMode {
516    /// Linear scale (default).
517    #[default]
518    Linear,
519    /// Logarithmic scale -- useful for tokens with very wide price ranges.
520    Log,
521}
522
523impl ScaleMode {
524    /// Toggles between Linear and Log.
525    pub fn toggle(&self) -> Self {
526        match self {
527            ScaleMode::Linear => ScaleMode::Log,
528            ScaleMode::Log => ScaleMode::Linear,
529        }
530    }
531
532    /// Returns a short display label.
533    pub fn label(&self) -> &'static str {
534        match self {
535            ScaleMode::Linear => "Lin",
536            ScaleMode::Log => "Log",
537        }
538    }
539}
540
541/// Color scheme for the monitor TUI.
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
543#[serde(rename_all = "kebab-case")]
544pub enum ColorScheme {
545    /// Classic green/red (default).
546    #[default]
547    GreenRed,
548    /// Blue/orange, better for certain color blindness.
549    BlueOrange,
550    /// Monochrome -- fully accessible grayscale.
551    Monochrome,
552}
553
554impl ColorScheme {
555    /// Cycles to the next color scheme.
556    pub fn next(&self) -> Self {
557        match self {
558            ColorScheme::GreenRed => ColorScheme::BlueOrange,
559            ColorScheme::BlueOrange => ColorScheme::Monochrome,
560            ColorScheme::Monochrome => ColorScheme::GreenRed,
561        }
562    }
563
564    /// Returns a short display label.
565    pub fn label(&self) -> &'static str {
566        match self {
567            ColorScheme::GreenRed => "green-red",
568            ColorScheme::BlueOrange => "blue-orange",
569            ColorScheme::Monochrome => "mono",
570        }
571    }
572}
573
574/// Alert configuration for price and whale detection.
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
576#[serde(default)]
577pub struct AlertConfig {
578    /// Minimum price threshold; alert fires when price drops below this.
579    pub price_min: Option<f64>,
580    /// Maximum price threshold; alert fires when price exceeds this.
581    pub price_max: Option<f64>,
582    /// Minimum USD value for whale transaction detection.
583    pub whale_min_usd: Option<f64>,
584    /// Volume spike threshold as a percentage increase from the rolling average.
585    pub volume_spike_threshold_pct: Option<f64>,
586}
587
588impl Default for AlertConfig {
589    #[allow(clippy::derivable_impls)]
590    fn default() -> Self {
591        Self {
592            price_min: None,
593            price_max: None,
594            whale_min_usd: None,
595            volume_spike_threshold_pct: None,
596        }
597    }
598}
599
600/// CSV export configuration.
601#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
602#[serde(default)]
603pub struct ExportConfig {
604    /// Base directory for exports (default: `./scope-exports/`).
605    pub path: Option<String>,
606}
607
608impl Default for ExportConfig {
609    #[allow(clippy::derivable_impls)]
610    fn default() -> Self {
611        Self { path: None }
612    }
613}
614
615/// Monitor-specific configuration.
616///
617/// Loaded from the `monitor:` section of `~/.scope-bca/config.yaml`.
618/// All fields have sensible defaults so the section is entirely optional.
619#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
620#[serde(default)]
621pub struct MonitorConfig {
622    /// Layout preset to use on startup.
623    pub layout: LayoutPreset,
624    /// Refresh interval in seconds.
625    pub refresh_seconds: u64,
626    /// Widget visibility toggles.
627    pub widgets: WidgetVisibility,
628    /// Y-axis scale mode for price charts.
629    pub scale: ScaleMode,
630    /// Color scheme.
631    pub color_scheme: ColorScheme,
632    /// Alert thresholds (price min/max, whale detection).
633    pub alerts: AlertConfig,
634    /// CSV export settings.
635    pub export: ExportConfig,
636    /// Whether to auto-pause data fetching when the user is interacting.
637    pub auto_pause_on_input: bool,
638    /// Exchange venue for real OHLC candle data (e.g., "binance").
639    #[serde(default)]
640    pub venue: Option<String>,
641}
642
643impl Default for MonitorConfig {
644    fn default() -> Self {
645        Self {
646            layout: LayoutPreset::Dashboard,
647            refresh_seconds: DEFAULT_MONITOR_REFRESH_SECS,
648            widgets: WidgetVisibility::default(),
649            scale: ScaleMode::Linear,
650            color_scheme: ColorScheme::GreenRed,
651            alerts: AlertConfig::default(),
652            export: ExportConfig::default(),
653            auto_pause_on_input: false,
654            venue: None,
655        }
656    }
657}
658
659// ============================================================================
660// Unit Tests
661// ============================================================================
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use std::io::Write;
667    use tempfile::NamedTempFile;
668
669    #[test]
670    fn test_a_leading_tilde_in_data_dir_is_the_home_folder() {
671        let home = Some(PathBuf::from("/home/u"));
672        assert_eq!(
673            expand_home(Path::new("~/.scope-bca/data"), home.clone()),
674            PathBuf::from("/home/u").join(".scope-bca/data")
675        );
676        assert_eq!(
677            expand_home(Path::new("~"), home.clone()),
678            PathBuf::from("/home/u")
679        );
680        // Only a whole `~` component, and only at the start.
681        assert_eq!(
682            expand_home(Path::new("~x/d"), home.clone()),
683            PathBuf::from("~x/d")
684        );
685        assert_eq!(
686            expand_home(Path::new("/a/~/d"), home),
687            PathBuf::from("/a/~/d")
688        );
689        // No home folder known: the path stays as written.
690        assert_eq!(expand_home(Path::new("~/d"), None), PathBuf::from("~/d"));
691    }
692
693    #[test]
694    fn test_default_config() {
695        let config = Config::default();
696
697        assert!(config.chains.api_keys.is_empty());
698        assert!(config.chains.ethereum_rpc.is_none());
699        assert!(config.chains.bsc_rpc.is_none());
700        assert!(config.chains.aegis_rpc.is_none());
701        assert!(config.chains.solana_rpc.is_none());
702        assert!(config.chains.tron_api.is_none());
703        assert_eq!(config.output.format, OutputFormat::Table);
704        assert!(config.output.color);
705        assert!(config.address_book.data_dir.is_none());
706    }
707
708    #[test]
709    fn test_load_from_yaml_full() {
710        let yaml = r#"
711chains:
712  ethereum_rpc: "https://example.com/rpc"
713  bsc_rpc: "https://bsc-dataseed.binance.org"
714  solana_rpc: "https://api.mainnet-beta.solana.com"
715  tron_api: "https://api.trongrid.io"
716  api_keys:
717    etherscan: "test-api-key"
718    polygonscan: "another-key"
719    bscscan: "bsc-key"
720    solscan: "sol-key"
721    tronscan: "tron-key"
722
723output:
724  format: json
725  color: false
726
727address_book:
728  data_dir: "/custom/data"
729"#;
730
731        let mut file = NamedTempFile::new().unwrap();
732        file.write_all(yaml.as_bytes()).unwrap();
733
734        let config = Config::load(Some(file.path())).unwrap();
735
736        // EVM chains
737        assert_eq!(
738            config.chains.ethereum_rpc,
739            Some("https://example.com/rpc".into())
740        );
741        assert_eq!(
742            config.chains.bsc_rpc,
743            Some("https://bsc-dataseed.binance.org".into())
744        );
745
746        // Non-EVM chains
747        assert_eq!(
748            config.chains.solana_rpc,
749            Some("https://api.mainnet-beta.solana.com".into())
750        );
751        assert_eq!(
752            config.chains.tron_api,
753            Some("https://api.trongrid.io".into())
754        );
755
756        // API keys
757        assert_eq!(
758            config.chains.api_keys.get("etherscan"),
759            Some(&"test-api-key".into())
760        );
761        assert_eq!(
762            config.chains.api_keys.get("polygonscan"),
763            Some(&"another-key".into())
764        );
765        assert_eq!(
766            config.chains.api_keys.get("bscscan"),
767            Some(&"bsc-key".into())
768        );
769        assert_eq!(
770            config.chains.api_keys.get("solscan"),
771            Some(&"sol-key".into())
772        );
773        assert_eq!(
774            config.chains.api_keys.get("tronscan"),
775            Some(&"tron-key".into())
776        );
777
778        assert_eq!(config.output.format, OutputFormat::Json);
779        assert!(!config.output.color);
780        assert_eq!(
781            config.address_book.data_dir,
782            Some(PathBuf::from("/custom/data"))
783        );
784    }
785
786    #[test]
787    fn test_load_partial_yaml_uses_defaults() {
788        let yaml = r#"
789chains:
790  ethereum_rpc: "https://partial.example.com"
791"#;
792
793        let mut file = NamedTempFile::new().unwrap();
794        file.write_all(yaml.as_bytes()).unwrap();
795
796        let config = Config::load(Some(file.path())).unwrap();
797
798        // Specified value
799        assert_eq!(
800            config.chains.ethereum_rpc,
801            Some("https://partial.example.com".into())
802        );
803
804        // Defaults
805        assert!(config.chains.api_keys.is_empty());
806        assert_eq!(config.output.format, OutputFormat::Table);
807        assert!(config.output.color);
808        assert_eq!(config.market.health, HealthThresholds::default());
809    }
810
811    #[test]
812    fn test_load_market_health_overrides_only_given_keys() {
813        let yaml = r#"
814market:
815  health:
816    min_levels: 20
817    max_spread_pct: 1.5
818"#;
819
820        let mut file = NamedTempFile::new().unwrap();
821        file.write_all(yaml.as_bytes()).unwrap();
822
823        let config = Config::load(Some(file.path())).unwrap();
824        let health = &config.market.health;
825
826        assert_eq!(health.min_levels, 20);
827        assert_eq!(health.max_spread_pct, 1.5);
828        // Keys not in the file keep the built-in default.
829        let defaults = HealthThresholds::default();
830        assert_eq!(health.min_top3_depth, defaults.min_top3_depth);
831        assert_eq!(health.min_top10_depth, defaults.min_top10_depth);
832        assert_eq!(health.min_depth, defaults.min_depth);
833    }
834
835    #[test]
836    fn test_load_missing_file_returns_defaults() {
837        let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
838        assert_eq!(config, Config::default());
839    }
840
841    #[test]
842    fn test_load_invalid_yaml_returns_error() {
843        let mut file = NamedTempFile::new().unwrap();
844        file.write_all(b"invalid: yaml: : content: [").unwrap();
845
846        let result = Config::load(Some(file.path()));
847        assert!(result.is_err());
848        assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
849    }
850
851    #[test]
852    fn test_load_empty_file_returns_defaults() {
853        let file = NamedTempFile::new().unwrap();
854        // Empty file
855
856        let config = Config::load(Some(file.path())).unwrap();
857        assert_eq!(config, Config::default());
858    }
859
860    #[test]
861    fn test_output_format_serialization() {
862        let json_format = OutputFormat::Json;
863        let serialized = serde_saphyr::to_string(&json_format).unwrap();
864        assert!(serialized.contains("json"));
865
866        let deserialized: OutputFormat = serde_saphyr::from_str("csv").unwrap();
867        assert_eq!(deserialized, OutputFormat::Csv);
868    }
869
870    #[test]
871    fn test_output_format_display() {
872        assert_eq!(OutputFormat::Table.to_string(), "table");
873        assert_eq!(OutputFormat::Json.to_string(), "json");
874        assert_eq!(OutputFormat::Csv.to_string(), "csv");
875        assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
876    }
877
878    #[test]
879    fn test_default_path_is_absolute_or_relative() {
880        // The config file is in the one scope home.
881        assert_eq!(Config::default_path(), crate::paths::config_file());
882        assert!(Config::default_path().ends_with(".scope-bca/config.yaml"));
883    }
884
885    #[test]
886    fn test_default_data_dir() {
887        let data_dir = Config::default_data_dir();
888        assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
889    }
890
891    #[test]
892    fn test_data_dir_uses_config_value() {
893        let config = Config {
894            address_book: AddressBookConfig {
895                data_dir: Some(PathBuf::from("/custom/path")),
896            },
897            ..Default::default()
898        };
899
900        assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
901    }
902
903    #[test]
904    fn test_data_dir_falls_back_to_default() {
905        let config = Config::default();
906        assert_eq!(config.data_dir(), Config::default_data_dir());
907    }
908
909    #[test]
910    fn test_config_clone_and_eq() {
911        let config1 = Config::default();
912        let config2 = config1.clone();
913        assert_eq!(config1, config2);
914    }
915
916    #[test]
917    fn test_config_path_returns_some() {
918        let path = Config::config_path();
919        // Should return Some on systems with a home dir
920        assert!(path.is_some());
921        assert!(path.unwrap().to_string_lossy().contains("scope"));
922    }
923
924    #[test]
925    fn test_config_debug() {
926        let config = Config::default();
927        let debug = format!("{:?}", config);
928        assert!(debug.contains("Config"));
929        assert!(debug.contains("ChainsConfig"));
930    }
931
932    #[test]
933    fn test_output_config_default() {
934        let output = OutputConfig::default();
935        assert_eq!(output.format, OutputFormat::Table);
936        assert!(output.color);
937    }
938
939    #[test]
940    fn test_config_serialization_roundtrip() {
941        let mut config = Config::default();
942        config
943            .chains
944            .api_keys
945            .insert("etherscan".to_string(), "test_key".to_string());
946        config.output.format = OutputFormat::Json;
947        config.output.color = false;
948        config.address_book.data_dir = Some(PathBuf::from("/custom"));
949
950        let yaml = serde_saphyr::to_string(&config).unwrap();
951        let deserialized: Config = serde_saphyr::from_str(&yaml).unwrap();
952        assert_eq!(config, deserialized);
953    }
954
955    #[test]
956    fn test_chains_config_with_multiple_api_keys() {
957        let mut api_keys = HashMap::new();
958        api_keys.insert("etherscan".into(), "key1".into());
959        api_keys.insert("polygonscan".into(), "key2".into());
960        api_keys.insert("bscscan".into(), "key3".into());
961
962        let chains = ChainsConfig {
963            ethereum_rpc: Some("https://rpc.example.com".into()),
964            api_keys,
965            ..Default::default()
966        };
967
968        assert_eq!(chains.api_keys.len(), 3);
969        assert!(chains.api_keys.contains_key("etherscan"));
970    }
971
972    #[test]
973    fn test_load_via_scope_config_env_var() {
974        let yaml = r#"
975chains:
976  ethereum_rpc: "https://env-test.example.com"
977output:
978  format: csv
979"#;
980        let mut file = NamedTempFile::new().unwrap();
981        file.write_all(yaml.as_bytes()).unwrap();
982
983        let path_str = file.path().to_string_lossy().to_string();
984        unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
985
986        // Load with None path — should pick up the env var
987        let config = Config::load(None).unwrap();
988        assert_eq!(
989            config.chains.ethereum_rpc,
990            Some("https://env-test.example.com".into())
991        );
992        assert_eq!(config.output.format, OutputFormat::Csv);
993
994        unsafe { std::env::remove_var("SCOPE_CONFIG") };
995    }
996
997    #[test]
998    fn test_output_format_default() {
999        let fmt = OutputFormat::default();
1000        assert_eq!(fmt, OutputFormat::Table);
1001    }
1002
1003    #[test]
1004    fn test_address_book_config_default() {
1005        let port = AddressBookConfig::default();
1006        assert!(port.data_dir.is_none());
1007    }
1008
1009    #[test]
1010    fn test_chains_config_default() {
1011        let chains = ChainsConfig::default();
1012        assert!(chains.ethereum_rpc.is_none());
1013        assert!(chains.bsc_rpc.is_none());
1014        assert!(chains.aegis_rpc.is_none());
1015        assert!(chains.solana_rpc.is_none());
1016        assert!(chains.tron_api.is_none());
1017        assert!(chains.api_keys.is_empty());
1018    }
1019
1020    #[test]
1021    fn test_load_unreadable_file_returns_error() {
1022        // Create a directory where a file is expected to trigger read error
1023        let dir = tempfile::tempdir().unwrap();
1024        let config_path = dir.path().join("config.yaml");
1025        // Create it as a directory so reading it as a file fails
1026        std::fs::create_dir_all(&config_path).unwrap();
1027        let result = Config::load(Some(&config_path));
1028        assert!(result.is_err());
1029    }
1030
1031    #[test]
1032    fn test_default_path_returns_valid_path() {
1033        let path = Config::default_path();
1034        // Should always return some path (may or may not exist)
1035        assert!(path.to_str().unwrap().contains("scope"));
1036    }
1037
1038    // ========================================================================
1039    // Ghola Configuration Tests
1040    // ========================================================================
1041
1042    #[test]
1043    fn test_ghola_config_default() {
1044        let ghola = GholaConfig::default();
1045        assert!(!ghola.enabled);
1046        assert!(!ghola.stealth);
1047        assert_eq!(ghola.buffer_size, 4096);
1048    }
1049
1050    #[test]
1051    fn test_config_default_ghola_disabled() {
1052        let config = Config::default();
1053        assert!(!config.ghola.enabled);
1054        assert!(!config.ghola.stealth);
1055        assert_eq!(config.ghola.buffer_size, 4096);
1056    }
1057
1058    #[test]
1059    fn test_load_ghola_enabled() {
1060        let yaml = r#"
1061ghola:
1062  enabled: true
1063  stealth: true
1064  buffer_size: 8192
1065"#;
1066        let mut file = NamedTempFile::new().unwrap();
1067        file.write_all(yaml.as_bytes()).unwrap();
1068
1069        let config = Config::load(Some(file.path())).unwrap();
1070        assert!(config.ghola.enabled);
1071        assert!(config.ghola.stealth);
1072        assert_eq!(config.ghola.buffer_size, 8192);
1073    }
1074
1075    #[test]
1076    fn test_load_ghola_partial() {
1077        let yaml = r#"
1078ghola:
1079  enabled: true
1080"#;
1081        let mut file = NamedTempFile::new().unwrap();
1082        file.write_all(yaml.as_bytes()).unwrap();
1083
1084        let config = Config::load(Some(file.path())).unwrap();
1085        assert!(config.ghola.enabled);
1086        assert!(!config.ghola.stealth); // defaults to false
1087        assert_eq!(config.ghola.buffer_size, 4096); // defaults to 4096
1088    }
1089
1090    #[test]
1091    fn test_load_ghola_absent_uses_defaults() {
1092        let yaml = r#"
1093chains:
1094  ethereum_rpc: "https://example.com"
1095"#;
1096        let mut file = NamedTempFile::new().unwrap();
1097        file.write_all(yaml.as_bytes()).unwrap();
1098
1099        let config = Config::load(Some(file.path())).unwrap();
1100        assert!(!config.ghola.enabled);
1101        assert!(!config.ghola.stealth);
1102        assert_eq!(config.ghola.buffer_size, 4096);
1103    }
1104
1105    #[test]
1106    fn test_load_ghola_custom_buffer_size() {
1107        let yaml = r#"
1108ghola:
1109  enabled: false
1110  buffer_size: 16384
1111"#;
1112        let mut file = NamedTempFile::new().unwrap();
1113        file.write_all(yaml.as_bytes()).unwrap();
1114
1115        let config = Config::load(Some(file.path())).unwrap();
1116        assert!(!config.ghola.enabled);
1117        assert_eq!(config.ghola.buffer_size, 16384);
1118    }
1119
1120    #[test]
1121    fn test_ghola_config_serialization_roundtrip() {
1122        let mut config = Config::default();
1123        config.ghola.enabled = true;
1124        config.ghola.stealth = true;
1125        config.ghola.buffer_size = 8192;
1126
1127        let yaml = serde_saphyr::to_string(&config).unwrap();
1128        let deserialized: Config = serde_saphyr::from_str(&yaml).unwrap();
1129        assert_eq!(config.ghola, deserialized.ghola);
1130    }
1131
1132    #[test]
1133    fn test_the_venue_catalog_url_defaults_unless_set() {
1134        let mut market = MarketConfig::default();
1135        assert_eq!(
1136            market.catalog_url(),
1137            crate::market::catalog::DEFAULT_CATALOG_URL
1138        );
1139        market.venue_catalog_url = Some("  ".into());
1140        assert_eq!(
1141            market.catalog_url(),
1142            crate::market::catalog::DEFAULT_CATALOG_URL
1143        );
1144        market.venue_catalog_url = Some("https://example.com/cat".into());
1145        assert_eq!(market.catalog_url(), "https://example.com/cat");
1146        let read: Config =
1147            serde_saphyr::from_str("market:\n  venue_catalog_url: https://x/c\n").unwrap();
1148        assert_eq!(read.market.catalog_url(), "https://x/c");
1149    }
1150}