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 #[must_use]
280 pub fn api_keys(&self) -> Vec<String> {
281 self.api_key
282 .split(',')
283 .map(str::trim)
284 .filter(|k| !k.is_empty())
285 .map(String::from)
286 .collect()
287 }
288}
289
290impl Default for Config {
291 fn default() -> Self {
297 Self::new()
298 }
299}
300
301impl Config {
302 #[must_use]
344 pub fn from_credentials(credentials: Credentials) -> Self {
345 Config {
346 credentials,
347 rest_api: RestApiConfig::default(),
348 websocket: WebSocketConfig::default(),
349 database: DatabaseConfig::default(),
350 rate_limiter: RateLimiterConfig::default(),
351 sleep_hours: DEFAULT_SLEEP_TIME,
352 page_size: DEFAULT_PAGE_SIZE,
353 days_to_look_back: DAYS_TO_BACK_LOOK,
354 api_version: Some(DEFAULT_API_VERSION),
355 }
356 }
357
358 pub fn new() -> Self {
370 match dotenv() {
372 Ok(_) => debug!("Successfully loaded .env file"),
373 Err(e) => debug!("Failed to load .env file: {e}"),
374 }
375
376 let username = get_env_or_default("IG_USERNAME", String::from("default_username"));
378 let password = get_env_or_default("IG_PASSWORD", String::from("default_password"));
379 let api_key = get_env_or_default("IG_API_KEY", String::from("default_api_key"));
380 let sleep_hours = get_env_or_default("TX_LOOP_INTERVAL_HOURS", DEFAULT_SLEEP_TIME);
381 let page_size = get_env_or_default("TX_PAGE_SIZE", DEFAULT_PAGE_SIZE);
382 let days_to_look_back = get_env_or_default("TX_DAYS_LOOKBACK", DAYS_TO_BACK_LOOK);
383
384 let rest_defaults = RestApiConfig::default();
387 let ws_defaults = WebSocketConfig::default();
388 let rate_limit_defaults = RateLimiterConfig::default();
389 let database_defaults = DatabaseConfig::default();
390
391 let database_url = get_env_or_default("DATABASE_URL", database_defaults.url);
392
393 if username == "default_username" {
395 error!("IG_USERNAME not found in environment variables or .env file");
396 }
397 if password == "default_password" {
398 error!("IG_PASSWORD not found in environment variables or .env file");
399 }
400 if api_key == "default_api_key" {
401 error!("IG_API_KEY not found in environment variables or .env file");
402 }
403 if env::var("DATABASE_URL").is_err() {
407 error!(
410 "DATABASE_URL not found in environment variables or .env file; \
411 using a credential-less placeholder and persistence will not connect"
412 );
413 }
414
415 Config {
416 credentials: Credentials {
417 username,
418 password,
419 account_id: get_env_or_default(
420 "IG_ACCOUNT_ID",
421 String::from(crate::constants::DEFAULT_ACCOUNT_ID),
422 ),
423 api_key,
424 client_token: None,
425 account_token: None,
426 },
427 rest_api: RestApiConfig {
428 base_url: get_env_or_default("IG_REST_BASE_URL", rest_defaults.base_url),
429 timeout: get_env_or_default("IG_REST_TIMEOUT", rest_defaults.timeout),
430 },
431 websocket: WebSocketConfig {
432 url: get_env_or_default("IG_WS_URL", ws_defaults.url),
433 reconnect_interval: get_env_or_default(
434 "IG_WS_RECONNECT_INTERVAL",
435 ws_defaults.reconnect_interval,
436 ),
437 },
438 database: DatabaseConfig {
439 url: database_url,
440 max_connections: get_env_or_default(
441 "DATABASE_MAX_CONNECTIONS",
442 database_defaults.max_connections,
443 ),
444 },
445 rate_limiter: RateLimiterConfig {
446 max_requests: get_env_or_default(
447 "IG_RATE_LIMIT_MAX_REQUESTS",
448 rate_limit_defaults.max_requests,
449 ),
450 period_seconds: get_env_or_default(
451 "IG_RATE_LIMIT_PERIOD_SECONDS",
452 rate_limit_defaults.period_seconds,
453 ),
454 burst_size: get_env_or_default(
455 "IG_RATE_LIMIT_BURST_SIZE",
456 rate_limit_defaults.burst_size,
457 ),
458 },
459 sleep_hours,
460 page_size,
461 days_to_look_back,
462 api_version: env::var("IG_API_VERSION")
463 .ok()
464 .and_then(|v| v.parse::<u8>().ok())
465 .filter(|&v| v == 2 || v == 3)
466 .or(Some(DEFAULT_API_VERSION)), }
468 }
469}
470
471#[cfg(test)]
472mod injection_tests {
473 use super::*;
474
475 fn injected_credentials() -> Credentials {
476 Credentials::new(
477 "embedder-user".to_string(),
478 "embedder-password".to_string(),
479 "EMBEDDER-ACC".to_string(),
480 "embedder-api-key".to_string(),
481 )
482 }
483
484 #[test]
485 fn test_credentials_new_leaves_session_tokens_unset() {
486 let credentials = injected_credentials();
487 assert_eq!(credentials.username, "embedder-user");
488 assert_eq!(credentials.password, "embedder-password");
489 assert_eq!(credentials.account_id, "EMBEDDER-ACC");
490 assert_eq!(credentials.api_key, "embedder-api-key");
491 assert!(credentials.client_token.is_none());
492 assert!(credentials.account_token.is_none());
493 }
494
495 #[test]
496 fn test_config_from_credentials_keeps_injected_credentials_and_env_free_defaults() {
497 let config = Config::from_credentials(injected_credentials());
505
506 assert_eq!(config.credentials.username, "embedder-user");
507 assert_eq!(config.credentials.api_key, "embedder-api-key");
508 assert_eq!(config.rest_api.base_url, DEFAULT_REST_BASE_URL);
509 assert_eq!(config.rest_api.timeout, DEFAULT_REST_TIMEOUT_SECS);
510 assert_eq!(config.websocket.url, DEFAULT_WS_URL);
511 assert_eq!(
512 config.websocket.reconnect_interval,
513 DEFAULT_WS_RECONNECT_INTERVAL_SECS
514 );
515 assert_eq!(config.database.url, DEFAULT_DATABASE_URL);
516 assert_eq!(
517 config.database.max_connections,
518 DEFAULT_DATABASE_MAX_CONNECTIONS
519 );
520 assert_eq!(
521 config.rate_limiter.max_requests,
522 DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
523 );
524 assert_eq!(
525 config.rate_limiter.period_seconds,
526 DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
527 );
528 assert_eq!(
529 config.rate_limiter.burst_size,
530 DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
531 );
532 assert_eq!(config.sleep_hours, DEFAULT_SLEEP_TIME);
533 assert_eq!(config.page_size, DEFAULT_PAGE_SIZE);
534 assert_eq!(config.days_to_look_back, DAYS_TO_BACK_LOOK);
535 assert_eq!(config.api_version, Some(DEFAULT_API_VERSION));
536 }
537
538 #[test]
539 fn test_config_from_credentials_struct_update_overrides_section() {
540 let config = Config {
543 rest_api: RestApiConfig {
544 base_url: "https://demo-api.ig.com/gateway/deal".to_string(),
545 timeout: 7,
546 },
547 ..Config::from_credentials(injected_credentials())
548 };
549
550 assert_eq!(
551 config.rest_api.base_url,
552 "https://demo-api.ig.com/gateway/deal"
553 );
554 assert_eq!(config.rest_api.timeout, 7);
555 assert_eq!(config.websocket.url, DEFAULT_WS_URL);
557 }
558
559 #[test]
560 fn test_section_defaults_match_documented_constants() {
561 let rest = RestApiConfig::default();
564 let ws = WebSocketConfig::default();
565 let rate_limiter = RateLimiterConfig::default();
566 let database = DatabaseConfig::default();
567
568 assert_eq!(rest.base_url, DEFAULT_REST_BASE_URL);
569 assert_eq!(rest.timeout, DEFAULT_REST_TIMEOUT_SECS);
570 assert_eq!(ws.url, DEFAULT_WS_URL);
571 assert_eq!(ws.reconnect_interval, DEFAULT_WS_RECONNECT_INTERVAL_SECS);
572 assert_eq!(
573 rate_limiter.max_requests,
574 DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
575 );
576 assert_eq!(
577 rate_limiter.period_seconds,
578 DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
579 );
580 assert_eq!(
581 rate_limiter.burst_size,
582 DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
583 );
584 assert_eq!(database.url, DEFAULT_DATABASE_URL);
585 assert_eq!(database.max_connections, DEFAULT_DATABASE_MAX_CONNECTIONS);
586 assert_ne!(
588 rate_limiter.burst_size,
589 crate::constants::DEFAULT_RATE_LIMIT_BURST_SIZE
590 );
591 }
592}
593
594#[cfg(test)]
595mod redaction_tests {
596 use super::*;
597
598 fn secret_credentials() -> Credentials {
599 Credentials {
600 username: "user@example.com".to_string(),
601 password: "SUPER-SECRET-PASSWORD".to_string(),
602 account_id: "ACC123".to_string(),
603 api_key: "SECRET-API-KEY".to_string(),
604 client_token: Some("SECRET-CST".to_string()),
605 account_token: Some("SECRET-XST".to_string()),
606 }
607 }
608
609 #[test]
610 fn test_credentials_debug_and_display_redact_secrets() {
611 let creds = secret_credentials();
612 for rendered in [format!("{creds:?}"), format!("{creds}")] {
613 for secret in [
614 "SUPER-SECRET-PASSWORD",
615 "SECRET-API-KEY",
616 "SECRET-CST",
617 "SECRET-XST",
618 ] {
619 assert!(
620 !rendered.contains(secret),
621 "credentials rendering leaked {secret}: {rendered}"
622 );
623 }
624 assert!(rendered.contains("<redacted>"));
625 assert!(rendered.contains("user@example.com"));
627 assert!(rendered.contains("ACC123"));
628 }
629 }
630
631 #[test]
632 fn test_config_debug_and_display_redact_credential_and_db_secrets() {
633 let config = Config {
634 credentials: secret_credentials(),
635 database: DatabaseConfig {
636 url: "postgres://dbuser:DB-SECRET-PW@host/db".to_string(),
637 max_connections: 5,
638 },
639 ..Config::default()
640 };
641 for rendered in [format!("{config:?}"), format!("{config}")] {
642 for secret in ["SUPER-SECRET-PASSWORD", "SECRET-API-KEY", "DB-SECRET-PW"] {
643 assert!(
644 !rendered.contains(secret),
645 "config rendering leaked {secret}: {rendered}"
646 );
647 }
648 assert!(rendered.contains("<redacted>"));
649 }
650 }
651
652 #[test]
653 fn test_api_keys_single_key_yields_one_element_pool() {
654 let c = Credentials::new("u".into(), "p".into(), "ACC".into(), "abc123".into());
655 assert_eq!(c.api_keys(), vec!["abc123".to_string()]);
656 }
657
658 #[test]
659 fn test_api_keys_comma_separated_list_yields_pool() {
660 let c = Credentials::new("u".into(), "p".into(), "ACC".into(), "a,b,c".into());
661 assert_eq!(
662 c.api_keys(),
663 vec!["a".to_string(), "b".to_string(), "c".to_string()]
664 );
665 }
666
667 #[test]
668 fn test_api_keys_trims_whitespace_and_drops_empty_entries() {
669 let c = Credentials::new("u".into(), "p".into(), "ACC".into(), " a , b , ,c, ".into());
670 assert_eq!(
671 c.api_keys(),
672 vec!["a".to_string(), "b".to_string(), "c".to_string()]
673 );
674 }
675
676 #[test]
677 fn test_api_keys_blank_value_yields_empty_pool() {
678 let c = Credentials::new("u".into(), "p".into(), "ACC".into(), " , ".into());
679 assert!(c.api_keys().is_empty());
680 }
681}