1use crate::error::{ConfigError, Result, ScopeError};
43use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45use std::path::{Path, PathBuf};
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
63#[serde(default)]
64pub struct Config {
65 pub chains: ChainsConfig,
67
68 pub output: OutputConfig,
70
71 #[serde(alias = "portfolio")]
73 pub address_book: AddressBookConfig,
74
75 pub monitor: crate::cli::monitor::MonitorConfig,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
83#[serde(default)]
84pub struct ChainsConfig {
85 pub ethereum_rpc: Option<String>,
92
93 pub bsc_rpc: Option<String>,
97
98 #[doc(hidden)]
100 pub aegis_rpc: Option<String>,
101
102 pub solana_rpc: Option<String>,
109
110 pub tron_api: Option<String>,
114
115 pub api_keys: HashMap<String, String>,
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[serde(default)]
128pub struct OutputConfig {
129 pub format: OutputFormat,
131
132 pub color: bool,
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
138#[serde(default)]
139pub struct AddressBookConfig {
140 pub data_dir: Option<PathBuf>,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
148#[serde(rename_all = "lowercase")]
149pub enum OutputFormat {
150 #[default]
152 Table,
153
154 Json,
156
157 Csv,
159
160 #[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 pub fn load(path: Option<&Path>) -> Result<Self> {
204 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 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 pub fn default_path() -> PathBuf {
243 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 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 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 pub fn config_path() -> Option<PathBuf> {
271 dirs::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
272 }
273
274 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 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#[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 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 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 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 assert_eq!(
420 config.chains.ethereum_rpc,
421 Some("https://partial.example.com".into())
422 );
423
424 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 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 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 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 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}