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//!
37//! ## Error Handling
38//!
39//! Configuration errors are wrapped in [`ScopeError::Config`] and include
40//! context about which source caused the failure.
41
42use crate::error::{ConfigError, Result, ScopeError};
43use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45use std::path::{Path, PathBuf};
46
47/// Application configuration.
48///
49/// Contains all settings for blockchain clients, output formatting,
50/// and address book management. Use [`Config::load`] to load from file
51/// or [`Config::default`] for sensible defaults.
52///
53/// # Examples
54///
55/// ```rust
56/// use scope::Config;
57///
58/// // Load from default location or custom path
59/// let config = Config::load(None).unwrap_or_default();
60/// println!("Output format: {:?}", config.output.format);
61/// ```
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
63#[serde(default)]
64pub struct Config {
65    /// Blockchain chain client configuration.
66    pub chains: ChainsConfig,
67
68    /// Output formatting configuration.
69    pub output: OutputConfig,
70
71    /// Address book configuration (label → address mapping storage).
72    #[serde(alias = "portfolio")]
73    pub address_book: AddressBookConfig,
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    /// Custom EVM chain JSON-RPC endpoint (reserved for internal use).
99    #[doc(hidden)]
100    pub aegis_rpc: Option<String>,
101
102    // =========================================================================
103    // Non-EVM Chains
104    // =========================================================================
105    /// Solana JSON-RPC endpoint URL.
106    ///
107    /// Example: `https://api.mainnet-beta.solana.com`
108    pub solana_rpc: Option<String>,
109
110    /// Tron API endpoint URL (TronGrid).
111    ///
112    /// Example: `https://api.trongrid.io`
113    pub tron_api: Option<String>,
114
115    // =========================================================================
116    // API Keys
117    // =========================================================================
118    /// API keys for block explorer services.
119    ///
120    /// Keys are service names (e.g., "etherscan", "polygonscan", "bscscan",
121    /// "solscan", "tronscan").
122    pub api_keys: HashMap<String, String>,
123}
124
125/// Output formatting configuration.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[serde(default)]
128pub struct OutputConfig {
129    /// Output format for analysis results.
130    pub format: OutputFormat,
131
132    /// Whether to use colored output in the terminal.
133    pub color: bool,
134}
135
136/// Address book configuration.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
138#[serde(default)]
139pub struct AddressBookConfig {
140    /// Directory for storing address book data.
141    ///
142    /// Defaults to `~/.local/share/scope` on Linux/macOS.
143    pub data_dir: Option<PathBuf>,
144}
145
146/// Available output formats for analysis results.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
148#[serde(rename_all = "lowercase")]
149pub enum OutputFormat {
150    /// Human-readable table format (default).
151    #[default]
152    Table,
153
154    /// JSON format for programmatic consumption.
155    Json,
156
157    /// CSV format for spreadsheet import.
158    Csv,
159
160    /// Markdown format for agent parsing (console output).
161    #[value(name = "markdown")]
162    Markdown,
163}
164
165impl Default for OutputConfig {
166    fn default() -> Self {
167        Self {
168            format: OutputFormat::Table,
169            color: true,
170        }
171    }
172}
173
174impl Config {
175    /// Loads configuration from a file path or the default location.
176    ///
177    /// # Arguments
178    ///
179    /// * `path` - Optional path to a configuration file. If `None`, looks
180    ///   for config at `~/.config/scope/config.yaml`.
181    ///
182    /// # Returns
183    ///
184    /// Returns the loaded configuration, or defaults if no config file exists.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`ScopeError::Config`] if the file exists but cannot be read
189    /// or contains invalid YAML.
190    ///
191    /// # Examples
192    ///
193    /// ```rust
194    /// use scope::Config;
195    /// use std::path::Path;
196    ///
197    /// // Load from default location
198    /// let config = Config::load(None).unwrap_or_default();
199    ///
200    /// // Load from custom path
201    /// let config = Config::load(Some(Path::new("/custom/config.yaml")));
202    /// ```
203    pub fn load(path: Option<&Path>) -> Result<Self> {
204        // Determine config path: CLI arg > env var > default location
205        let config_path = path
206            .map(PathBuf::from)
207            .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
208            .unwrap_or_else(Self::default_path);
209
210        // Return defaults if no config file exists
211        // This allows first-run without manual setup
212        if !config_path.exists() {
213            tracing::debug!(
214                path = %config_path.display(),
215                "No config file found, using defaults"
216            );
217            return Ok(Self::default());
218        }
219
220        tracing::debug!(path = %config_path.display(), "Loading configuration");
221
222        let contents = std::fs::read_to_string(&config_path).map_err(|e| {
223            ScopeError::Config(ConfigError::Read {
224                path: config_path.clone(),
225                source: e,
226            })
227        })?;
228
229        let config: Config =
230            serde_yaml::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
231
232        Ok(config)
233    }
234
235    /// Returns the default configuration file path.
236    ///
237    /// Checks multiple locations in order:
238    /// 1. `~/.config/scope/config.yaml` (XDG-style, preferred for CLI tools)
239    /// 2. Platform-specific config dir (e.g., `~/Library/Application Support/` on macOS)
240    ///
241    /// Returns the first location that exists, or the XDG-style path if neither exists.
242    pub fn default_path() -> PathBuf {
243        // Prefer XDG-style ~/.config/scope/ which is common for CLI tools
244        if let Some(home) = dirs::home_dir() {
245            let xdg_path = home.join(".config").join("scope").join("config.yaml");
246            if xdg_path.exists() {
247                return xdg_path;
248            }
249        }
250
251        // Check platform-specific config dir
252        if let Some(config_dir) = dirs::config_dir() {
253            let platform_path = config_dir.join("scope").join("config.yaml");
254            if platform_path.exists() {
255                return platform_path;
256            }
257        }
258
259        // Default to XDG-style path for new configs
260        dirs::home_dir()
261            .map(|h| h.join(".config").join("scope").join("config.yaml"))
262            .unwrap_or_else(|| PathBuf::from(".").join("scope").join("config.yaml"))
263    }
264
265    /// Returns the configuration file path, if it can be determined.
266    ///
267    /// Returns `Some(path)` for the config file location, or `None` if
268    /// the path cannot be determined (e.g., no home directory).
269    /// Prefers the XDG-style `~/.config/scope/` location.
270    pub fn config_path() -> Option<PathBuf> {
271        dirs::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
272    }
273
274    /// Returns the default data directory for address book storage.
275    ///
276    /// On Linux/macOS: `~/.local/share/scope`
277    /// On Windows: `%LOCALAPPDATA%\scope`
278    pub fn default_data_dir() -> PathBuf {
279        dirs::data_local_dir()
280            .unwrap_or_else(|| PathBuf::from("."))
281            .join("scope")
282    }
283
284    /// Returns the effective data directory, using config or default.
285    pub fn data_dir(&self) -> PathBuf {
286        self.address_book
287            .data_dir
288            .clone()
289            .unwrap_or_else(Self::default_data_dir)
290    }
291}
292
293impl std::fmt::Display for OutputFormat {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        match self {
296            OutputFormat::Table => write!(f, "table"),
297            OutputFormat::Json => write!(f, "json"),
298            OutputFormat::Csv => write!(f, "csv"),
299            OutputFormat::Markdown => write!(f, "markdown"),
300        }
301    }
302}
303
304// ============================================================================
305// Unit Tests
306// ============================================================================
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use std::io::Write;
312    use tempfile::NamedTempFile;
313
314    #[test]
315    fn test_default_config() {
316        let config = Config::default();
317
318        assert!(config.chains.api_keys.is_empty());
319        assert!(config.chains.ethereum_rpc.is_none());
320        assert!(config.chains.bsc_rpc.is_none());
321        assert!(config.chains.aegis_rpc.is_none());
322        assert!(config.chains.solana_rpc.is_none());
323        assert!(config.chains.tron_api.is_none());
324        assert_eq!(config.output.format, OutputFormat::Table);
325        assert!(config.output.color);
326        assert!(config.address_book.data_dir.is_none());
327    }
328
329    #[test]
330    fn test_load_from_yaml_full() {
331        let yaml = r#"
332chains:
333  ethereum_rpc: "https://example.com/rpc"
334  bsc_rpc: "https://bsc-dataseed.binance.org"
335  solana_rpc: "https://api.mainnet-beta.solana.com"
336  tron_api: "https://api.trongrid.io"
337  api_keys:
338    etherscan: "test-api-key"
339    polygonscan: "another-key"
340    bscscan: "bsc-key"
341    solscan: "sol-key"
342    tronscan: "tron-key"
343
344output:
345  format: json
346  color: false
347
348address_book:
349  data_dir: "/custom/data"
350"#;
351
352        let mut file = NamedTempFile::new().unwrap();
353        file.write_all(yaml.as_bytes()).unwrap();
354
355        let config = Config::load(Some(file.path())).unwrap();
356
357        // EVM chains
358        assert_eq!(
359            config.chains.ethereum_rpc,
360            Some("https://example.com/rpc".into())
361        );
362        assert_eq!(
363            config.chains.bsc_rpc,
364            Some("https://bsc-dataseed.binance.org".into())
365        );
366
367        // Non-EVM chains
368        assert_eq!(
369            config.chains.solana_rpc,
370            Some("https://api.mainnet-beta.solana.com".into())
371        );
372        assert_eq!(
373            config.chains.tron_api,
374            Some("https://api.trongrid.io".into())
375        );
376
377        // API keys
378        assert_eq!(
379            config.chains.api_keys.get("etherscan"),
380            Some(&"test-api-key".into())
381        );
382        assert_eq!(
383            config.chains.api_keys.get("polygonscan"),
384            Some(&"another-key".into())
385        );
386        assert_eq!(
387            config.chains.api_keys.get("bscscan"),
388            Some(&"bsc-key".into())
389        );
390        assert_eq!(
391            config.chains.api_keys.get("solscan"),
392            Some(&"sol-key".into())
393        );
394        assert_eq!(
395            config.chains.api_keys.get("tronscan"),
396            Some(&"tron-key".into())
397        );
398
399        assert_eq!(config.output.format, OutputFormat::Json);
400        assert!(!config.output.color);
401        assert_eq!(
402            config.address_book.data_dir,
403            Some(PathBuf::from("/custom/data"))
404        );
405    }
406
407    #[test]
408    fn test_load_partial_yaml_uses_defaults() {
409        let yaml = r#"
410chains:
411  ethereum_rpc: "https://partial.example.com"
412"#;
413
414        let mut file = NamedTempFile::new().unwrap();
415        file.write_all(yaml.as_bytes()).unwrap();
416
417        let config = Config::load(Some(file.path())).unwrap();
418
419        // Specified value
420        assert_eq!(
421            config.chains.ethereum_rpc,
422            Some("https://partial.example.com".into())
423        );
424
425        // Defaults
426        assert!(config.chains.api_keys.is_empty());
427        assert_eq!(config.output.format, OutputFormat::Table);
428        assert!(config.output.color);
429    }
430
431    #[test]
432    fn test_load_missing_file_returns_defaults() {
433        let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
434        assert_eq!(config, Config::default());
435    }
436
437    #[test]
438    fn test_load_invalid_yaml_returns_error() {
439        let mut file = NamedTempFile::new().unwrap();
440        file.write_all(b"invalid: yaml: : content: [").unwrap();
441
442        let result = Config::load(Some(file.path()));
443        assert!(result.is_err());
444        assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
445    }
446
447    #[test]
448    fn test_load_empty_file_returns_defaults() {
449        let file = NamedTempFile::new().unwrap();
450        // Empty file
451
452        let config = Config::load(Some(file.path())).unwrap();
453        assert_eq!(config, Config::default());
454    }
455
456    #[test]
457    fn test_output_format_serialization() {
458        let json_format = OutputFormat::Json;
459        let serialized = serde_yaml::to_string(&json_format).unwrap();
460        assert!(serialized.contains("json"));
461
462        let deserialized: OutputFormat = serde_yaml::from_str("csv").unwrap();
463        assert_eq!(deserialized, OutputFormat::Csv);
464    }
465
466    #[test]
467    fn test_output_format_display() {
468        assert_eq!(OutputFormat::Table.to_string(), "table");
469        assert_eq!(OutputFormat::Json.to_string(), "json");
470        assert_eq!(OutputFormat::Csv.to_string(), "csv");
471        assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
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            address_book: AddressBookConfig {
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.address_book.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_address_book_config_default() {
601        let port = AddressBookConfig::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}