1use crate::constants::{DAYS_TO_BACK_LOOK, DEFAULT_PAGE_SIZE, DEFAULT_SLEEP_TIME};
2use crate::utils::config::get_env_or_default;
3use dotenv::dotenv;
4use pretty_simple_display::{DebugPretty, DisplaySimple};
5use serde::{Deserialize, Serialize};
6use std::env;
7use tracing::error;
8use tracing::log::debug;
9
10#[derive(Serialize, Deserialize, Clone)]
18pub struct DatabaseConfig {
19 pub url: String,
21 pub max_connections: u32,
23}
24
25impl std::fmt::Debug for DatabaseConfig {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.debug_struct("DatabaseConfig")
30 .field("url", &"<redacted>")
31 .field("max_connections", &self.max_connections)
32 .finish()
33 }
34}
35
36impl std::fmt::Display for DatabaseConfig {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 write!(
39 f,
40 "DatabaseConfig {{ url: <redacted>, max_connections: {} }}",
41 self.max_connections
42 )
43 }
44}
45
46#[derive(Serialize, Deserialize, Clone)]
47pub struct Credentials {
49 pub username: String,
51 pub password: String,
53 pub account_id: String,
55 pub api_key: String,
57 pub client_token: Option<String>,
59 pub account_token: Option<String>,
61}
62
63impl std::fmt::Debug for Credentials {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("Credentials")
69 .field("username", &self.username)
70 .field("password", &"<redacted>")
71 .field("account_id", &self.account_id)
72 .field("api_key", &"<redacted>")
73 .field(
74 "client_token",
75 &self.client_token.as_ref().map(|_| "<redacted>"),
76 )
77 .field(
78 "account_token",
79 &self.account_token.as_ref().map(|_| "<redacted>"),
80 )
81 .finish()
82 }
83}
84
85impl std::fmt::Display for Credentials {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 write!(
88 f,
89 "Credentials {{ username: {}, account_id: {}, password: <redacted>, \
90 api_key: <redacted>, client_token: {}, account_token: {} }}",
91 self.username,
92 self.account_id,
93 if self.client_token.is_some() {
94 "<redacted>"
95 } else {
96 "None"
97 },
98 if self.account_token.is_some() {
99 "<redacted>"
100 } else {
101 "None"
102 },
103 )
104 }
105}
106
107#[derive(Debug, Serialize, Deserialize, Clone)]
108pub struct Config {
114 pub credentials: Credentials,
116 pub rest_api: RestApiConfig,
118 pub websocket: WebSocketConfig,
120 pub database: DatabaseConfig,
122 pub rate_limiter: RateLimiterConfig,
124 pub sleep_hours: u64,
126 pub page_size: u32,
128 pub days_to_look_back: i64,
130 pub api_version: Option<u8>,
132}
133
134impl std::fmt::Display for Config {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 write!(
141 f,
142 "Config {{ credentials: {}, rest_api: {}, websocket: {}, database: {}, \
143 rate_limiter: {}, sleep_hours: {}, page_size: {}, days_to_look_back: {}, \
144 api_version: {:?} }}",
145 self.credentials,
146 self.rest_api,
147 self.websocket,
148 self.database,
149 self.rate_limiter,
150 self.sleep_hours,
151 self.page_size,
152 self.days_to_look_back,
153 self.api_version,
154 )
155 }
156}
157
158#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
159pub struct RestApiConfig {
161 pub base_url: String,
163 pub timeout: u64,
165}
166
167#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
168pub struct WebSocketConfig {
170 pub url: String,
172 pub reconnect_interval: u64,
174}
175
176#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
177pub struct RateLimiterConfig {
179 pub max_requests: u32,
181 pub period_seconds: u64,
183 pub burst_size: u32,
185}
186
187impl Default for Config {
188 fn default() -> Self {
189 Self::new()
190 }
191}
192
193impl Config {
194 pub fn new() -> Self {
205 match dotenv() {
207 Ok(_) => debug!("Successfully loaded .env file"),
208 Err(e) => debug!("Failed to load .env file: {e}"),
209 }
210
211 let username = get_env_or_default("IG_USERNAME", String::from("default_username"));
213 let password = get_env_or_default("IG_PASSWORD", String::from("default_password"));
214 let api_key = get_env_or_default("IG_API_KEY", String::from("default_api_key"));
215 let sleep_hours = get_env_or_default("TX_LOOP_INTERVAL_HOURS", DEFAULT_SLEEP_TIME);
216 let page_size = get_env_or_default("TX_PAGE_SIZE", DEFAULT_PAGE_SIZE);
217 let days_to_look_back = get_env_or_default("TX_DAYS_LOOKBACK", DAYS_TO_BACK_LOOK);
218
219 let database_url = get_env_or_default(
220 "DATABASE_URL",
221 String::from(crate::constants::DEFAULT_DATABASE_URL),
222 );
223
224 if username == "default_username" {
226 error!("IG_USERNAME not found in environment variables or .env file");
227 }
228 if password == "default_password" {
229 error!("IG_PASSWORD not found in environment variables or .env file");
230 }
231 if api_key == "default_api_key" {
232 error!("IG_API_KEY not found in environment variables or .env file");
233 }
234 if env::var("DATABASE_URL").is_err() {
238 error!(
241 "DATABASE_URL not found in environment variables or .env file; \
242 using a credential-less placeholder and persistence will not connect"
243 );
244 }
245
246 Config {
247 credentials: Credentials {
248 username,
249 password,
250 account_id: get_env_or_default(
251 "IG_ACCOUNT_ID",
252 String::from(crate::constants::DEFAULT_ACCOUNT_ID),
253 ),
254 api_key,
255 client_token: None,
256 account_token: None,
257 },
258 rest_api: RestApiConfig {
259 base_url: get_env_or_default(
260 "IG_REST_BASE_URL",
261 String::from("https://demo-api.ig.com/gateway/deal"),
262 ),
263 timeout: get_env_or_default("IG_REST_TIMEOUT", 30),
264 },
265 websocket: WebSocketConfig {
266 url: get_env_or_default(
267 "IG_WS_URL",
268 String::from("wss://demo-apd.marketdatasystems.com"),
269 ),
270 reconnect_interval: get_env_or_default("IG_WS_RECONNECT_INTERVAL", 5),
271 },
272 database: DatabaseConfig {
273 url: database_url,
274 max_connections: get_env_or_default("DATABASE_MAX_CONNECTIONS", 5),
275 },
276 rate_limiter: RateLimiterConfig {
277 max_requests: get_env_or_default("IG_RATE_LIMIT_MAX_REQUESTS", 4), period_seconds: get_env_or_default("IG_RATE_LIMIT_PERIOD_SECONDS", 12), burst_size: get_env_or_default("IG_RATE_LIMIT_BURST_SIZE", 3),
280 },
281 sleep_hours,
282 page_size,
283 days_to_look_back,
284 api_version: env::var("IG_API_VERSION")
285 .ok()
286 .and_then(|v| v.parse::<u8>().ok())
287 .filter(|&v| v == 2 || v == 3)
288 .or(Some(3)), }
290 }
291}
292
293#[cfg(test)]
294mod redaction_tests {
295 use super::*;
296
297 fn secret_credentials() -> Credentials {
298 Credentials {
299 username: "user@example.com".to_string(),
300 password: "SUPER-SECRET-PASSWORD".to_string(),
301 account_id: "ACC123".to_string(),
302 api_key: "SECRET-API-KEY".to_string(),
303 client_token: Some("SECRET-CST".to_string()),
304 account_token: Some("SECRET-XST".to_string()),
305 }
306 }
307
308 #[test]
309 fn test_credentials_debug_and_display_redact_secrets() {
310 let creds = secret_credentials();
311 for rendered in [format!("{creds:?}"), format!("{creds}")] {
312 for secret in [
313 "SUPER-SECRET-PASSWORD",
314 "SECRET-API-KEY",
315 "SECRET-CST",
316 "SECRET-XST",
317 ] {
318 assert!(
319 !rendered.contains(secret),
320 "credentials rendering leaked {secret}: {rendered}"
321 );
322 }
323 assert!(rendered.contains("<redacted>"));
324 assert!(rendered.contains("user@example.com"));
326 assert!(rendered.contains("ACC123"));
327 }
328 }
329
330 #[test]
331 fn test_config_debug_and_display_redact_credential_and_db_secrets() {
332 let config = Config {
333 credentials: secret_credentials(),
334 database: DatabaseConfig {
335 url: "postgres://dbuser:DB-SECRET-PW@host/db".to_string(),
336 max_connections: 5,
337 },
338 ..Config::default()
339 };
340 for rendered in [format!("{config:?}"), format!("{config}")] {
341 for secret in ["SUPER-SECRET-PASSWORD", "SECRET-API-KEY", "DB-SECRET-PW"] {
342 assert!(
343 !rendered.contains(secret),
344 "config rendering leaked {secret}: {rendered}"
345 );
346 }
347 assert!(rendered.contains("<redacted>"));
348 }
349 }
350}