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