1use std::path::Path;
8
9use secrecy::SecretString;
10use serde::Deserialize;
11
12use crate::{Result, RhoodError};
13
14use crate::env::{Env, SystemEnv, env_non_empty, env_parsed};
15
16const DEFAULT_CLIENT_ID: &str = "c82SH0WZOsabOXGP2sxqcj34FxkvfnWRZBKlBjFS";
17const DEFAULT_TOKEN_EXPIRY_SECS: u64 = 86400;
18const DEFAULT_TOKEN_CACHE_PATH: &str = "~/.rhood/.rhood-token";
19const DEFAULT_BASE_URL: &str = "https://api.robinhood.com";
20const DEFAULT_PHOENIX_URL: &str = "https://phoenix.robinhood.com";
21const DEFAULT_BONFIRE_URL: &str = "https://bonfire.robinhood.com";
22const DEFAULT_DV_POLL_INTERVAL_SECS: u64 = 5;
23const DEFAULT_DV_TIMEOUT_SECS: u64 = 120;
24const DEFAULT_LOG_LEVEL: &str = "info";
25const DEFAULT_HTTP_REQUEST_TIMEOUT_SECS: u64 = 30;
26const DEFAULT_HTTP_CONNECT_TIMEOUT_SECS: u64 = 10;
27const DEFAULT_CACHE_ENABLED: bool = true;
28const DEFAULT_CACHE_INSTRUMENT_TTL_SECS: u64 = 86_400; const DEFAULT_CACHE_INSTRUMENT_MAX_ENTRIES: u64 = 10_000;
30const DEFAULT_CACHE_INSTRUMENT_ID_TTL_SECS: u64 = 86_400; const DEFAULT_CACHE_INSTRUMENT_ID_MAX_ENTRIES: u64 = 50_000;
32const DEFAULT_CACHE_INDEX_TTL_SECS: u64 = 604_800; const DEFAULT_CACHE_INDEX_MAX_ENTRIES: u64 = 100;
34const DEFAULT_CACHE_FUTURES_TTL_SECS: u64 = 3_600; const DEFAULT_CACHE_FUTURES_MAX_ENTRIES: u64 = 500;
36const DEFAULT_CACHE_ENRICHMENT_BATCH_SIZE: usize = 50;
37
38const ENV_CONFIG: &str = "RHOOD_CONFIG";
39const ENV_USERNAME: &str = "RHOOD_USERNAME";
40const ENV_PASSWORD: &str = "RHOOD_PASSWORD";
41const ENV_MFA: &str = "RHOOD_MFA";
42const ENV_CLIENT_ID: &str = "RHOOD_CLIENT_ID";
43const ENV_DEVICE_TOKEN: &str = "RHOOD_DEVICE_TOKEN";
44const ENV_TOKEN_EXPIRY_SECS: &str = "RHOOD_TOKEN_EXPIRY_SECS";
45const ENV_TOKEN_CACHE_PATH: &str = "RHOOD_TOKEN_CACHE_PATH";
46const ENV_API_URL: &str = "RHOOD_API_URL";
47const ENV_PHOENIX_URL: &str = "RHOOD_PHOENIX_URL";
48const ENV_BONFIRE_URL: &str = "RHOOD_BONFIRE_URL";
49const ENV_DV_POLL_INTERVAL_SECS: &str = "RHOOD_DV_POLL_INTERVAL_SECS";
50const ENV_DV_TIMEOUT_SECS: &str = "RHOOD_DV_TIMEOUT_SECS";
51const ENV_LOG_LEVEL: &str = "RHOOD_LOG_LEVEL";
52const ENV_READ_ONLY: &str = "RHOOD_READ_ONLY";
53const ENV_PASSWORD_FILE: &str = "RHOOD_PASSWORD_FILE";
54const ENV_MFA_FILE: &str = "RHOOD_MFA_FILE";
55const ENV_DEVICE_TOKEN_FILE: &str = "RHOOD_DEVICE_TOKEN_FILE";
56const ENV_HTTP_REQUEST_TIMEOUT_SECS: &str = "RHOOD_HTTP_REQUEST_TIMEOUT_SECS";
57const ENV_HTTP_CONNECT_TIMEOUT_SECS: &str = "RHOOD_HTTP_CONNECT_TIMEOUT_SECS";
58const ENV_CACHE_ENABLED: &str = "RHOOD_CACHE_ENABLED";
59const ENV_CACHE_INSTRUMENT_TTL_SECS: &str = "RHOOD_CACHE_INSTRUMENT_TTL_SECS";
60const ENV_CACHE_INSTRUMENT_MAX_ENTRIES: &str = "RHOOD_CACHE_INSTRUMENT_MAX_ENTRIES";
61const ENV_CACHE_INSTRUMENT_ID_TTL_SECS: &str = "RHOOD_CACHE_INSTRUMENT_ID_TTL_SECS";
62const ENV_CACHE_INSTRUMENT_ID_MAX_ENTRIES: &str = "RHOOD_CACHE_INSTRUMENT_ID_MAX_ENTRIES";
63const ENV_CACHE_INDEX_TTL_SECS: &str = "RHOOD_CACHE_INDEX_TTL_SECS";
64const ENV_CACHE_INDEX_MAX_ENTRIES: &str = "RHOOD_CACHE_INDEX_MAX_ENTRIES";
65const ENV_CACHE_FUTURES_TTL_SECS: &str = "RHOOD_CACHE_FUTURES_TTL_SECS";
66const ENV_CACHE_FUTURES_MAX_ENTRIES: &str = "RHOOD_CACHE_FUTURES_MAX_ENTRIES";
67const ENV_CACHE_ENRICHMENT_BATCH_SIZE: &str = "RHOOD_CACHE_ENRICHMENT_BATCH_SIZE";
68
69#[derive(Debug, Clone, Deserialize)]
74#[serde(default)]
75pub struct RhoodConfig {
76 pub read_only: bool,
78 pub auth: AuthConfig,
80 pub api: ApiConfig,
82 pub device_verification: DeviceVerificationConfig,
84 pub log: LogConfig,
86 pub http: HttpConfig,
88 pub cache: CacheConfig,
90}
91
92impl Default for RhoodConfig {
93 fn default() -> Self {
94 Self {
95 read_only: true,
96 auth: AuthConfig::default(),
97 api: ApiConfig::default(),
98 device_verification: DeviceVerificationConfig::default(),
99 log: LogConfig::default(),
100 http: HttpConfig::default(),
101 cache: CacheConfig::default(),
102 }
103 }
104}
105
106#[derive(Clone, Deserialize)]
108#[serde(default)]
109pub struct AuthConfig {
110 pub username: Option<String>,
112 pub password: Option<SecretString>,
114 pub mfa_secret: Option<SecretString>,
116 pub client_id: String,
118 pub device_token: Option<SecretString>,
120 pub token_expiry_secs: u64,
122 pub token_cache_path: String,
124 pub password_file: Option<String>,
126 pub mfa_secret_file: Option<String>,
128 pub device_token_file: Option<String>,
130}
131
132#[derive(Debug, Clone, Deserialize)]
134#[serde(default)]
135pub struct ApiConfig {
136 pub base_url: String,
138 pub phoenix_url: String,
140 pub bonfire_url: String,
142}
143
144#[derive(Debug, Clone, Deserialize)]
146#[serde(default)]
147pub struct DeviceVerificationConfig {
148 pub poll_interval_secs: u64,
150 pub timeout_secs: u64,
152}
153
154#[derive(Debug, Clone, Deserialize)]
162#[serde(default)]
163pub struct HttpConfig {
164 pub request_timeout_secs: u64,
166 pub connect_timeout_secs: u64,
168}
169
170impl Default for HttpConfig {
171 fn default() -> Self {
172 Self {
173 request_timeout_secs: DEFAULT_HTTP_REQUEST_TIMEOUT_SECS,
174 connect_timeout_secs: DEFAULT_HTTP_CONNECT_TIMEOUT_SECS,
175 }
176 }
177}
178
179#[derive(Debug, Clone, Deserialize)]
187#[serde(default)]
188pub struct CacheConfig {
189 pub enabled: bool,
192 pub instrument_ttl_secs: u64,
194 pub instrument_max_entries: u64,
196 pub instrument_id_ttl_secs: u64,
198 pub instrument_id_max_entries: u64,
200 pub index_ttl_secs: u64,
202 pub index_max_entries: u64,
204 pub futures_ttl_secs: u64,
206 pub futures_max_entries: u64,
208 pub enrichment_batch_size: usize,
210}
211
212impl Default for CacheConfig {
213 fn default() -> Self {
214 Self {
215 enabled: DEFAULT_CACHE_ENABLED,
216 instrument_ttl_secs: DEFAULT_CACHE_INSTRUMENT_TTL_SECS,
217 instrument_max_entries: DEFAULT_CACHE_INSTRUMENT_MAX_ENTRIES,
218 instrument_id_ttl_secs: DEFAULT_CACHE_INSTRUMENT_ID_TTL_SECS,
219 instrument_id_max_entries: DEFAULT_CACHE_INSTRUMENT_ID_MAX_ENTRIES,
220 index_ttl_secs: DEFAULT_CACHE_INDEX_TTL_SECS,
221 index_max_entries: DEFAULT_CACHE_INDEX_MAX_ENTRIES,
222 futures_ttl_secs: DEFAULT_CACHE_FUTURES_TTL_SECS,
223 futures_max_entries: DEFAULT_CACHE_FUTURES_MAX_ENTRIES,
224 enrichment_batch_size: DEFAULT_CACHE_ENRICHMENT_BATCH_SIZE,
225 }
226 }
227}
228
229#[derive(Debug, Clone, Deserialize)]
231#[serde(default)]
232pub struct LogConfig {
233 pub level: String,
235}
236
237impl Default for AuthConfig {
238 fn default() -> Self {
239 Self {
240 username: None,
241 password: None,
242 mfa_secret: None,
243 client_id: DEFAULT_CLIENT_ID.to_string(),
244 device_token: None,
245 token_expiry_secs: DEFAULT_TOKEN_EXPIRY_SECS,
246 token_cache_path: DEFAULT_TOKEN_CACHE_PATH.to_string(),
247 password_file: None,
248 mfa_secret_file: None,
249 device_token_file: None,
250 }
251 }
252}
253
254impl std::fmt::Debug for AuthConfig {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 f.debug_struct("AuthConfig")
257 .field("username", &self.username)
258 .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
259 .field(
260 "mfa_secret",
261 &self.mfa_secret.as_ref().map(|_| "[REDACTED]"),
262 )
263 .field("client_id", &self.client_id)
264 .field(
265 "device_token",
266 &self.device_token.as_ref().map(|_| "[REDACTED]"),
267 )
268 .field("token_expiry_secs", &self.token_expiry_secs)
269 .field("token_cache_path", &self.token_cache_path)
270 .finish()
271 }
272}
273
274impl Default for ApiConfig {
275 fn default() -> Self {
276 Self {
277 base_url: DEFAULT_BASE_URL.to_string(),
278 phoenix_url: DEFAULT_PHOENIX_URL.to_string(),
279 bonfire_url: DEFAULT_BONFIRE_URL.to_string(),
280 }
281 }
282}
283
284impl Default for DeviceVerificationConfig {
285 fn default() -> Self {
286 Self {
287 poll_interval_secs: DEFAULT_DV_POLL_INTERVAL_SECS,
288 timeout_secs: DEFAULT_DV_TIMEOUT_SECS,
289 }
290 }
291}
292
293impl Default for LogConfig {
294 fn default() -> Self {
295 Self {
296 level: DEFAULT_LOG_LEVEL.to_string(),
297 }
298 }
299}
300
301pub fn ensure_secret_file_permissions(path: &Path) -> Result<()> {
306 #[cfg(unix)]
307 {
308 use std::os::unix::fs::PermissionsExt as _;
309
310 let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
311 if mode & 0o077 != 0 {
312 return Err(RhoodError::InvalidParameter(format!(
313 "Secret-bearing file {} has insecure permissions {mode:04o}; run `chmod 600 {}` to restrict access",
314 path.display(),
315 path.display()
316 )));
317 }
318 }
319
320 #[cfg(not(unix))]
321 let _ = path;
322
323 Ok(())
324}
325
326fn read_secret_file(path: &str, field_name: &str) -> Result<String> {
327 let p = Path::new(path);
328 if !p.exists() {
329 return Err(RhoodError::InvalidParameter(format!(
330 "Secret file not found: {path} (from {field_name})"
331 )));
332 }
333 ensure_secret_file_permissions(p)?;
334 std::fs::read_to_string(p)
335 .map(|contents| contents.trim().to_string())
336 .map_err(|e| {
337 RhoodError::InvalidParameter(format!("Failed to read secret file {path}: {e}"))
338 })
339}
340
341pub fn resolve_secret(
346 direct: &mut Option<SecretString>,
347 file_path: Option<String>,
348 field_name: &str,
349) -> Result<()> {
350 let file_field = format!("{field_name}_file");
351 match (direct.is_some(), file_path) {
352 (true, Some(_)) => Err(RhoodError::InvalidParameter(format!(
353 "Conflicting config: both '{field_name}' and '{file_field}' are set. Use one or the other."
354 ))),
355 (false, Some(path)) => {
356 *direct = Some(SecretString::from(read_secret_file(&path, &file_field)?));
357 Ok(())
358 }
359 _ => Ok(()),
360 }
361}
362
363fn expand_tilde(path: &str) -> String {
364 if !path.starts_with('~') {
365 return path.to_string();
366 }
367 let home = dirs::home_dir().map_or_else(
368 || "~".to_string(),
369 |home| home.to_string_lossy().to_string(),
370 );
371 if path == "~" {
372 home
373 } else {
374 if let Some(rest) = path.strip_prefix("~/") {
376 format!("{home}/{rest}")
377 } else {
378 path.to_string()
379 }
380 }
381}
382
383fn strip_trailing_slash(url: &mut String) {
384 while url.ends_with('/') {
385 url.pop();
386 }
387}
388
389impl RhoodConfig {
390 pub fn load(path: Option<&Path>) -> Result<Self> {
407 Self::load_with_env(path, &SystemEnv)
408 }
409
410 pub fn load_with_env(path: Option<&Path>, env: &impl Env) -> Result<Self> {
420 let explicit = path.is_some();
421 let file_path = Self::resolve_path(path, env);
422
423 let mut config = match file_path.as_deref() {
424 Some(p) if p.exists() => {
425 let contents = std::fs::read_to_string(p)?;
426 toml::from_str::<RhoodConfig>(&contents).map_err(|error| {
427 RhoodError::InvalidParameter(format!("Invalid config TOML: {error}"))
428 })?
429 }
430 Some(p) if explicit => {
431 return Err(RhoodError::InvalidParameter(format!(
432 "Config file not found: {}",
433 p.display()
434 )));
435 }
436 _ => RhoodConfig::default(),
437 };
438
439 if config.contains_inline_secrets()
440 && let Some(config_path) = file_path.as_deref()
441 {
442 ensure_secret_file_permissions(config_path)?;
443 }
444
445 config.apply_env_overrides(env)?;
446 config.resolve_secret_files()?;
447 config.normalize();
448 Ok(config)
449 }
450
451 pub fn from_toml(toml_str: &str) -> Result<Self> {
462 Self::from_toml_with_env(toml_str, &SystemEnv)
463 }
464
465 pub fn from_toml_with_env(toml_str: &str, env: &impl Env) -> Result<Self> {
472 let mut config: RhoodConfig = toml::from_str(toml_str).map_err(|error| {
473 RhoodError::InvalidParameter(format!("Invalid config TOML: {error}"))
474 })?;
475 config.apply_env_overrides(env)?;
476 config.resolve_secret_files()?;
477 config.normalize();
478 Ok(config)
479 }
480
481 fn resolve_path(explicit: Option<&Path>, env: &impl Env) -> Option<std::path::PathBuf> {
482 if let Some(p) = explicit {
483 return Some(p.to_path_buf());
484 }
485 if let Some(env_path) = env_non_empty(env, ENV_CONFIG) {
486 return Some(std::path::PathBuf::from(env_path));
487 }
488 if let Some(platform_path) =
491 dirs::config_dir().map(|dir| dir.join("rhood").join("config.toml"))
492 && platform_path.exists()
493 {
494 return Some(platform_path);
495 }
496 if let Some(home) = dirs::home_dir() {
497 let xdg = home.join(".config").join("rhood").join("config.toml");
498 if xdg.exists() {
499 return Some(xdg);
500 }
501 }
502 dirs::config_dir().map(|dir| dir.join("rhood").join("config.toml"))
503 }
504
505 pub fn apply_env_overrides_and_normalize(&mut self) -> Result<()> {
519 self.apply_env_overrides_and_normalize_with_env(&SystemEnv)
520 }
521
522 pub fn apply_env_overrides_and_normalize_with_env(&mut self, env: &impl Env) -> Result<()> {
529 self.apply_env_overrides(env)?;
530 self.resolve_secret_files()?;
531 self.normalize();
532 Ok(())
533 }
534
535 fn apply_env_overrides(&mut self, env: &impl Env) -> Result<()> {
536 if let Some(v) = env_non_empty(env, ENV_USERNAME) {
537 self.auth.username = Some(v);
538 }
539 if let Some(val) = env_non_empty(env, ENV_PASSWORD) {
540 self.auth.password = Some(SecretString::from(val));
541 self.auth.password_file = None;
542 }
543 if let Some(val) = env_non_empty(env, ENV_MFA) {
544 self.auth.mfa_secret = Some(SecretString::from(val));
545 self.auth.mfa_secret_file = None;
546 }
547 if let Some(v) = env_non_empty(env, ENV_CLIENT_ID) {
548 self.auth.client_id = v;
549 }
550 if let Some(val) = env_non_empty(env, ENV_DEVICE_TOKEN) {
551 self.auth.device_token = Some(SecretString::from(val));
552 self.auth.device_token_file = None;
553 }
554 if let Some(v) = env_parsed::<u64>(env, ENV_TOKEN_EXPIRY_SECS)? {
555 self.auth.token_expiry_secs = v;
556 }
557 if let Some(v) = env_non_empty(env, ENV_TOKEN_CACHE_PATH) {
558 self.auth.token_cache_path = v;
559 }
560 if let Some(v) = env_non_empty(env, ENV_API_URL) {
561 self.api.base_url = v;
562 }
563 if let Some(v) = env_non_empty(env, ENV_PHOENIX_URL) {
564 self.api.phoenix_url = v;
565 }
566 if let Some(v) = env_non_empty(env, ENV_BONFIRE_URL) {
567 self.api.bonfire_url = v;
568 }
569 if let Some(v) = env_parsed::<u64>(env, ENV_DV_POLL_INTERVAL_SECS)? {
570 self.device_verification.poll_interval_secs = v;
571 }
572 if let Some(v) = env_parsed::<u64>(env, ENV_DV_TIMEOUT_SECS)? {
573 self.device_verification.timeout_secs = v;
574 }
575 if let Some(v) = env_parsed::<u64>(env, ENV_HTTP_REQUEST_TIMEOUT_SECS)? {
576 self.http.request_timeout_secs = v;
577 }
578 if let Some(v) = env_parsed::<u64>(env, ENV_HTTP_CONNECT_TIMEOUT_SECS)? {
579 self.http.connect_timeout_secs = v;
580 }
581 if let Some(v) = env_non_empty(env, ENV_CACHE_ENABLED) {
582 self.cache.enabled = v.eq_ignore_ascii_case("true") || v == "1";
583 }
584 if let Some(v) = env_parsed::<u64>(env, ENV_CACHE_INSTRUMENT_TTL_SECS)? {
585 self.cache.instrument_ttl_secs = v;
586 }
587 if let Some(v) = env_parsed::<u64>(env, ENV_CACHE_INSTRUMENT_MAX_ENTRIES)? {
588 self.cache.instrument_max_entries = v;
589 }
590 if let Some(v) = env_parsed::<u64>(env, ENV_CACHE_INSTRUMENT_ID_TTL_SECS)? {
591 self.cache.instrument_id_ttl_secs = v;
592 }
593 if let Some(v) = env_parsed::<u64>(env, ENV_CACHE_INSTRUMENT_ID_MAX_ENTRIES)? {
594 self.cache.instrument_id_max_entries = v;
595 }
596 if let Some(v) = env_parsed::<u64>(env, ENV_CACHE_INDEX_TTL_SECS)? {
597 self.cache.index_ttl_secs = v;
598 }
599 if let Some(v) = env_parsed::<u64>(env, ENV_CACHE_INDEX_MAX_ENTRIES)? {
600 self.cache.index_max_entries = v;
601 }
602 if let Some(v) = env_parsed::<u64>(env, ENV_CACHE_FUTURES_TTL_SECS)? {
603 self.cache.futures_ttl_secs = v;
604 }
605 if let Some(v) = env_parsed::<u64>(env, ENV_CACHE_FUTURES_MAX_ENTRIES)? {
606 self.cache.futures_max_entries = v;
607 }
608 if let Some(v) = env_non_empty(env, ENV_CACHE_ENRICHMENT_BATCH_SIZE) {
609 match v.parse::<usize>() {
610 Ok(parsed) if parsed > 0 => self.cache.enrichment_batch_size = parsed,
611 _ => tracing::warn!(
612 value = %v,
613 "invalid RHOOD_CACHE_ENRICHMENT_BATCH_SIZE, must be a positive integer; ignoring"
614 ),
615 }
616 }
617 if let Some(v) = env_non_empty(env, ENV_LOG_LEVEL) {
618 self.log.level = v;
619 }
620 if let Some(v) = env_non_empty(env, ENV_READ_ONLY) {
621 self.read_only = v.eq_ignore_ascii_case("true") || v == "1";
622 }
623 if let Some(v) = env_non_empty(env, ENV_PASSWORD_FILE) {
624 self.auth.password_file = Some(v);
625 }
626 if let Some(v) = env_non_empty(env, ENV_MFA_FILE) {
627 self.auth.mfa_secret_file = Some(v);
628 }
629 if let Some(v) = env_non_empty(env, ENV_DEVICE_TOKEN_FILE) {
630 self.auth.device_token_file = Some(v);
631 }
632 Ok(())
633 }
634
635 fn resolve_secret_files(&mut self) -> Result<()> {
636 resolve_secret(
637 &mut self.auth.password,
638 self.auth.password_file.take(),
639 "password",
640 )?;
641 resolve_secret(
642 &mut self.auth.mfa_secret,
643 self.auth.mfa_secret_file.take(),
644 "mfa_secret",
645 )?;
646 resolve_secret(
647 &mut self.auth.device_token,
648 self.auth.device_token_file.take(),
649 "device_token",
650 )?;
651 Ok(())
652 }
653
654 fn contains_inline_secrets(&self) -> bool {
655 self.auth.password.is_some()
656 || self.auth.mfa_secret.is_some()
657 || self.auth.device_token.is_some()
658 }
659
660 pub fn normalize(&mut self) {
670 self.auth.token_cache_path = expand_tilde(&self.auth.token_cache_path);
671 strip_trailing_slash(&mut self.api.base_url);
672 strip_trailing_slash(&mut self.api.phoenix_url);
673 strip_trailing_slash(&mut self.api.bonfire_url);
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use crate::env::MapEnv;
680
681 use super::*;
682 use secrecy::ExposeSecret;
683 use std::io::Write as _;
684 #[cfg(unix)]
685 use std::os::unix::fs::PermissionsExt;
686
687 #[cfg(unix)]
688 fn write_file_with_mode(path: &Path, contents: &str, mode: u32) -> std::io::Result<()> {
689 std::fs::write(path, contents)?;
690 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
691 }
692
693 fn expose_secret_opt(secret: &Option<SecretString>) -> Option<&str> {
695 secret.as_ref().map(|val| val.expose_secret())
696 }
697
698 #[test]
699 fn system_env_reads_process_env() {
700 let env = SystemEnv;
704 let value = env.get("RHOOD_UNSET_KEY_FOR_SYSTEM_ENV_SMOKE_TEST");
705 assert!(
706 value.is_none(),
707 "expected unset key to return None, got {value:?}"
708 );
709 }
710
711 #[test]
712 fn map_env_get_returns_inserted_values() {
713 let env = MapEnv::new()
714 .with("RHOOD_USERNAME", "alice")
715 .with("RHOOD_API_URL", "https://example.com");
716 assert_eq!(env.get("RHOOD_USERNAME").as_deref(), Some("alice"));
717 assert_eq!(
718 env.get("RHOOD_API_URL").as_deref(),
719 Some("https://example.com")
720 );
721 assert_eq!(env.get("RHOOD_MISSING"), None);
722 }
723
724 #[test]
725 fn map_env_preserves_empty_strings() {
726 let env = MapEnv::new().with("RHOOD_EMPTY", "");
729 assert_eq!(env.get("RHOOD_EMPTY").as_deref(), Some(""));
730 }
731
732 #[test]
733 fn map_env_default_is_empty() {
734 let env = MapEnv::default();
735 assert_eq!(env.get("ANY_KEY"), None);
736 }
737
738 #[test]
739 fn default_config_has_expected_values() {
740 let cfg = RhoodConfig::default();
741 assert_eq!(cfg.auth.client_id, DEFAULT_CLIENT_ID);
742 assert_eq!(cfg.auth.token_expiry_secs, DEFAULT_TOKEN_EXPIRY_SECS);
743 assert_eq!(cfg.auth.token_cache_path, DEFAULT_TOKEN_CACHE_PATH);
744 assert!(cfg.auth.username.is_none());
745 assert!(cfg.auth.password.is_none());
746 assert!(cfg.auth.mfa_secret.is_none());
747 assert!(cfg.auth.device_token.is_none());
748 assert_eq!(cfg.api.base_url, DEFAULT_BASE_URL);
749 assert_eq!(cfg.api.phoenix_url, DEFAULT_PHOENIX_URL);
750 assert_eq!(cfg.api.bonfire_url, DEFAULT_BONFIRE_URL);
751 assert_eq!(
752 cfg.device_verification.poll_interval_secs,
753 DEFAULT_DV_POLL_INTERVAL_SECS
754 );
755 assert_eq!(
756 cfg.device_verification.timeout_secs,
757 DEFAULT_DV_TIMEOUT_SECS
758 );
759 assert_eq!(cfg.log.level, DEFAULT_LOG_LEVEL);
760 }
761
762 #[test]
763 fn load_from_toml_string() {
764 let toml = r#"
765[auth]
766username = "alice"
767password = "secret123"
768mfa_secret = "JBSWY3DPEHPK3PXP"
769client_id = "custom-client-id"
770device_token = "dev-tok-123"
771token_expiry_secs = 3600
772token_cache_path = "/tmp/token"
773
774[api]
775base_url = "https://custom.api.com"
776phoenix_url = "https://custom.phoenix.com"
777
778[device_verification]
779poll_interval_secs = 10
780timeout_secs = 300
781
782[log]
783level = "debug"
784"#;
785 let cfg = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap();
786 assert_eq!(cfg.auth.username.as_deref(), Some("alice"));
787 assert_eq!(expose_secret_opt(&cfg.auth.password), Some("secret123"));
788 assert_eq!(
789 expose_secret_opt(&cfg.auth.mfa_secret),
790 Some("JBSWY3DPEHPK3PXP")
791 );
792 assert_eq!(cfg.auth.client_id, "custom-client-id");
793 assert_eq!(
794 expose_secret_opt(&cfg.auth.device_token),
795 Some("dev-tok-123")
796 );
797 assert_eq!(cfg.auth.token_expiry_secs, 3600);
798 assert_eq!(cfg.auth.token_cache_path, "/tmp/token");
799 assert_eq!(cfg.api.base_url, "https://custom.api.com");
800 assert_eq!(cfg.api.phoenix_url, "https://custom.phoenix.com");
801 assert_eq!(cfg.device_verification.poll_interval_secs, 10);
802 assert_eq!(cfg.device_verification.timeout_secs, 300);
803 assert_eq!(cfg.log.level, "debug");
804 }
805
806 #[test]
807 fn load_partial_toml_fills_defaults() {
808 let toml = r#"
809[auth]
810username = "bob"
811"#;
812 let cfg = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap();
813 assert_eq!(cfg.auth.username.as_deref(), Some("bob"));
814 assert_eq!(cfg.auth.client_id, DEFAULT_CLIENT_ID);
815 assert_eq!(cfg.auth.token_expiry_secs, DEFAULT_TOKEN_EXPIRY_SECS);
816 assert_eq!(cfg.api.base_url, DEFAULT_BASE_URL);
817 assert_eq!(
818 cfg.device_verification.timeout_secs,
819 DEFAULT_DV_TIMEOUT_SECS
820 );
821 assert_eq!(cfg.log.level, DEFAULT_LOG_LEVEL);
822 }
823
824 #[test]
825 fn missing_default_file_returns_defaults() {
826 let cfg = RhoodConfig::load_with_env(None, &MapEnv::new()).unwrap();
828 assert_eq!(cfg.auth.client_id, DEFAULT_CLIENT_ID);
829 assert_eq!(cfg.api.base_url, DEFAULT_BASE_URL);
830 }
831
832 #[test]
833 fn explicit_missing_file_is_error() {
834 let result = RhoodConfig::load_with_env(
835 Some(Path::new("/tmp/nonexistent-rhood-config.toml")),
836 &MapEnv::new(),
837 );
838 assert!(result.is_err());
839 let err = result.unwrap_err();
840 assert!(
841 err.to_string().contains("Config file not found"),
842 "unexpected error: {err}"
843 );
844 }
845
846 #[test]
847 fn load_from_file() {
848 let dir = tempfile::tempdir().unwrap();
849 let file_path = dir.path().join("config.toml");
850 {
851 let mut f = std::fs::File::create(&file_path).unwrap();
852 writeln!(
853 f,
854 r#"
855[auth]
856username = "charlie"
857token_expiry_secs = 7200
858
859[log]
860level = "warn"
861"#
862 )
863 .unwrap();
864 }
865 let cfg = RhoodConfig::load_with_env(Some(&file_path), &MapEnv::new()).unwrap();
866 assert_eq!(cfg.auth.username.as_deref(), Some("charlie"));
867 assert_eq!(cfg.auth.token_expiry_secs, 7200);
868 assert_eq!(cfg.auth.client_id, DEFAULT_CLIENT_ID);
869 assert_eq!(cfg.log.level, "warn");
870 }
871
872 #[cfg(unix)]
873 #[test]
874 fn owner_only_secret_config_file_loads() {
875 let dir = tempfile::tempdir().unwrap();
876 let config_path = dir.path().join("config.toml");
877 write_file_with_mode(&config_path, "[auth]\npassword = \"hunter2\"\n", 0o600).unwrap();
878
879 let config = RhoodConfig::load_with_env(Some(&config_path), &MapEnv::new()).unwrap();
880
881 assert_eq!(expose_secret_opt(&config.auth.password), Some("hunter2"));
882 }
883
884 #[cfg(unix)]
885 #[test]
886 fn group_readable_secret_config_file_is_rejected() {
887 let dir = tempfile::tempdir().unwrap();
888 let config_path = dir.path().join("config.toml");
889 write_file_with_mode(&config_path, "[auth]\npassword = \"hunter2\"\n", 0o640).unwrap();
890
891 let error = RhoodConfig::load_with_env(Some(&config_path), &MapEnv::new())
892 .err()
893 .unwrap();
894 let message = error.to_string();
895
896 assert!(message.contains(&config_path.display().to_string()));
897 assert!(message.contains("0640"));
898 assert!(message.contains("chmod 600"));
899 }
900
901 #[cfg(unix)]
902 #[test]
903 fn group_readable_secret_file_is_rejected() {
904 let dir = tempfile::tempdir().unwrap();
905 let secret_path = dir.path().join("password.txt");
906 write_file_with_mode(&secret_path, "hunter2\n", 0o640).unwrap();
907 let toml = format!("[auth]\npassword_file = \"{}\"\n", secret_path.display());
908
909 let error = RhoodConfig::from_toml_with_env(&toml, &MapEnv::new())
910 .err()
911 .unwrap();
912 let message = error.to_string();
913
914 assert!(message.contains(&secret_path.display().to_string()));
915 assert!(message.contains("0640"));
916 assert!(message.contains("chmod 600"));
917 }
918
919 #[cfg(unix)]
920 #[test]
921 fn group_readable_secret_free_config_file_loads() {
922 let dir = tempfile::tempdir().unwrap();
923 let config_path = dir.path().join("config.toml");
924 write_file_with_mode(&config_path, "[auth]\nusername = \"alice\"\n", 0o644).unwrap();
925
926 let config = RhoodConfig::load_with_env(Some(&config_path), &MapEnv::new()).unwrap();
927
928 assert_eq!(config.auth.username.as_deref(), Some("alice"));
929 }
930
931 #[test]
932 fn env_vars_override_toml() {
933 let env = MapEnv::new()
934 .with(ENV_USERNAME, "env-user")
935 .with(ENV_API_URL, "https://env.api.com")
936 .with(ENV_LOG_LEVEL, "trace");
937 let toml = r#"
938[auth]
939username = "toml-user"
940
941[api]
942base_url = "https://toml.api.com"
943
944[log]
945level = "debug"
946"#;
947 let cfg = RhoodConfig::from_toml_with_env(toml, &env).unwrap();
948 assert_eq!(cfg.auth.username.as_deref(), Some("env-user"));
949 assert_eq!(cfg.api.base_url, "https://env.api.com");
950 assert_eq!(cfg.log.level, "trace");
951 }
952
953 #[test]
954 fn empty_env_var_does_not_override() {
955 let env = MapEnv::new().with(ENV_USERNAME, "");
956 let toml = r#"
957[auth]
958username = "toml-user"
959"#;
960 let cfg = RhoodConfig::from_toml_with_env(toml, &env).unwrap();
961 assert_eq!(cfg.auth.username.as_deref(), Some("toml-user"));
962 }
963
964 #[test]
965 fn tilde_expansion_in_token_cache_path() {
966 let toml = r#"
967[auth]
968token_cache_path = "~/.rhood-token"
969"#;
970 let cfg = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap();
971 assert!(
972 !cfg.auth.token_cache_path.starts_with('~'),
973 "tilde should be expanded, got: {}",
974 cfg.auth.token_cache_path
975 );
976 let home = dirs::home_dir().unwrap().to_string_lossy().to_string();
977 assert_eq!(cfg.auth.token_cache_path, format!("{home}/.rhood-token"));
978 }
979
980 #[test]
981 fn absolute_path_unchanged() {
982 let toml = r#"
983[auth]
984token_cache_path = "/tmp/my-token"
985"#;
986 let cfg = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap();
987 assert_eq!(cfg.auth.token_cache_path, "/tmp/my-token");
988 }
989
990 #[test]
991 fn base_url_trailing_slash_normalized() {
992 let toml = r#"
993[api]
994base_url = "https://api.robinhood.com/"
995phoenix_url = "https://phoenix.robinhood.com/"
996"#;
997 let cfg = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap();
998 assert_eq!(cfg.api.base_url, "https://api.robinhood.com");
999 assert_eq!(cfg.api.phoenix_url, "https://phoenix.robinhood.com");
1000 }
1001
1002 #[test]
1003 fn default_config_read_only_is_true() {
1004 let cfg = RhoodConfig::default();
1005 assert!(cfg.read_only);
1006 }
1007
1008 #[test]
1009 fn read_only_from_toml() {
1010 let toml = "read_only = true\n";
1011 let cfg = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap();
1012 assert!(cfg.read_only);
1013 }
1014
1015 #[test]
1016 fn read_only_env_override() {
1017 let env = MapEnv::new().with("RHOOD_READ_ONLY", "true");
1018 let cfg = RhoodConfig::from_toml_with_env("read_only = false\n", &env).unwrap();
1019 assert!(cfg.read_only);
1020 }
1021
1022 #[test]
1023 fn read_only_env_override_false_enables_writes() {
1024 let env = MapEnv::new().with("RHOOD_READ_ONLY", "false");
1025 let cfg = RhoodConfig::from_toml_with_env("", &env).unwrap();
1026 assert!(!cfg.read_only);
1027 }
1028
1029 #[test]
1030 fn secret_file_reads_and_trims() {
1031 let dir = tempfile::tempdir().unwrap();
1032 let secret_path = dir.path().join("password.txt");
1033 std::fs::write(&secret_path, " hunter2\n ").unwrap();
1034 #[cfg(unix)]
1035 std::fs::set_permissions(&secret_path, std::fs::Permissions::from_mode(0o600)).unwrap();
1036
1037 let toml = format!(
1038 r#"
1039[auth]
1040password_file = "{}"
1041"#,
1042 secret_path.display()
1043 );
1044 let cfg = RhoodConfig::from_toml_with_env(&toml, &MapEnv::new()).unwrap();
1045 assert_eq!(expose_secret_opt(&cfg.auth.password), Some("hunter2"));
1046 assert!(
1047 cfg.auth.password_file.is_none(),
1048 "password_file should be consumed"
1049 );
1050 }
1051
1052 #[test]
1053 fn secret_file_conflict_is_error() {
1054 let dir = tempfile::tempdir().unwrap();
1055 let secret_path = dir.path().join("password.txt");
1056 std::fs::write(&secret_path, "from-file").unwrap();
1057
1058 let toml = format!(
1059 r#"
1060[auth]
1061password = "inline-value"
1062password_file = "{}"
1063"#,
1064 secret_path.display()
1065 );
1066 let err = RhoodConfig::from_toml_with_env(&toml, &MapEnv::new()).unwrap_err();
1067 let msg = err.to_string();
1068 assert!(
1069 msg.contains("Conflicting")
1070 && msg.contains("password")
1071 && msg.contains("password_file"),
1072 "unexpected error: {msg}"
1073 );
1074 }
1075
1076 #[test]
1077 fn secret_file_env_conflict() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let secret_path = dir.path().join("password.txt");
1080 std::fs::write(&secret_path, "from-file").unwrap();
1081
1082 let env = MapEnv::new()
1083 .with(ENV_PASSWORD, "from-env")
1084 .with(ENV_PASSWORD_FILE, secret_path.to_str().unwrap());
1085 let err = RhoodConfig::from_toml_with_env("", &env).unwrap_err();
1086 let msg = err.to_string();
1087 assert!(msg.contains("Conflicting"), "unexpected error: {msg}");
1088 }
1089
1090 #[test]
1091 fn secret_file_missing_is_error() {
1092 let toml = r#"
1093[auth]
1094password_file = "/tmp/nonexistent-rhood-secret-file-12345"
1095"#;
1096 let err = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap_err();
1097 let msg = err.to_string();
1098 assert!(
1099 msg.contains("Secret file not found") && msg.contains("password_file"),
1100 "unexpected error: {msg}"
1101 );
1102 }
1103
1104 #[test]
1105 fn secret_file_env_overrides_toml_file() {
1106 let dir = tempfile::tempdir().unwrap();
1107 let secret_path = dir.path().join("password.txt");
1108 std::fs::write(&secret_path, "from-file").unwrap();
1109
1110 let env = MapEnv::new().with(ENV_PASSWORD, "from-env");
1111 let toml = format!(
1112 r#"
1113[auth]
1114password_file = "{}"
1115"#,
1116 secret_path.display()
1117 );
1118 let cfg = RhoodConfig::from_toml_with_env(&toml, &env).unwrap();
1119 assert_eq!(expose_secret_opt(&cfg.auth.password), Some("from-env"));
1120 }
1121
1122 #[test]
1123 fn default_config_has_bonfire_url() {
1124 let cfg = RhoodConfig::default();
1125 assert_eq!(cfg.api.bonfire_url, "https://bonfire.robinhood.com");
1126 }
1127
1128 #[test]
1129 fn bonfire_url_from_toml() {
1130 let toml = r#"
1131[api]
1132bonfire_url = "https://custom.bonfire.com"
1133"#;
1134 let cfg = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap();
1135 assert_eq!(cfg.api.bonfire_url, "https://custom.bonfire.com");
1136 }
1137
1138 #[test]
1139 fn bonfire_url_env_override() {
1140 let env = MapEnv::new().with("RHOOD_BONFIRE_URL", "https://env.bonfire.com");
1141 let cfg = RhoodConfig::from_toml_with_env("", &env).unwrap();
1142 assert_eq!(cfg.api.bonfire_url, "https://env.bonfire.com");
1143 }
1144
1145 #[test]
1146 fn bonfire_url_trailing_slash_normalized() {
1147 let toml = r#"
1148[api]
1149bonfire_url = "https://bonfire.robinhood.com/"
1150"#;
1151 let cfg = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap();
1152 assert_eq!(cfg.api.bonfire_url, "https://bonfire.robinhood.com");
1153 }
1154
1155 #[test]
1156 fn debug_redacts_auth_secrets() {
1157 let toml = r#"
1158[auth]
1159username = "alice"
1160password = "hunter2"
1161mfa_secret = "JBSWY3DPEHPK3PXP"
1162device_token = "dev-tok-123"
1163"#;
1164 let cfg = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap();
1165 let debug_output = format!("{:?}", cfg.auth);
1166 assert!(
1167 debug_output.contains("[REDACTED]"),
1168 "Debug should contain [REDACTED]: {debug_output}"
1169 );
1170 assert!(
1171 !debug_output.contains("hunter2"),
1172 "Debug should not contain password: {debug_output}"
1173 );
1174 assert!(
1175 !debug_output.contains("JBSWY3DPEHPK3PXP"),
1176 "Debug should not contain mfa_secret: {debug_output}"
1177 );
1178 assert!(
1179 !debug_output.contains("dev-tok-123"),
1180 "Debug should not contain device_token: {debug_output}"
1181 );
1182 assert!(
1183 debug_output.contains("alice"),
1184 "Debug should show non-secret username: {debug_output}"
1185 );
1186 }
1187
1188 #[test]
1189 fn secret_fields_deserialize_from_toml() {
1190 let toml = r#"
1191[auth]
1192password = "p@ssword!"
1193mfa_secret = "TOTP_SECRET"
1194device_token = "device-123"
1195"#;
1196 let cfg = RhoodConfig::from_toml_with_env(toml, &MapEnv::new()).unwrap();
1197 assert_eq!(expose_secret_opt(&cfg.auth.password), Some("p@ssword!"));
1198 assert_eq!(expose_secret_opt(&cfg.auth.mfa_secret), Some("TOTP_SECRET"));
1199 assert_eq!(
1200 expose_secret_opt(&cfg.auth.device_token),
1201 Some("device-123")
1202 );
1203 }
1204
1205 #[test]
1206 fn secret_fields_default_to_none() {
1207 let cfg = RhoodConfig::default();
1208 assert!(cfg.auth.password.is_none());
1209 assert!(cfg.auth.mfa_secret.is_none());
1210 assert!(cfg.auth.device_token.is_none());
1211 }
1212
1213 #[test]
1214 fn http_config_defaults() {
1215 let cfg = HttpConfig::default();
1216 assert_eq!(cfg.request_timeout_secs, DEFAULT_HTTP_REQUEST_TIMEOUT_SECS);
1217 assert_eq!(cfg.connect_timeout_secs, DEFAULT_HTTP_CONNECT_TIMEOUT_SECS);
1218 }
1219
1220 #[test]
1221 fn http_env_overrides_applied() {
1222 let env = MapEnv::new()
1223 .with(ENV_HTTP_REQUEST_TIMEOUT_SECS, "45")
1224 .with(ENV_HTTP_CONNECT_TIMEOUT_SECS, "5");
1225 let mut cfg = RhoodConfig::default();
1226 cfg.apply_env_overrides(&env).unwrap();
1227 assert_eq!(cfg.http.request_timeout_secs, 45);
1228 assert_eq!(cfg.http.connect_timeout_secs, 5);
1229 }
1230
1231 #[test]
1232 fn malformed_numeric_environment_override_fails_configuration_loading() {
1233 let env = MapEnv::new().with(ENV_HTTP_REQUEST_TIMEOUT_SECS, "not-a-number");
1234
1235 let error = RhoodConfig::from_toml_with_env("", &env)
1236 .expect_err("malformed override must not fall back to defaults");
1237 let message = error.to_string();
1238
1239 assert!(message.contains(ENV_HTTP_REQUEST_TIMEOUT_SECS));
1240 assert!(message.contains("\"not-a-number\""));
1241 }
1242
1243 #[test]
1244 fn empty_numeric_environment_override_does_not_override_toml() {
1245 let env = MapEnv::new().with(ENV_HTTP_REQUEST_TIMEOUT_SECS, "");
1246 let config =
1247 RhoodConfig::from_toml_with_env("[http]\nrequest_timeout_secs = 45\n", &env).unwrap();
1248
1249 assert_eq!(config.http.request_timeout_secs, 45);
1250 }
1251
1252 #[test]
1253 fn cache_config_defaults() {
1254 let cfg = CacheConfig::default();
1255 assert!(cfg.enabled);
1256 assert_eq!(cfg.instrument_ttl_secs, DEFAULT_CACHE_INSTRUMENT_TTL_SECS);
1257 assert_eq!(
1258 cfg.instrument_max_entries,
1259 DEFAULT_CACHE_INSTRUMENT_MAX_ENTRIES
1260 );
1261 assert_eq!(
1262 cfg.instrument_id_ttl_secs,
1263 DEFAULT_CACHE_INSTRUMENT_ID_TTL_SECS
1264 );
1265 assert_eq!(
1266 cfg.instrument_id_max_entries,
1267 DEFAULT_CACHE_INSTRUMENT_ID_MAX_ENTRIES
1268 );
1269 assert_eq!(cfg.index_ttl_secs, DEFAULT_CACHE_INDEX_TTL_SECS);
1270 assert_eq!(cfg.index_max_entries, DEFAULT_CACHE_INDEX_MAX_ENTRIES);
1271 assert_eq!(cfg.futures_ttl_secs, DEFAULT_CACHE_FUTURES_TTL_SECS);
1272 assert_eq!(cfg.futures_max_entries, DEFAULT_CACHE_FUTURES_MAX_ENTRIES);
1273 assert_eq!(
1274 cfg.enrichment_batch_size,
1275 DEFAULT_CACHE_ENRICHMENT_BATCH_SIZE
1276 );
1277 }
1278
1279 #[test]
1280 fn cache_env_overrides_applied() {
1281 let env = MapEnv::new()
1282 .with(ENV_CACHE_ENABLED, "false")
1283 .with(ENV_CACHE_INSTRUMENT_TTL_SECS, "60")
1284 .with(ENV_CACHE_INSTRUMENT_MAX_ENTRIES, "1234")
1285 .with(ENV_CACHE_INSTRUMENT_ID_TTL_SECS, "120")
1286 .with(ENV_CACHE_INSTRUMENT_ID_MAX_ENTRIES, "5678")
1287 .with(ENV_CACHE_INDEX_TTL_SECS, "30")
1288 .with(ENV_CACHE_INDEX_MAX_ENTRIES, "9")
1289 .with(ENV_CACHE_FUTURES_TTL_SECS, "600")
1290 .with(ENV_CACHE_FUTURES_MAX_ENTRIES, "50")
1291 .with(ENV_CACHE_ENRICHMENT_BATCH_SIZE, "25");
1292 let mut cfg = RhoodConfig::default();
1293 cfg.apply_env_overrides(&env).unwrap();
1294 assert!(!cfg.cache.enabled);
1295 assert_eq!(cfg.cache.instrument_ttl_secs, 60);
1296 assert_eq!(cfg.cache.instrument_max_entries, 1234);
1297 assert_eq!(cfg.cache.instrument_id_ttl_secs, 120);
1298 assert_eq!(cfg.cache.instrument_id_max_entries, 5678);
1299 assert_eq!(cfg.cache.index_ttl_secs, 30);
1300 assert_eq!(cfg.cache.index_max_entries, 9);
1301 assert_eq!(cfg.cache.futures_ttl_secs, 600);
1302 assert_eq!(cfg.cache.futures_max_entries, 50);
1303 assert_eq!(cfg.cache.enrichment_batch_size, 25);
1304 }
1305}