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
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
80#[serde(default)]
81pub struct ChainsConfig {
82 pub ethereum_rpc: Option<String>,
89
90 pub bsc_rpc: Option<String>,
94
95 pub aegis_rpc: Option<String>,
99
100 pub solana_rpc: Option<String>,
107
108 pub tron_api: Option<String>,
112
113 pub api_keys: HashMap<String, String>,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(default)]
126pub struct OutputConfig {
127 pub format: OutputFormat,
129
130 pub color: bool,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
136#[serde(default)]
137pub struct PortfolioConfig {
138 pub data_dir: Option<PathBuf>,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
146#[serde(rename_all = "lowercase")]
147pub enum OutputFormat {
148 #[default]
150 Table,
151
152 Json,
154
155 Csv,
157}
158
159impl Default for OutputConfig {
160 fn default() -> Self {
161 Self {
162 format: OutputFormat::Table,
163 color: true,
164 }
165 }
166}
167
168impl Config {
169 pub fn load(path: Option<&Path>) -> Result<Self> {
198 let config_path = path
200 .map(PathBuf::from)
201 .or_else(|| std::env::var("SCOPE_CONFIG").ok().map(PathBuf::from))
202 .unwrap_or_else(Self::default_path);
203
204 if !config_path.exists() {
207 tracing::debug!(
208 path = %config_path.display(),
209 "No config file found, using defaults"
210 );
211 return Ok(Self::default());
212 }
213
214 tracing::debug!(path = %config_path.display(), "Loading configuration");
215
216 let contents = std::fs::read_to_string(&config_path).map_err(|e| {
217 ScopeError::Config(ConfigError::Read {
218 path: config_path.clone(),
219 source: e,
220 })
221 })?;
222
223 let config: Config =
224 serde_yaml::from_str(&contents).map_err(|e| ConfigError::Parse { source: e })?;
225
226 Ok(config)
227 }
228
229 pub fn default_path() -> PathBuf {
237 if let Some(home) = dirs::home_dir() {
239 let xdg_path = home.join(".config").join("scope").join("config.yaml");
240 if xdg_path.exists() {
241 return xdg_path;
242 }
243 }
244
245 if let Some(config_dir) = dirs::config_dir() {
247 let platform_path = config_dir.join("scope").join("config.yaml");
248 if platform_path.exists() {
249 return platform_path;
250 }
251 }
252
253 dirs::home_dir()
255 .map(|h| h.join(".config").join("scope").join("config.yaml"))
256 .unwrap_or_else(|| PathBuf::from(".").join("scope").join("config.yaml"))
257 }
258
259 pub fn config_path() -> Option<PathBuf> {
265 dirs::home_dir().map(|h| h.join(".config").join("scope").join("config.yaml"))
266 }
267
268 pub fn default_data_dir() -> PathBuf {
273 dirs::data_local_dir()
274 .unwrap_or_else(|| PathBuf::from("."))
275 .join("scope")
276 }
277
278 pub fn data_dir(&self) -> PathBuf {
280 self.portfolio
281 .data_dir
282 .clone()
283 .unwrap_or_else(Self::default_data_dir)
284 }
285}
286
287impl std::fmt::Display for OutputFormat {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 match self {
290 OutputFormat::Table => write!(f, "table"),
291 OutputFormat::Json => write!(f, "json"),
292 OutputFormat::Csv => write!(f, "csv"),
293 }
294 }
295}
296
297#[cfg(test)]
302mod tests {
303 use super::*;
304 use std::io::Write;
305 use tempfile::NamedTempFile;
306
307 #[test]
308 fn test_default_config() {
309 let config = Config::default();
310
311 assert!(config.chains.api_keys.is_empty());
312 assert!(config.chains.ethereum_rpc.is_none());
313 assert!(config.chains.bsc_rpc.is_none());
314 assert!(config.chains.aegis_rpc.is_none());
315 assert!(config.chains.solana_rpc.is_none());
316 assert!(config.chains.tron_api.is_none());
317 assert_eq!(config.output.format, OutputFormat::Table);
318 assert!(config.output.color);
319 assert!(config.portfolio.data_dir.is_none());
320 }
321
322 #[test]
323 fn test_load_from_yaml_full() {
324 let yaml = r#"
325chains:
326 ethereum_rpc: "https://example.com/rpc"
327 bsc_rpc: "https://bsc-dataseed.binance.org"
328 aegis_rpc: "http://localhost:8545"
329 solana_rpc: "https://api.mainnet-beta.solana.com"
330 tron_api: "https://api.trongrid.io"
331 api_keys:
332 etherscan: "test-api-key"
333 polygonscan: "another-key"
334 bscscan: "bsc-key"
335 solscan: "sol-key"
336 tronscan: "tron-key"
337
338output:
339 format: json
340 color: false
341
342portfolio:
343 data_dir: "/custom/data"
344"#;
345
346 let mut file = NamedTempFile::new().unwrap();
347 file.write_all(yaml.as_bytes()).unwrap();
348
349 let config = Config::load(Some(file.path())).unwrap();
350
351 assert_eq!(
353 config.chains.ethereum_rpc,
354 Some("https://example.com/rpc".into())
355 );
356 assert_eq!(
357 config.chains.bsc_rpc,
358 Some("https://bsc-dataseed.binance.org".into())
359 );
360 assert_eq!(
361 config.chains.aegis_rpc,
362 Some("http://localhost:8545".into())
363 );
364
365 assert_eq!(
367 config.chains.solana_rpc,
368 Some("https://api.mainnet-beta.solana.com".into())
369 );
370 assert_eq!(
371 config.chains.tron_api,
372 Some("https://api.trongrid.io".into())
373 );
374
375 assert_eq!(
377 config.chains.api_keys.get("etherscan"),
378 Some(&"test-api-key".into())
379 );
380 assert_eq!(
381 config.chains.api_keys.get("polygonscan"),
382 Some(&"another-key".into())
383 );
384 assert_eq!(
385 config.chains.api_keys.get("bscscan"),
386 Some(&"bsc-key".into())
387 );
388 assert_eq!(
389 config.chains.api_keys.get("solscan"),
390 Some(&"sol-key".into())
391 );
392 assert_eq!(
393 config.chains.api_keys.get("tronscan"),
394 Some(&"tron-key".into())
395 );
396
397 assert_eq!(config.output.format, OutputFormat::Json);
398 assert!(!config.output.color);
399 assert_eq!(
400 config.portfolio.data_dir,
401 Some(PathBuf::from("/custom/data"))
402 );
403 }
404
405 #[test]
406 fn test_load_partial_yaml_uses_defaults() {
407 let yaml = r#"
408chains:
409 ethereum_rpc: "https://partial.example.com"
410"#;
411
412 let mut file = NamedTempFile::new().unwrap();
413 file.write_all(yaml.as_bytes()).unwrap();
414
415 let config = Config::load(Some(file.path())).unwrap();
416
417 assert_eq!(
419 config.chains.ethereum_rpc,
420 Some("https://partial.example.com".into())
421 );
422
423 assert!(config.chains.api_keys.is_empty());
425 assert_eq!(config.output.format, OutputFormat::Table);
426 assert!(config.output.color);
427 }
428
429 #[test]
430 fn test_load_missing_file_returns_defaults() {
431 let config = Config::load(Some(Path::new("/nonexistent/path/config.yaml"))).unwrap();
432 assert_eq!(config, Config::default());
433 }
434
435 #[test]
436 fn test_load_invalid_yaml_returns_error() {
437 let mut file = NamedTempFile::new().unwrap();
438 file.write_all(b"invalid: yaml: : content: [").unwrap();
439
440 let result = Config::load(Some(file.path()));
441 assert!(result.is_err());
442 assert!(matches!(result.unwrap_err(), ScopeError::Config(_)));
443 }
444
445 #[test]
446 fn test_load_empty_file_returns_defaults() {
447 let file = NamedTempFile::new().unwrap();
448 let config = Config::load(Some(file.path())).unwrap();
451 assert_eq!(config, Config::default());
452 }
453
454 #[test]
455 fn test_output_format_serialization() {
456 let json_format = OutputFormat::Json;
457 let serialized = serde_yaml::to_string(&json_format).unwrap();
458 assert!(serialized.contains("json"));
459
460 let deserialized: OutputFormat = serde_yaml::from_str("csv").unwrap();
461 assert_eq!(deserialized, OutputFormat::Csv);
462 }
463
464 #[test]
465 fn test_output_format_display() {
466 assert_eq!(OutputFormat::Table.to_string(), "table");
467 assert_eq!(OutputFormat::Json.to_string(), "json");
468 assert_eq!(OutputFormat::Csv.to_string(), "csv");
469 }
470
471 #[test]
472 fn test_default_path_is_absolute_or_relative() {
473 let path = Config::default_path();
474 assert!(path.ends_with("scope/config.yaml") || path.ends_with("scope\\config.yaml"));
476 }
477
478 #[test]
479 fn test_default_data_dir() {
480 let data_dir = Config::default_data_dir();
481 assert!(data_dir.ends_with("scope") || data_dir.to_string_lossy().contains("scope"));
482 }
483
484 #[test]
485 fn test_data_dir_uses_config_value() {
486 let config = Config {
487 portfolio: PortfolioConfig {
488 data_dir: Some(PathBuf::from("/custom/path")),
489 },
490 ..Default::default()
491 };
492
493 assert_eq!(config.data_dir(), PathBuf::from("/custom/path"));
494 }
495
496 #[test]
497 fn test_data_dir_falls_back_to_default() {
498 let config = Config::default();
499 assert_eq!(config.data_dir(), Config::default_data_dir());
500 }
501
502 #[test]
503 fn test_config_clone_and_eq() {
504 let config1 = Config::default();
505 let config2 = config1.clone();
506 assert_eq!(config1, config2);
507 }
508
509 #[test]
510 fn test_config_path_returns_some() {
511 let path = Config::config_path();
512 assert!(path.is_some());
514 assert!(path.unwrap().to_string_lossy().contains("scope"));
515 }
516
517 #[test]
518 fn test_config_debug() {
519 let config = Config::default();
520 let debug = format!("{:?}", config);
521 assert!(debug.contains("Config"));
522 assert!(debug.contains("ChainsConfig"));
523 }
524
525 #[test]
526 fn test_output_config_default() {
527 let output = OutputConfig::default();
528 assert_eq!(output.format, OutputFormat::Table);
529 assert!(output.color);
530 }
531
532 #[test]
533 fn test_config_serialization_roundtrip() {
534 let mut config = Config::default();
535 config
536 .chains
537 .api_keys
538 .insert("etherscan".to_string(), "test_key".to_string());
539 config.output.format = OutputFormat::Json;
540 config.output.color = false;
541 config.portfolio.data_dir = Some(PathBuf::from("/custom"));
542
543 let yaml = serde_yaml::to_string(&config).unwrap();
544 let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
545 assert_eq!(config, deserialized);
546 }
547
548 #[test]
549 fn test_chains_config_with_multiple_api_keys() {
550 let mut api_keys = HashMap::new();
551 api_keys.insert("etherscan".into(), "key1".into());
552 api_keys.insert("polygonscan".into(), "key2".into());
553 api_keys.insert("bscscan".into(), "key3".into());
554
555 let chains = ChainsConfig {
556 ethereum_rpc: Some("https://rpc.example.com".into()),
557 api_keys,
558 ..Default::default()
559 };
560
561 assert_eq!(chains.api_keys.len(), 3);
562 assert!(chains.api_keys.contains_key("etherscan"));
563 }
564
565 #[test]
566 fn test_load_via_scope_config_env_var() {
567 let yaml = r#"
568chains:
569 ethereum_rpc: "https://env-test.example.com"
570output:
571 format: csv
572"#;
573 let mut file = NamedTempFile::new().unwrap();
574 file.write_all(yaml.as_bytes()).unwrap();
575
576 let path_str = file.path().to_string_lossy().to_string();
577 unsafe { std::env::set_var("SCOPE_CONFIG", &path_str) };
578
579 let config = Config::load(None).unwrap();
581 assert_eq!(
582 config.chains.ethereum_rpc,
583 Some("https://env-test.example.com".into())
584 );
585 assert_eq!(config.output.format, OutputFormat::Csv);
586
587 unsafe { std::env::remove_var("SCOPE_CONFIG") };
588 }
589
590 #[test]
591 fn test_output_format_default() {
592 let fmt = OutputFormat::default();
593 assert_eq!(fmt, OutputFormat::Table);
594 }
595
596 #[test]
597 fn test_portfolio_config_default() {
598 let port = PortfolioConfig::default();
599 assert!(port.data_dir.is_none());
600 }
601
602 #[test]
603 fn test_chains_config_default() {
604 let chains = ChainsConfig::default();
605 assert!(chains.ethereum_rpc.is_none());
606 assert!(chains.bsc_rpc.is_none());
607 assert!(chains.aegis_rpc.is_none());
608 assert!(chains.solana_rpc.is_none());
609 assert!(chains.tron_api.is_none());
610 assert!(chains.api_keys.is_empty());
611 }
612}