1use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20
21use crate::error::{CortexError, CortexResult};
22
23pub const DEFAULT_CORTEX_URL: &str = "wss://localhost:6868";
25
26const DEFAULT_RPC_TIMEOUT_SECS: u64 = 10;
28
29const DEFAULT_SUBSCRIBE_TIMEOUT_SECS: u64 = 15;
31
32const DEFAULT_HEADSET_CONNECT_TIMEOUT_SECS: u64 = 30;
34
35const DEFAULT_RECONNECT_BASE_DELAY_SECS: u64 = 1;
37
38const DEFAULT_RECONNECT_MAX_DELAY_SECS: u64 = 60;
40
41const DEFAULT_RECONNECT_MAX_ATTEMPTS: u32 = 0;
43
44const DEFAULT_HEALTH_INTERVAL_SECS: u64 = 30;
46
47const DEFAULT_HEALTH_MAX_FAILURES: u32 = 3;
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct CortexConfig {
80 pub client_id: String,
82
83 pub client_secret: String,
85
86 #[serde(default = "default_cortex_url")]
88 pub cortex_url: String,
89
90 #[serde(default)]
92 pub license: Option<String>,
93
94 #[serde(default = "default_true")]
96 pub decontaminated: bool,
97
98 #[serde(default)]
101 pub allow_insecure_tls: bool,
102
103 #[serde(default)]
105 pub timeouts: TimeoutConfig,
106
107 #[serde(default)]
109 pub reconnect: ReconnectConfig,
110
111 #[serde(default)]
113 pub health: HealthConfig,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct TimeoutConfig {
119 #[serde(default = "default_rpc_timeout")]
121 pub rpc_timeout_secs: u64,
122
123 #[serde(default = "default_subscribe_timeout")]
125 pub subscribe_timeout_secs: u64,
126
127 #[serde(default = "default_headset_connect_timeout")]
129 pub headset_connect_timeout_secs: u64,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ReconnectConfig {
135 #[serde(default = "default_true")]
137 pub enabled: bool,
138
139 #[serde(default = "default_reconnect_base_delay")]
141 pub base_delay_secs: u64,
142
143 #[serde(default = "default_reconnect_max_delay")]
145 pub max_delay_secs: u64,
146
147 #[serde(default = "default_reconnect_max_attempts")]
149 pub max_attempts: u32,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct HealthConfig {
155 #[serde(default = "default_true")]
157 pub enabled: bool,
158
159 #[serde(default = "default_health_interval")]
161 pub interval_secs: u64,
162
163 #[serde(default = "default_health_max_failures")]
165 pub max_consecutive_failures: u32,
166}
167
168fn 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
210impl 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
243impl CortexConfig {
246 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 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 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 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 pub fn discover(explicit_path: Option<&Path>) -> CortexResult<Self> {
347 if let Some(path) = explicit_path {
349 return Self::from_file(path);
350 }
351
352 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 let local_path = PathBuf::from("cortex.toml");
362 if local_path.exists() {
363 return Self::from_file(&local_path);
364 }
365
366 if let Some(config_dir) = dirs_config_path() {
368 if config_dir.exists() {
369 return Self::from_file(&config_dir);
370 }
371 }
372
373 Self::from_env()
375 }
376
377 #[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#[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
418fn 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 if let Some(rest) = authority.strip_prefix('[') {
427 let host = rest.split(']').next().unwrap_or("");
428 return host == "::1";
429 }
430
431 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
440fn 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)] fn set_env_var<K: AsRef<OsStr>, V: AsRef<OsStr>>(key: K, value: V) {
472 unsafe { std::env::set_var(key, value) };
475 }
476
477 #[allow(unsafe_code)] fn remove_env_var<K: AsRef<OsStr>>(key: K) {
479 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 assert!(config.should_accept_invalid_certs());
588
589 config.cortex_url = "wss://remote.example.com:6868".into();
591 assert!(!config.should_accept_invalid_certs());
592
593 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}