1use serde::{Deserialize, Serialize};
4use url::Url;
5
6use crate::schema::SchemaConfig;
7
8#[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#[derive(Clone, Serialize, Deserialize, PartialEq)]
22pub struct OracleConfig {
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub wallet_path: Option<String>,
26 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 #[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 pub db_url: String,
89 pub pool_max: usize,
91 #[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 pub url: String,
140 #[serde(default = "default_redis_pool_max")]
142 pub pool_max: usize,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub schema: Option<SchemaConfig>,
146 #[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 #[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 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 #[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 #[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}