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//!   aegis_rpc: "http://localhost:8545"
19//!
20//!   # Non-EVM chains
21//!   solana_rpc: "https://api.mainnet-beta.solana.com"
22//!   tron_api: "https://api.trongrid.io"
23//!
24//!   api_keys:
25//!     etherscan: "YOUR_API_KEY"
26//!     bscscan: "YOUR_API_KEY"
27//!     solscan: "YOUR_API_KEY"
28//!     tronscan: "YOUR_API_KEY"
29//!
30//! output:
31//!   format: table  # table, json, csv
32//!   color: true
33//!
34//! portfolio:
35//!   data_dir: "~/.local/share/scope"
36//! ```
37//!
38//! ## Error Handling
39//!
40//! Configuration errors are wrapped in [`ScopeError::Config`] and include
41//! context about which source caused the failure.
42
43use crate::error::{ConfigError, Result, ScopeError};
44use serde::{Deserialize, Serialize};
45use std::collections::HashMap;
46use std::path::{Path, PathBuf};
47
48/// Application configuration.
49///
50/// Contains all settings for blockchain clients, output formatting,
51/// and portfolio management. Use [`Config::load`] to load from file
52/// or [`Config::default`] for sensible defaults.
53///
54/// # Examples
55///
56/// ```rust
57/// use scope::Config;
58///
59/// // Load from default location or custom path
60/// let config = Config::load(None).unwrap_or_default();
61/// println!("Output format: {:?}", config.output.format);
62/// ```
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
64#[serde(default)]
65pub struct Config {
66    /// Blockchain chain client configuration.
67    pub chains: ChainsConfig,
68
69    /// Output formatting configuration.
70    pub output: OutputConfig,
71
72    /// Portfolio management configuration.
73    pub portfolio: PortfolioConfig,
74}
75
76/// Blockchain client configuration.
77///
78/// Contains RPC endpoints and API keys for various blockchain networks.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
80#[serde(default)]
81pub struct ChainsConfig {
82    // =========================================================================
83    // EVM-Compatible Chains
84    // =========================================================================
85    /// Ethereum JSON-RPC endpoint URL.
86    ///
87    /// Example: `https://mainnet.infura.io/v3/YOUR_PROJECT_ID`
88    pub ethereum_rpc: Option<String>,
89
90    /// BSC (BNB Smart Chain) JSON-RPC endpoint URL.
91    ///
92    /// Example: `https://bsc-dataseed.binance.org`
93    pub bsc_rpc: Option<String>,
94
95    /// Aegis (Wraith) blockchain JSON-RPC endpoint URL.
96    ///
97    /// Example: `http://localhost:8545`
98    pub aegis_rpc: Option<String>,
99
100    // =========================================================================
101    // Non-EVM Chains
102    // =========================================================================
103    /// Solana JSON-RPC endpoint URL.
104    ///
105    /// Example: `https://api.mainnet-beta.solana.com`
106    pub solana_rpc: Option<String>,
107
108    /// Tron API endpoint URL (TronGrid).
109    ///
110    /// Example: `https://api.trongrid.io`
111    pub tron_api: Option<String>,
112
113    // =========================================================================
114    // API Keys
115    // =========================================================================
116    /// API keys for block explorer services.
117    ///
118    /// Keys are service names (e.g., "etherscan", "polygonscan", "bscscan",
119    /// "solscan", "tronscan").
120    pub api_keys: HashMap<String, String>,
121}
122
123/// Output formatting configuration.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(default)]
126pub struct OutputConfig {
127    /// Output format for analysis results.
128    pub format: OutputFormat,
129
130    /// Whether to use colored output in the terminal.
131    pub color: bool,
132}
133
134/// Portfolio management configuration.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
136#[serde(default)]
137pub struct PortfolioConfig {
138    /// Directory for storing portfolio data.
139    ///
140    /// Defaults to `~/.local/share/scope` on Linux/macOS.
141    pub data_dir: Option<PathBuf>,
142}
143
144/// Available output formats for analysis results.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
146#[serde(rename_all = "lowercase")]
147pub enum OutputFormat {
148    /// Human-readable table format (default).
149    #[default]
150    Table,
151
152    /// JSON format for programmatic consumption.
153    Json,
154
155    /// CSV format for spreadsheet import.
156    Csv,
157}
158
159impl Default for OutputConfig {
160    fn default() -> Self {
161        Self {
162            format: OutputFormat::Table,
163            color: true,
164        }
165    }
166}
167
168impl Config {
169    /// Loads configuration from a file path or the default location.
170    ///
171    /// # Arguments
172    ///
173    /// * `path` - Optional path to a configuration file. If `None`, looks
174    ///   for config at `~/.config/scope/config.yaml`.
175    ///
176    /// # Returns
177    ///
178    /// Returns the loaded configuration, or defaults if no config file exists.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`ScopeError::Config`] if the file exists but cannot be read
183    /// or contains invalid YAML.
184    ///
185    /// # Examples
186    ///
187    /// ```rust
188    /// use scope::Config;
189    /// use std::path::Path;
190    ///
191    /// // Load from default location
192    /// let config = Config::load(None).unwrap_or_default();
193    ///
194    /// // Load from custom path
195    /// let config = Config::load(Some(Path::new("/custom/config.yaml")));
196    /// ```
197    pub fn load(path: Option<&Path>) -> Result<Self> {
198        // Determine config path: CLI arg > env var > default location
199        let config_path = path
200            .map(PathBuf::from)
201            .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
202            .unwrap_or_else(Self::default_path);
203
204        // Return defaults if no config file exists
205        // This allows first-run without manual setup
206        if !config_path.exists() {
207            tracing::debug!(
208                path = %config_path.display(),
209                "No config file found, using defaults"
210            );
211            return Ok(Self::default());
212        }
213
214        tracing::debug!(path = %config_path.display(), "Loading configuration");
215
216        let contents = std::fs::read_to_string(&config_path).map_err(|e| {
217            ScopeError::Config(ConfigError::Read {
218                path: config_path.clone(),
219                source: e,
220            })
221        })?;
222
223        let config: Config =
224            serde_yaml::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
225
226        Ok(config)
227    }
228
229    /// Returns the default configuration file path.
230    ///
231    /// Checks multiple locations in order:
232    /// 1. `~/.config/scope/config.yaml` (XDG-style, preferred for CLI tools)
233    /// 2. Platform-specific config dir (e.g., `~/Library/Application Support/` on macOS)
234    ///
235    /// Returns the first location that exists, or the XDG-style path if neither exists.
236    pub fn default_path() -> PathBuf {
237        // Prefer XDG-style ~/.config/scope/ which is common for CLI tools
238        if let Some(home) = dirs::home_dir() {
239            let xdg_path = home.join(".config").join("scope").join("config.yaml");
240            if xdg_path.exists() {
241                return xdg_path;
242            }
243        }
244
245        // Check platform-specific config dir
246        if let Some(config_dir) = dirs::config_dir() {
247            let platform_path = config_dir.join("scope").join("config.yaml");
248            if platform_path.exists() {
249                return platform_path;
250            }
251        }
252
253        // Default to XDG-style path for new configs
254        dirs::home_dir()
255            .map(|h| h.join(".config").join("scope").join("config.yaml"))
256            .unwrap_or_else(|| PathBuf::from(".").join("scope").join("config.yaml"))
257    }
258
259    /// Returns the configuration file path, if it can be determined.
260    ///
261    /// Returns `Some(path)` for the config file location, or `None` if
262    /// the path cannot be determined (e.g., no home directory).
263    /// Prefers the XDG-style `~/.config/scope/` location.
264    pub fn config_path() -> Option<PathBuf> {
265        dirs::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
266    }
267
268    /// Returns the default data directory for portfolio storage.
269    ///
270    /// On Linux/macOS: `~/.local/share/scope`
271    /// On Windows: `%LOCALAPPDATA%\bca`
272    pub fn default_data_dir() -> PathBuf {
273        dirs::data_local_dir()
274            .unwrap_or_else(|| PathBuf::from("."))
275            .join("scope")
276    }
277
278    /// Returns the effective data directory, using config or default.
279    pub fn data_dir(&self) -> PathBuf {
280        self.portfolio
281            .data_dir
282            .clone()
283            .unwrap_or_else(Self::default_data_dir)
284    }
285}
286
287impl std::fmt::Display for OutputFormat {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        match self {
290            OutputFormat::Table => write!(f, "table"),
291            OutputFormat::Json => write!(f, "json"),
292            OutputFormat::Csv => write!(f, "csv"),
293        }
294    }
295}
296
297// ============================================================================
298// Unit Tests
299// ============================================================================
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use std::io::Write;
305    use tempfile::NamedTempFile;
306
307    #[test]
308    fn test_default_config() {
309        let config = Config::default();
310
311        assert!(config.chains.api_keys.is_empty());
312        assert!(config.chains.ethereum_rpc.is_none());
313        assert!(config.chains.bsc_rpc.is_none());
314        assert!(config.chains.aegis_rpc.is_none());
315        assert!(config.chains.solana_rpc.is_none());
316        assert!(config.chains.tron_api.is_none());
317        assert_eq!(config.output.format, OutputFormat::Table);
318        assert!(config.output.color);
319        assert!(config.portfolio.data_dir.is_none());
320    }
321
322    #[test]
323    fn test_load_from_yaml_full() {
324        let yaml = r#"
325chains:
326  ethereum_rpc: "https://example.com/rpc"
327  bsc_rpc: "https://bsc-dataseed.binance.org"
328  aegis_rpc: "http://localhost:8545"
329  solana_rpc: "https://api.mainnet-beta.solana.com"
330  tron_api: "https://api.trongrid.io"
331  api_keys:
332    etherscan: "test-api-key"
333    polygonscan: "another-key"
334    bscscan: "bsc-key"
335    solscan: "sol-key"
336    tronscan: "tron-key"
337
338output:
339  format: json
340  color: false
341
342portfolio:
343  data_dir: "/custom/data"
344"#;
345
346        let mut file = NamedTempFile::new().unwrap();
347        file.write_all(yaml.as_bytes()).unwrap();
348
349        let config = Config::load(Some(file.path())).unwrap();
350
351        // EVM chains
352        assert_eq!(
353            config.chains.ethereum_rpc,
354            Some("https://example.com/rpc".into())
355        );
356        assert_eq!(
357            config.chains.bsc_rpc,
358            Some("https://bsc-dataseed.binance.org".into())
359        );
360        assert_eq!(
361            config.chains.aegis_rpc,
362            Some("http://localhost:8545".into())
363        );
364
365        // Non-EVM chains
366        assert_eq!(
367            config.chains.solana_rpc,
368            Some("https://api.mainnet-beta.solana.com".into())
369        );
370        assert_eq!(
371            config.chains.tron_api,
372            Some("https://api.trongrid.io".into())
373        );
374
375        // API keys
376        assert_eq!(
377            config.chains.api_keys.get("etherscan"),
378            Some(&"test-api-key".into())
379        );
380        assert_eq!(
381            config.chains.api_keys.get("polygonscan"),
382            Some(&"another-key".into())
383        );
384        assert_eq!(
385            config.chains.api_keys.get("bscscan"),
386            Some(&"bsc-key".into())
387        );
388        assert_eq!(
389            config.chains.api_keys.get("solscan"),
390            Some(&"sol-key".into())
391        );
392        assert_eq!(
393            config.chains.api_keys.get("tronscan"),
394            Some(&"tron-key".into())
395        );
396
397        assert_eq!(config.output.format, OutputFormat::Json);
398        assert!(!config.output.color);
399        assert_eq!(
400            config.portfolio.data_dir,
401            Some(PathBuf::from("/custom/data"))
402        );
403    }
404
405    #[test]
406    fn test_load_partial_yaml_uses_defaults() {
407        let yaml = r#"
408chains:
409  ethereum_rpc: "https://partial.example.com"
410"#;
411
412        let mut file = NamedTempFile::new().unwrap();
413        file.write_all(yaml.as_bytes()).unwrap();
414
415        let config = Config::load(Some(file.path())).unwrap();
416
417        // Specified value
418        assert_eq!(
419            config.chains.ethereum_rpc,
420            Some("https://partial.example.com".into())
421        );
422
423        // Defaults
424        assert!(config.chains.api_keys.is_empty());
425        assert_eq!(config.output.format, OutputFormat::Table);
426        assert!(config.output.color);
427    }
428
429    #[test]
430    fn test_load_missing_file_returns_defaults() {
431        let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
432        assert_eq!(config, Config::default());
433    }
434
435    #[test]
436    fn test_load_invalid_yaml_returns_error() {
437        let mut file = NamedTempFile::new().unwrap();
438        file.write_all(b"invalid: yaml: : content: [").unwrap();
439
440        let result = Config::load(Some(file.path()));
441        assert!(result.is_err());
442        assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
443    }
444
445    #[test]
446    fn test_load_empty_file_returns_defaults() {
447        let file = NamedTempFile::new().unwrap();
448        // Empty file
449
450        let config = Config::load(Some(file.path())).unwrap();
451        assert_eq!(config, Config::default());
452    }
453
454    #[test]
455    fn test_output_format_serialization() {
456        let json_format = OutputFormat::Json;
457        let serialized = serde_yaml::to_string(&json_format).unwrap();
458        assert!(serialized.contains("json"));
459
460        let deserialized: OutputFormat = serde_yaml::from_str("csv").unwrap();
461        assert_eq!(deserialized, OutputFormat::Csv);
462    }
463
464    #[test]
465    fn test_output_format_display() {
466        assert_eq!(OutputFormat::Table.to_string(), "table");
467        assert_eq!(OutputFormat::Json.to_string(), "json");
468        assert_eq!(OutputFormat::Csv.to_string(), "csv");
469    }
470
471    #[test]
472    fn test_default_path_is_absolute_or_relative() {
473        let path = Config::default_path();
474        // Should end with expected structure
475        assert!(path.ends_with("scope/config.yaml") || path.ends_with("scope\\config.yaml"));
476    }
477
478    #[test]
479    fn test_default_data_dir() {
480        let data_dir = Config::default_data_dir();
481        assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
482    }
483
484    #[test]
485    fn test_data_dir_uses_config_value() {
486        let config = Config {
487            portfolio: PortfolioConfig {
488                data_dir: Some(PathBuf::from("/custom/path")),
489            },
490            ..Default::default()
491        };
492
493        assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
494    }
495
496    #[test]
497    fn test_data_dir_falls_back_to_default() {
498        let config = Config::default();
499        assert_eq!(config.data_dir(), Config::default_data_dir());
500    }
501
502    #[test]
503    fn test_config_clone_and_eq() {
504        let config1 = Config::default();
505        let config2 = config1.clone();
506        assert_eq!(config1, config2);
507    }
508
509    #[test]
510    fn test_config_path_returns_some() {
511        let path = Config::config_path();
512        // Should return Some on systems with a home dir
513        assert!(path.is_some());
514        assert!(path.unwrap().to_string_lossy().contains("scope"));
515    }
516
517    #[test]
518    fn test_config_debug() {
519        let config = Config::default();
520        let debug = format!("{:?}", config);
521        assert!(debug.contains("Config"));
522        assert!(debug.contains("ChainsConfig"));
523    }
524
525    #[test]
526    fn test_output_config_default() {
527        let output = OutputConfig::default();
528        assert_eq!(output.format, OutputFormat::Table);
529        assert!(output.color);
530    }
531
532    #[test]
533    fn test_config_serialization_roundtrip() {
534        let mut config = Config::default();
535        config
536            .chains
537            .api_keys
538            .insert("etherscan".to_string(), "test_key".to_string());
539        config.output.format = OutputFormat::Json;
540        config.output.color = false;
541        config.portfolio.data_dir = Some(PathBuf::from("/custom"));
542
543        let yaml = serde_yaml::to_string(&config).unwrap();
544        let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
545        assert_eq!(config, deserialized);
546    }
547
548    #[test]
549    fn test_chains_config_with_multiple_api_keys() {
550        let mut api_keys = HashMap::new();
551        api_keys.insert("etherscan".into(), "key1".into());
552        api_keys.insert("polygonscan".into(), "key2".into());
553        api_keys.insert("bscscan".into(), "key3".into());
554
555        let chains = ChainsConfig {
556            ethereum_rpc: Some("https://rpc.example.com".into()),
557            api_keys,
558            ..Default::default()
559        };
560
561        assert_eq!(chains.api_keys.len(), 3);
562        assert!(chains.api_keys.contains_key("etherscan"));
563    }
564
565    #[test]
566    fn test_load_via_scope_config_env_var() {
567        let yaml = r#"
568chains:
569  ethereum_rpc: "https://env-test.example.com"
570output:
571  format: csv
572"#;
573        let mut file = NamedTempFile::new().unwrap();
574        file.write_all(yaml.as_bytes()).unwrap();
575
576        let path_str = file.path().to_string_lossy().to_string();
577        unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
578
579        // Load with None path — should pick up the env var
580        let config = Config::load(None).unwrap();
581        assert_eq!(
582            config.chains.ethereum_rpc,
583            Some("https://env-test.example.com".into())
584        );
585        assert_eq!(config.output.format, OutputFormat::Csv);
586
587        unsafe { std::env::remove_var("SCOPE_CONFIG") };
588    }
589
590    #[test]
591    fn test_output_format_default() {
592        let fmt = OutputFormat::default();
593        assert_eq!(fmt, OutputFormat::Table);
594    }
595
596    #[test]
597    fn test_portfolio_config_default() {
598        let port = PortfolioConfig::default();
599        assert!(port.data_dir.is_none());
600    }
601
602    #[test]
603    fn test_chains_config_default() {
604        let chains = ChainsConfig::default();
605        assert!(chains.ethereum_rpc.is_none());
606        assert!(chains.bsc_rpc.is_none());
607        assert!(chains.aegis_rpc.is_none());
608        assert!(chains.solana_rpc.is_none());
609        assert!(chains.tron_api.is_none());
610        assert!(chains.api_keys.is_empty());
611    }
612}