Skip to main content

rhood_core/
config.rs

1//! Configuration for the rhood-core client.
2//!
3//! [`RhoodConfig`] is loaded from a TOML file, environment variables, and
4//! built-in defaults. Environment variables override TOML values, and secrets
5//! can be supplied via `*_file` fields that read from disk.
6
7use 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; // 24h
29const DEFAULT_CACHE_INSTRUMENT_MAX_ENTRIES: u64 = 10_000;
30const DEFAULT_CACHE_INSTRUMENT_ID_TTL_SECS: u64 = 86_400; // 24h
31const DEFAULT_CACHE_INSTRUMENT_ID_MAX_ENTRIES: u64 = 50_000;
32const DEFAULT_CACHE_INDEX_TTL_SECS: u64 = 604_800; // 7d
33const DEFAULT_CACHE_INDEX_MAX_ENTRIES: u64 = 100;
34const DEFAULT_CACHE_FUTURES_TTL_SECS: u64 = 3_600; // 1h
35const 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/// Top-level configuration for the Robinhood API client.
70///
71/// Loaded via [`RhoodConfig::load`] from a TOML file, environment variables,
72/// and built-in defaults. Defaults to read-only mode.
73#[derive(Debug, Clone, Deserialize)]
74#[serde(default)]
75pub struct RhoodConfig {
76    /// When `true`, order placement and cancellation are blocked.
77    pub read_only: bool,
78    /// Authentication credentials and token caching settings.
79    pub auth: AuthConfig,
80    /// API base URLs.
81    pub api: ApiConfig,
82    /// Device verification polling and timeout settings.
83    pub device_verification: DeviceVerificationConfig,
84    /// Logging configuration.
85    pub log: LogConfig,
86    /// HTTP transport timeouts shared by all outbound clients.
87    pub http: HttpConfig,
88    /// Identity/metadata resolver caches (symbol ↔ instrument id, etc.).
89    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/// Authentication credentials and token caching settings.
107#[derive(Clone, Deserialize)]
108#[serde(default)]
109pub struct AuthConfig {
110    /// Robinhood account username (email or phone).
111    pub username: Option<String>,
112    /// Robinhood account password. Mutually exclusive with `password_file`.
113    pub password: Option<SecretString>,
114    /// Base32-encoded TOTP secret for MFA. Mutually exclusive with `mfa_secret_file`.
115    pub mfa_secret: Option<SecretString>,
116    /// OAuth2 client ID for the Robinhood API.
117    pub client_id: String,
118    /// Persistent device token. Mutually exclusive with `device_token_file`.
119    pub device_token: Option<SecretString>,
120    /// OAuth token lifetime in seconds.
121    pub token_expiry_secs: u64,
122    /// Filesystem path for the on-disk token cache (tilde-expanded).
123    pub token_cache_path: String,
124    /// Path to a file containing the password (alternative to inline `password`).
125    pub password_file: Option<String>,
126    /// Path to a file containing the MFA secret (alternative to inline `mfa_secret`).
127    pub mfa_secret_file: Option<String>,
128    /// Path to a file containing the device token (alternative to inline `device_token`).
129    pub device_token_file: Option<String>,
130}
131
132/// Robinhood API base URLs.
133#[derive(Debug, Clone, Deserialize)]
134#[serde(default)]
135pub struct ApiConfig {
136    /// Base URL for the main Robinhood REST API.
137    pub base_url: String,
138    /// Base URL for the Phoenix unified accounts API.
139    pub phoenix_url: String,
140    /// Base URL for the Bonfire API (recurring investments, unified transfers).
141    pub bonfire_url: String,
142}
143
144/// Settings for the device verification polling loop.
145#[derive(Debug, Clone, Deserialize)]
146#[serde(default)]
147pub struct DeviceVerificationConfig {
148    /// Seconds between poll attempts during device verification.
149    pub poll_interval_secs: u64,
150    /// Maximum seconds to wait for device approval before timing out.
151    pub timeout_secs: u64,
152}
153
154/// Bounded timeouts for every outbound HTTP call.
155///
156/// `request_timeout_secs` is the total-call ceiling (headers + body). A slow
157/// or hung upstream is aborted with a timeout error which prevents a single
158/// stuck call from starving concurrent tool invocations. `connect_timeout_secs`
159/// is the TCP-connect ceiling; it triggers before the request timeout when
160/// the host is unreachable.
161#[derive(Debug, Clone, Deserialize)]
162#[serde(default)]
163pub struct HttpConfig {
164    /// Total request timeout in seconds (headers + body).
165    pub request_timeout_secs: u64,
166    /// TCP connect timeout in seconds.
167    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/// Identity/metadata caches for symbol ↔ ID resolution.
180///
181/// Financial data is **never** cached (quotes, prices, positions, orders,
182/// etc.). Only immutable lookups: instrument metadata, index instruments,
183/// futures contracts, and the singleton futures account ID.
184///
185/// Set `enabled = false` to bypass all caches and hit upstream on every call.
186#[derive(Debug, Clone, Deserialize)]
187#[serde(default)]
188pub struct CacheConfig {
189    /// Master switch. When `false`, every cached wrapper falls through to the
190    /// underlying resolver and nothing is inserted into the caches.
191    pub enabled: bool,
192    /// Time-to-live for the symbol → instrument cache, in seconds.
193    pub instrument_ttl_secs: u64,
194    /// Maximum entries in the symbol → instrument cache.
195    pub instrument_max_entries: u64,
196    /// Time-to-live for the uuid → symbol reverse map, in seconds.
197    pub instrument_id_ttl_secs: u64,
198    /// Maximum entries in the uuid → symbol reverse map.
199    pub instrument_id_max_entries: u64,
200    /// Time-to-live for the symbol → index instrument cache, in seconds.
201    pub index_ttl_secs: u64,
202    /// Maximum entries in the symbol → index instrument cache.
203    pub index_max_entries: u64,
204    /// Time-to-live for the symbol → futures contract cache, in seconds.
205    pub futures_ttl_secs: u64,
206    /// Maximum entries in the symbol → futures contract cache.
207    pub futures_max_entries: u64,
208    /// Batch size for `resolve_symbols` chunked `?ids=` query-string requests.
209    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/// Logging configuration.
230#[derive(Debug, Clone, Deserialize)]
231#[serde(default)]
232pub struct LogConfig {
233    /// Tracing filter level (e.g., `"info"`, `"debug"`, `"trace"`).
234    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
301/// Rejects a secret-bearing file that grants any group or other access.
302///
303/// On non-Unix platforms, filesystem permission bits are not portable, so
304/// this check is intentionally a no-op.
305pub 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
341/// Resolves a secret value from either a direct value or a file path.
342///
343/// If both `direct` and `file_path` are set, returns an error. If only `file_path`
344/// is set, reads the file, trims whitespace, and stores the result in `direct`.
345pub 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        // path starts with "~/" or "~something", we are only expanding "~/"
375        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    /// Loads configuration from a TOML file, environment variables, and defaults.
391    ///
392    /// Resolution order:
393    /// 1. If `path` is `Some`, use that file (error if it does not exist).
394    /// 2. Otherwise check `RHOOD_CONFIG`, platform config dir, and XDG paths.
395    /// 3. If no file is found, start with defaults.
396    /// 4. Apply environment variable overrides.
397    /// 5. Resolve `*_file` secrets and normalize paths/URLs.
398    ///
399    /// Reads environment variables from the real process environment via
400    /// [`SystemEnv`]. For test injection, see [`RhoodConfig::load_with_env`].
401    ///
402    /// # Errors
403    ///
404    /// Returns an error if an explicitly provided file does not exist, the TOML
405    /// is invalid, an environment override is malformed, or secret file resolution fails.
406    pub fn load(path: Option<&Path>) -> Result<Self> {
407        Self::load_with_env(path, &SystemEnv)
408    }
409
410    /// Like [`RhoodConfig::load`], but reads env vars from the given [`Env`].
411    ///
412    /// Intended for tests: pass a [`MapEnv`](crate::env::MapEnv) preloaded with the env values the
413    /// test cares about. Production code should call [`RhoodConfig::load`].
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if an explicitly provided file does not exist, the TOML
418    /// is invalid, an environment override is malformed, or secret file resolution fails.
419    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    /// Parses configuration from a TOML string, then applies environment
452    /// overrides and secret file resolution.
453    ///
454    /// Reads env vars from [`SystemEnv`]. For test injection, see
455    /// [`RhoodConfig::from_toml_with_env`].
456    ///
457    /// # Errors
458    ///
459    /// Returns an error if the TOML is invalid, an environment override is malformed,
460    /// or secret file resolution fails.
461    pub fn from_toml(toml_str: &str) -> Result<Self> {
462        Self::from_toml_with_env(toml_str, &SystemEnv)
463    }
464
465    /// Like [`RhoodConfig::from_toml`], but reads env vars from the given [`Env`].
466    ///
467    /// # Errors
468    ///
469    /// Returns an error if the TOML is invalid, an environment override is malformed,
470    /// or secret file resolution fails.
471    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        // Check platform config dir first (e.g. ~/Library/Application Support on macOS),
489        // then fall back to XDG-style ~/.config for cross-platform consistency.
490        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    /// Applies environment variable overrides, resolves secret files, and
506    /// normalizes paths/URLs, reading env from [`SystemEnv`].
507    ///
508    /// Called automatically by [`load`](Self::load) and [`from_toml`](Self::from_toml).
509    /// Also useful when a downstream crate (e.g. `rhood-mcp`) deserializes its own
510    /// config struct that embeds `RhoodConfig` via `#[serde(flatten)]`.
511    ///
512    /// For test injection, see
513    /// [`apply_env_overrides_and_normalize_with_env`](Self::apply_env_overrides_and_normalize_with_env).
514    ///
515    /// # Errors
516    ///
517    /// Returns an error if an environment override is malformed or secret file resolution fails.
518    pub fn apply_env_overrides_and_normalize(&mut self) -> Result<()> {
519        self.apply_env_overrides_and_normalize_with_env(&SystemEnv)
520    }
521
522    /// Like [`apply_env_overrides_and_normalize`](Self::apply_env_overrides_and_normalize),
523    /// but reads env vars from the given [`Env`].
524    ///
525    /// # Errors
526    ///
527    /// Returns an error if an environment override is malformed or secret file resolution fails.
528    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    /// Restores invariants after in-place mutation of configuration fields.
661    ///
662    /// Expands a leading `~` in [`auth.token_cache_path`](AuthConfig::token_cache_path)
663    /// and strips trailing slashes from API base URLs.
664    ///
665    /// Called automatically by [`load`](Self::load), [`from_toml`](Self::from_toml),
666    /// and [`apply_env_overrides_and_normalize`](Self::apply_env_overrides_and_normalize).
667    /// Callers that mutate fields directly (e.g. applying CLI flag overrides after
668    /// load) should call this to keep the resulting config consistent.
669    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    /// Expose an `Option<SecretString>` as `Option<&str>` for test assertions.
694    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        // Smoke test only: setting a concrete process env var here would race
701        // with parallel tests reading the same key. We assert the absence of
702        // a key that is unlikely to be set in any CI environment instead.
703        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        // Matches SystemEnv / std::env::var semantics: an empty value is not
727        // the same as an unset key.
728        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        // With no RHOOD_CONFIG env and no file at the default path, load(None) returns defaults.
827        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}