Skip to main content

emotiv_cortex_v2/
config.rs

1//! # Configuration
2//!
3//! [`CortexConfig`] holds everything needed to connect to the Cortex API.
4//!
5//! ## Loading Priority
6//!
7//! Configuration is loaded from the first source that provides a value:
8//!
9//! 1. Explicit struct fields (programmatic construction)
10//! 2. Environment variables (`EMOTIV_CLIENT_ID`, `EMOTIV_CLIENT_SECRET`, etc.)
11//! 3. TOML config file at an explicit path
12//! 4. `./cortex.toml` in the current directory
13//! 5. `~/.config/emotiv-cortex/cortex.toml`
14//!
15//! Individual fields can always be overridden by environment variables,
16//! even when loading from a file.
17
18use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20
21use crate::error::{CortexError, CortexResult};
22
23/// Default Cortex WebSocket URL (localhost, self-signed TLS).
24pub const DEFAULT_CORTEX_URL: &str = "wss://localhost:6868";
25
26/// Default RPC call timeout in seconds.
27const DEFAULT_RPC_TIMEOUT_SECS: u64 = 10;
28
29/// Default stream subscribe timeout in seconds.
30const DEFAULT_SUBSCRIBE_TIMEOUT_SECS: u64 = 15;
31
32/// Default headset connection timeout in seconds.
33const DEFAULT_HEADSET_CONNECT_TIMEOUT_SECS: u64 = 30;
34
35/// Default reconnect base delay in seconds.
36const DEFAULT_RECONNECT_BASE_DELAY_SECS: u64 = 1;
37
38/// Default reconnect max delay in seconds.
39const DEFAULT_RECONNECT_MAX_DELAY_SECS: u64 = 60;
40
41/// Default max reconnect attempts (0 = unlimited).
42const DEFAULT_RECONNECT_MAX_ATTEMPTS: u32 = 0;
43
44/// Default health check interval in seconds.
45const DEFAULT_HEALTH_INTERVAL_SECS: u64 = 30;
46
47/// Default max consecutive health check failures before reconnect.
48const DEFAULT_HEALTH_MAX_FAILURES: u32 = 3;
49
50/// Configuration for connecting to the Emotiv Cortex API.
51///
52/// # Examples
53///
54/// ## From environment variables
55///
56/// ```no_run
57/// use emotiv_cortex_v2::config::CortexConfig;
58///
59/// // Set EMOTIV_CLIENT_ID and EMOTIV_CLIENT_SECRET env vars, then:
60/// let config = CortexConfig::from_env().expect("Missing env vars");
61/// ```
62///
63/// ## From a TOML file
64///
65/// ```no_run
66/// use emotiv_cortex_v2::config::CortexConfig;
67///
68/// let config = CortexConfig::from_file("cortex.toml").expect("Bad config");
69/// ```
70///
71/// ## Programmatic
72///
73/// ```
74/// use emotiv_cortex_v2::config::CortexConfig;
75///
76/// let config = CortexConfig::new("my-client-id", "my-client-secret");
77/// ```
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct CortexConfig {
80    /// Cortex API client ID from the [Emotiv Developer Portal](https://www.emotiv.com/developer/).
81    pub client_id: String,
82
83    /// Cortex API client secret.
84    pub client_secret: String,
85
86    /// WebSocket URL for the Cortex service.
87    #[serde(default = "default_cortex_url")]
88    pub cortex_url: String,
89
90    /// Emotiv license key for commercial/premium features.
91    #[serde(default)]
92    pub license: Option<String>,
93
94    /// Request decontaminated EEG data (motion artifact removal).
95    #[serde(default = "default_true")]
96    pub decontaminated: bool,
97
98    /// Allow insecure TLS connections to non-localhost hosts.
99    /// Only enable this for development/testing.
100    #[serde(default)]
101    pub allow_insecure_tls: bool,
102
103    /// Timeout configuration.
104    #[serde(default)]
105    pub timeouts: TimeoutConfig,
106
107    /// Auto-reconnect configuration.
108    #[serde(default)]
109    pub reconnect: ReconnectConfig,
110
111    /// Health monitoring configuration.
112    #[serde(default)]
113    pub health: HealthConfig,
114}
115
116/// Timeout settings for various Cortex operations.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct TimeoutConfig {
119    /// Timeout for individual JSON-RPC calls, in seconds.
120    #[serde(default = "default_rpc_timeout")]
121    pub rpc_timeout_secs: u64,
122
123    /// Timeout for stream subscribe operations, in seconds.
124    #[serde(default = "default_subscribe_timeout")]
125    pub subscribe_timeout_secs: u64,
126
127    /// Timeout for headset connection, in seconds.
128    #[serde(default = "default_headset_connect_timeout")]
129    pub headset_connect_timeout_secs: u64,
130}
131
132/// Auto-reconnect behavior when the WebSocket connection drops.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ReconnectConfig {
135    /// Enable auto-reconnect on connection loss.
136    #[serde(default = "default_true")]
137    pub enabled: bool,
138
139    /// Initial delay before the first reconnect attempt, in seconds.
140    #[serde(default = "default_reconnect_base_delay")]
141    pub base_delay_secs: u64,
142
143    /// Maximum delay between reconnect attempts (exponential backoff cap), in seconds.
144    #[serde(default = "default_reconnect_max_delay")]
145    pub max_delay_secs: u64,
146
147    /// Maximum number of reconnect attempts. 0 means unlimited.
148    #[serde(default = "default_reconnect_max_attempts")]
149    pub max_attempts: u32,
150}
151
152/// Health monitoring configuration (periodic heartbeat).
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct HealthConfig {
155    /// Enable periodic health checks.
156    #[serde(default = "default_true")]
157    pub enabled: bool,
158
159    /// Interval between health check calls, in seconds.
160    #[serde(default = "default_health_interval")]
161    pub interval_secs: u64,
162
163    /// Number of consecutive health check failures before triggering reconnect.
164    #[serde(default = "default_health_max_failures")]
165    pub max_consecutive_failures: u32,
166}
167
168// ─── Defaults ───────────────────────────────────────────────────────────
169
170fn default_cortex_url() -> String {
171    DEFAULT_CORTEX_URL.to_string()
172}
173
174fn default_true() -> bool {
175    true
176}
177
178fn default_rpc_timeout() -> u64 {
179    DEFAULT_RPC_TIMEOUT_SECS
180}
181
182fn default_subscribe_timeout() -> u64 {
183    DEFAULT_SUBSCRIBE_TIMEOUT_SECS
184}
185
186fn default_headset_connect_timeout() -> u64 {
187    DEFAULT_HEADSET_CONNECT_TIMEOUT_SECS
188}
189
190fn default_reconnect_base_delay() -> u64 {
191    DEFAULT_RECONNECT_BASE_DELAY_SECS
192}
193
194fn default_reconnect_max_delay() -> u64 {
195    DEFAULT_RECONNECT_MAX_DELAY_SECS
196}
197
198fn default_reconnect_max_attempts() -> u32 {
199    DEFAULT_RECONNECT_MAX_ATTEMPTS
200}
201
202fn default_health_interval() -> u64 {
203    DEFAULT_HEALTH_INTERVAL_SECS
204}
205
206fn default_health_max_failures() -> u32 {
207    DEFAULT_HEALTH_MAX_FAILURES
208}
209
210// ─── Default impls ──────────────────────────────────────────────────────
211
212impl Default for TimeoutConfig {
213    fn default() -> Self {
214        Self {
215            rpc_timeout_secs: DEFAULT_RPC_TIMEOUT_SECS,
216            subscribe_timeout_secs: DEFAULT_SUBSCRIBE_TIMEOUT_SECS,
217            headset_connect_timeout_secs: DEFAULT_HEADSET_CONNECT_TIMEOUT_SECS,
218        }
219    }
220}
221
222impl Default for ReconnectConfig {
223    fn default() -> Self {
224        Self {
225            enabled: true,
226            base_delay_secs: DEFAULT_RECONNECT_BASE_DELAY_SECS,
227            max_delay_secs: DEFAULT_RECONNECT_MAX_DELAY_SECS,
228            max_attempts: DEFAULT_RECONNECT_MAX_ATTEMPTS,
229        }
230    }
231}
232
233impl Default for HealthConfig {
234    fn default() -> Self {
235        Self {
236            enabled: true,
237            interval_secs: DEFAULT_HEALTH_INTERVAL_SECS,
238            max_consecutive_failures: DEFAULT_HEALTH_MAX_FAILURES,
239        }
240    }
241}
242
243// ─── CortexConfig impl ─────────────────────────────────────────────────
244
245impl CortexConfig {
246    /// Create a config with just client credentials (all other fields use defaults).
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use emotiv_cortex_v2::CortexConfig;
252    ///
253    /// let config = CortexConfig::new("my-client-id", "my-client-secret");
254    /// assert_eq!(config.cortex_url, "wss://localhost:6868");
255    /// assert!(config.decontaminated);
256    /// ```
257    pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
258        Self {
259            client_id: client_id.into(),
260            client_secret: client_secret.into(),
261            cortex_url: default_cortex_url(),
262            license: None,
263            decontaminated: true,
264            allow_insecure_tls: false,
265            timeouts: TimeoutConfig::default(),
266            reconnect: ReconnectConfig::default(),
267            health: HealthConfig::default(),
268        }
269    }
270
271    /// Load config from environment variables.
272    ///
273    /// Required: `EMOTIV_CLIENT_ID`, `EMOTIV_CLIENT_SECRET`
274    ///
275    /// Optional: `EMOTIV_CORTEX_URL`, `EMOTIV_LICENSE`
276    ///
277    /// # Errors
278    /// Returns any error produced by the underlying Cortex API call,
279    /// including connection, authentication, protocol, timeout, and configuration errors.
280    pub fn from_env() -> CortexResult<Self> {
281        let client_id =
282            std::env::var("EMOTIV_CLIENT_ID").map_err(|_| CortexError::ConfigError {
283                reason: "EMOTIV_CLIENT_ID environment variable not set".into(),
284            })?;
285        let client_secret =
286            std::env::var("EMOTIV_CLIENT_SECRET").map_err(|_| CortexError::ConfigError {
287                reason: "EMOTIV_CLIENT_SECRET environment variable not set".into(),
288            })?;
289
290        let mut config = Self::new(client_id, client_secret);
291
292        if let Ok(url) = std::env::var("EMOTIV_CORTEX_URL") {
293            config.cortex_url = url;
294        }
295        if let Ok(license) = std::env::var("EMOTIV_LICENSE") {
296            config.license = Some(license);
297        }
298
299        Ok(config)
300    }
301
302    /// Load config from a TOML file, with environment variable overrides.
303    ///
304    /// Environment variables take precedence over file values for
305    /// `client_id`, `client_secret`, `cortex_url`, and `license`.
306    ///
307    /// # Errors
308    /// Returns any error produced by the underlying Cortex API call,
309    /// including connection, authentication, protocol, timeout, and configuration errors.
310    pub fn from_file(path: impl AsRef<Path>) -> CortexResult<Self> {
311        let path = path.as_ref();
312        let contents = std::fs::read_to_string(path).map_err(|e| CortexError::ConfigError {
313            reason: format!("Failed to read config file '{}': {}", path.display(), e),
314        })?;
315        let mut config: Self = parse_toml_config(&contents)?;
316
317        // Environment variable overrides
318        if let Ok(id) = std::env::var("EMOTIV_CLIENT_ID") {
319            config.client_id = id;
320        }
321        if let Ok(secret) = std::env::var("EMOTIV_CLIENT_SECRET") {
322            config.client_secret = secret;
323        }
324        if let Ok(url) = std::env::var("EMOTIV_CORTEX_URL") {
325            config.cortex_url = url;
326        }
327        if let Ok(license) = std::env::var("EMOTIV_LICENSE") {
328            config.license = Some(license);
329        }
330
331        Ok(config)
332    }
333
334    /// Discover and load config from the standard search path:
335    ///
336    /// 1. Explicit path (if `Some`)
337    /// 2. `CORTEX_CONFIG` environment variable
338    /// 3. `./cortex.toml`
339    /// 4. `~/.config/emotiv-cortex/cortex.toml`
340    ///
341    /// Falls back to environment-variable-only config if no file is found.
342    ///
343    /// # Errors
344    /// Returns any error produced by the underlying Cortex API call,
345    /// including connection, authentication, protocol, timeout, and configuration errors.
346    pub fn discover(explicit_path: Option<&Path>) -> CortexResult<Self> {
347        // 1. Explicit path
348        if let Some(path) = explicit_path {
349            return Self::from_file(path);
350        }
351
352        // 2. CORTEX_CONFIG env var
353        if let Ok(path) = std::env::var("CORTEX_CONFIG") {
354            let path = PathBuf::from(path);
355            if path.exists() {
356                return Self::from_file(&path);
357            }
358        }
359
360        // 3. ./cortex.toml
361        let local_path = PathBuf::from("cortex.toml");
362        if local_path.exists() {
363            return Self::from_file(&local_path);
364        }
365
366        // 4. ~/.config/emotiv-cortex/cortex.toml
367        if let Some(config_dir) = dirs_config_path() {
368            if config_dir.exists() {
369                return Self::from_file(&config_dir);
370            }
371        }
372
373        // 5. Environment variables only
374        Self::from_env()
375    }
376
377    /// Returns `true` if insecure TLS should be allowed for the configured URL.
378    ///
379    /// Insecure TLS is always allowed for `localhost` and `127.0.0.1`
380    /// (the Cortex service uses a self-signed cert). For other hosts,
381    /// `allow_insecure_tls` must be explicitly set.
382    ///
383    /// # Examples
384    ///
385    /// ```
386    /// use emotiv_cortex_v2::CortexConfig;
387    ///
388    /// let config = CortexConfig::new("id", "secret");
389    /// assert!(config.should_accept_invalid_certs()); // localhost
390    ///
391    /// let mut remote = CortexConfig::new("id", "secret");
392    /// remote.cortex_url = "wss://remote.example.com:6868".into();
393    /// assert!(!remote.should_accept_invalid_certs());
394    /// ```
395    #[must_use]
396    pub fn should_accept_invalid_certs(&self) -> bool {
397        if is_localhost(&self.cortex_url) {
398            return true;
399        }
400        self.allow_insecure_tls
401    }
402}
403
404// ─── Helpers ────────────────────────────────────────────────────────────
405
406#[cfg(feature = "config-toml")]
407fn parse_toml_config(contents: &str) -> CortexResult<CortexConfig> {
408    Ok(toml::from_str(contents)?)
409}
410
411#[cfg(not(feature = "config-toml"))]
412fn parse_toml_config(_contents: &str) -> CortexResult<CortexConfig> {
413    Err(CortexError::ConfigError {
414        reason: "TOML config parsing is disabled. Enable the `config-toml` feature on `emotiv-cortex-v2` to use CortexConfig::from_file/discover file loading.".into(),
415    })
416}
417
418/// Check if a WebSocket URL points to localhost.
419fn is_localhost(url: &str) -> bool {
420    let authority = url
421        .strip_prefix("wss://")
422        .or_else(|| url.strip_prefix("ws://"))
423        .unwrap_or(url);
424
425    // Handle IPv6 bracket notation: [::1]:6868
426    if let Some(rest) = authority.strip_prefix('[') {
427        let host = rest.split(']').next().unwrap_or("");
428        return host == "::1";
429    }
430
431    // Regular host:port — split on last colon to separate port
432    let host = if let Some(idx) = authority.rfind(':') {
433        &authority[..idx]
434    } else {
435        authority
436    };
437    matches!(host, "localhost" | "127.0.0.1")
438}
439
440/// Platform-appropriate config directory path.
441fn dirs_config_path() -> Option<PathBuf> {
442    #[cfg(target_os = "windows")]
443    {
444        std::env::var("APPDATA")
445            .ok()
446            .map(|dir| PathBuf::from(dir).join("emotiv-cortex").join("cortex.toml"))
447    }
448    #[cfg(not(target_os = "windows"))]
449    {
450        std::env::var("HOME").ok().map(|dir| {
451            PathBuf::from(dir)
452                .join(".config")
453                .join("emotiv-cortex")
454                .join("cortex.toml")
455        })
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use std::ffi::{OsStr, OsString};
463    use std::fs;
464    use std::path::{Path, PathBuf};
465    use std::sync::Mutex;
466    use std::time::{SystemTime, UNIX_EPOCH};
467
468    static ENV_LOCK: Mutex<()> = Mutex::new(());
469
470    #[allow(unsafe_code)] // Test-only; guarded by ENV_LOCK; no concurrent env mutation.
471    fn set_env_var<K: AsRef<OsStr>, V: AsRef<OsStr>>(key: K, value: V) {
472        // SAFETY: Test-only helper guarded by `ENV_LOCK`; tests do not spawn threads
473        // while mutating process environment variables.
474        unsafe { std::env::set_var(key, value) };
475    }
476
477    #[allow(unsafe_code)] // Test-only; guarded by ENV_LOCK; no concurrent env mutation.
478    fn remove_env_var<K: AsRef<OsStr>>(key: K) {
479        // SAFETY: Test-only helper guarded by `ENV_LOCK`; tests do not spawn threads
480        // while mutating process environment variables.
481        unsafe { std::env::remove_var(key) };
482    }
483
484    struct EnvGuard {
485        saved: Vec<(&'static str, Option<OsString>)>,
486    }
487
488    impl EnvGuard {
489        fn capture(keys: &[&'static str]) -> Self {
490            let saved = keys.iter().map(|k| (*k, std::env::var_os(k))).collect();
491            Self { saved }
492        }
493    }
494
495    impl Drop for EnvGuard {
496        fn drop(&mut self) {
497            for (key, value) in &self.saved {
498                if let Some(value) = value {
499                    set_env_var(key, value);
500                } else {
501                    remove_env_var(key);
502                }
503            }
504        }
505    }
506
507    struct CurrentDirGuard {
508        original: PathBuf,
509    }
510
511    impl CurrentDirGuard {
512        fn enter(path: &Path) -> Self {
513            let original = std::env::current_dir().unwrap();
514            std::env::set_current_dir(path).unwrap();
515            Self { original }
516        }
517    }
518
519    impl Drop for CurrentDirGuard {
520        fn drop(&mut self) {
521            std::env::set_current_dir(&self.original).unwrap();
522        }
523    }
524
525    fn unique_temp_dir(label: &str) -> PathBuf {
526        let now = SystemTime::now()
527            .duration_since(UNIX_EPOCH)
528            .unwrap()
529            .as_nanos();
530        let dir = std::env::temp_dir().join(format!(
531            "emotiv-cortex-config-tests-{}-{}-{}",
532            label,
533            std::process::id(),
534            now
535        ));
536        fs::create_dir_all(&dir).unwrap();
537        dir
538    }
539
540    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
541        ENV_LOCK
542            .lock()
543            .unwrap_or_else(std::sync::PoisonError::into_inner)
544    }
545
546    fn write_minimal_config(path: &Path, id: &str, secret: &str, url: &str) {
547        fs::write(
548            path,
549            format!(
550                r#"
551client_id = "{id}"
552client_secret = "{secret}"
553cortex_url = "{url}"
554"#
555            ),
556        )
557        .unwrap();
558    }
559
560    #[test]
561    fn test_new_defaults() {
562        let config = CortexConfig::new("id", "secret");
563        assert_eq!(config.client_id, "id");
564        assert_eq!(config.client_secret, "secret");
565        assert_eq!(config.cortex_url, DEFAULT_CORTEX_URL);
566        assert!(config.decontaminated);
567        assert!(!config.allow_insecure_tls);
568        assert_eq!(config.timeouts.rpc_timeout_secs, DEFAULT_RPC_TIMEOUT_SECS);
569        assert!(config.reconnect.enabled);
570        assert!(config.health.enabled);
571    }
572
573    #[test]
574    fn test_is_localhost() {
575        assert!(is_localhost("wss://localhost:6868"));
576        assert!(is_localhost("wss://127.0.0.1:6868"));
577        assert!(is_localhost("ws://localhost:6868"));
578        assert!(is_localhost("wss://[::1]:6868"));
579        assert!(!is_localhost("wss://example.com:6868"));
580        assert!(!is_localhost("wss://192.168.1.100:6868"));
581    }
582
583    #[test]
584    fn test_should_accept_invalid_certs() {
585        let mut config = CortexConfig::new("id", "secret");
586        // Localhost always allowed
587        assert!(config.should_accept_invalid_certs());
588
589        // Non-localhost denied by default
590        config.cortex_url = "wss://remote.example.com:6868".into();
591        assert!(!config.should_accept_invalid_certs());
592
593        // Non-localhost allowed with explicit flag
594        config.allow_insecure_tls = true;
595        assert!(config.should_accept_invalid_certs());
596    }
597
598    #[cfg(feature = "config-toml")]
599    #[test]
600    fn test_deserialize_toml() {
601        let toml_str = r#"
602            client_id = "test-id"
603            client_secret = "test-secret"
604            cortex_url = "wss://localhost:9999"
605            license = "ABCD-1234"
606            decontaminated = false
607
608            [timeouts]
609            rpc_timeout_secs = 30
610
611            [reconnect]
612            enabled = false
613            max_attempts = 5
614
615            [health]
616            interval_secs = 60
617        "#;
618
619        let config: CortexConfig = toml::from_str(toml_str).unwrap();
620        assert_eq!(config.client_id, "test-id");
621        assert_eq!(config.cortex_url, "wss://localhost:9999");
622        assert_eq!(config.license, Some("ABCD-1234".into()));
623        assert!(!config.decontaminated);
624        assert_eq!(config.timeouts.rpc_timeout_secs, 30);
625        assert!(!config.reconnect.enabled);
626        assert_eq!(config.reconnect.max_attempts, 5);
627        assert_eq!(config.health.interval_secs, 60);
628    }
629
630    #[cfg(not(feature = "config-toml"))]
631    #[test]
632    fn test_from_file_requires_config_toml_feature() {
633        let _lock = env_lock();
634        let dir = unique_temp_dir("from-file-disabled-feature");
635        let config_path = dir.join("cortex.toml");
636        fs::write(
637            &config_path,
638            r#"
639client_id = "file-id"
640client_secret = "file-secret"
641"#,
642        )
643        .unwrap();
644
645        let err = CortexConfig::from_file(&config_path).unwrap_err();
646        assert!(matches!(err, CortexError::ConfigError { .. }));
647        assert!(
648            err.to_string().contains("config-toml"),
649            "unexpected error: {err}"
650        );
651
652        fs::remove_dir_all(dir).unwrap();
653    }
654
655    #[test]
656    fn test_from_env_requires_credentials_and_applies_overrides() {
657        let _lock = env_lock();
658        let _env = EnvGuard::capture(&[
659            "EMOTIV_CLIENT_ID",
660            "EMOTIV_CLIENT_SECRET",
661            "EMOTIV_CORTEX_URL",
662            "EMOTIV_LICENSE",
663        ]);
664
665        remove_env_var("EMOTIV_CLIENT_ID");
666        remove_env_var("EMOTIV_CLIENT_SECRET");
667        remove_env_var("EMOTIV_CORTEX_URL");
668        remove_env_var("EMOTIV_LICENSE");
669
670        let missing_id = CortexConfig::from_env().unwrap_err();
671        assert!(matches!(missing_id, CortexError::ConfigError { .. }));
672        assert!(
673            missing_id.to_string().contains("EMOTIV_CLIENT_ID"),
674            "unexpected error: {missing_id}"
675        );
676
677        set_env_var("EMOTIV_CLIENT_ID", "env-id");
678        let missing_secret = CortexConfig::from_env().unwrap_err();
679        assert!(matches!(missing_secret, CortexError::ConfigError { .. }));
680        assert!(
681            missing_secret.to_string().contains("EMOTIV_CLIENT_SECRET"),
682            "unexpected error: {missing_secret}"
683        );
684
685        set_env_var("EMOTIV_CLIENT_SECRET", "env-secret");
686        set_env_var("EMOTIV_CORTEX_URL", "wss://env.example:6868");
687        set_env_var("EMOTIV_LICENSE", "LICENSE-FROM-ENV");
688
689        let config = CortexConfig::from_env().unwrap();
690        assert_eq!(config.client_id, "env-id");
691        assert_eq!(config.client_secret, "env-secret");
692        assert_eq!(config.cortex_url, "wss://env.example:6868");
693        assert_eq!(config.license.as_deref(), Some("LICENSE-FROM-ENV"));
694    }
695
696    #[cfg(feature = "config-toml")]
697    #[test]
698    fn test_from_file_env_overrides_precedence() {
699        let _lock = env_lock();
700        let _env = EnvGuard::capture(&[
701            "EMOTIV_CLIENT_ID",
702            "EMOTIV_CLIENT_SECRET",
703            "EMOTIV_CORTEX_URL",
704            "EMOTIV_LICENSE",
705        ]);
706
707        let dir = unique_temp_dir("from-file-overrides");
708        let config_path = dir.join("cortex.toml");
709        fs::write(
710            &config_path,
711            r#"
712client_id = "file-id"
713client_secret = "file-secret"
714cortex_url = "wss://file.example:6868"
715license = "FILE-LICENSE"
716"#,
717        )
718        .unwrap();
719
720        set_env_var("EMOTIV_CLIENT_ID", "env-id");
721        set_env_var("EMOTIV_CLIENT_SECRET", "env-secret");
722        set_env_var("EMOTIV_CORTEX_URL", "wss://env.example:6868");
723        set_env_var("EMOTIV_LICENSE", "ENV-LICENSE");
724
725        let config = CortexConfig::from_file(&config_path).unwrap();
726        assert_eq!(config.client_id, "env-id");
727        assert_eq!(config.client_secret, "env-secret");
728        assert_eq!(config.cortex_url, "wss://env.example:6868");
729        assert_eq!(config.license.as_deref(), Some("ENV-LICENSE"));
730
731        fs::remove_dir_all(dir).unwrap();
732    }
733
734    #[cfg(feature = "config-toml")]
735    #[test]
736    fn test_discover_search_priority() {
737        let _lock = env_lock();
738        let mut env_keys = vec![
739            "EMOTIV_CLIENT_ID",
740            "EMOTIV_CLIENT_SECRET",
741            "EMOTIV_CORTEX_URL",
742            "EMOTIV_LICENSE",
743            "CORTEX_CONFIG",
744        ];
745        #[cfg(target_os = "windows")]
746        env_keys.push("APPDATA");
747        #[cfg(not(target_os = "windows"))]
748        env_keys.push("HOME");
749        let _env = EnvGuard::capture(&env_keys);
750
751        let root = unique_temp_dir("discover-priority");
752        let cwd = root.join("cwd");
753        fs::create_dir_all(&cwd).unwrap();
754
755        let explicit_path = root.join("explicit.toml");
756        let env_path = root.join("env.toml");
757        write_minimal_config(
758            &explicit_path,
759            "explicit-id",
760            "explicit-secret",
761            "wss://explicit",
762        );
763        write_minimal_config(
764            &env_path,
765            "env-file-id",
766            "env-file-secret",
767            "wss://env-file",
768        );
769
770        let home_root = root.join("home-root");
771        let home_config = {
772            #[cfg(target_os = "windows")]
773            {
774                set_env_var("APPDATA", &home_root);
775                home_root.join("emotiv-cortex").join("cortex.toml")
776            }
777            #[cfg(not(target_os = "windows"))]
778            {
779                set_env_var("HOME", &home_root);
780                home_root
781                    .join(".config")
782                    .join("emotiv-cortex")
783                    .join("cortex.toml")
784            }
785        };
786        fs::create_dir_all(home_config.parent().unwrap()).unwrap();
787        write_minimal_config(&home_config, "home-id", "home-secret", "wss://home");
788        remove_env_var("EMOTIV_CLIENT_ID");
789        remove_env_var("EMOTIV_CLIENT_SECRET");
790        remove_env_var("EMOTIV_CORTEX_URL");
791
792        {
793            let _cwd = CurrentDirGuard::enter(&cwd);
794
795            set_env_var("CORTEX_CONFIG", env_path.to_string_lossy().to_string());
796            write_minimal_config(
797                &cwd.join("cortex.toml"),
798                "local-id",
799                "local-secret",
800                "wss://local",
801            );
802
803            let explicit = CortexConfig::discover(Some(&explicit_path)).unwrap();
804            assert_eq!(explicit.client_id, "explicit-id");
805
806            let via_env_pointer = CortexConfig::discover(None).unwrap();
807            assert_eq!(via_env_pointer.client_id, "env-file-id");
808
809            remove_env_var("CORTEX_CONFIG");
810            let via_local = CortexConfig::discover(None).unwrap();
811            assert_eq!(via_local.client_id, "local-id");
812
813            fs::remove_file(cwd.join("cortex.toml")).unwrap();
814            let via_home = CortexConfig::discover(None).unwrap();
815            assert_eq!(via_home.client_id, "home-id");
816
817            fs::remove_file(&home_config).unwrap();
818            set_env_var("EMOTIV_CLIENT_ID", "fallback-id");
819            set_env_var("EMOTIV_CLIENT_SECRET", "fallback-secret");
820            set_env_var("EMOTIV_CORTEX_URL", "wss://fallback");
821            let via_env_only = CortexConfig::discover(None).unwrap();
822            assert_eq!(via_env_only.client_id, "fallback-id");
823            assert_eq!(via_env_only.client_secret, "fallback-secret");
824            assert_eq!(via_env_only.cortex_url, "wss://fallback");
825        }
826
827        fs::remove_dir_all(root).unwrap();
828    }
829
830    #[cfg(feature = "config-toml")]
831    #[test]
832    fn test_from_file_missing_and_invalid_errors() {
833        let _lock = env_lock();
834        let dir = unique_temp_dir("from-file-errors");
835
836        let missing = CortexConfig::from_file(dir.join("missing.toml")).unwrap_err();
837        assert!(matches!(missing, CortexError::ConfigError { .. }));
838        assert!(
839            missing.to_string().contains("Failed to read config file"),
840            "unexpected error: {missing}"
841        );
842
843        let invalid_path = dir.join("invalid.toml");
844        fs::write(&invalid_path, "client_id = [").unwrap();
845        let invalid = CortexConfig::from_file(&invalid_path).unwrap_err();
846        assert!(matches!(invalid, CortexError::ConfigError { .. }));
847
848        fs::remove_dir_all(dir).unwrap();
849    }
850}