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