1use crate::constants::{
2 DAYS_TO_BACK_LOOK, DEFAULT_API_VERSION, DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE,
3 DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS, DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS,
4 DEFAULT_DATABASE_MAX_CONNECTIONS, DEFAULT_DATABASE_URL, DEFAULT_PAGE_SIZE,
5 DEFAULT_REST_BASE_URL, DEFAULT_REST_TIMEOUT_SECS, DEFAULT_SLEEP_TIME,
6 DEFAULT_WS_RECONNECT_INTERVAL_SECS, DEFAULT_WS_URL,
7};
8use crate::utils::config::get_env_or_default;
9use dotenv::dotenv;
10use pretty_simple_display::{DebugPretty, DisplaySimple};
11use serde::{Deserialize, Serialize};
12use std::env;
13use tracing::{debug, error};
14
15#[derive(Serialize, Deserialize, Clone)]
23pub struct DatabaseConfig {
24 pub url: String,
26 pub max_connections: u32,
28}
29
30impl std::fmt::Debug for DatabaseConfig {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.debug_struct("DatabaseConfig")
35 .field("url", &"<redacted>")
36 .field("max_connections", &self.max_connections)
37 .finish()
38 }
39}
40
41impl std::fmt::Display for DatabaseConfig {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 write!(
44 f,
45 "DatabaseConfig {{ url: <redacted>, max_connections: {} }}",
46 self.max_connections
47 )
48 }
49}
50
51#[derive(Serialize, Deserialize, Clone)]
52pub struct Credentials {
54 pub username: String,
56 pub password: String,
58 pub account_id: String,
60 pub api_key: String,
62 pub client_token: Option<String>,
64 pub account_token: Option<String>,
66}
67
68impl std::fmt::Debug for Credentials {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("Credentials")
74 .field("username", &self.username)
75 .field("password", &"<redacted>")
76 .field("account_id", &self.account_id)
77 .field("api_key", &"<redacted>")
78 .field(
79 "client_token",
80 &self.client_token.as_ref().map(|_| "<redacted>"),
81 )
82 .field(
83 "account_token",
84 &self.account_token.as_ref().map(|_| "<redacted>"),
85 )
86 .finish()
87 }
88}
89
90impl std::fmt::Display for Credentials {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 write!(
93 f,
94 "Credentials {{ username: {}, account_id: {}, password: <redacted>, \
95 api_key: <redacted>, client_token: {}, account_token: {} }}",
96 self.username,
97 self.account_id,
98 if self.client_token.is_some() {
99 "<redacted>"
100 } else {
101 "None"
102 },
103 if self.account_token.is_some() {
104 "<redacted>"
105 } else {
106 "None"
107 },
108 )
109 }
110}
111
112#[derive(Debug, Serialize, Deserialize, Clone)]
113pub struct Config {
119 pub credentials: Credentials,
121 pub rest_api: RestApiConfig,
123 pub websocket: WebSocketConfig,
125 pub database: DatabaseConfig,
127 pub rate_limiter: RateLimiterConfig,
129 pub sleep_hours: u64,
131 pub page_size: u32,
133 pub days_to_look_back: i64,
135 pub api_version: Option<u8>,
140}
141
142impl std::fmt::Display for Config {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 write!(
149 f,
150 "Config {{ credentials: {}, rest_api: {}, websocket: {}, database: {}, \
151 rate_limiter: {}, sleep_hours: {}, page_size: {}, days_to_look_back: {}, \
152 api_version: {:?} }}",
153 self.credentials,
154 self.rest_api,
155 self.websocket,
156 self.database,
157 self.rate_limiter,
158 self.sleep_hours,
159 self.page_size,
160 self.days_to_look_back,
161 self.api_version,
162 )
163 }
164}
165
166#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
167pub struct RestApiConfig {
169 pub base_url: String,
171 pub timeout: u64,
173}
174
175#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
176pub struct WebSocketConfig {
178 pub url: String,
180 pub reconnect_interval: u64,
182}
183
184#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
185pub struct RateLimiterConfig {
187 pub max_requests: u32,
189 pub period_seconds: u64,
191 pub burst_size: u32,
193}
194
195impl Default for RestApiConfig {
202 fn default() -> Self {
204 Self {
205 base_url: String::from(DEFAULT_REST_BASE_URL),
206 timeout: DEFAULT_REST_TIMEOUT_SECS,
207 }
208 }
209}
210
211impl Default for WebSocketConfig {
212 fn default() -> Self {
214 Self {
215 url: String::from(DEFAULT_WS_URL),
216 reconnect_interval: DEFAULT_WS_RECONNECT_INTERVAL_SECS,
217 }
218 }
219}
220
221impl Default for RateLimiterConfig {
222 fn default() -> Self {
224 Self {
225 max_requests: DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS,
226 period_seconds: DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS,
227 burst_size: DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE,
228 }
229 }
230}
231
232impl Default for DatabaseConfig {
233 fn default() -> Self {
236 Self {
237 url: String::from(DEFAULT_DATABASE_URL),
238 max_connections: DEFAULT_DATABASE_MAX_CONNECTIONS,
239 }
240 }
241}
242
243impl Credentials {
244 #[must_use]
257 pub fn new(username: String, password: String, account_id: String, api_key: String) -> Self {
258 Self {
259 username,
260 password,
261 account_id,
262 api_key,
263 client_token: None,
264 account_token: None,
265 }
266 }
267}
268
269impl Default for Config {
270 fn default() -> Self {
276 Self::new()
277 }
278}
279
280impl Config {
281 #[must_use]
323 pub fn from_credentials(credentials: Credentials) -> Self {
324 Config {
325 credentials,
326 rest_api: RestApiConfig::default(),
327 websocket: WebSocketConfig::default(),
328 database: DatabaseConfig::default(),
329 rate_limiter: RateLimiterConfig::default(),
330 sleep_hours: DEFAULT_SLEEP_TIME,
331 page_size: DEFAULT_PAGE_SIZE,
332 days_to_look_back: DAYS_TO_BACK_LOOK,
333 api_version: Some(DEFAULT_API_VERSION),
334 }
335 }
336
337 pub fn new() -> Self {
349 match dotenv() {
351 Ok(_) => debug!("Successfully loaded .env file"),
352 Err(e) => debug!("Failed to load .env file: {e}"),
353 }
354
355 let username = get_env_or_default("IG_USERNAME", String::from("default_username"));
357 let password = get_env_or_default("IG_PASSWORD", String::from("default_password"));
358 let api_key = get_env_or_default("IG_API_KEY", String::from("default_api_key"));
359 let sleep_hours = get_env_or_default("TX_LOOP_INTERVAL_HOURS", DEFAULT_SLEEP_TIME);
360 let page_size = get_env_or_default("TX_PAGE_SIZE", DEFAULT_PAGE_SIZE);
361 let days_to_look_back = get_env_or_default("TX_DAYS_LOOKBACK", DAYS_TO_BACK_LOOK);
362
363 let rest_defaults = RestApiConfig::default();
366 let ws_defaults = WebSocketConfig::default();
367 let rate_limit_defaults = RateLimiterConfig::default();
368 let database_defaults = DatabaseConfig::default();
369
370 let database_url = get_env_or_default("DATABASE_URL", database_defaults.url);
371
372 if username == "default_username" {
374 error!("IG_USERNAME not found in environment variables or .env file");
375 }
376 if password == "default_password" {
377 error!("IG_PASSWORD not found in environment variables or .env file");
378 }
379 if api_key == "default_api_key" {
380 error!("IG_API_KEY not found in environment variables or .env file");
381 }
382 if env::var("DATABASE_URL").is_err() {
386 error!(
389 "DATABASE_URL not found in environment variables or .env file; \
390 using a credential-less placeholder and persistence will not connect"
391 );
392 }
393
394 Config {
395 credentials: Credentials {
396 username,
397 password,
398 account_id: get_env_or_default(
399 "IG_ACCOUNT_ID",
400 String::from(crate::constants::DEFAULT_ACCOUNT_ID),
401 ),
402 api_key,
403 client_token: None,
404 account_token: None,
405 },
406 rest_api: RestApiConfig {
407 base_url: get_env_or_default("IG_REST_BASE_URL", rest_defaults.base_url),
408 timeout: get_env_or_default("IG_REST_TIMEOUT", rest_defaults.timeout),
409 },
410 websocket: WebSocketConfig {
411 url: get_env_or_default("IG_WS_URL", ws_defaults.url),
412 reconnect_interval: get_env_or_default(
413 "IG_WS_RECONNECT_INTERVAL",
414 ws_defaults.reconnect_interval,
415 ),
416 },
417 database: DatabaseConfig {
418 url: database_url,
419 max_connections: get_env_or_default(
420 "DATABASE_MAX_CONNECTIONS",
421 database_defaults.max_connections,
422 ),
423 },
424 rate_limiter: RateLimiterConfig {
425 max_requests: get_env_or_default(
426 "IG_RATE_LIMIT_MAX_REQUESTS",
427 rate_limit_defaults.max_requests,
428 ),
429 period_seconds: get_env_or_default(
430 "IG_RATE_LIMIT_PERIOD_SECONDS",
431 rate_limit_defaults.period_seconds,
432 ),
433 burst_size: get_env_or_default(
434 "IG_RATE_LIMIT_BURST_SIZE",
435 rate_limit_defaults.burst_size,
436 ),
437 },
438 sleep_hours,
439 page_size,
440 days_to_look_back,
441 api_version: env::var("IG_API_VERSION")
442 .ok()
443 .and_then(|v| v.parse::<u8>().ok())
444 .filter(|&v| v == 2 || v == 3)
445 .or(Some(DEFAULT_API_VERSION)), }
447 }
448}
449
450#[cfg(test)]
451mod injection_tests {
452 use super::*;
453
454 fn injected_credentials() -> Credentials {
455 Credentials::new(
456 "embedder-user".to_string(),
457 "embedder-password".to_string(),
458 "EMBEDDER-ACC".to_string(),
459 "embedder-api-key".to_string(),
460 )
461 }
462
463 #[test]
464 fn test_credentials_new_leaves_session_tokens_unset() {
465 let credentials = injected_credentials();
466 assert_eq!(credentials.username, "embedder-user");
467 assert_eq!(credentials.password, "embedder-password");
468 assert_eq!(credentials.account_id, "EMBEDDER-ACC");
469 assert_eq!(credentials.api_key, "embedder-api-key");
470 assert!(credentials.client_token.is_none());
471 assert!(credentials.account_token.is_none());
472 }
473
474 #[test]
475 fn test_config_from_credentials_keeps_injected_credentials_and_env_free_defaults() {
476 let config = Config::from_credentials(injected_credentials());
484
485 assert_eq!(config.credentials.username, "embedder-user");
486 assert_eq!(config.credentials.api_key, "embedder-api-key");
487 assert_eq!(config.rest_api.base_url, DEFAULT_REST_BASE_URL);
488 assert_eq!(config.rest_api.timeout, DEFAULT_REST_TIMEOUT_SECS);
489 assert_eq!(config.websocket.url, DEFAULT_WS_URL);
490 assert_eq!(
491 config.websocket.reconnect_interval,
492 DEFAULT_WS_RECONNECT_INTERVAL_SECS
493 );
494 assert_eq!(config.database.url, DEFAULT_DATABASE_URL);
495 assert_eq!(
496 config.database.max_connections,
497 DEFAULT_DATABASE_MAX_CONNECTIONS
498 );
499 assert_eq!(
500 config.rate_limiter.max_requests,
501 DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
502 );
503 assert_eq!(
504 config.rate_limiter.period_seconds,
505 DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
506 );
507 assert_eq!(
508 config.rate_limiter.burst_size,
509 DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
510 );
511 assert_eq!(config.sleep_hours, DEFAULT_SLEEP_TIME);
512 assert_eq!(config.page_size, DEFAULT_PAGE_SIZE);
513 assert_eq!(config.days_to_look_back, DAYS_TO_BACK_LOOK);
514 assert_eq!(config.api_version, Some(DEFAULT_API_VERSION));
515 }
516
517 #[test]
518 fn test_config_from_credentials_struct_update_overrides_section() {
519 let config = Config {
522 rest_api: RestApiConfig {
523 base_url: "https://demo-api.ig.com/gateway/deal".to_string(),
524 timeout: 7,
525 },
526 ..Config::from_credentials(injected_credentials())
527 };
528
529 assert_eq!(
530 config.rest_api.base_url,
531 "https://demo-api.ig.com/gateway/deal"
532 );
533 assert_eq!(config.rest_api.timeout, 7);
534 assert_eq!(config.websocket.url, DEFAULT_WS_URL);
536 }
537
538 #[test]
539 fn test_section_defaults_match_documented_constants() {
540 let rest = RestApiConfig::default();
543 let ws = WebSocketConfig::default();
544 let rate_limiter = RateLimiterConfig::default();
545 let database = DatabaseConfig::default();
546
547 assert_eq!(rest.base_url, DEFAULT_REST_BASE_URL);
548 assert_eq!(rest.timeout, DEFAULT_REST_TIMEOUT_SECS);
549 assert_eq!(ws.url, DEFAULT_WS_URL);
550 assert_eq!(ws.reconnect_interval, DEFAULT_WS_RECONNECT_INTERVAL_SECS);
551 assert_eq!(
552 rate_limiter.max_requests,
553 DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
554 );
555 assert_eq!(
556 rate_limiter.period_seconds,
557 DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
558 );
559 assert_eq!(
560 rate_limiter.burst_size,
561 DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
562 );
563 assert_eq!(database.url, DEFAULT_DATABASE_URL);
564 assert_eq!(database.max_connections, DEFAULT_DATABASE_MAX_CONNECTIONS);
565 assert_ne!(
567 rate_limiter.burst_size,
568 crate::constants::DEFAULT_RATE_LIMIT_BURST_SIZE
569 );
570 }
571}
572
573#[cfg(test)]
574mod redaction_tests {
575 use super::*;
576
577 fn secret_credentials() -> Credentials {
578 Credentials {
579 username: "user@example.com".to_string(),
580 password: "SUPER-SECRET-PASSWORD".to_string(),
581 account_id: "ACC123".to_string(),
582 api_key: "SECRET-API-KEY".to_string(),
583 client_token: Some("SECRET-CST".to_string()),
584 account_token: Some("SECRET-XST".to_string()),
585 }
586 }
587
588 #[test]
589 fn test_credentials_debug_and_display_redact_secrets() {
590 let creds = secret_credentials();
591 for rendered in [format!("{creds:?}"), format!("{creds}")] {
592 for secret in [
593 "SUPER-SECRET-PASSWORD",
594 "SECRET-API-KEY",
595 "SECRET-CST",
596 "SECRET-XST",
597 ] {
598 assert!(
599 !rendered.contains(secret),
600 "credentials rendering leaked {secret}: {rendered}"
601 );
602 }
603 assert!(rendered.contains("<redacted>"));
604 assert!(rendered.contains("user@example.com"));
606 assert!(rendered.contains("ACC123"));
607 }
608 }
609
610 #[test]
611 fn test_config_debug_and_display_redact_credential_and_db_secrets() {
612 let config = Config {
613 credentials: secret_credentials(),
614 database: DatabaseConfig {
615 url: "postgres://dbuser:DB-SECRET-PW@host/db".to_string(),
616 max_connections: 5,
617 },
618 ..Config::default()
619 };
620 for rendered in [format!("{config:?}"), format!("{config}")] {
621 for secret in ["SUPER-SECRET-PASSWORD", "SECRET-API-KEY", "DB-SECRET-PW"] {
622 assert!(
623 !rendered.contains(secret),
624 "config rendering leaked {secret}: {rendered}"
625 );
626 }
627 assert!(rendered.contains("<redacted>"));
628 }
629 }
630}