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