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