Skip to main content

data_connector/
config.rs

1//! Storage backend configuration types.
2
3use serde::{Deserialize, Serialize};
4use url::Url;
5
6use crate::schema::SchemaConfig;
7
8/// History backend configuration
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
10#[serde(rename_all = "lowercase")]
11pub enum HistoryBackend {
12    #[default]
13    Memory,
14    None,
15    Oracle,
16    Postgres,
17    Redis,
18}
19
20/// Oracle history backend configuration
21#[derive(Clone, Serialize, Deserialize, PartialEq)]
22pub struct OracleConfig {
23    /// ATP wallet or TLS config files directory
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub wallet_path: Option<String>,
26    /// DSN (e.g. `tcps://host:port/service`)
27    pub connect_descriptor: String,
28    #[serde(default)]
29    pub external_auth: bool,
30    pub username: String,
31    pub password: String,
32    #[serde(default = "default_pool_min")]
33    pub pool_min: usize,
34    #[serde(default = "default_pool_max")]
35    pub pool_max: usize,
36    #[serde(default = "default_pool_timeout_secs")]
37    pub pool_timeout_secs: u64,
38    /// Optional schema customization (table names, column names, extra columns).
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub schema: Option<SchemaConfig>,
41}
42
43impl OracleConfig {
44    pub fn default_pool_min() -> usize {
45        default_pool_min()
46    }
47
48    pub fn default_pool_max() -> usize {
49        default_pool_max()
50    }
51
52    pub fn default_pool_timeout_secs() -> u64 {
53        default_pool_timeout_secs()
54    }
55}
56
57fn default_pool_min() -> usize {
58    1
59}
60
61fn default_pool_max() -> usize {
62    16
63}
64
65fn default_pool_timeout_secs() -> u64 {
66    30
67}
68
69impl std::fmt::Debug for OracleConfig {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("OracleConfig")
72            .field("wallet_path", &self.wallet_path)
73            .field("connect_descriptor", &self.connect_descriptor)
74            .field("external_auth", &self.external_auth)
75            .field("username", &self.username)
76            .field("pool_min", &self.pool_min)
77            .field("pool_max", &self.pool_max)
78            .field("pool_timeout_secs", &self.pool_timeout_secs)
79            .field("schema", &self.schema)
80            .finish()
81    }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub struct PostgresConfig {
86    // Database connection URL,
87    // postgres://[user[:password]@][netloc][:port][/dbname][?param1=value1&...]
88    pub db_url: String,
89    // Database pool max size
90    pub pool_max: usize,
91    /// Optional schema customization (table names, column names, extra columns).
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub schema: Option<SchemaConfig>,
94}
95
96impl PostgresConfig {
97    pub fn default_pool_max() -> usize {
98        16
99    }
100
101    pub fn validate(&self) -> Result<(), String> {
102        let s = self.db_url.trim();
103        if s.is_empty() {
104            return Err("db_url should not be empty".to_string());
105        }
106
107        let url = Url::parse(s).map_err(|e| format!("invalid db_url: {e}"))?;
108
109        let scheme = url.scheme();
110        if scheme != "postgres" && scheme != "postgresql" {
111            return Err(format!("unsupported URL scheme: {scheme}"));
112        }
113
114        if url.host().is_none() {
115            return Err("db_url must have a host".to_string());
116        }
117
118        let path = url.path();
119        let dbname = path
120            .strip_prefix('/')
121            .filter(|p| !p.is_empty())
122            .map(|s| s.to_string());
123        if dbname.is_none() {
124            return Err("db_url must include a database name".to_string());
125        }
126
127        if self.pool_max == 0 {
128            return Err("pool_max must be greater than 0".to_string());
129        }
130
131        Ok(())
132    }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct RedisConfig {
137    // Redis connection URL
138    // redis://[:password@]host[:port][/db]
139    pub url: String,
140    // Connection pool max size
141    #[serde(default = "default_redis_pool_max")]
142    pub pool_max: usize,
143    /// Optional schema customization (key prefix, field names, extra fields).
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub schema: Option<SchemaConfig>,
146    // Data retention in days. If None, data persists indefinitely.
147    #[serde(default = "default_redis_retention_days")]
148    pub retention_days: Option<u64>,
149}
150
151fn default_redis_pool_max() -> usize {
152    16
153}
154
155#[expect(
156    clippy::unnecessary_wraps,
157    reason = "serde default function must match field type Option<u64>"
158)]
159fn default_redis_retention_days() -> Option<u64> {
160    Some(30)
161}
162
163impl RedisConfig {
164    pub fn validate(&self) -> Result<(), String> {
165        let s = self.url.trim();
166        if s.is_empty() {
167            return Err("redis url should not be empty".to_string());
168        }
169
170        let url = Url::parse(s).map_err(|e| format!("invalid redis url: {e}"))?;
171
172        let scheme = url.scheme();
173        if scheme != "redis" && scheme != "rediss" {
174            return Err(format!("unsupported URL scheme: {scheme}"));
175        }
176
177        if url.host().is_none() {
178            return Err("redis url must have a host".to_string());
179        }
180
181        if self.pool_max == 0 {
182            return Err("pool_max must be greater than 0".to_string());
183        }
184
185        Ok(())
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    // ── PostgresConfig::validate ────────────────────────────────────────
194
195    #[test]
196    fn postgres_valid_url_succeeds() {
197        let cfg = PostgresConfig {
198            db_url: "postgres://user:pass@localhost:5432/mydb".to_string(),
199            pool_max: 16,
200            schema: None,
201        };
202        cfg.validate()
203            .expect("valid postgres URL should pass validation");
204    }
205
206    #[test]
207    fn postgres_postgresql_scheme_succeeds() {
208        let cfg = PostgresConfig {
209            db_url: "postgresql://user:pass@localhost/mydb".to_string(),
210            pool_max: 8,
211            schema: None,
212        };
213        cfg.validate()
214            .expect("postgresql:// scheme should also be accepted");
215    }
216
217    #[test]
218    fn postgres_empty_url_fails() {
219        let cfg = PostgresConfig {
220            db_url: "  ".to_string(),
221            pool_max: 16,
222            schema: None,
223        };
224        let err = cfg.validate().expect_err("empty URL should fail");
225        assert!(
226            err.contains("not be empty"),
227            "unexpected error message: {err}"
228        );
229    }
230
231    #[test]
232    fn postgres_non_postgres_scheme_fails() {
233        let cfg = PostgresConfig {
234            db_url: "mysql://user:pass@localhost/mydb".to_string(),
235            pool_max: 16,
236            schema: None,
237        };
238        let err = cfg.validate().expect_err("mysql scheme should be rejected");
239        assert!(
240            err.contains("unsupported URL scheme"),
241            "unexpected error message: {err}"
242        );
243    }
244
245    #[test]
246    fn postgres_missing_host_fails() {
247        // `postgres:///mydb` is a valid URL with no host
248        let cfg = PostgresConfig {
249            db_url: "postgres:///mydb".to_string(),
250            pool_max: 16,
251            schema: None,
252        };
253        let err = cfg.validate().expect_err("missing host should fail");
254        assert!(
255            err.contains("must have a host"),
256            "unexpected error message: {err}"
257        );
258    }
259
260    #[test]
261    fn postgres_missing_database_name_fails() {
262        let cfg = PostgresConfig {
263            db_url: "postgres://user:pass@localhost".to_string(),
264            pool_max: 16,
265            schema: None,
266        };
267        let err = cfg
268            .validate()
269            .expect_err("missing database name should fail");
270        assert!(
271            err.contains("database name"),
272            "unexpected error message: {err}"
273        );
274    }
275
276    #[test]
277    fn postgres_pool_max_zero_fails() {
278        let cfg = PostgresConfig {
279            db_url: "postgres://user:pass@localhost/mydb".to_string(),
280            pool_max: 0,
281            schema: None,
282        };
283        let err = cfg.validate().expect_err("pool_max=0 should fail");
284        assert!(
285            err.contains("greater than 0"),
286            "unexpected error message: {err}"
287        );
288    }
289
290    // ── RedisConfig::validate ───────────────────────────────────────────
291
292    #[test]
293    fn redis_valid_url_succeeds() {
294        let cfg = RedisConfig {
295            url: "redis://:password@localhost:6379/0".to_string(),
296            pool_max: 16,
297            retention_days: Some(30),
298            schema: None,
299        };
300        cfg.validate()
301            .expect("valid redis URL should pass validation");
302    }
303
304    #[test]
305    fn redis_rediss_scheme_succeeds() {
306        let cfg = RedisConfig {
307            url: "rediss://:password@redis.example.com:6380".to_string(),
308            pool_max: 8,
309            retention_days: None,
310            schema: None,
311        };
312        cfg.validate()
313            .expect("rediss:// scheme should also be accepted");
314    }
315
316    #[test]
317    fn redis_empty_url_fails() {
318        let cfg = RedisConfig {
319            url: String::new(),
320            pool_max: 16,
321            retention_days: Some(30),
322            schema: None,
323        };
324        let err = cfg.validate().expect_err("empty URL should fail");
325        assert!(
326            err.contains("not be empty"),
327            "unexpected error message: {err}"
328        );
329    }
330
331    #[test]
332    fn redis_non_redis_scheme_fails() {
333        let cfg = RedisConfig {
334            url: "http://localhost:6379".to_string(),
335            pool_max: 16,
336            retention_days: Some(30),
337            schema: None,
338        };
339        let err = cfg.validate().expect_err("http scheme should be rejected");
340        assert!(
341            err.contains("unsupported URL scheme"),
342            "unexpected error message: {err}"
343        );
344    }
345
346    #[test]
347    fn redis_missing_host_fails() {
348        let cfg = RedisConfig {
349            url: "redis:///0".to_string(),
350            pool_max: 16,
351            retention_days: Some(30),
352            schema: None,
353        };
354        let err = cfg.validate().expect_err("missing host should fail");
355        assert!(
356            err.contains("must have a host"),
357            "unexpected error message: {err}"
358        );
359    }
360
361    #[test]
362    fn redis_pool_max_zero_fails() {
363        let cfg = RedisConfig {
364            url: "redis://localhost:6379".to_string(),
365            pool_max: 0,
366            retention_days: Some(30),
367            schema: None,
368        };
369        let err = cfg.validate().expect_err("pool_max=0 should fail");
370        assert!(
371            err.contains("greater than 0"),
372            "unexpected error message: {err}"
373        );
374    }
375
376    // ── HistoryBackend ──────────────────────────────────────────────────
377
378    #[test]
379    fn history_backend_default_is_memory() {
380        assert_eq!(HistoryBackend::default(), HistoryBackend::Memory);
381    }
382
383    #[test]
384    fn history_backend_serde_roundtrip_memory() {
385        let backend = HistoryBackend::Memory;
386        let json = serde_json::to_string(&backend).expect("serialize Memory");
387        assert_eq!(json, r#""memory""#);
388        let deserialized: HistoryBackend = serde_json::from_str(&json).expect("deserialize Memory");
389        assert_eq!(deserialized, HistoryBackend::Memory);
390    }
391
392    #[test]
393    fn history_backend_serde_roundtrip_none() {
394        let backend = HistoryBackend::None;
395        let json = serde_json::to_string(&backend).expect("serialize None");
396        assert_eq!(json, r#""none""#);
397        let deserialized: HistoryBackend = serde_json::from_str(&json).expect("deserialize None");
398        assert_eq!(deserialized, HistoryBackend::None);
399    }
400
401    #[test]
402    fn history_backend_serde_roundtrip_oracle() {
403        let backend = HistoryBackend::Oracle;
404        let json = serde_json::to_string(&backend).expect("serialize Oracle");
405        assert_eq!(json, r#""oracle""#);
406        let deserialized: HistoryBackend = serde_json::from_str(&json).expect("deserialize Oracle");
407        assert_eq!(deserialized, HistoryBackend::Oracle);
408    }
409
410    #[test]
411    fn history_backend_serde_roundtrip_postgres() {
412        let backend = HistoryBackend::Postgres;
413        let json = serde_json::to_string(&backend).expect("serialize Postgres");
414        assert_eq!(json, r#""postgres""#);
415        let deserialized: HistoryBackend =
416            serde_json::from_str(&json).expect("deserialize Postgres");
417        assert_eq!(deserialized, HistoryBackend::Postgres);
418    }
419
420    #[test]
421    fn history_backend_serde_roundtrip_redis() {
422        let backend = HistoryBackend::Redis;
423        let json = serde_json::to_string(&backend).expect("serialize Redis");
424        assert_eq!(json, r#""redis""#);
425        let deserialized: HistoryBackend = serde_json::from_str(&json).expect("deserialize Redis");
426        assert_eq!(deserialized, HistoryBackend::Redis);
427    }
428}