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//! ```
40//!
41//! ## Error Handling
42//!
43//! Configuration errors are wrapped in [`ScopeError::Config`] and include
44//! context about which source caused the failure.
45
46use crate::error::{ConfigError, Result, ScopeError};
47use serde::{Deserialize, Serialize};
48use std::collections::HashMap;
49use std::path::{Path, PathBuf};
50
51/// Application configuration.
52///
53/// Contains all settings for blockchain clients, output formatting,
54/// and address book management. Use [`Config::load`] to load from file
55/// or [`Config::default`] for sensible defaults.
56///
57/// # Examples
58///
59/// ```rust
60/// use scope::Config;
61///
62/// // Load from default location or custom path
63/// let config = Config::load(None).unwrap_or_default();
64/// println!("Output format: {:?}", config.output.format);
65/// ```
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
67#[serde(default)]
68pub struct Config {
69    /// Blockchain chain client configuration.
70    pub chains: ChainsConfig,
71
72    /// Output formatting configuration.
73    pub output: OutputConfig,
74
75    /// Address book configuration (label → address mapping storage).
76    #[serde(alias = "portfolio")]
77    pub address_book: AddressBookConfig,
78
79    /// Monitor TUI configuration (layout, widgets, refresh rate).
80    pub monitor: crate::cli::monitor::MonitorConfig,
81
82    /// Ghola sidecar configuration for stealth HTTP transport.
83    pub ghola: GholaConfig,
84}
85
86/// Blockchain client configuration.
87///
88/// Contains RPC endpoints and API keys for various blockchain networks.
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
90#[serde(default)]
91pub struct ChainsConfig {
92    // =========================================================================
93    // EVM-Compatible Chains
94    // =========================================================================
95    /// Ethereum JSON-RPC endpoint URL.
96    ///
97    /// Example: `https://mainnet.infura.io/v3/YOUR_PROJECT_ID`
98    pub ethereum_rpc: Option<String>,
99
100    /// BSC (BNB Smart Chain) JSON-RPC endpoint URL.
101    ///
102    /// Example: `https://bsc-dataseed.binance.org`
103    pub bsc_rpc: Option<String>,
104
105    /// Custom EVM chain JSON-RPC endpoint (reserved for internal use).
106    #[doc(hidden)]
107    pub aegis_rpc: Option<String>,
108
109    // =========================================================================
110    // Non-EVM Chains
111    // =========================================================================
112    /// Solana JSON-RPC endpoint URL.
113    ///
114    /// Example: `https://api.mainnet-beta.solana.com`
115    pub solana_rpc: Option<String>,
116
117    /// Tron API endpoint URL (TronGrid).
118    ///
119    /// Example: `https://api.trongrid.io`
120    pub tron_api: Option<String>,
121
122    // =========================================================================
123    // API Keys
124    // =========================================================================
125    /// API keys for block explorer services.
126    ///
127    /// Keys are service names (e.g., "etherscan", "polygonscan", "bscscan",
128    /// "solscan", "tronscan").
129    pub api_keys: HashMap<String, String>,
130}
131
132/// Output formatting configuration.
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134#[serde(default)]
135pub struct OutputConfig {
136    /// Output format for analysis results.
137    pub format: OutputFormat,
138
139    /// Whether to use colored output in the terminal.
140    pub color: bool,
141}
142
143/// Address book configuration.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
145#[serde(default)]
146pub struct AddressBookConfig {
147    /// Directory for storing address book data.
148    ///
149    /// Defaults to `~/.local/share/scope` on Linux/macOS.
150    pub data_dir: Option<PathBuf>,
151}
152
153/// Ghola sidecar configuration.
154///
155/// Controls whether HTTP requests are routed through the
156/// [Ghola](https://github.com/robot-accomplice/ghola) sidecar for
157/// stealth analysis (temporal drift, ghost signing, chain-aware headers).
158///
159/// Ghola is an external Go binary; all fields default to `false` so
160/// existing installations see no behavior change.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
162#[serde(default)]
163pub struct GholaConfig {
164    /// When `true`, route HTTP requests through the Ghola sidecar
165    /// (`127.0.0.1:18789`) instead of using `reqwest` directly.
166    pub enabled: bool,
167
168    /// When `true` (and `enabled` is `true`), the sidecar applies
169    /// temporal drift and ghost signing to every outgoing request.
170    pub stealth: bool,
171}
172
173/// Available output formats for analysis results.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
175#[serde(rename_all = "lowercase")]
176pub enum OutputFormat {
177    /// Human-readable table format (default).
178    #[default]
179    Table,
180
181    /// JSON format for programmatic consumption.
182    Json,
183
184    /// CSV format for spreadsheet import.
185    Csv,
186
187    /// Markdown format for agent parsing (console output).
188    #[value(name = "markdown")]
189    Markdown,
190}
191
192impl Default for OutputConfig {
193    fn default() -> Self {
194        Self {
195            format: OutputFormat::Table,
196            color: true,
197        }
198    }
199}
200
201impl Config {
202    /// Loads configuration from a file path or the default location.
203    ///
204    /// # Arguments
205    ///
206    /// * `path` - Optional path to a configuration file. If `None`, looks
207    ///   for config at `~/.config/scope/config.yaml`.
208    ///
209    /// # Returns
210    ///
211    /// Returns the loaded configuration, or defaults if no config file exists.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`ScopeError::Config`] if the file exists but cannot be read
216    /// or contains invalid YAML.
217    ///
218    /// # Examples
219    ///
220    /// ```rust
221    /// use scope::Config;
222    /// use std::path::Path;
223    ///
224    /// // Load from default location
225    /// let config = Config::load(None).unwrap_or_default();
226    ///
227    /// // Load from custom path
228    /// let config = Config::load(Some(Path::new("/custom/config.yaml")));
229    /// ```
230    pub fn load(path: Option<&Path>) -> Result<Self> {
231        // Determine config path: CLI arg > env var > default location
232        let config_path = path
233            .map(PathBuf::from)
234            .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
235            .unwrap_or_else(Self::default_path);
236
237        // Return defaults if no config file exists
238        // This allows first-run without manual setup
239        if !config_path.exists() {
240            tracing::debug!(
241                path = %config_path.display(),
242                "No config file found, using defaults"
243            );
244            return Ok(Self::default());
245        }
246
247        tracing::debug!(path = %config_path.display(), "Loading configuration");
248
249        let contents = std::fs::read_to_string(&config_path).map_err(|e| {
250            ScopeError::Config(ConfigError::Read {
251                path: config_path.clone(),
252                source: e,
253            })
254        })?;
255
256        let config: Config =
257            serde_yaml::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
258
259        Ok(config)
260    }
261
262    /// Returns the default configuration file path.
263    ///
264    /// Checks multiple locations in order:
265    /// 1. `~/.config/scope/config.yaml` (XDG-style, preferred for CLI tools)
266    /// 2. Platform-specific config dir (e.g., `~/Library/Application Support/` on macOS)
267    ///
268    /// Returns the first location that exists, or the XDG-style path if neither exists.
269    pub fn default_path() -> PathBuf {
270        // Prefer XDG-style ~/.config/scope/ which is common for CLI tools
271        if let Some(home) = dirs::home_dir() {
272            let xdg_path = home.join(".config").join("scope").join("config.yaml");
273            if xdg_path.exists() {
274                return xdg_path;
275            }
276        }
277
278        // Check platform-specific config dir
279        if let Some(config_dir) = dirs::config_dir() {
280            let platform_path = config_dir.join("scope").join("config.yaml");
281            if platform_path.exists() {
282                return platform_path;
283            }
284        }
285
286        // Default to XDG-style path for new configs
287        dirs::home_dir()
288            .map(|h| h.join(".config").join("scope").join("config.yaml"))
289            .unwrap_or_else(|| PathBuf::from(".").join("scope").join("config.yaml"))
290    }
291
292    /// Returns the configuration file path, if it can be determined.
293    ///
294    /// Returns `Some(path)` for the config file location, or `None` if
295    /// the path cannot be determined (e.g., no home directory).
296    /// Prefers the XDG-style `~/.config/scope/` location.
297    pub fn config_path() -> Option<PathBuf> {
298        dirs::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
299    }
300
301    /// Returns the default data directory for address book storage.
302    ///
303    /// Uses `~/.config/scope/data` on all platforms for consistency.
304    pub fn default_data_dir() -> PathBuf {
305        dirs::home_dir()
306            .map(|h| h.join(".config").join("scope").join("data"))
307            .unwrap_or_else(|| PathBuf::from(".config").join("scope").join("data"))
308    }
309
310    /// Returns the effective data directory, using config or default.
311    pub fn data_dir(&self) -> PathBuf {
312        self.address_book
313            .data_dir
314            .clone()
315            .unwrap_or_else(Self::default_data_dir)
316    }
317}
318
319impl std::fmt::Display for OutputFormat {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        match self {
322            OutputFormat::Table => write!(f, "table"),
323            OutputFormat::Json => write!(f, "json"),
324            OutputFormat::Csv => write!(f, "csv"),
325            OutputFormat::Markdown => write!(f, "markdown"),
326        }
327    }
328}
329
330// ============================================================================
331// Unit Tests
332// ============================================================================
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use std::io::Write;
338    use tempfile::NamedTempFile;
339
340    #[test]
341    fn test_default_config() {
342        let config = Config::default();
343
344        assert!(config.chains.api_keys.is_empty());
345        assert!(config.chains.ethereum_rpc.is_none());
346        assert!(config.chains.bsc_rpc.is_none());
347        assert!(config.chains.aegis_rpc.is_none());
348        assert!(config.chains.solana_rpc.is_none());
349        assert!(config.chains.tron_api.is_none());
350        assert_eq!(config.output.format, OutputFormat::Table);
351        assert!(config.output.color);
352        assert!(config.address_book.data_dir.is_none());
353    }
354
355    #[test]
356    fn test_load_from_yaml_full() {
357        let yaml = r#"
358chains:
359  ethereum_rpc: "https://example.com/rpc"
360  bsc_rpc: "https://bsc-dataseed.binance.org"
361  solana_rpc: "https://api.mainnet-beta.solana.com"
362  tron_api: "https://api.trongrid.io"
363  api_keys:
364    etherscan: "test-api-key"
365    polygonscan: "another-key"
366    bscscan: "bsc-key"
367    solscan: "sol-key"
368    tronscan: "tron-key"
369
370output:
371  format: json
372  color: false
373
374address_book:
375  data_dir: "/custom/data"
376"#;
377
378        let mut file = NamedTempFile::new().unwrap();
379        file.write_all(yaml.as_bytes()).unwrap();
380
381        let config = Config::load(Some(file.path())).unwrap();
382
383        // EVM chains
384        assert_eq!(
385            config.chains.ethereum_rpc,
386            Some("https://example.com/rpc".into())
387        );
388        assert_eq!(
389            config.chains.bsc_rpc,
390            Some("https://bsc-dataseed.binance.org".into())
391        );
392
393        // Non-EVM chains
394        assert_eq!(
395            config.chains.solana_rpc,
396            Some("https://api.mainnet-beta.solana.com".into())
397        );
398        assert_eq!(
399            config.chains.tron_api,
400            Some("https://api.trongrid.io".into())
401        );
402
403        // API keys
404        assert_eq!(
405            config.chains.api_keys.get("etherscan"),
406            Some(&"test-api-key".into())
407        );
408        assert_eq!(
409            config.chains.api_keys.get("polygonscan"),
410            Some(&"another-key".into())
411        );
412        assert_eq!(
413            config.chains.api_keys.get("bscscan"),
414            Some(&"bsc-key".into())
415        );
416        assert_eq!(
417            config.chains.api_keys.get("solscan"),
418            Some(&"sol-key".into())
419        );
420        assert_eq!(
421            config.chains.api_keys.get("tronscan"),
422            Some(&"tron-key".into())
423        );
424
425        assert_eq!(config.output.format, OutputFormat::Json);
426        assert!(!config.output.color);
427        assert_eq!(
428            config.address_book.data_dir,
429            Some(PathBuf::from("/custom/data"))
430        );
431    }
432
433    #[test]
434    fn test_load_partial_yaml_uses_defaults() {
435        let yaml = r#"
436chains:
437  ethereum_rpc: "https://partial.example.com"
438"#;
439
440        let mut file = NamedTempFile::new().unwrap();
441        file.write_all(yaml.as_bytes()).unwrap();
442
443        let config = Config::load(Some(file.path())).unwrap();
444
445        // Specified value
446        assert_eq!(
447            config.chains.ethereum_rpc,
448            Some("https://partial.example.com".into())
449        );
450
451        // Defaults
452        assert!(config.chains.api_keys.is_empty());
453        assert_eq!(config.output.format, OutputFormat::Table);
454        assert!(config.output.color);
455    }
456
457    #[test]
458    fn test_load_missing_file_returns_defaults() {
459        let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
460        assert_eq!(config, Config::default());
461    }
462
463    #[test]
464    fn test_load_invalid_yaml_returns_error() {
465        let mut file = NamedTempFile::new().unwrap();
466        file.write_all(b"invalid: yaml: : content: [").unwrap();
467
468        let result = Config::load(Some(file.path()));
469        assert!(result.is_err());
470        assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
471    }
472
473    #[test]
474    fn test_load_empty_file_returns_defaults() {
475        let file = NamedTempFile::new().unwrap();
476        // Empty file
477
478        let config = Config::load(Some(file.path())).unwrap();
479        assert_eq!(config, Config::default());
480    }
481
482    #[test]
483    fn test_output_format_serialization() {
484        let json_format = OutputFormat::Json;
485        let serialized = serde_yaml::to_string(&json_format).unwrap();
486        assert!(serialized.contains("json"));
487
488        let deserialized: OutputFormat = serde_yaml::from_str("csv").unwrap();
489        assert_eq!(deserialized, OutputFormat::Csv);
490    }
491
492    #[test]
493    fn test_output_format_display() {
494        assert_eq!(OutputFormat::Table.to_string(), "table");
495        assert_eq!(OutputFormat::Json.to_string(), "json");
496        assert_eq!(OutputFormat::Csv.to_string(), "csv");
497        assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
498    }
499
500    #[test]
501    fn test_default_path_is_absolute_or_relative() {
502        let path = Config::default_path();
503        // Should end with expected structure
504        assert!(path.ends_with("scope/config.yaml") || path.ends_with("scope\\config.yaml"));
505    }
506
507    #[test]
508    fn test_default_data_dir() {
509        let data_dir = Config::default_data_dir();
510        assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
511    }
512
513    #[test]
514    fn test_data_dir_uses_config_value() {
515        let config = Config {
516            address_book: AddressBookConfig {
517                data_dir: Some(PathBuf::from("/custom/path")),
518            },
519            ..Default::default()
520        };
521
522        assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
523    }
524
525    #[test]
526    fn test_data_dir_falls_back_to_default() {
527        let config = Config::default();
528        assert_eq!(config.data_dir(), Config::default_data_dir());
529    }
530
531    #[test]
532    fn test_config_clone_and_eq() {
533        let config1 = Config::default();
534        let config2 = config1.clone();
535        assert_eq!(config1, config2);
536    }
537
538    #[test]
539    fn test_config_path_returns_some() {
540        let path = Config::config_path();
541        // Should return Some on systems with a home dir
542        assert!(path.is_some());
543        assert!(path.unwrap().to_string_lossy().contains("scope"));
544    }
545
546    #[test]
547    fn test_config_debug() {
548        let config = Config::default();
549        let debug = format!("{:?}", config);
550        assert!(debug.contains("Config"));
551        assert!(debug.contains("ChainsConfig"));
552    }
553
554    #[test]
555    fn test_output_config_default() {
556        let output = OutputConfig::default();
557        assert_eq!(output.format, OutputFormat::Table);
558        assert!(output.color);
559    }
560
561    #[test]
562    fn test_config_serialization_roundtrip() {
563        let mut config = Config::default();
564        config
565            .chains
566            .api_keys
567            .insert("etherscan".to_string(), "test_key".to_string());
568        config.output.format = OutputFormat::Json;
569        config.output.color = false;
570        config.address_book.data_dir = Some(PathBuf::from("/custom"));
571
572        let yaml = serde_yaml::to_string(&config).unwrap();
573        let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
574        assert_eq!(config, deserialized);
575    }
576
577    #[test]
578    fn test_chains_config_with_multiple_api_keys() {
579        let mut api_keys = HashMap::new();
580        api_keys.insert("etherscan".into(), "key1".into());
581        api_keys.insert("polygonscan".into(), "key2".into());
582        api_keys.insert("bscscan".into(), "key3".into());
583
584        let chains = ChainsConfig {
585            ethereum_rpc: Some("https://rpc.example.com".into()),
586            api_keys,
587            ..Default::default()
588        };
589
590        assert_eq!(chains.api_keys.len(), 3);
591        assert!(chains.api_keys.contains_key("etherscan"));
592    }
593
594    #[test]
595    fn test_load_via_scope_config_env_var() {
596        let yaml = r#"
597chains:
598  ethereum_rpc: "https://env-test.example.com"
599output:
600  format: csv
601"#;
602        let mut file = NamedTempFile::new().unwrap();
603        file.write_all(yaml.as_bytes()).unwrap();
604
605        let path_str = file.path().to_string_lossy().to_string();
606        unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
607
608        // Load with None path — should pick up the env var
609        let config = Config::load(None).unwrap();
610        assert_eq!(
611            config.chains.ethereum_rpc,
612            Some("https://env-test.example.com".into())
613        );
614        assert_eq!(config.output.format, OutputFormat::Csv);
615
616        unsafe { std::env::remove_var("SCOPE_CONFIG") };
617    }
618
619    #[test]
620    fn test_output_format_default() {
621        let fmt = OutputFormat::default();
622        assert_eq!(fmt, OutputFormat::Table);
623    }
624
625    #[test]
626    fn test_address_book_config_default() {
627        let port = AddressBookConfig::default();
628        assert!(port.data_dir.is_none());
629    }
630
631    #[test]
632    fn test_chains_config_default() {
633        let chains = ChainsConfig::default();
634        assert!(chains.ethereum_rpc.is_none());
635        assert!(chains.bsc_rpc.is_none());
636        assert!(chains.aegis_rpc.is_none());
637        assert!(chains.solana_rpc.is_none());
638        assert!(chains.tron_api.is_none());
639        assert!(chains.api_keys.is_empty());
640    }
641
642    #[test]
643    fn test_load_unreadable_file_returns_error() {
644        // Create a directory where a file is expected to trigger read error
645        let dir = tempfile::tempdir().unwrap();
646        let config_path = dir.path().join("config.yaml");
647        // Create it as a directory so reading it as a file fails
648        std::fs::create_dir_all(&config_path).unwrap();
649        let result = Config::load(Some(&config_path));
650        assert!(result.is_err());
651    }
652
653    #[test]
654    fn test_default_path_returns_valid_path() {
655        let path = Config::default_path();
656        // Should always return some path (may or may not exist)
657        assert!(path.to_str().unwrap().contains("scope"));
658    }
659
660    // ========================================================================
661    // Ghola Configuration Tests
662    // ========================================================================
663
664    #[test]
665    fn test_ghola_config_default() {
666        let ghola = GholaConfig::default();
667        assert!(!ghola.enabled);
668        assert!(!ghola.stealth);
669    }
670
671    #[test]
672    fn test_config_default_ghola_disabled() {
673        let config = Config::default();
674        assert!(!config.ghola.enabled);
675        assert!(!config.ghola.stealth);
676    }
677
678    #[test]
679    fn test_load_ghola_enabled() {
680        let yaml = r#"
681ghola:
682  enabled: true
683  stealth: true
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        assert!(config.ghola.enabled);
690        assert!(config.ghola.stealth);
691    }
692
693    #[test]
694    fn test_load_ghola_partial() {
695        let yaml = r#"
696ghola:
697  enabled: true
698"#;
699        let mut file = NamedTempFile::new().unwrap();
700        file.write_all(yaml.as_bytes()).unwrap();
701
702        let config = Config::load(Some(file.path())).unwrap();
703        assert!(config.ghola.enabled);
704        assert!(!config.ghola.stealth); // defaults to false
705    }
706
707    #[test]
708    fn test_load_ghola_absent_uses_defaults() {
709        let yaml = r#"
710chains:
711  ethereum_rpc: "https://example.com"
712"#;
713        let mut file = NamedTempFile::new().unwrap();
714        file.write_all(yaml.as_bytes()).unwrap();
715
716        let config = Config::load(Some(file.path())).unwrap();
717        assert!(!config.ghola.enabled);
718        assert!(!config.ghola.stealth);
719    }
720
721    #[test]
722    fn test_ghola_config_serialization_roundtrip() {
723        let mut config = Config::default();
724        config.ghola.enabled = true;
725        config.ghola.stealth = true;
726
727        let yaml = serde_yaml::to_string(&config).unwrap();
728        let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
729        assert_eq!(config.ghola, deserialized.ghola);
730    }
731}