1use crate::error::{ConfigError, Result, ScopeError};
48use serde::{Deserialize, Serialize};
49use std::collections::HashMap;
50use std::path::{Path, PathBuf};
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
68#[serde(default)]
69pub struct Config {
70 pub chains: ChainsConfig,
72
73 pub output: OutputConfig,
75
76 #[serde(alias = "portfolio")]
78 pub address_book: AddressBookConfig,
79
80 pub monitor: crate::cli::monitor::MonitorConfig,
82
83 pub ghola: GholaConfig,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
91#[serde(default)]
92pub struct ChainsConfig {
93 pub ethereum_rpc: Option<String>,
100
101 pub bsc_rpc: Option<String>,
105
106 #[doc(hidden)]
108 pub aegis_rpc: Option<String>,
109
110 pub solana_rpc: Option<String>,
117
118 pub tron_api: Option<String>,
122
123 pub api_keys: HashMap<String, String>,
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135#[serde(default)]
136pub struct OutputConfig {
137 pub format: OutputFormat,
139
140 pub color: bool,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
146#[serde(default)]
147pub struct AddressBookConfig {
148 pub data_dir: Option<PathBuf>,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(default)]
164pub struct GholaConfig {
165 pub enabled: bool,
168
169 pub stealth: bool,
172
173 pub buffer_size: u32,
177}
178
179impl Default for GholaConfig {
180 fn default() -> Self {
181 Self {
182 enabled: false,
183 stealth: false,
184 buffer_size: 4096,
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
191#[serde(rename_all = "lowercase")]
192pub enum OutputFormat {
193 #[default]
195 Table,
196
197 Json,
199
200 Csv,
202
203 #[value(name = "markdown")]
205 Markdown,
206}
207
208impl Default for OutputConfig {
209 fn default() -> Self {
210 Self {
211 format: OutputFormat::Table,
212 color: true,
213 }
214 }
215}
216
217impl Config {
218 pub fn load(path: Option<&Path>) -> Result<Self> {
247 let config_path = path
249 .map(PathBuf::from)
250 .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
251 .unwrap_or_else(Self::default_path);
252
253 if !config_path.exists() {
256 tracing::debug!(
257 path = %config_path.display(),
258 "No config file found, using defaults"
259 );
260 return Ok(Self::default());
261 }
262
263 tracing::debug!(path = %config_path.display(), "Loading configuration");
264
265 let contents = std::fs::read_to_string(&config_path).map_err(|e| {
266 ScopeError::Config(ConfigError::Read {
267 path: config_path.clone(),
268 source: e,
269 })
270 })?;
271
272 let config: Config =
273 serde_yaml::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
274
275 Ok(config)
276 }
277
278 pub fn default_path() -> PathBuf {
286 if let Some(home) = dirs::home_dir() {
288 let xdg_path = home.join(".config").join("scope").join("config.yaml");
289 if xdg_path.exists() {
290 return xdg_path;
291 }
292 }
293
294 if let Some(config_dir) = dirs::config_dir() {
296 let platform_path = config_dir.join("scope").join("config.yaml");
297 if platform_path.exists() {
298 return platform_path;
299 }
300 }
301
302 dirs::home_dir()
304 .map(|h| h.join(".config").join("scope").join("config.yaml"))
305 .unwrap_or_else(|| PathBuf::from(".").join("scope").join("config.yaml"))
306 }
307
308 pub fn config_path() -> Option<PathBuf> {
314 dirs::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
315 }
316
317 pub fn default_data_dir() -> PathBuf {
321 dirs::home_dir()
322 .map(|h| h.join(".config").join("scope").join("data"))
323 .unwrap_or_else(|| PathBuf::from(".config").join("scope").join("data"))
324 }
325
326 pub fn data_dir(&self) -> PathBuf {
328 self.address_book
329 .data_dir
330 .clone()
331 .unwrap_or_else(Self::default_data_dir)
332 }
333}
334
335impl std::fmt::Display for OutputFormat {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 match self {
338 OutputFormat::Table => write!(f, "table"),
339 OutputFormat::Json => write!(f, "json"),
340 OutputFormat::Csv => write!(f, "csv"),
341 OutputFormat::Markdown => write!(f, "markdown"),
342 }
343 }
344}
345
346#[cfg(test)]
351mod tests {
352 use super::*;
353 use std::io::Write;
354 use tempfile::NamedTempFile;
355
356 #[test]
357 fn test_default_config() {
358 let config = Config::default();
359
360 assert!(config.chains.api_keys.is_empty());
361 assert!(config.chains.ethereum_rpc.is_none());
362 assert!(config.chains.bsc_rpc.is_none());
363 assert!(config.chains.aegis_rpc.is_none());
364 assert!(config.chains.solana_rpc.is_none());
365 assert!(config.chains.tron_api.is_none());
366 assert_eq!(config.output.format, OutputFormat::Table);
367 assert!(config.output.color);
368 assert!(config.address_book.data_dir.is_none());
369 }
370
371 #[test]
372 fn test_load_from_yaml_full() {
373 let yaml = r#"
374chains:
375 ethereum_rpc: "https://example.com/rpc"
376 bsc_rpc: "https://bsc-dataseed.binance.org"
377 solana_rpc: "https://api.mainnet-beta.solana.com"
378 tron_api: "https://api.trongrid.io"
379 api_keys:
380 etherscan: "test-api-key"
381 polygonscan: "another-key"
382 bscscan: "bsc-key"
383 solscan: "sol-key"
384 tronscan: "tron-key"
385
386output:
387 format: json
388 color: false
389
390address_book:
391 data_dir: "/custom/data"
392"#;
393
394 let mut file = NamedTempFile::new().unwrap();
395 file.write_all(yaml.as_bytes()).unwrap();
396
397 let config = Config::load(Some(file.path())).unwrap();
398
399 assert_eq!(
401 config.chains.ethereum_rpc,
402 Some("https://example.com/rpc".into())
403 );
404 assert_eq!(
405 config.chains.bsc_rpc,
406 Some("https://bsc-dataseed.binance.org".into())
407 );
408
409 assert_eq!(
411 config.chains.solana_rpc,
412 Some("https://api.mainnet-beta.solana.com".into())
413 );
414 assert_eq!(
415 config.chains.tron_api,
416 Some("https://api.trongrid.io".into())
417 );
418
419 assert_eq!(
421 config.chains.api_keys.get("etherscan"),
422 Some(&"test-api-key".into())
423 );
424 assert_eq!(
425 config.chains.api_keys.get("polygonscan"),
426 Some(&"another-key".into())
427 );
428 assert_eq!(
429 config.chains.api_keys.get("bscscan"),
430 Some(&"bsc-key".into())
431 );
432 assert_eq!(
433 config.chains.api_keys.get("solscan"),
434 Some(&"sol-key".into())
435 );
436 assert_eq!(
437 config.chains.api_keys.get("tronscan"),
438 Some(&"tron-key".into())
439 );
440
441 assert_eq!(config.output.format, OutputFormat::Json);
442 assert!(!config.output.color);
443 assert_eq!(
444 config.address_book.data_dir,
445 Some(PathBuf::from("/custom/data"))
446 );
447 }
448
449 #[test]
450 fn test_load_partial_yaml_uses_defaults() {
451 let yaml = r#"
452chains:
453 ethereum_rpc: "https://partial.example.com"
454"#;
455
456 let mut file = NamedTempFile::new().unwrap();
457 file.write_all(yaml.as_bytes()).unwrap();
458
459 let config = Config::load(Some(file.path())).unwrap();
460
461 assert_eq!(
463 config.chains.ethereum_rpc,
464 Some("https://partial.example.com".into())
465 );
466
467 assert!(config.chains.api_keys.is_empty());
469 assert_eq!(config.output.format, OutputFormat::Table);
470 assert!(config.output.color);
471 }
472
473 #[test]
474 fn test_load_missing_file_returns_defaults() {
475 let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
476 assert_eq!(config, Config::default());
477 }
478
479 #[test]
480 fn test_load_invalid_yaml_returns_error() {
481 let mut file = NamedTempFile::new().unwrap();
482 file.write_all(b"invalid: yaml: : content: [").unwrap();
483
484 let result = Config::load(Some(file.path()));
485 assert!(result.is_err());
486 assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
487 }
488
489 #[test]
490 fn test_load_empty_file_returns_defaults() {
491 let file = NamedTempFile::new().unwrap();
492 let config = Config::load(Some(file.path())).unwrap();
495 assert_eq!(config, Config::default());
496 }
497
498 #[test]
499 fn test_output_format_serialization() {
500 let json_format = OutputFormat::Json;
501 let serialized = serde_yaml::to_string(&json_format).unwrap();
502 assert!(serialized.contains("json"));
503
504 let deserialized: OutputFormat = serde_yaml::from_str("csv").unwrap();
505 assert_eq!(deserialized, OutputFormat::Csv);
506 }
507
508 #[test]
509 fn test_output_format_display() {
510 assert_eq!(OutputFormat::Table.to_string(), "table");
511 assert_eq!(OutputFormat::Json.to_string(), "json");
512 assert_eq!(OutputFormat::Csv.to_string(), "csv");
513 assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
514 }
515
516 #[test]
517 fn test_default_path_is_absolute_or_relative() {
518 let path = Config::default_path();
519 assert!(path.ends_with("scope/config.yaml") || path.ends_with("scope\\config.yaml"));
521 }
522
523 #[test]
524 fn test_default_data_dir() {
525 let data_dir = Config::default_data_dir();
526 assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
527 }
528
529 #[test]
530 fn test_data_dir_uses_config_value() {
531 let config = Config {
532 address_book: AddressBookConfig {
533 data_dir: Some(PathBuf::from("/custom/path")),
534 },
535 ..Default::default()
536 };
537
538 assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
539 }
540
541 #[test]
542 fn test_data_dir_falls_back_to_default() {
543 let config = Config::default();
544 assert_eq!(config.data_dir(), Config::default_data_dir());
545 }
546
547 #[test]
548 fn test_config_clone_and_eq() {
549 let config1 = Config::default();
550 let config2 = config1.clone();
551 assert_eq!(config1, config2);
552 }
553
554 #[test]
555 fn test_config_path_returns_some() {
556 let path = Config::config_path();
557 assert!(path.is_some());
559 assert!(path.unwrap().to_string_lossy().contains("scope"));
560 }
561
562 #[test]
563 fn test_config_debug() {
564 let config = Config::default();
565 let debug = format!("{:?}", config);
566 assert!(debug.contains("Config"));
567 assert!(debug.contains("ChainsConfig"));
568 }
569
570 #[test]
571 fn test_output_config_default() {
572 let output = OutputConfig::default();
573 assert_eq!(output.format, OutputFormat::Table);
574 assert!(output.color);
575 }
576
577 #[test]
578 fn test_config_serialization_roundtrip() {
579 let mut config = Config::default();
580 config
581 .chains
582 .api_keys
583 .insert("etherscan".to_string(), "test_key".to_string());
584 config.output.format = OutputFormat::Json;
585 config.output.color = false;
586 config.address_book.data_dir = Some(PathBuf::from("/custom"));
587
588 let yaml = serde_yaml::to_string(&config).unwrap();
589 let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
590 assert_eq!(config, deserialized);
591 }
592
593 #[test]
594 fn test_chains_config_with_multiple_api_keys() {
595 let mut api_keys = HashMap::new();
596 api_keys.insert("etherscan".into(), "key1".into());
597 api_keys.insert("polygonscan".into(), "key2".into());
598 api_keys.insert("bscscan".into(), "key3".into());
599
600 let chains = ChainsConfig {
601 ethereum_rpc: Some("https://rpc.example.com".into()),
602 api_keys,
603 ..Default::default()
604 };
605
606 assert_eq!(chains.api_keys.len(), 3);
607 assert!(chains.api_keys.contains_key("etherscan"));
608 }
609
610 #[test]
611 fn test_load_via_scope_config_env_var() {
612 let yaml = r#"
613chains:
614 ethereum_rpc: "https://env-test.example.com"
615output:
616 format: csv
617"#;
618 let mut file = NamedTempFile::new().unwrap();
619 file.write_all(yaml.as_bytes()).unwrap();
620
621 let path_str = file.path().to_string_lossy().to_string();
622 unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
623
624 let config = Config::load(None).unwrap();
626 assert_eq!(
627 config.chains.ethereum_rpc,
628 Some("https://env-test.example.com".into())
629 );
630 assert_eq!(config.output.format, OutputFormat::Csv);
631
632 unsafe { std::env::remove_var("SCOPE_CONFIG") };
633 }
634
635 #[test]
636 fn test_output_format_default() {
637 let fmt = OutputFormat::default();
638 assert_eq!(fmt, OutputFormat::Table);
639 }
640
641 #[test]
642 fn test_address_book_config_default() {
643 let port = AddressBookConfig::default();
644 assert!(port.data_dir.is_none());
645 }
646
647 #[test]
648 fn test_chains_config_default() {
649 let chains = ChainsConfig::default();
650 assert!(chains.ethereum_rpc.is_none());
651 assert!(chains.bsc_rpc.is_none());
652 assert!(chains.aegis_rpc.is_none());
653 assert!(chains.solana_rpc.is_none());
654 assert!(chains.tron_api.is_none());
655 assert!(chains.api_keys.is_empty());
656 }
657
658 #[test]
659 fn test_load_unreadable_file_returns_error() {
660 let dir = tempfile::tempdir().unwrap();
662 let config_path = dir.path().join("config.yaml");
663 std::fs::create_dir_all(&config_path).unwrap();
665 let result = Config::load(Some(&config_path));
666 assert!(result.is_err());
667 }
668
669 #[test]
670 fn test_default_path_returns_valid_path() {
671 let path = Config::default_path();
672 assert!(path.to_str().unwrap().contains("scope"));
674 }
675
676 #[test]
681 fn test_ghola_config_default() {
682 let ghola = GholaConfig::default();
683 assert!(!ghola.enabled);
684 assert!(!ghola.stealth);
685 assert_eq!(ghola.buffer_size, 4096);
686 }
687
688 #[test]
689 fn test_config_default_ghola_disabled() {
690 let config = Config::default();
691 assert!(!config.ghola.enabled);
692 assert!(!config.ghola.stealth);
693 assert_eq!(config.ghola.buffer_size, 4096);
694 }
695
696 #[test]
697 fn test_load_ghola_enabled() {
698 let yaml = r#"
699ghola:
700 enabled: true
701 stealth: true
702 buffer_size: 8192
703"#;
704 let mut file = NamedTempFile::new().unwrap();
705 file.write_all(yaml.as_bytes()).unwrap();
706
707 let config = Config::load(Some(file.path())).unwrap();
708 assert!(config.ghola.enabled);
709 assert!(config.ghola.stealth);
710 assert_eq!(config.ghola.buffer_size, 8192);
711 }
712
713 #[test]
714 fn test_load_ghola_partial() {
715 let yaml = r#"
716ghola:
717 enabled: true
718"#;
719 let mut file = NamedTempFile::new().unwrap();
720 file.write_all(yaml.as_bytes()).unwrap();
721
722 let config = Config::load(Some(file.path())).unwrap();
723 assert!(config.ghola.enabled);
724 assert!(!config.ghola.stealth); assert_eq!(config.ghola.buffer_size, 4096); }
727
728 #[test]
729 fn test_load_ghola_absent_uses_defaults() {
730 let yaml = r#"
731chains:
732 ethereum_rpc: "https://example.com"
733"#;
734 let mut file = NamedTempFile::new().unwrap();
735 file.write_all(yaml.as_bytes()).unwrap();
736
737 let config = Config::load(Some(file.path())).unwrap();
738 assert!(!config.ghola.enabled);
739 assert!(!config.ghola.stealth);
740 assert_eq!(config.ghola.buffer_size, 4096);
741 }
742
743 #[test]
744 fn test_load_ghola_custom_buffer_size() {
745 let yaml = r#"
746ghola:
747 enabled: false
748 buffer_size: 16384
749"#;
750 let mut file = NamedTempFile::new().unwrap();
751 file.write_all(yaml.as_bytes()).unwrap();
752
753 let config = Config::load(Some(file.path())).unwrap();
754 assert!(!config.ghola.enabled);
755 assert_eq!(config.ghola.buffer_size, 16384);
756 }
757
758 #[test]
759 fn test_ghola_config_serialization_roundtrip() {
760 let mut config = Config::default();
761 config.ghola.enabled = true;
762 config.ghola.stealth = true;
763 config.ghola.buffer_size = 8192;
764
765 let yaml = serde_yaml::to_string(&config).unwrap();
766 let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
767 assert_eq!(config.ghola, deserialized.ghola);
768 }
769}