use std::{env, fmt, str::FromStr};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
pub enum RithmicEnv {
#[default]
Demo,
Live,
Test,
}
impl fmt::Display for RithmicEnv {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
RithmicEnv::Demo => write!(f, "demo"),
RithmicEnv::Live => write!(f, "live"),
RithmicEnv::Test => write!(f, "test"),
}
}
}
impl FromStr for RithmicEnv {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"demo" | "development" => Ok(RithmicEnv::Demo),
"live" | "production" => Ok(RithmicEnv::Live),
"test" => Ok(RithmicEnv::Test),
_ => Err(ConfigError::InvalidEnvironment(s.to_string())),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ConfigError {
InvalidEnvironment(String),
InvalidValue {
var: String,
reason: String,
},
MissingEnvVar(String),
MissingField(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ConfigError::MissingEnvVar(var) => {
write!(f, "Missing environment variable: {}", var)
}
ConfigError::InvalidEnvironment(env) => {
write!(f, "Invalid environment: {}", env)
}
ConfigError::InvalidValue { var, reason } => {
write!(f, "Invalid value for {}: {}", var, reason)
}
ConfigError::MissingField(field) => {
write!(f, "Missing required field: {}", field)
}
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RithmicAccount {
pub account_id: String,
pub fcm_id: String,
pub ib_id: String,
}
impl RithmicAccount {
pub fn new(
fcm_id: impl Into<String>,
ib_id: impl Into<String>,
account_id: impl Into<String>,
) -> Self {
Self {
account_id: account_id.into(),
fcm_id: fcm_id.into(),
ib_id: ib_id.into(),
}
}
pub fn from_env(env: RithmicEnv) -> Result<Self, ConfigError> {
let (account_id, fcm_id, ib_id) = match &env {
RithmicEnv::Demo => (
env::var("RITHMIC_DEMO_ACCOUNT_ID").map_err(|_| {
ConfigError::MissingEnvVar("RITHMIC_DEMO_ACCOUNT_ID".to_string())
})?,
env::var("RITHMIC_DEMO_FCM_ID")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_FCM_ID".to_string()))?,
env::var("RITHMIC_DEMO_IB_ID")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_IB_ID".to_string()))?,
),
RithmicEnv::Live => (
env::var("RITHMIC_LIVE_ACCOUNT_ID").map_err(|_| {
ConfigError::MissingEnvVar("RITHMIC_LIVE_ACCOUNT_ID".to_string())
})?,
env::var("RITHMIC_LIVE_FCM_ID")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_FCM_ID".to_string()))?,
env::var("RITHMIC_LIVE_IB_ID")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_IB_ID".to_string()))?,
),
RithmicEnv::Test => (
env::var("RITHMIC_TEST_ACCOUNT_ID").map_err(|_| {
ConfigError::MissingEnvVar("RITHMIC_TEST_ACCOUNT_ID".to_string())
})?,
env::var("RITHMIC_TEST_FCM_ID")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_FCM_ID".to_string()))?,
env::var("RITHMIC_TEST_IB_ID")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_IB_ID".to_string()))?,
),
};
Ok(Self {
account_id,
fcm_id,
ib_id,
})
}
}
#[derive(Clone)]
pub struct RithmicConfig {
pub url: String,
pub beta_url: String,
pub user: String,
pub password: String,
pub system_name: String,
pub env: RithmicEnv,
pub app_name: String,
pub app_version: String,
}
impl fmt::Debug for RithmicConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RithmicConfig")
.field("url", &self.url)
.field("beta_url", &self.beta_url)
.field("user", &self.user)
.field("password", &"[REDACTED]")
.field("system_name", &self.system_name)
.field("env", &self.env)
.field("app_name", &self.app_name)
.field("app_version", &self.app_version)
.finish()
}
}
impl RithmicConfig {
pub fn from_env(env: RithmicEnv) -> Result<Self, ConfigError> {
let (url, beta_url, user, password, system_name) = match &env {
RithmicEnv::Demo => (
env::var("RITHMIC_DEMO_URL")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_URL".to_string()))?,
env::var("RITHMIC_DEMO_ALT_URL")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_ALT_URL".to_string()))?,
env::var("RITHMIC_DEMO_USER")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_USER".to_string()))?,
env::var("RITHMIC_DEMO_PW")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_DEMO_PW".to_string()))?,
"Rithmic Paper Trading".to_string(),
),
RithmicEnv::Live => (
env::var("RITHMIC_LIVE_URL")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_URL".to_string()))?,
env::var("RITHMIC_LIVE_ALT_URL")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_ALT_URL".to_string()))?,
env::var("RITHMIC_LIVE_USER")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_USER".to_string()))?,
env::var("RITHMIC_LIVE_PW")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_LIVE_PW".to_string()))?,
"Rithmic 01".to_string(),
),
RithmicEnv::Test => (
env::var("RITHMIC_TEST_URL")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_URL".to_string()))?,
env::var("RITHMIC_TEST_ALT_URL")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_ALT_URL".to_string()))?,
env::var("RITHMIC_TEST_USER")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_USER".to_string()))?,
env::var("RITHMIC_TEST_PW")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_TEST_PW".to_string()))?,
"Rithmic Test".to_string(),
),
};
let app_name = env::var("RITHMIC_APP_NAME")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_APP_NAME".to_string()))?;
let app_version = env::var("RITHMIC_APP_VERSION")
.map_err(|_| ConfigError::MissingEnvVar("RITHMIC_APP_VERSION".to_string()))?;
Ok(Self {
url,
beta_url,
user,
password,
system_name,
env,
app_name,
app_version,
})
}
pub fn builder(env: RithmicEnv) -> RithmicConfigBuilder {
RithmicConfigBuilder::new(env)
}
}
pub struct RithmicConfigBuilder {
env: Option<RithmicEnv>,
url: Option<String>,
beta_url: Option<String>,
user: Option<String>,
password: Option<String>,
system_name: Option<String>,
app_name: Option<String>,
app_version: Option<String>,
}
impl RithmicConfigBuilder {
pub fn new(env: RithmicEnv) -> Self {
let system_name = match &env {
RithmicEnv::Demo => "Rithmic Paper Trading".to_string(),
RithmicEnv::Live => "Rithmic 01".to_string(),
RithmicEnv::Test => "Rithmic Test".to_string(),
};
Self {
env: Some(env),
url: None,
beta_url: None,
user: None,
password: None,
system_name: Some(system_name),
app_name: None,
app_version: None,
}
}
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
pub fn beta_url(mut self, beta_url: impl Into<String>) -> Self {
self.beta_url = Some(beta_url.into());
self
}
pub fn user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
pub fn password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
pub fn system_name(mut self, system_name: impl Into<String>) -> Self {
self.system_name = Some(system_name.into());
self
}
pub fn app_name(mut self, app_name: impl Into<String>) -> Self {
self.app_name = Some(app_name.into());
self
}
pub fn app_version(mut self, app_version: impl Into<String>) -> Self {
self.app_version = Some(app_version.into());
self
}
pub fn build(self) -> Result<RithmicConfig, ConfigError> {
Ok(RithmicConfig {
env: self
.env
.ok_or_else(|| ConfigError::MissingField("env".to_string()))?,
url: self
.url
.ok_or_else(|| ConfigError::MissingField("url".to_string()))?,
beta_url: self
.beta_url
.ok_or_else(|| ConfigError::MissingField("beta_url".to_string()))?,
user: self
.user
.ok_or_else(|| ConfigError::MissingField("user".to_string()))?,
password: self
.password
.ok_or_else(|| ConfigError::MissingField("password".to_string()))?,
system_name: self
.system_name
.ok_or_else(|| ConfigError::MissingField("system_name".to_string()))?,
app_name: self
.app_name
.ok_or_else(|| ConfigError::MissingField("app_name".to_string()))?,
app_version: self
.app_version
.ok_or_else(|| ConfigError::MissingField("app_version".to_string()))?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn demo_env_vars() -> Vec<(&'static str, Option<&'static str>)> {
vec![
("RITHMIC_DEMO_ACCOUNT_ID", Some("test_account")),
("RITHMIC_DEMO_FCM_ID", Some("test_fcm")),
("RITHMIC_DEMO_IB_ID", Some("test_ib")),
("RITHMIC_DEMO_USER", Some("demo_user")),
("RITHMIC_DEMO_PW", Some("demo_password")),
("RITHMIC_DEMO_URL", Some("wss://test-demo.example.com:443")),
(
"RITHMIC_DEMO_ALT_URL",
Some("wss://test-demo-alt.example.com:443"),
),
("RITHMIC_APP_NAME", Some("test_app")),
("RITHMIC_APP_VERSION", Some("1")),
]
}
fn live_env_vars() -> Vec<(&'static str, Option<&'static str>)> {
vec![
("RITHMIC_LIVE_ACCOUNT_ID", Some("test_account")),
("RITHMIC_LIVE_FCM_ID", Some("test_fcm")),
("RITHMIC_LIVE_IB_ID", Some("test_ib")),
("RITHMIC_LIVE_USER", Some("live_user")),
("RITHMIC_LIVE_PW", Some("live_password")),
("RITHMIC_LIVE_URL", Some("wss://test-live.example.com:443")),
(
"RITHMIC_LIVE_ALT_URL",
Some("wss://test-live-alt.example.com:443"),
),
("RITHMIC_APP_NAME", Some("test_app")),
("RITHMIC_APP_VERSION", Some("1")),
]
}
#[test]
fn test_rithmic_env_display() {
assert_eq!(RithmicEnv::Demo.to_string(), "demo");
assert_eq!(RithmicEnv::Live.to_string(), "live");
assert_eq!(RithmicEnv::Test.to_string(), "test");
}
#[test]
fn test_rithmic_env_from_str() {
assert_eq!("demo".parse::<RithmicEnv>().unwrap(), RithmicEnv::Demo);
assert_eq!(
"development".parse::<RithmicEnv>().unwrap(),
RithmicEnv::Demo
);
assert_eq!("live".parse::<RithmicEnv>().unwrap(), RithmicEnv::Live);
assert_eq!(
"production".parse::<RithmicEnv>().unwrap(),
RithmicEnv::Live
);
assert_eq!("test".parse::<RithmicEnv>().unwrap(), RithmicEnv::Test);
let result = "invalid".parse::<RithmicEnv>();
assert!(result.is_err());
if let Err(ConfigError::InvalidEnvironment(env)) = result {
assert_eq!(env, "invalid");
} else {
panic!("Expected InvalidEnvironment error");
}
}
#[test]
fn test_config_error_display() {
let err = ConfigError::MissingEnvVar("TEST_VAR".to_string());
assert_eq!(err.to_string(), "Missing environment variable: TEST_VAR");
let err = ConfigError::InvalidEnvironment("bad_env".to_string());
assert_eq!(err.to_string(), "Invalid environment: bad_env");
let err = ConfigError::InvalidValue {
var: "TEST".to_string(),
reason: "too short".to_string(),
};
assert_eq!(err.to_string(), "Invalid value for TEST: too short");
let err = ConfigError::MissingField("field".to_string());
assert_eq!(err.to_string(), "Missing required field: field");
}
#[test]
fn test_account_from_env_demo_success() {
temp_env::with_vars(demo_env_vars(), || {
let account = RithmicAccount::from_env(RithmicEnv::Demo).unwrap();
assert_eq!(account.account_id, "test_account");
assert_eq!(account.fcm_id, "test_fcm");
assert_eq!(account.ib_id, "test_ib");
});
}
#[test]
fn test_from_env_demo_success() {
temp_env::with_vars(demo_env_vars(), || {
let config = RithmicConfig::from_env(RithmicEnv::Demo).unwrap();
assert_eq!(config.user, "demo_user");
assert_eq!(config.password, "demo_password");
assert_eq!(config.url, "wss://test-demo.example.com:443");
assert_eq!(config.beta_url, "wss://test-demo-alt.example.com:443");
assert_eq!(config.system_name, "Rithmic Paper Trading");
assert_eq!(config.env, RithmicEnv::Demo);
});
}
#[test]
fn test_from_env_live_success() {
temp_env::with_vars(live_env_vars(), || {
let config = RithmicConfig::from_env(RithmicEnv::Live).unwrap();
assert_eq!(config.user, "live_user");
assert_eq!(config.password, "live_password");
assert_eq!(config.system_name, "Rithmic 01");
assert_eq!(config.env, RithmicEnv::Live);
});
}
#[test]
fn test_account_from_env_missing_account_id() {
temp_env::with_vars(
vec![
("RITHMIC_DEMO_ACCOUNT_ID", None::<&str>),
("RITHMIC_DEMO_FCM_ID", Some("test_fcm")),
("RITHMIC_DEMO_IB_ID", Some("test_ib")),
("RITHMIC_DEMO_USER", Some("demo_user")),
("RITHMIC_DEMO_PW", Some("demo_password")),
("RITHMIC_DEMO_URL", Some("wss://test-demo.example.com:443")),
(
"RITHMIC_DEMO_ALT_URL",
Some("wss://test-demo-alt.example.com:443"),
),
],
|| {
let result = RithmicAccount::from_env(RithmicEnv::Demo);
assert!(result.is_err());
if let Err(ConfigError::MissingEnvVar(var)) = result {
assert_eq!(var, "RITHMIC_DEMO_ACCOUNT_ID");
} else {
panic!("Expected MissingEnvVar error");
}
},
);
}
#[test]
fn test_from_env_missing_credentials() {
temp_env::with_vars(
vec![
("RITHMIC_DEMO_USER", None::<&str>),
("RITHMIC_DEMO_PW", None),
("RITHMIC_DEMO_URL", Some("wss://test-demo.example.com:443")),
(
"RITHMIC_DEMO_ALT_URL",
Some("wss://test-demo-alt.example.com:443"),
),
],
|| {
let result = RithmicConfig::from_env(RithmicEnv::Demo);
assert!(result.is_err());
if let Err(ConfigError::MissingEnvVar(var)) = result {
assert_eq!(var, "RITHMIC_DEMO_USER");
} else {
panic!("Expected MissingEnvVar error");
}
},
);
}
#[test]
fn test_from_env_missing_url() {
temp_env::with_vars(
vec![
("RITHMIC_DEMO_USER", Some("demo_user")),
("RITHMIC_DEMO_PW", Some("demo_password")),
("RITHMIC_DEMO_URL", None::<&str>),
("RITHMIC_DEMO_ALT_URL", None),
],
|| {
let result = RithmicConfig::from_env(RithmicEnv::Demo);
assert!(result.is_err());
if let Err(ConfigError::MissingEnvVar(var)) = result {
assert_eq!(var, "RITHMIC_DEMO_URL");
} else {
panic!("Expected MissingEnvVar error");
}
},
);
}
#[test]
fn test_account_new_complete() {
let account = RithmicAccount::new("my_fcm", "my_ib", "my_account");
assert_eq!(account.account_id, "my_account");
assert_eq!(account.fcm_id, "my_fcm");
assert_eq!(account.ib_id, "my_ib");
}
#[test]
fn test_builder_complete() {
let config = RithmicConfig::builder(RithmicEnv::Demo)
.user("my_user")
.password("my_password")
.url("wss://test.example.com:443")
.beta_url("wss://test-alt.example.com:443")
.app_name("test_app")
.app_version("1")
.build()
.unwrap();
assert_eq!(config.user, "my_user");
assert_eq!(config.password, "my_password");
assert_eq!(config.env, RithmicEnv::Demo);
assert_eq!(config.url, "wss://test.example.com:443");
assert_eq!(config.beta_url, "wss://test-alt.example.com:443");
assert_eq!(config.system_name, "Rithmic Paper Trading");
}
#[test]
fn test_builder_custom_urls() {
let config = RithmicConfig::builder(RithmicEnv::Demo)
.user("my_user")
.password("my_password")
.url("wss://custom.example.com:443")
.beta_url("wss://custom-beta.example.com:443")
.system_name("Custom System")
.app_name("test_app")
.app_version("1")
.build()
.unwrap();
assert_eq!(config.url, "wss://custom.example.com:443");
assert_eq!(config.beta_url, "wss://custom-beta.example.com:443");
assert_eq!(config.system_name, "Custom System");
}
#[test]
fn test_builder_missing_user() {
let result = RithmicConfig::builder(RithmicEnv::Demo)
.password("my_password")
.url("wss://test.example.com:443")
.beta_url("wss://test-alt.example.com:443")
.build();
assert!(result.is_err());
if let Err(ConfigError::MissingField(field)) = result {
assert_eq!(field, "user");
} else {
panic!("Expected MissingField error");
}
}
#[test]
fn test_builder_demo_defaults() {
let builder = RithmicConfigBuilder::new(RithmicEnv::Demo);
let config = builder
.user("test")
.password("test")
.url("wss://test.example.com:443")
.beta_url("wss://test-alt.example.com:443")
.app_name("test_app")
.app_version("1")
.build()
.unwrap();
assert_eq!(config.system_name, "Rithmic Paper Trading");
}
#[test]
fn test_builder_live_defaults() {
let builder = RithmicConfigBuilder::new(RithmicEnv::Live);
let config = builder
.user("test")
.password("test")
.url("wss://test.example.com:443")
.beta_url("wss://test-alt.example.com:443")
.app_name("test_app")
.app_version("1")
.build()
.unwrap();
assert_eq!(config.system_name, "Rithmic 01");
}
#[test]
fn test_builder_test_defaults() {
let builder = RithmicConfigBuilder::new(RithmicEnv::Test);
let config = builder
.user("test")
.password("test")
.url("wss://test.example.com:443")
.beta_url("wss://test-alt.example.com:443")
.app_name("test_app")
.app_version("1")
.build()
.unwrap();
assert_eq!(config.system_name, "Rithmic Test");
}
#[test]
fn test_builder_into_string_conversions() {
let config = RithmicConfig::builder(RithmicEnv::Demo)
.user(String::from("my_user"))
.password(String::from("my_password"))
.url(String::from("wss://test.example.com:443"))
.beta_url(String::from("wss://test-alt.example.com:443"))
.app_name("test_app")
.app_version("1")
.build()
.unwrap();
assert_eq!(config.user, "my_user");
}
#[test]
fn test_debug_redacts_password() {
let config = RithmicConfig::builder(RithmicEnv::Demo)
.user("my_user")
.password("super_secret_password")
.url("wss://test.example.com:443")
.beta_url("wss://test-alt.example.com:443")
.app_name("test_app")
.app_version("1")
.build()
.unwrap();
let debug_output = format!("{:?}", config);
assert!(
!debug_output.contains("super_secret_password"),
"Debug output should not contain the actual password"
);
assert!(
debug_output.contains("[REDACTED]"),
"Debug output should contain [REDACTED] for the password"
);
assert!(debug_output.contains("my_user"));
}
}