1use crate::error::{ConfigError, Result, ScopeError};
44use serde::{Deserialize, Serialize};
45use std::collections::HashMap;
46use std::path::{Path, PathBuf};
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
64#[serde(default)]
65pub struct Config {
66 pub chains: ChainsConfig,
68
69 pub output: OutputConfig,
71
72 pub portfolio: PortfolioConfig,
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 pub aegis_rpc: Option<String>,
102
103 pub solana_rpc: Option<String>,
110
111 pub tron_api: Option<String>,
115
116 pub api_keys: HashMap<String, String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128#[serde(default)]
129pub struct OutputConfig {
130 pub format: OutputFormat,
132
133 pub color: bool,
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
139#[serde(default)]
140pub struct PortfolioConfig {
141 pub data_dir: Option<PathBuf>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
149#[serde(rename_all = "lowercase")]
150pub enum OutputFormat {
151 #[default]
153 Table,
154
155 Json,
157
158 Csv,
160}
161
162impl Default for OutputConfig {
163 fn default() -> Self {
164 Self {
165 format: OutputFormat::Table,
166 color: true,
167 }
168 }
169}
170
171impl Config {
172 pub fn load(path: Option<&Path>) -> Result<Self> {
201 let config_path = path
203 .map(PathBuf::from)
204 .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
205 .unwrap_or_else(Self::default_path);
206
207 if !config_path.exists() {
210 tracing::debug!(
211 path = %config_path.display(),
212 "No config file found, using defaults"
213 );
214 return Ok(Self::default());
215 }
216
217 tracing::debug!(path = %config_path.display(), "Loading configuration");
218
219 let contents = std::fs::read_to_string(&config_path).map_err(|e| {
220 ScopeError::Config(ConfigError::Read {
221 path: config_path.clone(),
222 source: e,
223 })
224 })?;
225
226 let config: Config =
227 serde_yaml::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
228
229 Ok(config)
230 }
231
232 pub fn default_path() -> PathBuf {
240 if let Some(home) = dirs::home_dir() {
242 let xdg_path = home.join(".config").join("scope").join("config.yaml");
243 if xdg_path.exists() {
244 return xdg_path;
245 }
246 }
247
248 if let Some(config_dir) = dirs::config_dir() {
250 let platform_path = config_dir.join("scope").join("config.yaml");
251 if platform_path.exists() {
252 return platform_path;
253 }
254 }
255
256 dirs::home_dir()
258 .map(|h| h.join(".config").join("scope").join("config.yaml"))
259 .unwrap_or_else(|| PathBuf::from(".").join("scope").join("config.yaml"))
260 }
261
262 pub fn config_path() -> Option<PathBuf> {
268 dirs::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
269 }
270
271 pub fn default_data_dir() -> PathBuf {
276 dirs::data_local_dir()
277 .unwrap_or_else(|| PathBuf::from("."))
278 .join("scope")
279 }
280
281 pub fn data_dir(&self) -> PathBuf {
283 self.portfolio
284 .data_dir
285 .clone()
286 .unwrap_or_else(Self::default_data_dir)
287 }
288}
289
290impl std::fmt::Display for OutputFormat {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 match self {
293 OutputFormat::Table => write!(f, "table"),
294 OutputFormat::Json => write!(f, "json"),
295 OutputFormat::Csv => write!(f, "csv"),
296 }
297 }
298}
299
300#[cfg(test)]
305mod tests {
306 use super::*;
307 use std::io::Write;
308 use tempfile::NamedTempFile;
309
310 #[test]
311 fn test_default_config() {
312 let config = Config::default();
313
314 assert!(config.chains.api_keys.is_empty());
315 assert!(config.chains.ethereum_rpc.is_none());
316 assert!(config.chains.bsc_rpc.is_none());
317 assert!(config.chains.aegis_rpc.is_none());
318 assert!(config.chains.solana_rpc.is_none());
319 assert!(config.chains.tron_api.is_none());
320 assert_eq!(config.output.format, OutputFormat::Table);
321 assert!(config.output.color);
322 assert!(config.portfolio.data_dir.is_none());
323 }
324
325 #[test]
326 fn test_load_from_yaml_full() {
327 let yaml = r#"
328chains:
329 ethereum_rpc: "https://example.com/rpc"
330 bsc_rpc: "https://bsc-dataseed.binance.org"
331 aegis_rpc: "http://localhost:8545"
332 solana_rpc: "https://api.mainnet-beta.solana.com"
333 tron_api: "https://api.trongrid.io"
334 api_keys:
335 etherscan: "test-api-key"
336 polygonscan: "another-key"
337 bscscan: "bsc-key"
338 solscan: "sol-key"
339 tronscan: "tron-key"
340
341output:
342 format: json
343 color: false
344
345portfolio:
346 data_dir: "/custom/data"
347"#;
348
349 let mut file = NamedTempFile::new().unwrap();
350 file.write_all(yaml.as_bytes()).unwrap();
351
352 let config = Config::load(Some(file.path())).unwrap();
353
354 assert_eq!(
356 config.chains.ethereum_rpc,
357 Some("https://example.com/rpc".into())
358 );
359 assert_eq!(
360 config.chains.bsc_rpc,
361 Some("https://bsc-dataseed.binance.org".into())
362 );
363 assert_eq!(
364 config.chains.aegis_rpc,
365 Some("http://localhost:8545".into())
366 );
367
368 assert_eq!(
370 config.chains.solana_rpc,
371 Some("https://api.mainnet-beta.solana.com".into())
372 );
373 assert_eq!(
374 config.chains.tron_api,
375 Some("https://api.trongrid.io".into())
376 );
377
378 assert_eq!(
380 config.chains.api_keys.get("etherscan"),
381 Some(&"test-api-key".into())
382 );
383 assert_eq!(
384 config.chains.api_keys.get("polygonscan"),
385 Some(&"another-key".into())
386 );
387 assert_eq!(
388 config.chains.api_keys.get("bscscan"),
389 Some(&"bsc-key".into())
390 );
391 assert_eq!(
392 config.chains.api_keys.get("solscan"),
393 Some(&"sol-key".into())
394 );
395 assert_eq!(
396 config.chains.api_keys.get("tronscan"),
397 Some(&"tron-key".into())
398 );
399
400 assert_eq!(config.output.format, OutputFormat::Json);
401 assert!(!config.output.color);
402 assert_eq!(
403 config.portfolio.data_dir,
404 Some(PathBuf::from("/custom/data"))
405 );
406 }
407
408 #[test]
409 fn test_load_partial_yaml_uses_defaults() {
410 let yaml = r#"
411chains:
412 ethereum_rpc: "https://partial.example.com"
413"#;
414
415 let mut file = NamedTempFile::new().unwrap();
416 file.write_all(yaml.as_bytes()).unwrap();
417
418 let config = Config::load(Some(file.path())).unwrap();
419
420 assert_eq!(
422 config.chains.ethereum_rpc,
423 Some("https://partial.example.com".into())
424 );
425
426 assert!(config.chains.api_keys.is_empty());
428 assert_eq!(config.output.format, OutputFormat::Table);
429 assert!(config.output.color);
430 }
431
432 #[test]
433 fn test_load_missing_file_returns_defaults() {
434 let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
435 assert_eq!(config, Config::default());
436 }
437
438 #[test]
439 fn test_load_invalid_yaml_returns_error() {
440 let mut file = NamedTempFile::new().unwrap();
441 file.write_all(b"invalid: yaml: : content: [").unwrap();
442
443 let result = Config::load(Some(file.path()));
444 assert!(result.is_err());
445 assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
446 }
447
448 #[test]
449 fn test_load_empty_file_returns_defaults() {
450 let file = NamedTempFile::new().unwrap();
451 let config = Config::load(Some(file.path())).unwrap();
454 assert_eq!(config, Config::default());
455 }
456
457 #[test]
458 fn test_output_format_serialization() {
459 let json_format = OutputFormat::Json;
460 let serialized = serde_yaml::to_string(&json_format).unwrap();
461 assert!(serialized.contains("json"));
462
463 let deserialized: OutputFormat = serde_yaml::from_str("csv").unwrap();
464 assert_eq!(deserialized, OutputFormat::Csv);
465 }
466
467 #[test]
468 fn test_output_format_display() {
469 assert_eq!(OutputFormat::Table.to_string(), "table");
470 assert_eq!(OutputFormat::Json.to_string(), "json");
471 assert_eq!(OutputFormat::Csv.to_string(), "csv");
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 portfolio: PortfolioConfig {
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.portfolio.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_portfolio_config_default() {
601 let port = PortfolioConfig::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}