qml_rs/storage/
config.rs

1use serde::{Deserialize, Serialize};
2use std::time::Duration;
3
4/// Configuration for storage backends
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(tag = "type", rename_all = "lowercase")]
7pub enum StorageConfig {
8    /// In-memory storage configuration
9    Memory(MemoryConfig),
10    /// Redis storage configuration
11    #[cfg(feature = "redis")]
12    Redis(RedisConfig),
13    /// PostgreSQL storage configuration
14    #[cfg(feature = "postgres")]
15    Postgres(PostgresConfig),
16}
17
18impl Default for StorageConfig {
19    fn default() -> Self {
20        Self::Memory(MemoryConfig::default())
21    }
22}
23
24/// Configuration for in-memory storage
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct MemoryConfig {
27    /// Maximum number of jobs to keep in memory
28    pub max_jobs: Option<usize>,
29    /// Whether to enable auto-cleanup of completed jobs
30    pub auto_cleanup: bool,
31    /// Interval for cleanup operations
32    pub cleanup_interval: Option<Duration>,
33}
34
35impl Default for MemoryConfig {
36    fn default() -> Self {
37        Self {
38            max_jobs: Some(10_000),
39            auto_cleanup: true,
40            cleanup_interval: Some(Duration::from_secs(300)), // 5 minutes
41        }
42    }
43}
44
45impl MemoryConfig {
46    /// Create a new memory config with default settings
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Set the maximum number of jobs to keep in memory
52    pub fn with_max_jobs(mut self, max_jobs: usize) -> Self {
53        self.max_jobs = Some(max_jobs);
54        self
55    }
56
57    /// Disable job limit (unlimited jobs in memory)
58    pub fn unlimited(mut self) -> Self {
59        self.max_jobs = None;
60        self
61    }
62
63    /// Enable auto-cleanup of completed jobs
64    pub fn with_auto_cleanup(mut self, enabled: bool) -> Self {
65        self.auto_cleanup = enabled;
66        self
67    }
68
69    /// Set the cleanup interval
70    pub fn with_cleanup_interval(mut self, interval: Duration) -> Self {
71        self.cleanup_interval = Some(interval);
72        self
73    }
74}
75
76/// Configuration for Redis storage
77#[cfg(feature = "redis")]
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct RedisConfig {
80    /// Redis connection URL (redis://localhost:6379)
81    pub url: String,
82    /// Connection pool size
83    pub pool_size: u32,
84    /// Connection timeout
85    pub connection_timeout: Duration,
86    /// Command timeout
87    pub command_timeout: Duration,
88    /// Key prefix for all QML keys
89    pub key_prefix: String,
90    /// Database number (0-15 for standard Redis)
91    pub database: Option<u8>,
92    /// Username for authentication (Redis 6.0+)
93    pub username: Option<String>,
94    /// Password for authentication
95    pub password: Option<String>,
96    /// Enable TLS/SSL
97    pub tls: bool,
98    /// TTL for completed jobs (None = no expiration)
99    pub completed_job_ttl: Option<Duration>,
100    /// TTL for failed jobs (None = no expiration)
101    pub failed_job_ttl: Option<Duration>,
102}
103
104#[cfg(feature = "redis")]
105impl Default for RedisConfig {
106    fn default() -> Self {
107        Self {
108            url: std::env::var("REDIS_URL")
109                .unwrap_or_else(|_| "redis://localhost:6379".to_string()),
110            pool_size: 10,
111            connection_timeout: Duration::from_secs(5),
112            command_timeout: Duration::from_secs(5),
113            key_prefix: "qml".to_string(),
114            database: None,
115            username: std::env::var("REDIS_USERNAME")
116                .ok()
117                .filter(|s| !s.is_empty()),
118            password: std::env::var("REDIS_PASSWORD")
119                .ok()
120                .filter(|s| !s.is_empty()),
121            tls: false,
122            completed_job_ttl: None,
123            failed_job_ttl: None,
124        }
125    }
126}
127
128#[cfg(feature = "redis")]
129impl RedisConfig {
130    /// Create a new Redis config with default settings
131    pub fn new() -> Self {
132        Self::default()
133    }
134    /// Set the Redis connection URL
135    pub fn with_url<S: Into<String>>(mut self, url: S) -> Self {
136        self.url = url.into();
137        self
138    }
139
140    /// Set the connection pool size
141    pub fn with_pool_size(mut self, size: u32) -> Self {
142        self.pool_size = size;
143        self
144    }
145
146    /// Set connection timeout
147    pub fn with_connection_timeout(mut self, timeout: Duration) -> Self {
148        self.connection_timeout = timeout;
149        self
150    }
151
152    /// Set command timeout
153    pub fn with_command_timeout(mut self, timeout: Duration) -> Self {
154        self.command_timeout = timeout;
155        self
156    }
157
158    /// Set the key prefix for QML keys
159    pub fn with_key_prefix<S: Into<String>>(mut self, prefix: S) -> Self {
160        self.key_prefix = prefix.into();
161        self
162    }
163
164    /// Set the Redis database number
165    pub fn with_database(mut self, database: u8) -> Self {
166        self.database = Some(database);
167        self
168    }
169
170    /// Set authentication credentials
171    pub fn with_credentials<U: Into<String>, P: Into<String>>(
172        mut self,
173        username: U,
174        password: P,
175    ) -> Self {
176        self.username = Some(username.into());
177        self.password = Some(password.into());
178        self
179    }
180
181    /// Set password for authentication (username will be empty)
182    pub fn with_password<P: Into<String>>(mut self, password: P) -> Self {
183        self.password = Some(password.into());
184        self
185    }
186
187    /// Enable TLS/SSL
188    pub fn with_tls(mut self, enabled: bool) -> Self {
189        self.tls = enabled;
190        self
191    }
192
193    /// Set TTL for completed jobs
194    pub fn with_completed_job_ttl(mut self, ttl: Duration) -> Self {
195        self.completed_job_ttl = Some(ttl);
196        self
197    }
198
199    /// Set TTL for failed jobs
200    pub fn with_failed_job_ttl(mut self, ttl: Duration) -> Self {
201        self.failed_job_ttl = Some(ttl);
202        self
203    }
204
205    /// Disable TTL for completed jobs (keep forever)
206    pub fn no_completed_job_ttl(mut self) -> Self {
207        self.completed_job_ttl = None;
208        self
209    }
210
211    /// Disable TTL for failed jobs (keep forever)
212    pub fn no_failed_job_ttl(mut self) -> Self {
213        self.failed_job_ttl = None;
214        self
215    }
216
217    /// Generate the full Redis URL including credentials and database
218    pub fn full_url(&self) -> String {
219        let mut url = self.url.clone();
220
221        // Add credentials if provided
222        if let (Some(username), Some(password)) = (&self.username, &self.password) {
223            url = url.replace("redis://", &format!("redis://{}:{}@", username, password));
224        } else if let Some(password) = &self.password {
225            url = url.replace("redis://", &format!("redis://:{}@", password));
226        }
227
228        // Add database if specified
229        if let Some(db) = self.database {
230            if !url.ends_with('/') {
231                url.push('/');
232            }
233            url.push_str(&db.to_string());
234        }
235
236        url
237    }
238}
239
240/// Configuration for PostgreSQL storage
241#[cfg(feature = "postgres")]
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct PostgresConfig {
244    /// PostgreSQL connection URL (postgresql://user:password@localhost/qml)
245    pub database_url: String,
246    /// Maximum number of connections in the pool
247    pub max_connections: u32,
248    /// Minimum number of connections in the pool
249    pub min_connections: u32,
250    /// Connection timeout
251    pub connect_timeout: Duration,
252    /// Command timeout
253    pub command_timeout: Duration,
254    /// Table name for jobs (default: qml_jobs)
255    pub table_name: String,
256    /// Schema name (default: public)
257    pub schema_name: String,
258    /// Enable automatic migration on startup
259    pub auto_migrate: bool,
260    /// Connection idle timeout
261    pub idle_timeout: Duration,
262    /// Maximum connection lifetime
263    pub max_lifetime: Option<Duration>,
264    /// Enable SSL/TLS
265    pub require_ssl: bool,
266}
267
268#[cfg(feature = "postgres")]
269impl Default for PostgresConfig {
270    fn default() -> Self {
271        Self {
272            database_url: std::env::var("DATABASE_URL").unwrap_or_else(|_| {
273                "postgresql://postgres:password@localhost:5432/qml".to_string()
274            }),
275            max_connections: 20,
276            min_connections: 1,
277            connect_timeout: Duration::from_secs(30),
278            command_timeout: Duration::from_secs(30),
279            table_name: "qml_jobs".to_string(),
280            schema_name: "qml".to_string(),
281            auto_migrate: true,
282            idle_timeout: Duration::from_secs(600),
283            max_lifetime: Some(Duration::from_secs(1800)),
284            require_ssl: false,
285        }
286    }
287}
288
289#[cfg(feature = "postgres")]
290impl PostgresConfig {
291    /// Create a new PostgreSQL config with default settings
292    pub fn new() -> Self {
293        Self::default()
294    }
295
296    /// Create a new PostgreSQL config without reading environment variables
297    pub fn with_defaults() -> Self {
298        Self {
299            database_url: String::new(), // Empty, must be set with with_database_url()
300            max_connections: 20,
301            min_connections: 1,
302            connect_timeout: Duration::from_secs(30),
303            command_timeout: Duration::from_secs(30),
304            table_name: "qml_jobs".to_string(),
305            schema_name: "qml".to_string(),
306            auto_migrate: true,
307            idle_timeout: Duration::from_secs(600),
308            max_lifetime: Some(Duration::from_secs(1800)),
309            require_ssl: false,
310        }
311    }
312
313    /// Set the database URL
314    pub fn with_database_url<S: Into<String>>(mut self, url: S) -> Self {
315        self.database_url = url.into();
316        self
317    }
318
319    /// Set the maximum number of connections in the pool
320    pub fn with_max_connections(mut self, max: u32) -> Self {
321        self.max_connections = max;
322        self
323    }
324
325    /// Set the minimum number of connections in the pool
326    pub fn with_min_connections(mut self, min: u32) -> Self {
327        self.min_connections = min;
328        self
329    }
330
331    /// Set connection timeout
332    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
333        self.connect_timeout = timeout;
334        self
335    }
336
337    /// Set command timeout
338    pub fn with_command_timeout(mut self, timeout: Duration) -> Self {
339        self.command_timeout = timeout;
340        self
341    }
342
343    /// Set the table name for jobs
344    pub fn with_table_name<S: Into<String>>(mut self, name: S) -> Self {
345        self.table_name = name.into();
346        self
347    }
348
349    /// Set the schema name
350    pub fn with_schema_name<S: Into<String>>(mut self, name: S) -> Self {
351        self.schema_name = name.into();
352        self
353    }
354
355    /// Enable or disable automatic migration
356    pub fn with_auto_migrate(mut self, enabled: bool) -> Self {
357        self.auto_migrate = enabled;
358        self
359    }
360
361    /// Set idle timeout for connections
362    pub fn with_idle_timeout(mut self, timeout: Duration) -> Self {
363        self.idle_timeout = timeout;
364        self
365    }
366
367    /// Set maximum connection lifetime
368    pub fn with_max_lifetime(mut self, lifetime: Duration) -> Self {
369        self.max_lifetime = Some(lifetime);
370        self
371    }
372
373    /// Disable maximum connection lifetime
374    pub fn without_max_lifetime(mut self) -> Self {
375        self.max_lifetime = None;
376        self
377    }
378
379    /// Enable or disable SSL requirement
380    pub fn with_ssl(mut self, require_ssl: bool) -> Self {
381        self.require_ssl = require_ssl;
382        self
383    }
384
385    /// Get the full table name including schema
386    pub fn full_table_name(&self) -> String {
387        format!("{}.{}", self.schema_name, self.table_name)
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_memory_config_default() {
397        let config = MemoryConfig::default();
398        assert_eq!(config.max_jobs, Some(10_000));
399        assert!(config.auto_cleanup);
400        assert_eq!(config.cleanup_interval, Some(Duration::from_secs(300)));
401    }
402
403    #[test]
404    fn test_memory_config_builder() {
405        let config = MemoryConfig::new()
406            .with_max_jobs(5_000)
407            .with_auto_cleanup(false)
408            .with_cleanup_interval(Duration::from_secs(600));
409
410        assert_eq!(config.max_jobs, Some(5_000));
411        assert!(!config.auto_cleanup);
412        assert_eq!(config.cleanup_interval, Some(Duration::from_secs(600)));
413    }
414
415    #[test]
416    #[cfg(feature = "redis")]
417    fn test_redis_config_default() {
418        let config = RedisConfig::default();
419        // Should use default fallback when REDIS_URL is not set
420        assert_eq!(config.url, "redis://localhost:6379");
421        assert_eq!(config.pool_size, 10);
422        assert_eq!(config.key_prefix, "qml");
423        assert!(!config.tls);
424    }
425
426    #[test]
427    #[cfg(feature = "redis")]
428    fn test_redis_config_builder() {
429        let config = RedisConfig::new()
430            .with_url("redis://localhost:6380")
431            .with_pool_size(20)
432            .with_key_prefix("test")
433            .with_database(1)
434            .with_credentials("user", "pass")
435            .with_tls(true);
436
437        assert_eq!(config.url, "redis://localhost:6380");
438        assert_eq!(config.pool_size, 20);
439        assert_eq!(config.key_prefix, "test");
440        assert_eq!(config.database, Some(1));
441        assert_eq!(config.username, Some("user".to_string()));
442        assert_eq!(config.password, Some("pass".to_string()));
443        assert!(config.tls);
444    }
445
446    #[test]
447    #[cfg(feature = "redis")]
448    fn test_redis_full_url() {
449        let config = RedisConfig::new()
450            .with_url("redis://localhost:6379")
451            .with_credentials("user", "pass")
452            .with_database(5);
453
454        assert_eq!(config.full_url(), "redis://user:pass@localhost:6379/5");
455    }
456
457    #[test]
458    #[cfg(feature = "redis")]
459    fn test_redis_full_url_password_only() {
460        let config = RedisConfig::new()
461            .with_url("redis://localhost:6379")
462            .with_password("pass")
463            .with_database(2);
464
465        assert_eq!(config.full_url(), "redis://:pass@localhost:6379/2");
466    }
467
468    #[test]
469    #[cfg(feature = "redis")]
470    fn test_storage_config_serialization() {
471        let memory_config = StorageConfig::Memory(MemoryConfig::default());
472        let redis_config = StorageConfig::Redis(RedisConfig::default());
473
474        // Test that configs can be serialized/deserialized
475        let memory_json = serde_json::to_string(&memory_config).unwrap();
476        let redis_json = serde_json::to_string(&redis_config).unwrap();
477
478        let _: StorageConfig = serde_json::from_str(&memory_json).unwrap();
479        let _: StorageConfig = serde_json::from_str(&redis_json).unwrap();
480    }
481
482    #[test]
483    #[cfg(feature = "postgres")]
484    fn test_postgres_config_default() {
485        let config = PostgresConfig::default();
486        assert_eq!(
487            config.database_url,
488            "postgresql://postgres:password@localhost:5432/qml"
489        );
490        assert_eq!(config.max_connections, 20);
491        assert_eq!(config.min_connections, 1);
492        assert_eq!(config.table_name, "qml_jobs");
493        assert_eq!(config.schema_name, "qml");
494        assert!(config.auto_migrate);
495        assert!(!config.require_ssl);
496    }
497
498    #[test]
499    #[cfg(feature = "postgres")]
500    fn test_postgres_config_builder() {
501        let config = PostgresConfig::new()
502            .with_database_url("postgresql://user:pass@localhost:5433/testdb")
503            .with_max_connections(50)
504            .with_min_connections(5)
505            .with_table_name("custom_jobs")
506            .with_schema_name("qml")
507            .with_auto_migrate(false)
508            .with_ssl(true);
509
510        assert_eq!(
511            config.database_url,
512            "postgresql://user:pass@localhost:5433/testdb"
513        );
514        assert_eq!(config.max_connections, 50);
515        assert_eq!(config.min_connections, 5);
516        assert_eq!(config.table_name, "custom_jobs");
517        assert_eq!(config.schema_name, "qml");
518        assert!(!config.auto_migrate);
519        assert!(config.require_ssl);
520    }
521
522    #[test]
523    #[cfg(feature = "postgres")]
524    fn test_postgres_full_table_name() {
525        let config = PostgresConfig::new()
526            .with_schema_name("qml")
527            .with_table_name("jobs");
528
529        assert_eq!(config.full_table_name(), "qml.jobs");
530    }
531
532    #[test]
533    #[cfg(feature = "postgres")]
534    fn test_postgres_config_serialization() {
535        let postgres_config = StorageConfig::Postgres(PostgresConfig::default());
536
537        // Test that config can be serialized/deserialized
538        let postgres_json = serde_json::to_string(&postgres_config).unwrap();
539        let _: StorageConfig = serde_json::from_str(&postgres_json).unwrap();
540    }
541}