1use serde::Deserialize;
11use std::env;
12use std::path::PathBuf;
13
14#[derive(Debug, thiserror::Error)]
16pub enum ConfigError {
17 #[error("Environment variable error: {0}")]
19 EnvError(#[from] env::VarError),
20
21 #[error("Configuration parsing error: {0}")]
23 ParseError(String),
24
25 #[error("File not found: {0}")]
27 FileNotFound(String),
28}
29
30#[derive(Debug, Clone, Deserialize)]
32pub struct TestConfig {
33 #[serde(default)]
35 pub skip_private_tests: bool,
36
37 #[serde(default)]
39 pub enable_integration_tests: bool,
40
41 #[serde(default = "default_timeout")]
43 pub test_timeout_ms: u64,
44
45 #[serde(default)]
47 pub binance: ExchangeConfig,
48
49 #[serde(default)]
51 pub okx: ExchangeConfig,
52
53 #[serde(default)]
55 pub bybit: ExchangeConfig,
56
57 #[serde(default)]
59 pub kraken: ExchangeConfig,
60
61 #[serde(default)]
63 pub kucoin: ExchangeConfig,
64
65 #[serde(default)]
67 pub hyperliquid: ExchangeConfig,
68
69 #[serde(default)]
71 pub test_data: TestDataConfig,
72
73 #[serde(default)]
75 pub benchmark: BenchmarkConfig,
76}
77
78fn default_timeout() -> u64 {
79 30000
80}
81
82#[derive(Debug, Clone, Default, Deserialize)]
84pub struct ExchangeConfig {
85 pub api_key: Option<String>,
87 pub api_secret: Option<String>,
89 pub testnet_api_key: Option<String>,
91 pub testnet_api_secret: Option<String>,
93 #[serde(default)]
95 pub use_testnet: bool,
96}
97
98#[derive(Debug, Clone, Deserialize)]
100pub struct TestDataConfig {
101 #[serde(default = "default_fixtures_dir")]
103 pub fixtures_dir: String,
104
105 #[serde(default = "default_log_level")]
107 pub log_level: String,
108}
109
110fn default_fixtures_dir() -> String {
111 "tests/fixtures".to_string()
112}
113
114fn default_log_level() -> String {
115 "info".to_string()
116}
117
118impl Default for TestDataConfig {
119 fn default() -> Self {
120 Self {
121 fixtures_dir: default_fixtures_dir(),
122 log_level: default_log_level(),
123 }
124 }
125}
126
127#[derive(Debug, Clone, Deserialize)]
129pub struct BenchmarkConfig {
130 #[serde(default = "default_sample_size")]
132 pub sample_size: usize,
133
134 #[serde(default = "default_warmup_iterations")]
136 pub warmup_iterations: usize,
137}
138
139fn default_sample_size() -> usize {
140 100
141}
142
143fn default_warmup_iterations() -> usize {
144 10
145}
146
147impl Default for BenchmarkConfig {
148 fn default() -> Self {
149 Self {
150 sample_size: default_sample_size(),
151 warmup_iterations: default_warmup_iterations(),
152 }
153 }
154}
155
156impl Default for TestConfig {
157 fn default() -> Self {
158 Self {
159 skip_private_tests: false,
160 enable_integration_tests: false,
161 test_timeout_ms: default_timeout(),
162 binance: ExchangeConfig::default(),
163 okx: ExchangeConfig::default(),
164 bybit: ExchangeConfig::default(),
165 kraken: ExchangeConfig::default(),
166 kucoin: ExchangeConfig::default(),
167 hyperliquid: ExchangeConfig::default(),
168 test_data: TestDataConfig::default(),
169 benchmark: BenchmarkConfig::default(),
170 }
171 }
172}
173
174impl TestConfig {
175 pub fn from_env() -> Result<Self, ConfigError> {
181 let mut config = TestConfig::default();
182
183 if let Ok(val) = env::var("CCXT_SKIP_PRIVATE_TESTS") {
184 config.skip_private_tests = val.parse().unwrap_or(false);
185 }
186 if let Ok(val) = env::var("CCXT_ENABLE_INTEGRATION_TESTS") {
187 config.enable_integration_tests = val.parse().unwrap_or(false);
188 }
189 if let Ok(val) = env::var("CCXT_TEST_TIMEOUT_MS") {
190 config.test_timeout_ms = val.parse().unwrap_or(default_timeout());
191 }
192
193 config.binance = Self::load_exchange_config("BINANCE")?;
194 config.okx = Self::load_exchange_config("OKX")?;
195 config.bybit = Self::load_exchange_config("BYBIT")?;
196 config.kraken = Self::load_exchange_config("KRAKEN")?;
197 config.kucoin = Self::load_exchange_config("KUCOIN")?;
198 config.hyperliquid = Self::load_exchange_config("HYPERLIQUID")?;
199
200 if let Ok(val) = env::var("CCXT_FIXTURES_DIR") {
201 config.test_data.fixtures_dir = val;
202 }
203 if let Ok(val) = env::var("CCXT_LOG_LEVEL") {
204 config.test_data.log_level = val;
205 }
206
207 if let Ok(val) = env::var("CCXT_BENCH_SAMPLE_SIZE") {
208 config.benchmark.sample_size = val.parse().unwrap_or(default_sample_size());
209 }
210 if let Ok(val) = env::var("CCXT_BENCH_WARMUP_ITERATIONS") {
211 config.benchmark.warmup_iterations = val.parse().unwrap_or(default_warmup_iterations());
212 }
213
214 Ok(config)
215 }
216
217 #[cfg(feature = "test-utils")]
227 pub fn from_dotenv(path: &str) -> Result<Self, ConfigError> {
228 dotenvy::from_filename(path)
229 .map_err(|e| ConfigError::FileNotFound(format!("{}: {}", path, e)))?;
230 Self::from_env()
231 }
232
233 #[cfg(feature = "test-utils")]
239 pub fn from_default_dotenv() -> Result<Self, ConfigError> {
240 dotenvy::dotenv().ok();
241 Self::from_env()
242 }
243
244 fn load_exchange_config(exchange: &str) -> Result<ExchangeConfig, ConfigError> {
246 let mut config = ExchangeConfig::default();
247
248 let api_key_var = format!("{}_API_KEY", exchange);
249 let api_secret_var = format!("{}_API_SECRET", exchange);
250 let testnet_key_var = format!("{}_TESTNET_API_KEY", exchange);
251 let testnet_secret_var = format!("{}_TESTNET_API_SECRET", exchange);
252 let use_testnet_var = format!("{}_USE_TESTNET", exchange);
253
254 config.api_key = env::var(&api_key_var).ok();
255 config.api_secret = env::var(&api_secret_var).ok();
256 config.testnet_api_key = env::var(&testnet_key_var).ok();
257 config.testnet_api_secret = env::var(&testnet_secret_var).ok();
258
259 if let Ok(val) = env::var(&use_testnet_var) {
260 config.use_testnet = val.parse().unwrap_or(false);
261 }
262
263 Ok(config)
264 }
265
266 pub fn should_skip_private_tests(&self) -> bool {
268 self.skip_private_tests
269 }
270
271 pub fn is_integration_enabled(&self) -> bool {
273 self.enable_integration_tests
274 }
275
276 pub fn has_binance_credentials(&self) -> bool {
278 self.binance.has_credentials()
279 }
280
281 pub fn has_okx_credentials(&self) -> bool {
283 self.okx.has_credentials()
284 }
285
286 pub fn has_bybit_credentials(&self) -> bool {
288 self.bybit.has_credentials()
289 }
290
291 pub fn has_kraken_credentials(&self) -> bool {
293 self.kraken.has_credentials()
294 }
295
296 pub fn has_kucoin_credentials(&self) -> bool {
298 self.kucoin.has_credentials()
299 }
300
301 pub fn has_hyperliquid_credentials(&self) -> bool {
303 self.hyperliquid.has_credentials()
304 }
305
306 pub fn get_active_api_key(&self, exchange: &str) -> Option<(String, String)> {
316 let config = match exchange.to_lowercase().as_str() {
317 "binance" => &self.binance,
318 "okx" => &self.okx,
319 "bybit" => &self.bybit,
320 "kraken" => &self.kraken,
321 "kucoin" => &self.kucoin,
322 "hyperliquid" => &self.hyperliquid,
323 _ => return None,
324 };
325
326 config.get_active_credentials()
327 }
328
329 pub fn get_fixture_path(&self, category: &str, filename: &str) -> PathBuf {
340 PathBuf::from(&self.test_data.fixtures_dir)
341 .join(category)
342 .join(filename)
343 }
344}
345
346impl ExchangeConfig {
347 pub fn has_credentials(&self) -> bool {
349 if self.use_testnet {
350 self.testnet_api_key.is_some() && self.testnet_api_secret.is_some()
351 } else {
352 self.api_key.is_some() && self.api_secret.is_some()
353 }
354 }
355
356 pub fn get_active_credentials(&self) -> Option<(String, String)> {
362 if self.use_testnet {
363 match (&self.testnet_api_key, &self.testnet_api_secret) {
364 (Some(key), Some(secret)) => Some((key.clone(), secret.clone())),
365 _ => None,
366 }
367 } else {
368 match (&self.api_key, &self.api_secret) {
369 (Some(key), Some(secret)) => Some((key.clone(), secret.clone())),
370 _ => None,
371 }
372 }
373 }
374}
375
376#[macro_export]
388macro_rules! skip_if {
389 ($config:expr, $condition:expr, $reason:expr) => {
390 if $condition {
391 println!("SKIPPED: {}", $reason);
392 return;
393 }
394 };
395
396 (private_tests) => {{
397 #[cfg(feature = "test-utils")]
398 {
399 let config = $crate::test_config::TestConfig::from_default_dotenv().unwrap_or_default();
400 if config.should_skip_private_tests() {
401 println!("SKIPPED: Private tests are disabled");
402 return;
403 }
404 }
405 #[cfg(not(feature = "test-utils"))]
406 {
407 println!("SKIPPED: test-utils feature not enabled");
408 return;
409 }
410 }};
411}
412
413#[macro_export]
427macro_rules! require_credentials {
428 ($config:expr, $exchange:ident) => {
429 paste::paste! {
430 if !$config.[<has_ $exchange _credentials>]() {
431 println!("SKIPPED: No {} credentials", stringify!($exchange));
432 return;
433 }
434 }
435 };
436
437 ($exchange:ident) => {{
438 #[cfg(feature = "test-utils")]
439 {
440 let config = $crate::test_config::TestConfig::from_default_dotenv().unwrap_or_default();
441 paste::paste! {
442 if !config.[<has_ $exchange _credentials>]() {
443 println!("SKIPPED: No {} credentials", stringify!($exchange));
444 return;
445 }
446 }
447 }
448 #[cfg(not(feature = "test-utils"))]
449 {
450 println!("SKIPPED: test-utils feature not enabled");
451 return;
452 }
453 }};
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn test_default_config() {
462 let config = TestConfig::default();
463 assert!(!config.skip_private_tests);
464 assert!(!config.enable_integration_tests);
465 assert_eq!(config.test_timeout_ms, 30000);
466 assert_eq!(config.test_data.fixtures_dir, "tests/fixtures");
467 assert_eq!(config.benchmark.sample_size, 100);
468 }
469
470 #[test]
471 fn test_exchange_config_no_credentials() {
472 let config = ExchangeConfig::default();
473 assert!(!config.has_credentials());
474 assert!(config.get_active_credentials().is_none());
475 }
476
477 #[test]
478 fn test_exchange_config_with_credentials() {
479 let config = ExchangeConfig {
480 api_key: Some("test_key".to_string()),
481 api_secret: Some("test_secret".to_string()),
482 testnet_api_key: None,
483 testnet_api_secret: None,
484 use_testnet: false,
485 };
486
487 assert!(config.has_credentials());
488 let (key, secret) = config.get_active_credentials().unwrap();
489 assert_eq!(key, "test_key");
490 assert_eq!(secret, "test_secret");
491 }
492
493 #[test]
494 fn test_exchange_config_testnet() {
495 let config = ExchangeConfig {
496 api_key: Some("prod_key".to_string()),
497 api_secret: Some("prod_secret".to_string()),
498 testnet_api_key: Some("test_key".to_string()),
499 testnet_api_secret: Some("test_secret".to_string()),
500 use_testnet: true,
501 };
502
503 assert!(config.has_credentials());
504 let (key, secret) = config.get_active_credentials().unwrap();
505 assert_eq!(key, "test_key");
506 assert_eq!(secret, "test_secret");
507 }
508
509 #[test]
510 fn test_fixture_path() {
511 let config = TestConfig::default();
512 let path = config.get_fixture_path("tickers", "binance_btcusdt.json");
513 assert_eq!(
514 path.to_str().unwrap(),
515 "tests/fixtures/tickers/binance_btcusdt.json"
516 );
517 }
518
519 #[test]
520 fn test_from_env_with_defaults() {
521 let config = TestConfig::from_env().unwrap();
523 assert_eq!(config.test_timeout_ms, 30000);
524 assert_eq!(config.test_data.fixtures_dir, "tests/fixtures");
525 }
526}