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