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