#[allow(deprecated)]
use crate::request_handler::DEFAULT_REQUEST_TIMEOUT;
use std::{env, fmt, str::FromStr, time::Duration};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
#[non_exhaustive]
pub enum RithmicEnv {
#[default]
Demo,
Live,
Test,
}
impl RithmicEnv {
fn var_prefix(self) -> &'static str {
match self {
RithmicEnv::Demo => "RITHMIC_DEMO",
RithmicEnv::Live => "RITHMIC_LIVE",
RithmicEnv::Test => "RITHMIC_TEST",
}
}
fn default_system_name(self) -> &'static str {
match self {
RithmicEnv::Demo => "Rithmic Paper Trading",
RithmicEnv::Live => "Rithmic 01",
RithmicEnv::Test => "Rithmic 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"),
}
}
}
fn require_env(var: &str) -> Result<String, ConfigError> {
env::var(var).map_err(|_| ConfigError::MissingEnvVar(var.to_string()))
}
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, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConfigError {
InvalidEnvironment(String),
#[non_exhaustive]
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))]
#[non_exhaustive]
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 prefix = env.var_prefix();
Ok(Self {
account_id: require_env(&format!("{prefix}_ACCOUNT_ID"))?,
fcm_id: require_env(&format!("{prefix}_FCM_ID"))?,
ib_id: require_env(&format!("{prefix}_IB_ID"))?,
})
}
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct LoginConfig {
pub aggregated_quotes: Option<bool>,
pub mac_addr: Option<Vec<String>>,
pub os_version: Option<String>,
pub os_platform: Option<String>,
}
const REQUEST_TIMEOUT_VAR: &str = "RITHMIC_REQUEST_TIMEOUT_SECS";
fn parse_whole_seconds(value: &str) -> Option<u64> {
let canonical = !value.is_empty()
&& value.bytes().all(|b| b.is_ascii_digit())
&& (value == "0" || !value.starts_with('0'));
if canonical { value.parse().ok() } else { None }
}
#[derive(Clone)]
#[non_exhaustive]
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,
#[deprecated(
since = "3.1.0",
note = "the library no longer times out requests; wrap the call in tokio::time::timeout"
)]
pub request_timeout: Duration,
}
impl fmt::Debug for RithmicConfig {
#[allow(deprecated)]
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)
.field("request_timeout", &self.request_timeout)
.finish()
}
}
impl RithmicConfig {
#[allow(deprecated)]
pub fn from_env(env: RithmicEnv) -> Result<Self, ConfigError> {
let prefix = env.var_prefix();
let url = require_env(&format!("{prefix}_URL"))?;
let beta_url = require_env(&format!("{prefix}_ALT_URL"))?;
let user = require_env(&format!("{prefix}_USER"))?;
let password = require_env(&format!("{prefix}_PW"))?;
let system_name = env::var(format!("{prefix}_SYSTEM_NAME"))
.unwrap_or_else(|_| env.default_system_name().to_string());
let app_name = require_env("RITHMIC_APP_NAME")?;
let app_version = require_env("RITHMIC_APP_VERSION")?;
let request_timeout = match env::var(REQUEST_TIMEOUT_VAR) {
Err(env::VarError::NotPresent) => DEFAULT_REQUEST_TIMEOUT,
Err(env::VarError::NotUnicode(_)) => {
return Err(ConfigError::InvalidValue {
var: REQUEST_TIMEOUT_VAR.to_string(),
reason: "expected whole seconds, got a non-unicode value".to_string(),
});
}
Ok(value) => match parse_whole_seconds(value.trim()) {
Some(0) => DEFAULT_REQUEST_TIMEOUT,
Some(secs) => Duration::from_secs(secs),
None => {
return Err(ConfigError::InvalidValue {
var: REQUEST_TIMEOUT_VAR.to_string(),
reason: format!("expected whole seconds (digits only), got {value:?}"),
});
}
},
};
Ok(Self {
url,
beta_url,
user,
password,
system_name,
env,
app_name,
app_version,
request_timeout,
})
}
pub fn builder(env: RithmicEnv) -> RithmicConfigBuilder {
RithmicConfigBuilder::new(env)
}
}
#[must_use = "the builder does nothing until build() is called"]
pub struct RithmicConfigBuilder {
env: 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>,
request_timeout: Duration,
}
impl RithmicConfigBuilder {
#[allow(deprecated)]
pub fn from_env(env: RithmicEnv) -> Result<Self, ConfigError> {
let config = RithmicConfig::from_env(env)?;
Ok(Self {
env: config.env,
url: Some(config.url),
beta_url: Some(config.beta_url),
user: Some(config.user),
password: Some(config.password),
system_name: Some(config.system_name),
app_name: Some(config.app_name),
app_version: Some(config.app_version),
request_timeout: config.request_timeout,
})
}
#[allow(deprecated)]
pub fn new(env: RithmicEnv) -> Self {
let system_name = env.default_system_name().to_string();
Self {
env,
url: None,
beta_url: None,
user: None,
password: None,
system_name: Some(system_name),
app_name: None,
app_version: None,
request_timeout: DEFAULT_REQUEST_TIMEOUT,
}
}
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
}
#[deprecated(
since = "3.1.0",
note = "the library no longer times out requests; wrap the call in tokio::time::timeout"
)]
#[allow(deprecated)]
pub fn request_timeout(mut self, request_timeout: Duration) -> Self {
self.request_timeout = if request_timeout.is_zero() {
DEFAULT_REQUEST_TIMEOUT
} else {
request_timeout
};
self
}
pub fn app_version(mut self, app_version: impl Into<String>) -> Self {
self.app_version = Some(app_version.into());
self
}
#[allow(deprecated)]
pub fn build(self) -> Result<RithmicConfig, ConfigError> {
Ok(RithmicConfig {
env: self.env,
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()))?,
request_timeout: self.request_timeout,
})
}
}
#[cfg(test)]
#[allow(deprecated)]
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 from_env_reads_the_request_timeout_when_set() {
let mut vars = demo_env_vars();
vars.push((REQUEST_TIMEOUT_VAR, Some("5")));
temp_env::with_vars(vars, || {
let config = RithmicConfig::from_env(RithmicEnv::Demo).unwrap();
assert_eq!(config.request_timeout, Duration::from_secs(5));
});
}
#[test]
fn from_env_defaults_the_request_timeout_when_unset() {
let mut vars = demo_env_vars();
vars.push((REQUEST_TIMEOUT_VAR, None));
temp_env::with_vars(vars, || {
let config = RithmicConfig::from_env(RithmicEnv::Demo).unwrap();
assert_eq!(config.request_timeout, DEFAULT_REQUEST_TIMEOUT);
});
}
#[test]
fn from_env_rejects_an_unusable_request_timeout() {
for value in ["soon", "+30", "-30", "007", "30.5", "30s", ""] {
let mut vars = demo_env_vars();
vars.push((REQUEST_TIMEOUT_VAR, Some(value)));
temp_env::with_vars(vars, || {
assert!(
matches!(
RithmicConfig::from_env(RithmicEnv::Demo),
Err(ConfigError::InvalidValue { .. })
),
"{value:?} should have been rejected"
);
});
}
}
#[test]
fn from_env_treats_a_zero_request_timeout_as_the_default() {
let mut vars = demo_env_vars();
vars.push((REQUEST_TIMEOUT_VAR, Some("0")));
temp_env::with_vars(vars, || {
let config = RithmicConfig::from_env(RithmicEnv::Demo).unwrap();
assert_eq!(config.request_timeout, DEFAULT_REQUEST_TIMEOUT);
});
}
#[test]
fn the_builder_treats_a_zero_request_timeout_as_the_default() {
let config = RithmicConfig::builder(RithmicEnv::Demo)
.user("u")
.password("p")
.url("ws://localhost:9999")
.beta_url("ws://localhost:9998")
.app_name("a")
.app_version("1")
.request_timeout(Duration::ZERO)
.build()
.unwrap();
assert_eq!(config.request_timeout, DEFAULT_REQUEST_TIMEOUT);
}
#[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 from_env_overrides_the_system_name_when_set() {
let mut vars = live_env_vars();
vars.push(("RITHMIC_LIVE_SYSTEM_NAME", Some("Thrive Trading")));
temp_env::with_vars(vars, || {
let config = RithmicConfig::from_env(RithmicEnv::Live).unwrap();
assert_eq!(config.system_name, "Thrive Trading");
});
}
#[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_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_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"));
}
}