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