riglr_config/
database.rs

1//! Database configuration
2
3use crate::{ConfigError, ConfigResult};
4use lazy_static::lazy_static;
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use validator::Validate;
8
9lazy_static! {
10    static ref REDIS_URL_REGEX: Regex = Regex::new(r"^rediss?://").unwrap();
11    static ref NEO4J_URL_REGEX: Regex = Regex::new(r"^(neo4j(\+s)?|bolt(\+s)?)://").unwrap();
12    static ref HTTP_URL_REGEX: Regex = Regex::new(r"^https?://").unwrap();
13    static ref POSTGRES_URL_REGEX: Regex = Regex::new(r"^postgres(ql)?://").unwrap();
14}
15
16/// Private helper function for Redis URL validation
17fn _validate_redis_url(url: &str) -> Result<(), validator::ValidationError> {
18    if !REDIS_URL_REGEX.is_match(url) {
19        return Err(validator::ValidationError::new(
20            "REDIS_URL must start with redis:// or rediss://",
21        ));
22    }
23    if url::Url::parse(url).is_err() {
24        return Err(validator::ValidationError::new("Invalid Redis URL format"));
25    }
26    Ok(())
27}
28
29/// Validate Redis URL field (for use with validator crate)
30fn validate_redis_url_field(url: &str) -> Result<(), validator::ValidationError> {
31    _validate_redis_url(url)
32}
33
34/// Database configuration
35#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
36pub struct DatabaseConfig {
37    /// Redis connection URL
38    #[validate(custom(function = "validate_redis_url_field"))]
39    pub redis_url: String,
40
41    /// Neo4j connection URL (optional, for graph memory)
42    #[serde(default)]
43    pub neo4j_url: Option<String>,
44
45    /// Neo4j username (if not in URL)
46    #[serde(default)]
47    pub neo4j_username: Option<String>,
48
49    /// Neo4j password (if not in URL)
50    #[serde(default)]
51    pub neo4j_password: Option<String>,
52
53    /// ClickHouse URL (optional, for analytics)
54    #[serde(default)]
55    pub clickhouse_url: Option<String>,
56
57    /// ClickHouse database name
58    #[serde(default = "default_clickhouse_db")]
59    #[validate(length(min = 1))]
60    pub clickhouse_database: String,
61
62    /// PostgreSQL URL (optional, for relational data)
63    #[serde(default)]
64    pub postgres_url: Option<String>,
65
66    /// Connection pool settings
67    #[serde(flatten)]
68    #[validate(nested)]
69    pub pool: PoolConfig,
70}
71
72/// Database connection pool configuration
73#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
74pub struct PoolConfig {
75    /// Maximum number of connections in the pool
76    #[serde(default = "default_max_connections")]
77    #[validate(range(min = 1, max = 1000))]
78    pub max_connections: u32,
79
80    /// Minimum number of connections to maintain
81    #[serde(default = "default_min_connections")]
82    #[validate(range(min = 0, max = 100))]
83    pub min_connections: u32,
84
85    /// Connection timeout in seconds
86    #[serde(default = "default_connection_timeout")]
87    #[validate(range(min = 1, max = 300))]
88    pub connection_timeout_secs: u64,
89
90    /// Idle timeout in seconds
91    #[serde(default = "default_idle_timeout")]
92    pub idle_timeout_secs: u64,
93
94    /// Maximum connection lifetime in seconds
95    #[serde(default = "default_max_lifetime")]
96    pub max_lifetime_secs: u64,
97}
98
99/// Validates a Neo4j URL format and scheme
100pub fn validate_neo4j_url(url: &str) -> ConfigResult<()> {
101    if !NEO4J_URL_REGEX.is_match(url) {
102        return Err(ConfigError::validation(
103            "NEO4J_URL must start with neo4j://, neo4j+s://, bolt://, or bolt+s://",
104        ));
105    }
106    if url::Url::parse(url).is_err() {
107        return Err(ConfigError::validation("Invalid Neo4j URL format"));
108    }
109    Ok(())
110}
111
112/// Validates a ClickHouse URL format and scheme
113pub fn validate_clickhouse_url(url: &str) -> ConfigResult<()> {
114    if !HTTP_URL_REGEX.is_match(url) {
115        return Err(ConfigError::validation(
116            "CLICKHOUSE_URL must start with http:// or https://",
117        ));
118    }
119    if url::Url::parse(url).is_err() {
120        return Err(ConfigError::validation("Invalid ClickHouse URL format"));
121    }
122    Ok(())
123}
124
125/// Validates a PostgreSQL URL format and scheme
126pub fn validate_postgres_url(url: &str) -> ConfigResult<()> {
127    if !POSTGRES_URL_REGEX.is_match(url) {
128        return Err(ConfigError::validation(
129            "POSTGRES_URL must start with postgres:// or postgresql://",
130        ));
131    }
132    if url::Url::parse(url).is_err() {
133        return Err(ConfigError::validation("Invalid PostgreSQL URL format"));
134    }
135    Ok(())
136}
137
138impl DatabaseConfig {
139    /// Validates all database configuration settings
140    ///
141    /// This method validates:
142    /// - Redis URL format and connectivity
143    /// - Neo4j URL format (if provided)
144    /// - ClickHouse URL format (if provided)
145    /// - PostgreSQL URL format (if provided)
146    /// - Connection pool configuration
147    ///
148    /// # Errors
149    ///
150    /// Returns `ConfigError` if any validation fails
151    pub fn validate_config(&self) -> ConfigResult<()> {
152        // Use validator crate for field validation (this includes Redis URL validation)
153        Validate::validate(self)
154            .map_err(|e| ConfigError::validation(format!("Validation failed: {}", e)))?;
155
156        if let Some(ref neo4j_url) = self.neo4j_url {
157            validate_neo4j_url(neo4j_url)?;
158        }
159
160        if let Some(ref clickhouse_url) = self.clickhouse_url {
161            validate_clickhouse_url(clickhouse_url)?;
162        }
163
164        if let Some(ref postgres_url) = self.postgres_url {
165            validate_postgres_url(postgres_url)?;
166        }
167
168        self.pool.validate_config()?;
169
170        Ok(())
171    }
172}
173
174impl PoolConfig {
175    /// Validates connection pool configuration settings
176    ///
177    /// This method validates:
178    /// - Maximum connections is greater than 0
179    /// - Minimum connections doesn't exceed maximum connections
180    /// - Connection timeout is greater than 0
181    ///
182    /// # Errors
183    ///
184    /// Returns `ConfigError` if any validation fails
185    pub fn validate_config(&self) -> ConfigResult<()> {
186        // Use validator crate for field validation
187        Validate::validate(self)
188            .map_err(|e| ConfigError::validation(format!("Validation failed: {}", e)))?;
189
190        // Additional custom validation
191        if self.min_connections > self.max_connections {
192            return Err(ConfigError::validation(
193                "min_connections cannot be greater than max_connections",
194            ));
195        }
196
197        Ok(())
198    }
199}
200
201// Default value functions
202fn default_clickhouse_db() -> String {
203    "riglr".to_string()
204}
205fn default_max_connections() -> u32 {
206    10
207}
208fn default_min_connections() -> u32 {
209    2
210}
211fn default_connection_timeout() -> u64 {
212    30
213}
214fn default_idle_timeout() -> u64 {
215    600
216}
217fn default_max_lifetime() -> u64 {
218    3600
219}
220
221impl Default for DatabaseConfig {
222    fn default() -> Self {
223        Self {
224            redis_url: "redis://localhost:6379".to_string(),
225            neo4j_url: None,
226            neo4j_username: None,
227            neo4j_password: None,
228            clickhouse_url: None,
229            clickhouse_database: default_clickhouse_db(),
230            postgres_url: None,
231            pool: PoolConfig::default(),
232        }
233    }
234}
235
236impl Default for PoolConfig {
237    fn default() -> Self {
238        Self {
239            max_connections: default_max_connections(),
240            min_connections: default_min_connections(),
241            connection_timeout_secs: default_connection_timeout(),
242            idle_timeout_secs: default_idle_timeout(),
243            max_lifetime_secs: default_max_lifetime(),
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    // Test default value functions
253    #[test]
254    fn test_default_clickhouse_db_should_return_riglr() {
255        assert_eq!(default_clickhouse_db(), "riglr");
256    }
257
258    #[test]
259    fn test_default_max_connections_should_return_10() {
260        assert_eq!(default_max_connections(), 10);
261    }
262
263    #[test]
264    fn test_default_min_connections_should_return_2() {
265        assert_eq!(default_min_connections(), 2);
266    }
267
268    #[test]
269    fn test_default_connection_timeout_should_return_30() {
270        assert_eq!(default_connection_timeout(), 30);
271    }
272
273    #[test]
274    fn test_default_idle_timeout_should_return_600() {
275        assert_eq!(default_idle_timeout(), 600);
276    }
277
278    #[test]
279    fn test_default_max_lifetime_should_return_3600() {
280        assert_eq!(default_max_lifetime(), 3600);
281    }
282
283    // Test Default implementations
284    #[test]
285    fn test_pool_config_default_should_use_default_values() {
286        let pool = PoolConfig::default();
287        assert_eq!(pool.max_connections, 10);
288        assert_eq!(pool.min_connections, 2);
289        assert_eq!(pool.connection_timeout_secs, 30);
290        assert_eq!(pool.idle_timeout_secs, 600);
291        assert_eq!(pool.max_lifetime_secs, 3600);
292    }
293
294    #[test]
295    fn test_database_config_default_should_use_default_values() {
296        let config = DatabaseConfig::default();
297        assert_eq!(config.redis_url, "redis://localhost:6379");
298        assert_eq!(config.neo4j_url, None);
299        assert_eq!(config.neo4j_username, None);
300        assert_eq!(config.neo4j_password, None);
301        assert_eq!(config.clickhouse_url, None);
302        assert_eq!(config.clickhouse_database, "riglr");
303        assert_eq!(config.postgres_url, None);
304        assert_eq!(config.pool.max_connections, 10);
305    }
306
307    // Test PoolConfig validation - Happy Path
308    #[test]
309    fn test_pool_config_validate_when_valid_should_return_ok() {
310        let pool = PoolConfig {
311            max_connections: 10,
312            min_connections: 2,
313            connection_timeout_secs: 30,
314            idle_timeout_secs: 600,
315            max_lifetime_secs: 3600,
316        };
317        assert!(pool.validate_config().is_ok());
318    }
319
320    #[test]
321    fn test_pool_config_validate_when_min_equals_max_should_return_ok() {
322        let pool = PoolConfig {
323            max_connections: 5,
324            min_connections: 5,
325            connection_timeout_secs: 30,
326            idle_timeout_secs: 600,
327            max_lifetime_secs: 3600,
328        };
329        assert!(pool.validate_config().is_ok());
330    }
331
332    // Test PoolConfig validation - Error Paths
333    #[test]
334    fn test_pool_config_validate_when_max_connections_zero_should_return_err() {
335        let pool = PoolConfig {
336            max_connections: 0,
337            min_connections: 0,
338            connection_timeout_secs: 30,
339            idle_timeout_secs: 600,
340            max_lifetime_secs: 3600,
341        };
342        let result = pool.validate_config();
343        assert!(result.is_err());
344        // The validator crate generates different error messages
345        let err_msg = result.unwrap_err().to_string();
346        assert!(err_msg.contains("Validation failed") || err_msg.contains("max_connections"));
347    }
348
349    #[test]
350    fn test_pool_config_validate_when_min_greater_than_max_should_return_err() {
351        let pool = PoolConfig {
352            max_connections: 5,
353            min_connections: 10,
354            connection_timeout_secs: 30,
355            idle_timeout_secs: 600,
356            max_lifetime_secs: 3600,
357        };
358        let result = pool.validate_config();
359        assert!(result.is_err());
360        assert!(result
361            .unwrap_err()
362            .to_string()
363            .contains("min_connections cannot be greater than max_connections"));
364    }
365
366    #[test]
367    fn test_pool_config_validate_when_connection_timeout_zero_should_return_err() {
368        let pool = PoolConfig {
369            max_connections: 10,
370            min_connections: 2,
371            connection_timeout_secs: 0,
372            idle_timeout_secs: 600,
373            max_lifetime_secs: 3600,
374        };
375        let result = pool.validate_config();
376        assert!(result.is_err());
377        // The validator crate generates different error messages
378        let err_msg = result.unwrap_err().to_string();
379        assert!(
380            err_msg.contains("Validation failed") || err_msg.contains("connection_timeout_secs")
381        );
382    }
383
384    // Test Neo4j URL validation - Happy Path
385    #[test]
386    fn test_validate_neo4j_url_when_neo4j_scheme_should_return_ok() {
387        assert!(validate_neo4j_url("neo4j://localhost:7687").is_ok());
388    }
389
390    #[test]
391    fn test_validate_neo4j_url_when_neo4j_secure_scheme_should_return_ok() {
392        assert!(validate_neo4j_url("neo4j+s://localhost:7687").is_ok());
393    }
394
395    #[test]
396    fn test_validate_neo4j_url_when_bolt_scheme_should_return_ok() {
397        assert!(validate_neo4j_url("bolt://localhost:7687").is_ok());
398    }
399
400    #[test]
401    fn test_validate_neo4j_url_when_bolt_secure_scheme_should_return_ok() {
402        assert!(validate_neo4j_url("bolt+s://localhost:7687").is_ok());
403    }
404
405    // Test Neo4j URL validation - Error Paths
406    #[test]
407    fn test_validate_neo4j_url_when_invalid_scheme_should_return_err() {
408        let result = validate_neo4j_url("http://localhost:7687");
409        assert!(result.is_err());
410        assert!(result
411            .unwrap_err()
412            .to_string()
413            .contains("NEO4J_URL must start with neo4j://"));
414    }
415
416    #[test]
417    fn test_validate_neo4j_url_when_malformed_url_should_return_err() {
418        let result = validate_neo4j_url("neo4j://[invalid");
419        assert!(result.is_err());
420        assert!(result
421            .unwrap_err()
422            .to_string()
423            .contains("Invalid Neo4j URL"));
424    }
425
426    // Test ClickHouse URL validation - Happy Path
427    #[test]
428    fn test_validate_clickhouse_url_when_http_scheme_should_return_ok() {
429        assert!(validate_clickhouse_url("http://localhost:8123").is_ok());
430    }
431
432    #[test]
433    fn test_validate_clickhouse_url_when_https_scheme_should_return_ok() {
434        assert!(validate_clickhouse_url("https://localhost:8443").is_ok());
435    }
436
437    // Test ClickHouse URL validation - Error Paths
438    #[test]
439    fn test_validate_clickhouse_url_when_invalid_scheme_should_return_err() {
440        let result = validate_clickhouse_url("tcp://localhost:8123");
441        assert!(result.is_err());
442        assert!(result
443            .unwrap_err()
444            .to_string()
445            .contains("CLICKHOUSE_URL must start with http:// or https://"));
446    }
447
448    #[test]
449    fn test_validate_clickhouse_url_when_malformed_url_should_return_err() {
450        let result = validate_clickhouse_url("http://[invalid");
451        assert!(result.is_err());
452        assert!(result
453            .unwrap_err()
454            .to_string()
455            .contains("Invalid ClickHouse URL"));
456    }
457
458    // Test PostgreSQL URL validation - Happy Path
459    #[test]
460    fn test_validate_postgres_url_when_postgres_scheme_should_return_ok() {
461        assert!(validate_postgres_url("postgres://localhost:5432/db").is_ok());
462    }
463
464    #[test]
465    fn test_validate_postgres_url_when_postgresql_scheme_should_return_ok() {
466        assert!(validate_postgres_url("postgresql://localhost:5432/db").is_ok());
467    }
468
469    // Test PostgreSQL URL validation - Error Paths
470    #[test]
471    fn test_validate_postgres_url_when_invalid_scheme_should_return_err() {
472        let result = validate_postgres_url("mysql://localhost:5432/db");
473        assert!(result.is_err());
474        assert!(result
475            .unwrap_err()
476            .to_string()
477            .contains("POSTGRES_URL must start with postgres:// or postgresql://"));
478    }
479
480    #[test]
481    fn test_validate_postgres_url_when_malformed_url_should_return_err() {
482        let result = validate_postgres_url("postgres://[invalid");
483        assert!(result.is_err());
484        assert!(result
485            .unwrap_err()
486            .to_string()
487            .contains("Invalid PostgreSQL URL"));
488    }
489
490    // Test DatabaseConfig validate - Happy Path
491    #[test]
492    fn test_database_config_validate_when_minimal_config_should_return_ok() {
493        let config = DatabaseConfig {
494            redis_url: "redis://localhost:6379".to_string(),
495            neo4j_url: None,
496            neo4j_username: None,
497            neo4j_password: None,
498            clickhouse_url: None,
499            clickhouse_database: "riglr".to_string(),
500            postgres_url: None,
501            pool: PoolConfig::default(),
502        };
503        assert!(config.validate_config().is_ok());
504    }
505
506    #[test]
507    fn test_database_config_validate_when_all_urls_provided_should_return_ok() {
508        let config = DatabaseConfig {
509            redis_url: "redis://localhost:6379".to_string(),
510            neo4j_url: Some("neo4j://localhost:7687".to_string()),
511            neo4j_username: Some("neo4j".to_string()),
512            neo4j_password: Some("password".to_string()),
513            clickhouse_url: Some("http://localhost:8123".to_string()),
514            clickhouse_database: "riglr".to_string(),
515            postgres_url: Some("postgres://localhost:5432/db".to_string()),
516            pool: PoolConfig::default(),
517        };
518        assert!(config.validate_config().is_ok());
519    }
520
521    // Test DatabaseConfig validate - Error Paths
522    #[test]
523    fn test_database_config_validate_when_invalid_redis_url_should_return_err() {
524        let config = DatabaseConfig {
525            redis_url: "http://localhost:6379".to_string(),
526            ..Default::default()
527        };
528        let result = config.validate_config();
529        assert!(result.is_err());
530        assert!(result
531            .unwrap_err()
532            .to_string()
533            .contains("REDIS_URL must start with redis:// or rediss://"));
534    }
535
536    #[test]
537    fn test_database_config_validate_when_invalid_neo4j_url_should_return_err() {
538        let config = DatabaseConfig {
539            redis_url: "redis://localhost:6379".to_string(),
540            neo4j_url: Some("http://localhost:7687".to_string()),
541            ..Default::default()
542        };
543        let result = config.validate_config();
544        assert!(result.is_err());
545        assert!(result
546            .unwrap_err()
547            .to_string()
548            .contains("NEO4J_URL must start with neo4j://"));
549    }
550
551    #[test]
552    fn test_database_config_validate_when_invalid_clickhouse_url_should_return_err() {
553        let config = DatabaseConfig {
554            redis_url: "redis://localhost:6379".to_string(),
555            clickhouse_url: Some("tcp://localhost:8123".to_string()),
556            ..Default::default()
557        };
558        let result = config.validate_config();
559        assert!(result.is_err());
560        assert!(result
561            .unwrap_err()
562            .to_string()
563            .contains("CLICKHOUSE_URL must start with http:// or https://"));
564    }
565
566    #[test]
567    fn test_database_config_validate_when_invalid_postgres_url_should_return_err() {
568        let config = DatabaseConfig {
569            redis_url: "redis://localhost:6379".to_string(),
570            postgres_url: Some("mysql://localhost:5432/db".to_string()),
571            ..Default::default()
572        };
573        let result = config.validate_config();
574        assert!(result.is_err());
575        assert!(result
576            .unwrap_err()
577            .to_string()
578            .contains("POSTGRES_URL must start with postgres:// or postgresql://"));
579    }
580
581    #[test]
582    fn test_database_config_validate_when_invalid_pool_config_should_return_err() {
583        let config = DatabaseConfig {
584            redis_url: "redis://localhost:6379".to_string(),
585            pool: PoolConfig {
586                max_connections: 0,
587                min_connections: 0,
588                connection_timeout_secs: 30,
589                idle_timeout_secs: 600,
590                max_lifetime_secs: 3600,
591            },
592            ..Default::default()
593        };
594        let result = config.validate_config();
595        assert!(result.is_err());
596        // The validator crate generates different error messages
597        let err_msg = result.unwrap_err().to_string();
598        assert!(err_msg.contains("Validation failed") || err_msg.contains("max_connections"));
599    }
600
601    // Test edge cases for URL validation
602
603    #[test]
604    fn test_validate_neo4j_url_when_neo4j_prefix_in_middle_should_return_err() {
605        let result = validate_neo4j_url("http://neo4j://localhost:7687");
606        assert!(result.is_err());
607        assert!(result
608            .unwrap_err()
609            .to_string()
610            .contains("NEO4J_URL must start with neo4j://"));
611    }
612
613    #[test]
614    fn test_validate_clickhouse_url_when_http_prefix_in_middle_should_return_err() {
615        let result = validate_clickhouse_url("tcp://http://localhost:8123");
616        assert!(result.is_err());
617        assert!(result
618            .unwrap_err()
619            .to_string()
620            .contains("CLICKHOUSE_URL must start with http:// or https://"));
621    }
622
623    #[test]
624    fn test_validate_postgres_url_when_postgres_prefix_in_middle_should_return_err() {
625        let result = validate_postgres_url("mysql://postgres://localhost:5432/db");
626        assert!(result.is_err());
627        assert!(result
628            .unwrap_err()
629            .to_string()
630            .contains("POSTGRES_URL must start with postgres:// or postgresql://"));
631    }
632
633    // Test with edge case values for pool config
634    #[test]
635    fn test_pool_config_validate_when_very_large_values_should_return_ok() {
636        let pool = PoolConfig {
637            max_connections: 1000,        // Maximum allowed by validation
638            min_connections: 100,         // Maximum allowed by validation
639            connection_timeout_secs: 300, // Maximum allowed by validation
640            idle_timeout_secs: u64::MAX,  // No validation limit
641            max_lifetime_secs: u64::MAX,  // No validation limit
642        };
643        assert!(pool.validate_config().is_ok());
644    }
645
646    #[test]
647    fn test_pool_config_validate_when_min_zero_max_one_should_return_ok() {
648        let pool = PoolConfig {
649            max_connections: 1,
650            min_connections: 0,
651            connection_timeout_secs: 1,
652            idle_timeout_secs: 0,
653            max_lifetime_secs: 0,
654        };
655        assert!(pool.validate_config().is_ok());
656    }
657}