1use crate::error::{ConfigError, Result, ScopeError};
47use serde::{Deserialize, Serialize};
48use std::collections::HashMap;
49use std::path::{Path, PathBuf};
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
67#[serde(default)]
68pub struct Config {
69 pub chains: ChainsConfig,
71
72 pub output: OutputConfig,
74
75 #[serde(alias = "portfolio")]
77 pub address_book: AddressBookConfig,
78
79 pub monitor: crate::cli::monitor::MonitorConfig,
81
82 pub ghola: GholaConfig,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
90#[serde(default)]
91pub struct ChainsConfig {
92 pub ethereum_rpc: Option<String>,
99
100 pub bsc_rpc: Option<String>,
104
105 #[doc(hidden)]
107 pub aegis_rpc: Option<String>,
108
109 pub solana_rpc: Option<String>,
116
117 pub tron_api: Option<String>,
121
122 pub api_keys: HashMap<String, String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134#[serde(default)]
135pub struct OutputConfig {
136 pub format: OutputFormat,
138
139 pub color: bool,
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
145#[serde(default)]
146pub struct AddressBookConfig {
147 pub data_dir: Option<PathBuf>,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
162#[serde(default)]
163pub struct GholaConfig {
164 pub enabled: bool,
167
168 pub stealth: bool,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
175#[serde(rename_all = "lowercase")]
176pub enum OutputFormat {
177 #[default]
179 Table,
180
181 Json,
183
184 Csv,
186
187 #[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 pub fn load(path: Option<&Path>) -> Result<Self> {
231 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 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 pub fn default_path() -> PathBuf {
270 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 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 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 pub fn config_path() -> Option<PathBuf> {
298 dirs::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
299 }
300
301 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 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#[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 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 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 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 assert_eq!(
447 config.chains.ethereum_rpc,
448 Some("https://partial.example.com".into())
449 );
450
451 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 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 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 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 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 let dir = tempfile::tempdir().unwrap();
646 let config_path = dir.path().join("config.yaml");
647 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 assert!(path.to_str().unwrap().contains("scope"));
658 }
659
660 #[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); }
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}