riglr_config/
lib.rs

1//! # RIGLR Configuration
2//!
3//! Unified, hierarchical configuration management for RIGLR applications.
4//!
5//! ## Features
6//!
7//! - **Single source of truth**: All configuration in one place
8//! - **Environment-based**: Supports dev, staging, and production environments
9//! - **Convention over configuration**: RPC_URL_{CHAIN_ID} pattern for dynamic chain support
10//! - **Fail-fast validation**: Catches configuration errors at startup
11//! - **Hierarchical structure**: Organized into logical sections
12//! - **Type-safe**: Strongly typed configuration with serde
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! use riglr_config::Config;
18//!
19//! // Load configuration from environment (fail-fast)
20//! let config = Config::from_env();
21//!
22//! // Access configuration values
23//! println!("Redis URL: {}", config.database.redis_url);
24//! println!("Environment: {:?}", config.app.environment);
25//!
26//! // Get RPC URL for a specific chain
27//! if let Some(rpc_url) = config.network.get_rpc_url("1") {
28//!     println!("Ethereum RPC: {}", rpc_url);
29//! }
30//! ```
31
32mod app;
33mod builder;
34mod database;
35mod environment;
36mod error;
37mod features;
38mod network;
39mod providers;
40
41pub use app::{AppConfig, Environment, LogLevel, RetryConfig};
42pub use builder::ConfigBuilder;
43pub use database::DatabaseConfig;
44pub use environment::EnvironmentSource;
45pub use error::{ConfigError, ConfigResult};
46pub use features::{Feature, FeaturesConfig};
47pub use network::{
48    AddressValidator, ChainConfig, ChainContract, EvmNetworkConfig, NetworkConfig,
49    SolanaNetworkConfig,
50};
51pub use providers::{AiProvider, BlockchainProvider, DataProvider, ProvidersConfig};
52// Validator trait removed - configs now directly implement validate_config()
53
54#[cfg(test)]
55pub mod test_helpers;
56
57use serde::{Deserialize, Serialize};
58use std::sync::Arc;
59
60/// Main configuration structure that aggregates all subsystems
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct Config {
63    /// Application-level configuration
64    #[serde(flatten)]
65    pub app: AppConfig,
66
67    /// Database connections
68    #[serde(flatten)]
69    pub database: DatabaseConfig,
70
71    /// Network and blockchain configuration
72    #[serde(flatten)]
73    pub network: NetworkConfig,
74
75    /// External API providers
76    #[serde(flatten)]
77    pub providers: ProvidersConfig,
78
79    /// Feature flags
80    #[serde(flatten)]
81    pub features: FeaturesConfig,
82}
83
84impl Config {
85    /// Try to load configuration from environment variables
86    ///
87    /// This will:
88    /// 1. Load .env file if present
89    /// 2. Parse environment variables
90    /// 3. Apply convention-based patterns (RPC_URL_{CHAIN_ID})
91    /// 4. Load chains.toml if specified
92    /// 5. Validate all configuration
93    ///
94    /// Returns a Result instead of exiting on failure, making it suitable
95    /// for library usage and programmatic configuration building.
96    pub fn try_from_env() -> ConfigResult<Arc<Self>> {
97        // Load .env file if present
98        dotenvy::dotenv().ok();
99
100        // Build configuration from environment
101        let mut config = envy::from_env::<Config>()
102            .map_err(|e| ConfigError::EnvParse(format!("Failed to parse environment: {}", e)))?;
103
104        // Apply dynamic patterns
105        config.network.extract_rpc_urls();
106
107        // Load chain contracts from TOML if specified
108        if let Err(e) = config.network.load_chain_contracts() {
109            tracing::warn!("Failed to load chain contracts: {}", e);
110        }
111
112        // Validate configuration
113        config.validate_config()?;
114
115        tracing::info!("✅ Configuration loaded and validated successfully");
116
117        Ok(Arc::new(config))
118    }
119
120    /// Load configuration from environment variables (fail-fast)
121    ///
122    /// This will:
123    /// 1. Load .env file if present
124    /// 2. Parse environment variables
125    /// 3. Apply convention-based patterns (RPC_URL_{CHAIN_ID})
126    /// 4. Load chains.toml if specified
127    /// 5. Validate all configuration
128    ///
129    /// This is a wrapper around `try_from_env()` that exits the process
130    /// on failure, suitable for application main binaries.
131    pub fn from_env() -> Arc<Self> {
132        match Self::try_from_env() {
133            Ok(config) => config,
134            Err(e) => {
135                eprintln!("❌ FATAL: Failed to load configuration:");
136                eprintln!("   {}", e);
137                eprintln!("   See .env.example for required variables");
138                std::process::exit(1);
139            }
140        }
141    }
142
143    /// Validate the entire configuration
144    pub fn validate_config(&self) -> ConfigResult<()> {
145        self.app.validate_config()?;
146        self.database.validate_config()?;
147        self.network.validate_config(None)?;
148        self.providers.validate_config()?;
149        self.features.validate_config()?;
150
151        // Cross-validation
152        self.validate_cross_dependencies()?;
153
154        Ok(())
155    }
156
157    /// Validate cross-dependencies between configuration sections
158    fn validate_cross_dependencies(&self) -> ConfigResult<()> {
159        // Production environment checks
160        if self.app.environment == Environment::Production {
161            // Ensure not using localhost in production
162            if self.database.redis_url.contains("localhost") {
163                return Err(ConfigError::validation(
164                    "Production cannot use localhost for Redis",
165                ));
166            }
167
168            // Ensure not using testnet in production
169            if self.app.use_testnet {
170                return Err(ConfigError::validation("Production cannot use testnet"));
171            }
172
173            // Ensure critical features are configured
174            if self.features.enable_trading && self.providers.alchemy_api_key.is_none() {
175                return Err(ConfigError::validation(
176                    "Trading is enabled in production, but ALCHEMY_API_KEY is not set",
177                ));
178            }
179        }
180
181        // Feature dependencies
182        if self.features.enable_graph_memory && self.database.neo4j_url.is_none() {
183            return Err(ConfigError::validation(
184                "Graph memory feature requires NEO4J_URL to be configured",
185            ));
186        }
187
188        if self.features.enable_bridging && self.providers.lifi_api_key.is_none() {
189            return Err(ConfigError::validation(
190                "Bridging feature requires LIFI_API_KEY to be configured",
191            ));
192        }
193
194        if self.features.enable_social_monitoring && self.providers.twitter_bearer_token.is_none() {
195            tracing::warn!("Social monitoring enabled without Twitter bearer token");
196        }
197
198        Ok(())
199    }
200
201    /// Create a builder for constructing configuration programmatically
202    pub fn builder() -> ConfigBuilder {
203        ConfigBuilder::new()
204    }
205}
206
207/// Re-export commonly used types
208pub mod prelude {
209    pub use crate::{
210        AddressValidator, AppConfig, ChainConfig, Config, ConfigBuilder, ConfigError, ConfigResult,
211        DatabaseConfig, Environment, Feature, FeaturesConfig, NetworkConfig, ProvidersConfig,
212        RetryConfig,
213    };
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn create_test_config() -> Config {
221        let mut features = FeaturesConfig::default();
222        // Disable features that require external dependencies for basic tests
223        features.enable_bridging = false; // Requires lifi_api_key
224        features.enable_graph_memory = false; // Requires neo4j_url
225
226        Config {
227            app: AppConfig::default(),
228            database: DatabaseConfig::default(),
229            network: NetworkConfig::default(),
230            providers: ProvidersConfig::default(),
231            features,
232        }
233    }
234
235    fn create_production_config() -> Config {
236        let mut config = create_test_config();
237        config.app.environment = Environment::Production;
238        config
239    }
240
241    #[test]
242    fn test_config_validate_when_valid_should_return_ok() {
243        let config = create_test_config();
244        assert!(config.validate_config().is_ok());
245    }
246
247    #[test]
248    fn test_config_validate_cross_dependencies_when_production_with_localhost_redis_should_return_err(
249    ) {
250        let mut config = create_production_config();
251        config.database.redis_url = "redis://localhost:6379".to_string();
252
253        let result = config.validate_cross_dependencies();
254        assert!(result.is_err());
255        assert!(result
256            .unwrap_err()
257            .to_string()
258            .contains("Production cannot use localhost for Redis"));
259    }
260
261    #[test]
262    fn test_config_validate_cross_dependencies_when_production_with_testnet_should_return_err() {
263        let mut config = create_production_config();
264        config.app.use_testnet = true;
265        config.database.redis_url = "redis://production-redis.example.com:6379".to_string(); // Avoid localhost validation error
266
267        let result = config.validate_cross_dependencies();
268        assert!(result.is_err());
269        assert!(result
270            .unwrap_err()
271            .to_string()
272            .contains("Production cannot use testnet"));
273    }
274
275    #[test]
276    fn test_config_validate_cross_dependencies_when_graph_memory_without_neo4j_should_return_err() {
277        let mut config = create_test_config();
278        config.features.enable_graph_memory = true;
279        config.database.neo4j_url = None;
280
281        let result = config.validate_cross_dependencies();
282        assert!(result.is_err());
283        assert!(result
284            .unwrap_err()
285            .to_string()
286            .contains("Graph memory feature requires NEO4J_URL"));
287    }
288
289    #[test]
290    fn test_prod_config_missing_alchemy_key_fails() {
291        let mut config = create_production_config();
292        config.features.enable_trading = true;
293        config.providers.alchemy_api_key = None;
294        config.database.redis_url = "redis://production-redis.example.com:6379".to_string(); // Avoid localhost validation error
295
296        // This should now fail in production
297        let result = config.validate_cross_dependencies();
298        assert!(result.is_err());
299        assert!(result
300            .unwrap_err()
301            .to_string()
302            .contains("Trading is enabled in production, but ALCHEMY_API_KEY is not set"));
303    }
304
305    #[test]
306    fn test_dev_config_missing_alchemy_key_succeeds() {
307        let mut config = create_test_config();
308        config.app.environment = Environment::Development;
309        config.features.enable_trading = true;
310        config.providers.alchemy_api_key = None;
311
312        // This should succeed in development (no error)
313        let result = config.validate_cross_dependencies();
314        assert!(result.is_ok());
315    }
316
317    #[test]
318    fn test_config_validate_cross_dependencies_when_social_monitoring_without_twitter_should_warn()
319    {
320        let mut config = create_test_config();
321        config.features.enable_social_monitoring = true;
322        config.providers.twitter_bearer_token = None;
323
324        // This should succeed but log a warning
325        let result = config.validate_cross_dependencies();
326        assert!(result.is_ok());
327    }
328
329    #[test]
330    fn test_config_validate_cross_dependencies_when_valid_production_should_return_ok() {
331        let mut config = create_production_config();
332        config.database.redis_url = "redis://production-server:6379".to_string();
333        config.app.use_testnet = false;
334        // Either provide an Alchemy key or disable trading for production validation
335        config.providers.alchemy_api_key = Some("test-alchemy-key".to_string());
336
337        let result = config.validate_cross_dependencies();
338        assert!(result.is_ok(), "Validation failed: {:?}", result);
339    }
340
341    #[test]
342    fn test_config_builder_build_when_valid_should_return_ok() {
343        let result = ConfigBuilder::new().build();
344        assert!(result.is_ok());
345    }
346
347    #[test]
348    fn test_config_builder_build_when_invalid_should_return_err() {
349        let mut app_config = AppConfig::default();
350        app_config.environment = Environment::Production;
351
352        let mut database_config = DatabaseConfig::default();
353        database_config.redis_url = "redis://localhost:6379".to_string();
354
355        let result = ConfigBuilder::new()
356            .app(app_config)
357            .database(database_config)
358            .build();
359
360        assert!(result.is_err());
361    }
362
363    #[test]
364    fn test_config_builder_chaining_should_work() {
365        let app_config = AppConfig::default();
366        let database_config = DatabaseConfig::default();
367        let network_config = NetworkConfig::default();
368        let providers_config = ProvidersConfig::default();
369        let features_config = FeaturesConfig::default();
370
371        let result = ConfigBuilder::new()
372            .app(app_config)
373            .database(database_config)
374            .network(network_config)
375            .providers(providers_config)
376            .features(features_config)
377            .build();
378
379        assert!(result.is_ok());
380    }
381
382    #[test]
383    fn test_config_builder_default_should_create_valid_builder() {
384        let builder = ConfigBuilder::new();
385        let result = builder.build();
386        assert!(result.is_ok());
387    }
388
389    #[test]
390    fn test_config_builder_should_create_same_as_new() {
391        let builder1 = ConfigBuilder::new();
392        let builder2 = ConfigBuilder::new();
393
394        let config1 = builder1.build().unwrap();
395        let config2 = builder2.build().unwrap();
396
397        assert_eq!(config1.app.environment, config2.app.environment);
398    }
399
400    #[test]
401    fn test_config_validate_when_app_validation_fails_should_return_err() {
402        let mut config = create_test_config();
403        // Create invalid app config - this would depend on AppConfig validation logic
404        // For now, we'll create a scenario that might fail cross-validation
405        config.app.environment = Environment::Production;
406        config.database.redis_url = "redis://localhost:6379".to_string();
407
408        let result = config.validate_config();
409        assert!(result.is_err());
410    }
411
412    #[test]
413    fn test_config_validate_cross_dependencies_when_graph_memory_with_neo4j_should_return_ok() {
414        let mut config = create_test_config();
415        config.features.enable_graph_memory = true;
416        config.database.neo4j_url = Some("bolt://localhost:7687".to_string());
417
418        let result = config.validate_cross_dependencies();
419        assert!(result.is_ok());
420    }
421
422    #[test]
423    fn test_config_validate_cross_dependencies_when_development_with_localhost_should_return_ok() {
424        let mut config = create_test_config();
425        config.app.environment = Environment::Development;
426        config.database.redis_url = "redis://localhost:6379".to_string();
427
428        let result = config.validate_cross_dependencies();
429        assert!(result.is_ok());
430    }
431
432    #[test]
433    fn test_config_validate_cross_dependencies_when_staging_should_return_ok() {
434        let mut config = create_test_config();
435        config.app.environment = Environment::Staging;
436        config.database.redis_url = "redis://staging-server:6379".to_string();
437
438        let result = config.validate_cross_dependencies();
439        assert!(result.is_ok());
440    }
441
442    #[test]
443    fn test_config_builder_should_validate_during_build() {
444        // Test that the builder properly validates the config during build
445        let mut features_config = FeaturesConfig::default();
446        features_config.enable_graph_memory = true;
447
448        let mut database_config = DatabaseConfig::default();
449        database_config.neo4j_url = None; // This should cause validation to fail
450
451        let result = ConfigBuilder::new()
452            .features(features_config)
453            .database(database_config)
454            .build();
455
456        assert!(result.is_err());
457    }
458
459    #[test]
460    fn test_config_validate_cross_dependencies_when_bridging_without_lifi_key_should_return_err() {
461        let mut config = create_test_config();
462        config.features.enable_bridging = true;
463        config.providers.lifi_api_key = None;
464
465        let result = config.validate_cross_dependencies();
466        assert!(result.is_err());
467        if let Err(e) = result {
468            assert!(e.to_string().contains("LIFI_API_KEY"));
469        }
470    }
471
472    #[test]
473    fn test_config_validate_cross_dependencies_when_bridging_with_lifi_key_should_return_ok() {
474        let mut config = create_test_config();
475        config.features.enable_bridging = true;
476        config.providers.lifi_api_key = Some("test-lifi-key".to_string());
477
478        let result = config.validate_cross_dependencies();
479        assert!(result.is_ok());
480    }
481
482    #[test]
483    fn test_config_validate_cross_dependencies_when_social_monitoring_without_twitter_token_should_return_ok(
484    ) {
485        let mut config = create_test_config();
486        config.features.enable_social_monitoring = true;
487        config.providers.twitter_bearer_token = None;
488
489        // Should return Ok but log a warning (warning is not testable here)
490        let result = config.validate_cross_dependencies();
491        assert!(result.is_ok());
492    }
493
494    #[test]
495    fn test_config_validate_cross_dependencies_when_social_monitoring_with_twitter_token_should_return_ok(
496    ) {
497        let mut config = create_test_config();
498        config.features.enable_social_monitoring = true;
499        config.providers.twitter_bearer_token = Some("test-twitter-token".to_string());
500
501        let result = config.validate_cross_dependencies();
502        assert!(result.is_ok());
503    }
504
505    #[test]
506    fn test_config_validate_cross_dependencies_when_bridging_disabled_without_lifi_key_should_return_ok(
507    ) {
508        let mut config = create_test_config();
509        config.features.enable_bridging = false;
510        config.providers.lifi_api_key = None;
511
512        // Bridging is disabled, so missing key should be OK
513        let result = config.validate_cross_dependencies();
514        assert!(result.is_ok());
515    }
516}