1use std::{env, fmt, str::FromStr};
28
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
35pub enum RithmicEnv {
36 #[default]
38 Demo,
39 Live,
41 Test,
43}
44
45impl fmt::Display for RithmicEnv {
46 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
47 match self {
48 RithmicEnv::Demo => write!(f, "demo"),
49 RithmicEnv::Live => write!(f, "live"),
50 RithmicEnv::Test => write!(f, "test"),
51 }
52 }
53}
54
55impl FromStr for RithmicEnv {
56 type Err = ConfigError;
57
58 fn from_str(s: &str) -> Result<Self, Self::Err> {
59 match s {
60 "demo" | "development" => Ok(RithmicEnv::Demo),
61 "live" | "production" => Ok(RithmicEnv::Live),
62 "test" => Ok(RithmicEnv::Test),
63 _ => Err(ConfigError::InvalidEnvironment(s.to_string())),
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
70#[non_exhaustive]
71pub enum ConfigError {
72 InvalidEnvironment(String),
74 InvalidValue {
76 var: String,
78 reason: String,
80 },
81 MissingEnvVar(String),
83 MissingField(String),
85}
86
87impl fmt::Display for ConfigError {
88 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
89 match self {
90 ConfigError::MissingEnvVar(var) => {
91 write!(f, "Missing environment variable: {}", var)
92 }
93 ConfigError::InvalidEnvironment(env) => {
94 write!(f, "Invalid environment: {}", env)
95 }
96 ConfigError::InvalidValue { var, reason } => {
97 write!(f, "Invalid value for {}: {}", var, reason)
98 }
99 ConfigError::MissingField(field) => {
100 write!(f, "Missing required field: {}", field)
101 }
102 }
103 }
104}
105
106impl std::error::Error for ConfigError {}
107
108#[derive(Clone, Debug, PartialEq, Eq)]
121#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
122pub struct RithmicAccount {
123 pub account_id: String,
125 pub fcm_id: String,
127 pub ib_id: String,
129}
130
131impl RithmicAccount {
132 pub fn new(
134 fcm_id: impl Into<String>,
135 ib_id: impl Into<String>,
136 account_id: impl Into<String>,
137 ) -> Self {
138 Self {
139 account_id: account_id.into(),
140 fcm_id: fcm_id.into(),
141 ib_id: ib_id.into(),
142 }
143 }
144
145 pub fn from_env(env: RithmicEnv) -> Result<Self, ConfigError> {
150 let (account_id, fcm_id, ib_id) = match &env {
151 RithmicEnv::Demo => (
152 env::var("RITHMIC_DEMO_ACCOUNT_ID").map_err(|_| {
153 ConfigError::MissingEnvVar("RITHMIC_DEMO_ACCOUNT_ID".to_string())
154 })?,
155 env::var("RITHMIC_DEMO_FCM_ID")
156 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_FCM_ID".to_string()))?,
157 env::var("RITHMIC_DEMO_IB_ID")
158 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_IB_ID".to_string()))?,
159 ),
160 RithmicEnv::Live => (
161 env::var("RITHMIC_LIVE_ACCOUNT_ID").map_err(|_| {
162 ConfigError::MissingEnvVar("RITHMIC_LIVE_ACCOUNT_ID".to_string())
163 })?,
164 env::var("RITHMIC_LIVE_FCM_ID")
165 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_FCM_ID".to_string()))?,
166 env::var("RITHMIC_LIVE_IB_ID")
167 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_IB_ID".to_string()))?,
168 ),
169 RithmicEnv::Test => (
170 env::var("RITHMIC_TEST_ACCOUNT_ID").map_err(|_| {
171 ConfigError::MissingEnvVar("RITHMIC_TEST_ACCOUNT_ID".to_string())
172 })?,
173 env::var("RITHMIC_TEST_FCM_ID")
174 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_FCM_ID".to_string()))?,
175 env::var("RITHMIC_TEST_IB_ID")
176 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_IB_ID".to_string()))?,
177 ),
178 };
179
180 Ok(Self {
181 account_id,
182 fcm_id,
183 ib_id,
184 })
185 }
186}
187
188#[derive(Clone)]
192pub struct RithmicConfig {
193 pub url: String,
195 pub beta_url: String,
197 pub user: String,
199 pub password: String,
201 pub system_name: String,
203 pub env: RithmicEnv,
205 pub app_name: String,
207 pub app_version: String,
209}
210
211impl fmt::Debug for RithmicConfig {
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 f.debug_struct("RithmicConfig")
214 .field("url", &self.url)
215 .field("beta_url", &self.beta_url)
216 .field("user", &self.user)
217 .field("password", &"[REDACTED]")
218 .field("system_name", &self.system_name)
219 .field("env", &self.env)
220 .field("app_name", &self.app_name)
221 .field("app_version", &self.app_version)
222 .finish()
223 }
224}
225
226impl RithmicConfig {
227 pub fn from_env(env: RithmicEnv) -> Result<Self, ConfigError> {
267 let (url, beta_url, user, password, system_name) = match &env {
268 RithmicEnv::Demo => (
269 env::var("RITHMIC_DEMO_URL")
270 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_URL".to_string()))?,
271 env::var("RITHMIC_DEMO_ALT_URL")
272 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_ALT_URL".to_string()))?,
273 env::var("RITHMIC_DEMO_USER")
274 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_USER".to_string()))?,
275 env::var("RITHMIC_DEMO_PW")
276 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_PW".to_string()))?,
277 "Rithmic Paper Trading".to_string(),
278 ),
279 RithmicEnv::Live => (
280 env::var("RITHMIC_LIVE_URL")
281 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_URL".to_string()))?,
282 env::var("RITHMIC_LIVE_ALT_URL")
283 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_ALT_URL".to_string()))?,
284 env::var("RITHMIC_LIVE_USER")
285 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_USER".to_string()))?,
286 env::var("RITHMIC_LIVE_PW")
287 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_PW".to_string()))?,
288 "Rithmic 01".to_string(),
289 ),
290 RithmicEnv::Test => (
291 env::var("RITHMIC_TEST_URL")
292 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_URL".to_string()))?,
293 env::var("RITHMIC_TEST_ALT_URL")
294 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_ALT_URL".to_string()))?,
295 env::var("RITHMIC_TEST_USER")
296 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_USER".to_string()))?,
297 env::var("RITHMIC_TEST_PW")
298 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_PW".to_string()))?,
299 "Rithmic Test".to_string(),
300 ),
301 };
302
303 let app_name = env::var("RITHMIC_APP_NAME")
304 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_APP_NAME".to_string()))?;
305
306 let app_version = env::var("RITHMIC_APP_VERSION")
307 .map_err(|_| ConfigError::MissingEnvVar("RITHMIC_APP_VERSION".to_string()))?;
308
309 Ok(Self {
310 url,
311 beta_url,
312 user,
313 password,
314 system_name,
315 env,
316 app_name,
317 app_version,
318 })
319 }
320
321 pub fn builder(env: RithmicEnv) -> RithmicConfigBuilder {
338 RithmicConfigBuilder::new(env)
339 }
340}
341
342pub struct RithmicConfigBuilder {
344 env: Option<RithmicEnv>,
345 url: Option<String>,
346 beta_url: Option<String>,
347 user: Option<String>,
348 password: Option<String>,
349 system_name: Option<String>,
350 app_name: Option<String>,
351 app_version: Option<String>,
352}
353
354impl RithmicConfigBuilder {
355 pub fn new(env: RithmicEnv) -> Self {
357 let system_name = match &env {
359 RithmicEnv::Demo => "Rithmic Paper Trading".to_string(),
360 RithmicEnv::Live => "Rithmic 01".to_string(),
361 RithmicEnv::Test => "Rithmic Test".to_string(),
362 };
363
364 Self {
365 env: Some(env),
366 url: None,
367 beta_url: None,
368 user: None,
369 password: None,
370 system_name: Some(system_name),
371 app_name: None,
372 app_version: None,
373 }
374 }
375
376 pub fn url(mut self, url: impl Into<String>) -> Self {
378 self.url = Some(url.into());
379 self
380 }
381
382 pub fn beta_url(mut self, beta_url: impl Into<String>) -> Self {
384 self.beta_url = Some(beta_url.into());
385 self
386 }
387
388 pub fn user(mut self, user: impl Into<String>) -> Self {
390 self.user = Some(user.into());
391 self
392 }
393
394 pub fn password(mut self, password: impl Into<String>) -> Self {
396 self.password = Some(password.into());
397 self
398 }
399
400 pub fn system_name(mut self, system_name: impl Into<String>) -> Self {
402 self.system_name = Some(system_name.into());
403 self
404 }
405
406 pub fn app_name(mut self, app_name: impl Into<String>) -> Self {
408 self.app_name = Some(app_name.into());
409 self
410 }
411
412 pub fn app_version(mut self, app_version: impl Into<String>) -> Self {
414 self.app_version = Some(app_version.into());
415 self
416 }
417
418 pub fn build(self) -> Result<RithmicConfig, ConfigError> {
422 Ok(RithmicConfig {
423 env: self
424 .env
425 .ok_or_else(|| ConfigError::MissingField("env".to_string()))?,
426 url: self
427 .url
428 .ok_or_else(|| ConfigError::MissingField("url".to_string()))?,
429 beta_url: self
430 .beta_url
431 .ok_or_else(|| ConfigError::MissingField("beta_url".to_string()))?,
432 user: self
433 .user
434 .ok_or_else(|| ConfigError::MissingField("user".to_string()))?,
435 password: self
436 .password
437 .ok_or_else(|| ConfigError::MissingField("password".to_string()))?,
438 system_name: self
439 .system_name
440 .ok_or_else(|| ConfigError::MissingField("system_name".to_string()))?,
441 app_name: self
442 .app_name
443 .ok_or_else(|| ConfigError::MissingField("app_name".to_string()))?,
444 app_version: self
445 .app_version
446 .ok_or_else(|| ConfigError::MissingField("app_version".to_string()))?,
447 })
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 fn demo_env_vars() -> Vec<(&'static str, Option<&'static str>)> {
456 vec![
457 ("RITHMIC_DEMO_ACCOUNT_ID", Some("test_account")),
458 ("RITHMIC_DEMO_FCM_ID", Some("test_fcm")),
459 ("RITHMIC_DEMO_IB_ID", Some("test_ib")),
460 ("RITHMIC_DEMO_USER", Some("demo_user")),
461 ("RITHMIC_DEMO_PW", Some("demo_password")),
462 ("RITHMIC_DEMO_URL", Some("wss://test-demo.example.com:443")),
463 (
464 "RITHMIC_DEMO_ALT_URL",
465 Some("wss://test-demo-alt.example.com:443"),
466 ),
467 ("RITHMIC_APP_NAME", Some("test_app")),
468 ("RITHMIC_APP_VERSION", Some("1")),
469 ]
470 }
471
472 fn live_env_vars() -> Vec<(&'static str, Option<&'static str>)> {
473 vec![
474 ("RITHMIC_LIVE_ACCOUNT_ID", Some("test_account")),
475 ("RITHMIC_LIVE_FCM_ID", Some("test_fcm")),
476 ("RITHMIC_LIVE_IB_ID", Some("test_ib")),
477 ("RITHMIC_LIVE_USER", Some("live_user")),
478 ("RITHMIC_LIVE_PW", Some("live_password")),
479 ("RITHMIC_LIVE_URL", Some("wss://test-live.example.com:443")),
480 (
481 "RITHMIC_LIVE_ALT_URL",
482 Some("wss://test-live-alt.example.com:443"),
483 ),
484 ("RITHMIC_APP_NAME", Some("test_app")),
485 ("RITHMIC_APP_VERSION", Some("1")),
486 ]
487 }
488
489 #[test]
490 fn test_rithmic_env_display() {
491 assert_eq!(RithmicEnv::Demo.to_string(), "demo");
492 assert_eq!(RithmicEnv::Live.to_string(), "live");
493 assert_eq!(RithmicEnv::Test.to_string(), "test");
494 }
495
496 #[test]
497 fn test_rithmic_env_from_str() {
498 assert_eq!("demo".parse::<RithmicEnv>().unwrap(), RithmicEnv::Demo);
499 assert_eq!(
500 "development".parse::<RithmicEnv>().unwrap(),
501 RithmicEnv::Demo
502 );
503 assert_eq!("live".parse::<RithmicEnv>().unwrap(), RithmicEnv::Live);
504 assert_eq!(
505 "production".parse::<RithmicEnv>().unwrap(),
506 RithmicEnv::Live
507 );
508 assert_eq!("test".parse::<RithmicEnv>().unwrap(), RithmicEnv::Test);
509
510 let result = "invalid".parse::<RithmicEnv>();
512 assert!(result.is_err());
513 if let Err(ConfigError::InvalidEnvironment(env)) = result {
514 assert_eq!(env, "invalid");
515 } else {
516 panic!("Expected InvalidEnvironment error");
517 }
518 }
519
520 #[test]
521 fn test_config_error_display() {
522 let err = ConfigError::MissingEnvVar("TEST_VAR".to_string());
523 assert_eq!(err.to_string(), "Missing environment variable: TEST_VAR");
524
525 let err = ConfigError::InvalidEnvironment("bad_env".to_string());
526 assert_eq!(err.to_string(), "Invalid environment: bad_env");
527
528 let err = ConfigError::InvalidValue {
529 var: "TEST".to_string(),
530 reason: "too short".to_string(),
531 };
532 assert_eq!(err.to_string(), "Invalid value for TEST: too short");
533
534 let err = ConfigError::MissingField("field".to_string());
535 assert_eq!(err.to_string(), "Missing required field: field");
536 }
537
538 #[test]
539 fn test_account_from_env_demo_success() {
540 temp_env::with_vars(demo_env_vars(), || {
541 let account = RithmicAccount::from_env(RithmicEnv::Demo).unwrap();
542
543 assert_eq!(account.account_id, "test_account");
544 assert_eq!(account.fcm_id, "test_fcm");
545 assert_eq!(account.ib_id, "test_ib");
546 });
547 }
548
549 #[test]
550 fn test_from_env_demo_success() {
551 temp_env::with_vars(demo_env_vars(), || {
552 let config = RithmicConfig::from_env(RithmicEnv::Demo).unwrap();
553
554 assert_eq!(config.user, "demo_user");
555 assert_eq!(config.password, "demo_password");
556 assert_eq!(config.url, "wss://test-demo.example.com:443");
557 assert_eq!(config.beta_url, "wss://test-demo-alt.example.com:443");
558 assert_eq!(config.system_name, "Rithmic Paper Trading");
559 assert_eq!(config.env, RithmicEnv::Demo);
560 });
561 }
562
563 #[test]
564 fn test_from_env_live_success() {
565 temp_env::with_vars(live_env_vars(), || {
566 let config = RithmicConfig::from_env(RithmicEnv::Live).unwrap();
567
568 assert_eq!(config.user, "live_user");
569 assert_eq!(config.password, "live_password");
570 assert_eq!(config.system_name, "Rithmic 01");
571 assert_eq!(config.env, RithmicEnv::Live);
572 });
573 }
574
575 #[test]
576 fn test_account_from_env_missing_account_id() {
577 temp_env::with_vars(
578 vec![
579 ("RITHMIC_DEMO_ACCOUNT_ID", None::<&str>),
580 ("RITHMIC_DEMO_FCM_ID", Some("test_fcm")),
581 ("RITHMIC_DEMO_IB_ID", Some("test_ib")),
582 ("RITHMIC_DEMO_USER", Some("demo_user")),
583 ("RITHMIC_DEMO_PW", Some("demo_password")),
584 ("RITHMIC_DEMO_URL", Some("wss://test-demo.example.com:443")),
585 (
586 "RITHMIC_DEMO_ALT_URL",
587 Some("wss://test-demo-alt.example.com:443"),
588 ),
589 ],
590 || {
591 let result = RithmicAccount::from_env(RithmicEnv::Demo);
592 assert!(result.is_err());
593
594 if let Err(ConfigError::MissingEnvVar(var)) = result {
595 assert_eq!(var, "RITHMIC_DEMO_ACCOUNT_ID");
596 } else {
597 panic!("Expected MissingEnvVar error");
598 }
599 },
600 );
601 }
602
603 #[test]
604 fn test_from_env_missing_credentials() {
605 temp_env::with_vars(
606 vec![
607 ("RITHMIC_DEMO_USER", None::<&str>),
608 ("RITHMIC_DEMO_PW", None),
609 ("RITHMIC_DEMO_URL", Some("wss://test-demo.example.com:443")),
610 (
611 "RITHMIC_DEMO_ALT_URL",
612 Some("wss://test-demo-alt.example.com:443"),
613 ),
614 ],
615 || {
616 let result = RithmicConfig::from_env(RithmicEnv::Demo);
617 assert!(result.is_err());
618
619 if let Err(ConfigError::MissingEnvVar(var)) = result {
620 assert_eq!(var, "RITHMIC_DEMO_USER");
621 } else {
622 panic!("Expected MissingEnvVar error");
623 }
624 },
625 );
626 }
627
628 #[test]
629 fn test_from_env_missing_url() {
630 temp_env::with_vars(
631 vec![
632 ("RITHMIC_DEMO_USER", Some("demo_user")),
633 ("RITHMIC_DEMO_PW", Some("demo_password")),
634 ("RITHMIC_DEMO_URL", None::<&str>),
635 ("RITHMIC_DEMO_ALT_URL", None),
636 ],
637 || {
638 let result = RithmicConfig::from_env(RithmicEnv::Demo);
639 assert!(result.is_err());
640
641 if let Err(ConfigError::MissingEnvVar(var)) = result {
642 assert_eq!(var, "RITHMIC_DEMO_URL");
643 } else {
644 panic!("Expected MissingEnvVar error");
645 }
646 },
647 );
648 }
649
650 #[test]
651 fn test_account_new_complete() {
652 let account = RithmicAccount::new("my_fcm", "my_ib", "my_account");
653
654 assert_eq!(account.account_id, "my_account");
655 assert_eq!(account.fcm_id, "my_fcm");
656 assert_eq!(account.ib_id, "my_ib");
657 }
658
659 #[test]
660 fn test_builder_complete() {
661 let config = RithmicConfig::builder(RithmicEnv::Demo)
662 .user("my_user")
663 .password("my_password")
664 .url("wss://test.example.com:443")
665 .beta_url("wss://test-alt.example.com:443")
666 .app_name("test_app")
667 .app_version("1")
668 .build()
669 .unwrap();
670
671 assert_eq!(config.user, "my_user");
672 assert_eq!(config.password, "my_password");
673 assert_eq!(config.env, RithmicEnv::Demo);
674 assert_eq!(config.url, "wss://test.example.com:443");
675 assert_eq!(config.beta_url, "wss://test-alt.example.com:443");
676 assert_eq!(config.system_name, "Rithmic Paper Trading");
678 }
679
680 #[test]
681 fn test_builder_custom_urls() {
682 let config = RithmicConfig::builder(RithmicEnv::Demo)
683 .user("my_user")
684 .password("my_password")
685 .url("wss://custom.example.com:443")
686 .beta_url("wss://custom-beta.example.com:443")
687 .system_name("Custom System")
688 .app_name("test_app")
689 .app_version("1")
690 .build()
691 .unwrap();
692
693 assert_eq!(config.url, "wss://custom.example.com:443");
694 assert_eq!(config.beta_url, "wss://custom-beta.example.com:443");
695 assert_eq!(config.system_name, "Custom System");
696 }
697
698 #[test]
699 fn test_builder_missing_user() {
700 let result = RithmicConfig::builder(RithmicEnv::Demo)
701 .password("my_password")
702 .url("wss://test.example.com:443")
703 .beta_url("wss://test-alt.example.com:443")
704 .build();
705
706 assert!(result.is_err());
707 if let Err(ConfigError::MissingField(field)) = result {
708 assert_eq!(field, "user");
709 } else {
710 panic!("Expected MissingField error");
711 }
712 }
713
714 #[test]
715 fn test_builder_demo_defaults() {
716 let builder = RithmicConfigBuilder::new(RithmicEnv::Demo);
717 let config = builder
718 .user("test")
719 .password("test")
720 .url("wss://test.example.com:443")
721 .beta_url("wss://test-alt.example.com:443")
722 .app_name("test_app")
723 .app_version("1")
724 .build()
725 .unwrap();
726
727 assert_eq!(config.system_name, "Rithmic Paper Trading");
729 }
730
731 #[test]
732 fn test_builder_live_defaults() {
733 let builder = RithmicConfigBuilder::new(RithmicEnv::Live);
734 let config = builder
735 .user("test")
736 .password("test")
737 .url("wss://test.example.com:443")
738 .beta_url("wss://test-alt.example.com:443")
739 .app_name("test_app")
740 .app_version("1")
741 .build()
742 .unwrap();
743
744 assert_eq!(config.system_name, "Rithmic 01");
746 }
747
748 #[test]
749 fn test_builder_test_defaults() {
750 let builder = RithmicConfigBuilder::new(RithmicEnv::Test);
751 let config = builder
752 .user("test")
753 .password("test")
754 .url("wss://test.example.com:443")
755 .beta_url("wss://test-alt.example.com:443")
756 .app_name("test_app")
757 .app_version("1")
758 .build()
759 .unwrap();
760
761 assert_eq!(config.system_name, "Rithmic Test");
763 }
764
765 #[test]
766 fn test_builder_into_string_conversions() {
767 let config = RithmicConfig::builder(RithmicEnv::Demo)
769 .user(String::from("my_user"))
770 .password(String::from("my_password"))
771 .url(String::from("wss://test.example.com:443"))
772 .beta_url(String::from("wss://test-alt.example.com:443"))
773 .app_name("test_app")
774 .app_version("1")
775 .build()
776 .unwrap();
777
778 assert_eq!(config.user, "my_user");
779 }
780
781 #[test]
782 fn test_debug_redacts_password() {
783 let config = RithmicConfig::builder(RithmicEnv::Demo)
784 .user("my_user")
785 .password("super_secret_password")
786 .url("wss://test.example.com:443")
787 .beta_url("wss://test-alt.example.com:443")
788 .app_name("test_app")
789 .app_version("1")
790 .build()
791 .unwrap();
792
793 let debug_output = format!("{:?}", config);
794 assert!(
795 !debug_output.contains("super_secret_password"),
796 "Debug output should not contain the actual password"
797 );
798 assert!(
799 debug_output.contains("[REDACTED]"),
800 "Debug output should contain [REDACTED] for the password"
801 );
802 assert!(debug_output.contains("my_user"));
804 }
805}