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    /// Uses `~/.config/scope/data` on all platforms for consistency.
277    pub fn default_data_dir() -> PathBuf {
278        dirs::home_dir()
279            .map(|h| h.join(".config").join("scope").join("data"))
280            .unwrap_or_else(|| PathBuf::from(".config").join("scope").join("data"))
281    }
282
283    /// Returns the effective data directory, using config or default.
284    pub fn data_dir(&self) -> PathBuf {
285        self.address_book
286            .data_dir
287            .clone()
288            .unwrap_or_else(Self::default_data_dir)
289    }
290}
291
292impl std::fmt::Display for OutputFormat {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        match self {
295            OutputFormat::Table => write!(f, "table"),
296            OutputFormat::Json => write!(f, "json"),
297            OutputFormat::Csv => write!(f, "csv"),
298            OutputFormat::Markdown => write!(f, "markdown"),
299        }
300    }
301}
302
303// ============================================================================
304// Unit Tests
305// ============================================================================
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use std::io::Write;
311    use tempfile::NamedTempFile;
312
313    #[test]
314    fn test_default_config() {
315        let config = Config::default();
316
317        assert!(config.chains.api_keys.is_empty());
318        assert!(config.chains.ethereum_rpc.is_none());
319        assert!(config.chains.bsc_rpc.is_none());
320        assert!(config.chains.aegis_rpc.is_none());
321        assert!(config.chains.solana_rpc.is_none());
322        assert!(config.chains.tron_api.is_none());
323        assert_eq!(config.output.format, OutputFormat::Table);
324        assert!(config.output.color);
325        assert!(config.address_book.data_dir.is_none());
326    }
327
328    #[test]
329    fn test_load_from_yaml_full() {
330        let yaml = r#"
331chains:
332  ethereum_rpc: "https://example.com/rpc"
333  bsc_rpc: "https://bsc-dataseed.binance.org"
334  solana_rpc: "https://api.mainnet-beta.solana.com"
335  tron_api: "https://api.trongrid.io"
336  api_keys:
337    etherscan: "test-api-key"
338    polygonscan: "another-key"
339    bscscan: "bsc-key"
340    solscan: "sol-key"
341    tronscan: "tron-key"
342
343output:
344  format: json
345  color: false
346
347address_book:
348  data_dir: "/custom/data"
349"#;
350
351        let mut file = NamedTempFile::new().unwrap();
352        file.write_all(yaml.as_bytes()).unwrap();
353
354        let config = Config::load(Some(file.path())).unwrap();
355
356        // EVM chains
357        assert_eq!(
358            config.chains.ethereum_rpc,
359            Some("https://example.com/rpc".into())
360        );
361        assert_eq!(
362            config.chains.bsc_rpc,
363            Some("https://bsc-dataseed.binance.org".into())
364        );
365
366        // Non-EVM chains
367        assert_eq!(
368            config.chains.solana_rpc,
369            Some("https://api.mainnet-beta.solana.com".into())
370        );
371        assert_eq!(
372            config.chains.tron_api,
373            Some("https://api.trongrid.io".into())
374        );
375
376        // API keys
377        assert_eq!(
378            config.chains.api_keys.get("etherscan"),
379            Some(&"test-api-key".into())
380        );
381        assert_eq!(
382            config.chains.api_keys.get("polygonscan"),
383            Some(&"another-key".into())
384        );
385        assert_eq!(
386            config.chains.api_keys.get("bscscan"),
387            Some(&"bsc-key".into())
388        );
389        assert_eq!(
390            config.chains.api_keys.get("solscan"),
391            Some(&"sol-key".into())
392        );
393        assert_eq!(
394            config.chains.api_keys.get("tronscan"),
395            Some(&"tron-key".into())
396        );
397
398        assert_eq!(config.output.format, OutputFormat::Json);
399        assert!(!config.output.color);
400        assert_eq!(
401            config.address_book.data_dir,
402            Some(PathBuf::from("/custom/data"))
403        );
404    }
405
406    #[test]
407    fn test_load_partial_yaml_uses_defaults() {
408        let yaml = r#"
409chains:
410  ethereum_rpc: "https://partial.example.com"
411"#;
412
413        let mut file = NamedTempFile::new().unwrap();
414        file.write_all(yaml.as_bytes()).unwrap();
415
416        let config = Config::load(Some(file.path())).unwrap();
417
418        // Specified value
419        assert_eq!(
420            config.chains.ethereum_rpc,
421            Some("https://partial.example.com".into())
422        );
423
424        // Defaults
425        assert!(config.chains.api_keys.is_empty());
426        assert_eq!(config.output.format, OutputFormat::Table);
427        assert!(config.output.color);
428    }
429
430    #[test]
431    fn test_load_missing_file_returns_defaults() {
432        let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
433        assert_eq!(config, Config::default());
434    }
435
436    #[test]
437    fn test_load_invalid_yaml_returns_error() {
438        let mut file = NamedTempFile::new().unwrap();
439        file.write_all(b"invalid: yaml: : content: [").unwrap();
440
441        let result = Config::load(Some(file.path()));
442        assert!(result.is_err());
443        assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
444    }
445
446    #[test]
447    fn test_load_empty_file_returns_defaults() {
448        let file = NamedTempFile::new().unwrap();
449        // Empty file
450
451        let config = Config::load(Some(file.path())).unwrap();
452        assert_eq!(config, Config::default());
453    }
454
455    #[test]
456    fn test_output_format_serialization() {
457        let json_format = OutputFormat::Json;
458        let serialized = serde_yaml::to_string(&json_format).unwrap();
459        assert!(serialized.contains("json"));
460
461        let deserialized: OutputFormat = serde_yaml::from_str("csv").unwrap();
462        assert_eq!(deserialized, OutputFormat::Csv);
463    }
464
465    #[test]
466    fn test_output_format_display() {
467        assert_eq!(OutputFormat::Table.to_string(), "table");
468        assert_eq!(OutputFormat::Json.to_string(), "json");
469        assert_eq!(OutputFormat::Csv.to_string(), "csv");
470        assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
471    }
472
473    #[test]
474    fn test_default_path_is_absolute_or_relative() {
475        let path = Config::default_path();
476        // Should end with expected structure
477        assert!(path.ends_with("scope/config.yaml") || path.ends_with("scope\\config.yaml"));
478    }
479
480    #[test]
481    fn test_default_data_dir() {
482        let data_dir = Config::default_data_dir();
483        assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
484    }
485
486    #[test]
487    fn test_data_dir_uses_config_value() {
488        let config = Config {
489            address_book: AddressBookConfig {
490                data_dir: Some(PathBuf::from("/custom/path")),
491            },
492            ..Default::default()
493        };
494
495        assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
496    }
497
498    #[test]
499    fn test_data_dir_falls_back_to_default() {
500        let config = Config::default();
501        assert_eq!(config.data_dir(), Config::default_data_dir());
502    }
503
504    #[test]
505    fn test_config_clone_and_eq() {
506        let config1 = Config::default();
507        let config2 = config1.clone();
508        assert_eq!(config1, config2);
509    }
510
511    #[test]
512    fn test_config_path_returns_some() {
513        let path = Config::config_path();
514        // Should return Some on systems with a home dir
515        assert!(path.is_some());
516        assert!(path.unwrap().to_string_lossy().contains("scope"));
517    }
518
519    #[test]
520    fn test_config_debug() {
521        let config = Config::default();
522        let debug = format!("{:?}", config);
523        assert!(debug.contains("Config"));
524        assert!(debug.contains("ChainsConfig"));
525    }
526
527    #[test]
528    fn test_output_config_default() {
529        let output = OutputConfig::default();
530        assert_eq!(output.format, OutputFormat::Table);
531        assert!(output.color);
532    }
533
534    #[test]
535    fn test_config_serialization_roundtrip() {
536        let mut config = Config::default();
537        config
538            .chains
539            .api_keys
540            .insert("etherscan".to_string(), "test_key".to_string());
541        config.output.format = OutputFormat::Json;
542        config.output.color = false;
543        config.address_book.data_dir = Some(PathBuf::from("/custom"));
544
545        let yaml = serde_yaml::to_string(&config).unwrap();
546        let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
547        assert_eq!(config, deserialized);
548    }
549
550    #[test]
551    fn test_chains_config_with_multiple_api_keys() {
552        let mut api_keys = HashMap::new();
553        api_keys.insert("etherscan".into(), "key1".into());
554        api_keys.insert("polygonscan".into(), "key2".into());
555        api_keys.insert("bscscan".into(), "key3".into());
556
557        let chains = ChainsConfig {
558            ethereum_rpc: Some("https://rpc.example.com".into()),
559            api_keys,
560            ..Default::default()
561        };
562
563        assert_eq!(chains.api_keys.len(), 3);
564        assert!(chains.api_keys.contains_key("etherscan"));
565    }
566
567    #[test]
568    fn test_load_via_scope_config_env_var() {
569        let yaml = r#"
570chains:
571  ethereum_rpc: "https://env-test.example.com"
572output:
573  format: csv
574"#;
575        let mut file = NamedTempFile::new().unwrap();
576        file.write_all(yaml.as_bytes()).unwrap();
577
578        let path_str = file.path().to_string_lossy().to_string();
579        unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
580
581        // Load with None path — should pick up the env var
582        let config = Config::load(None).unwrap();
583        assert_eq!(
584            config.chains.ethereum_rpc,
585            Some("https://env-test.example.com".into())
586        );
587        assert_eq!(config.output.format, OutputFormat::Csv);
588
589        unsafe { std::env::remove_var("SCOPE_CONFIG") };
590    }
591
592    #[test]
593    fn test_output_format_default() {
594        let fmt = OutputFormat::default();
595        assert_eq!(fmt, OutputFormat::Table);
596    }
597
598    #[test]
599    fn test_address_book_config_default() {
600        let port = AddressBookConfig::default();
601        assert!(port.data_dir.is_none());
602    }
603
604    #[test]
605    fn test_chains_config_default() {
606        let chains = ChainsConfig::default();
607        assert!(chains.ethereum_rpc.is_none());
608        assert!(chains.bsc_rpc.is_none());
609        assert!(chains.aegis_rpc.is_none());
610        assert!(chains.solana_rpc.is_none());
611        assert!(chains.tron_api.is_none());
612        assert!(chains.api_keys.is_empty());
613    }
614}