1use serde::Deserialize;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Deserialize, Default)]
15struct FileConfig {
16 server: Option<ServerSection>,
17 auth: Option<AuthSection>,
18 tls: Option<TlsSection>,
19}
20
21#[derive(Debug, Deserialize, Default)]
22struct ServerSection {
23 host: Option<String>,
24 port: Option<u16>,
25 data_dir: Option<String>,
26 shutdown_timeout_secs: Option<u64>,
27}
28
29#[derive(Debug, Deserialize, Default)]
30struct AuthSection {
31 api_keys: Option<Vec<String>>,
32}
33
34#[derive(Debug, Deserialize, Default)]
35struct TlsSection {
36 cert: Option<String>,
37 key: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq)]
46pub struct ServerConfig {
47 pub host: String,
48 pub port: u16,
49 pub data_dir: String,
50 pub api_keys: Vec<String>,
51 pub tls_cert: Option<String>,
52 pub tls_key: Option<String>,
53 pub shutdown_timeout_secs: u64,
54}
55
56impl Default for ServerConfig {
57 fn default() -> Self {
58 Self {
59 host: "127.0.0.1".to_string(),
60 port: 8080,
61 data_dir: "./velesdb_data".to_string(),
62 api_keys: Vec::new(),
63 tls_cert: None,
64 tls_key: None,
65 shutdown_timeout_secs: 30,
66 }
67 }
68}
69
70impl ServerConfig {
75 pub fn load(cli: CliOverrides) -> anyhow::Result<Self> {
81 let defaults = Self::default();
82 let file_cfg = load_toml_file(&cli.config_path)?;
83 Ok(Self::merge(defaults, file_cfg, cli))
84 }
85
86 fn merge(defaults: Self, file: FileConfig, cli: CliOverrides) -> Self {
87 let server = file.server.unwrap_or_default();
88 let auth = file.auth.unwrap_or_default();
89 let tls = file.tls.unwrap_or_default();
90
91 let host = server.host.unwrap_or(defaults.host);
93 let port = server.port.unwrap_or(defaults.port);
94 let data_dir = server.data_dir.unwrap_or(defaults.data_dir);
95 let shutdown_timeout_secs = server
96 .shutdown_timeout_secs
97 .unwrap_or(defaults.shutdown_timeout_secs);
98 let api_keys = auth.api_keys.unwrap_or(defaults.api_keys);
99 let tls_cert = tls.cert.or(defaults.tls_cert);
100 let tls_key = tls.key.or(defaults.tls_key);
101
102 let host = cli.host.unwrap_or(host);
104 let port = cli.port.unwrap_or(port);
105 let data_dir = cli.data_dir.unwrap_or(data_dir);
106 let api_keys = cli.api_keys.unwrap_or(api_keys);
107 let tls_cert = cli.tls_cert.or(tls_cert);
108 let tls_key = cli.tls_key.or(tls_key);
109
110 Self {
111 host,
112 port,
113 data_dir,
114 api_keys,
115 tls_cert,
116 tls_key,
117 shutdown_timeout_secs,
118 }
119 }
120
121 pub fn validate(&self) -> anyhow::Result<()> {
123 if self.port == 0 {
124 anyhow::bail!("invalid port: 0 is not allowed");
125 }
126 if self.data_dir.is_empty() {
127 anyhow::bail!("data_dir must not be empty");
128 }
129
130 match (&self.tls_cert, &self.tls_key) {
132 (Some(_), None) => {
133 anyhow::bail!("tls_cert is set but tls_key is missing");
134 }
135 (None, Some(_)) => {
136 anyhow::bail!("tls_key is set but tls_cert is missing");
137 }
138 (Some(cert), Some(key)) => {
139 if !Path::new(cert).exists() {
140 anyhow::bail!("TLS cert file not found: {cert}");
141 }
142 if !Path::new(key).exists() {
143 anyhow::bail!("TLS key file not found: {key}");
144 }
145 }
146 (None, None) => {}
147 }
148
149 Ok(())
150 }
151
152 pub fn auth_enabled(&self) -> bool {
154 !self.api_keys.is_empty()
155 }
156
157 pub fn tls_enabled(&self) -> bool {
159 self.tls_cert.is_some() && self.tls_key.is_some()
160 }
161}
162
163#[derive(Debug, Default)]
170pub struct CliOverrides {
171 pub config_path: Option<PathBuf>,
172 pub host: Option<String>,
173 pub port: Option<u16>,
174 pub data_dir: Option<String>,
175 pub api_keys: Option<Vec<String>>,
176 pub tls_cert: Option<String>,
177 pub tls_key: Option<String>,
178}
179
180fn load_toml_file(path: &Option<PathBuf>) -> anyhow::Result<FileConfig> {
185 let candidate = match path {
186 Some(p) => {
187 if !p.exists() {
188 anyhow::bail!("config file not found: {}", p.display());
189 }
190 p.clone()
191 }
192 None => {
193 let default_path = PathBuf::from("velesdb.toml");
194 if !default_path.exists() {
195 return Ok(FileConfig::default());
196 }
197 default_path
198 }
199 };
200
201 let contents = std::fs::read_to_string(&candidate)
202 .map_err(|e| anyhow::anyhow!("failed to read config file {}: {e}", candidate.display()))?;
203
204 let cfg: FileConfig = toml::from_str(&contents)
205 .map_err(|e| anyhow::anyhow!("failed to parse config file {}: {e}", candidate.display()))?;
206
207 Ok(cfg)
208}
209
210pub fn parse_api_keys_env() -> Option<Vec<String>> {
216 let val = std::env::var("VELESDB_API_KEYS").ok()?;
217 let keys: Vec<String> = val
218 .split(',')
219 .map(|s| s.trim().to_string())
220 .filter(|s| !s.is_empty())
221 .collect();
222 if keys.is_empty() {
223 None
224 } else {
225 Some(keys)
226 }
227}
228
229#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::io::Write;
237
238 #[test]
239 fn test_defaults() {
240 let cfg = ServerConfig::default();
241 assert_eq!(cfg.host, "127.0.0.1");
242 assert_eq!(cfg.port, 8080);
243 assert_eq!(cfg.data_dir, "./velesdb_data");
244 assert!(cfg.api_keys.is_empty());
245 assert!(cfg.tls_cert.is_none());
246 assert!(cfg.tls_key.is_none());
247 assert_eq!(cfg.shutdown_timeout_secs, 30);
248 assert!(!cfg.auth_enabled());
249 assert!(!cfg.tls_enabled());
250 }
251
252 #[test]
253 fn test_toml_overrides_defaults() {
254 let toml_content = r#"
255[server]
256host = "0.0.0.0"
257port = 9090
258data_dir = "/var/velesdb"
259shutdown_timeout_secs = 60
260
261[auth]
262api_keys = ["key-alpha", "key-beta"]
263
264[tls]
265cert = "/etc/ssl/cert.pem"
266key = "/etc/ssl/key.pem"
267"#;
268 let file_cfg: FileConfig = toml::from_str(toml_content).unwrap();
269 let cli = CliOverrides::default();
270 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
271
272 assert_eq!(cfg.host, "0.0.0.0");
273 assert_eq!(cfg.port, 9090);
274 assert_eq!(cfg.data_dir, "/var/velesdb");
275 assert_eq!(cfg.shutdown_timeout_secs, 60);
276 assert_eq!(cfg.api_keys, vec!["key-alpha", "key-beta"]);
277 assert_eq!(cfg.tls_cert.as_deref(), Some("/etc/ssl/cert.pem"));
278 assert_eq!(cfg.tls_key.as_deref(), Some("/etc/ssl/key.pem"));
279 assert!(cfg.auth_enabled());
280 assert!(cfg.tls_enabled());
281 }
282
283 #[test]
284 fn test_cli_overrides_toml() {
285 let toml_content = r#"
286[server]
287host = "0.0.0.0"
288port = 9090
289"#;
290 let file_cfg: FileConfig = toml::from_str(toml_content).unwrap();
291 let cli = CliOverrides {
292 port: Some(3000),
293 host: Some("10.0.0.1".to_string()),
294 ..Default::default()
295 };
296 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
297
298 assert_eq!(cfg.host, "10.0.0.1");
300 assert_eq!(cfg.port, 3000);
301 assert_eq!(cfg.data_dir, "./velesdb_data");
303 }
304
305 #[test]
306 fn test_partial_toml_uses_defaults_for_missing() {
307 let toml_content = r#"
308[server]
309port = 4000
310"#;
311 let file_cfg: FileConfig = toml::from_str(toml_content).unwrap();
312 let cli = CliOverrides::default();
313 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
314
315 assert_eq!(cfg.port, 4000);
316 assert_eq!(cfg.host, "127.0.0.1"); assert_eq!(cfg.data_dir, "./velesdb_data"); }
319
320 #[test]
321 fn test_empty_toml_uses_all_defaults() {
322 let file_cfg: FileConfig = toml::from_str("").unwrap();
323 let cli = CliOverrides::default();
324 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
325
326 assert_eq!(cfg, ServerConfig::default());
327 }
328
329 #[test]
330 fn test_validate_port_zero_rejected() {
331 let cfg = ServerConfig {
332 port: 0,
333 ..ServerConfig::default()
334 };
335 let err = cfg.validate().unwrap_err();
336 assert!(err.to_string().contains("port"));
337 }
338
339 #[test]
340 fn test_validate_empty_data_dir_rejected() {
341 let cfg = ServerConfig {
342 data_dir: String::new(),
343 ..ServerConfig::default()
344 };
345 let err = cfg.validate().unwrap_err();
346 assert!(err.to_string().contains("data_dir"));
347 }
348
349 #[test]
350 fn test_validate_tls_cert_without_key() {
351 let cfg = ServerConfig {
352 tls_cert: Some("/tmp/cert.pem".to_string()),
353 ..ServerConfig::default()
354 };
355 let err = cfg.validate().unwrap_err();
356 assert!(err.to_string().contains("tls_key is missing"));
357 }
358
359 #[test]
360 fn test_validate_tls_key_without_cert() {
361 let cfg = ServerConfig {
362 tls_key: Some("/tmp/key.pem".to_string()),
363 ..ServerConfig::default()
364 };
365 let err = cfg.validate().unwrap_err();
366 assert!(err.to_string().contains("tls_cert is missing"));
367 }
368
369 #[test]
370 fn test_validate_tls_missing_cert_file() {
371 let cfg = ServerConfig {
372 tls_cert: Some("/nonexistent/cert.pem".to_string()),
373 tls_key: Some("/nonexistent/key.pem".to_string()),
374 ..ServerConfig::default()
375 };
376 let err = cfg.validate().unwrap_err();
377 assert!(err.to_string().contains("cert file not found"));
378 }
379
380 #[test]
381 fn test_validate_tls_valid_files() {
382 let dir = tempfile::tempdir().unwrap();
383 let cert_path = dir.path().join("cert.pem");
384 let key_path = dir.path().join("key.pem");
385 std::fs::File::create(&cert_path)
386 .unwrap()
387 .write_all(b"cert")
388 .unwrap();
389 std::fs::File::create(&key_path)
390 .unwrap()
391 .write_all(b"key")
392 .unwrap();
393
394 let cfg = ServerConfig {
395 tls_cert: Some(cert_path.to_string_lossy().to_string()),
396 tls_key: Some(key_path.to_string_lossy().to_string()),
397 ..ServerConfig::default()
398 };
399 cfg.validate().expect("valid TLS config should pass");
400 }
401
402 #[test]
403 fn test_parse_api_keys_env() {
404 let input = "key1, key2 , key3";
406 let keys: Vec<String> = input
407 .split(',')
408 .map(|s| s.trim().to_string())
409 .filter(|s| !s.is_empty())
410 .collect();
411 assert_eq!(keys, vec!["key1", "key2", "key3"]);
412 }
413
414 #[test]
415 fn test_load_toml_file_not_found_explicit_path() {
416 let result = load_toml_file(&Some(PathBuf::from("/nonexistent/velesdb.toml")));
417 assert!(result.is_err());
418 assert!(result
419 .unwrap_err()
420 .to_string()
421 .contains("config file not found"));
422 }
423
424 #[test]
425 fn test_load_toml_file_no_default_returns_empty() {
426 let result = load_toml_file(&None);
428 assert!(result.is_ok());
429 }
430
431 #[test]
432 fn test_full_priority_chain() {
433 let toml_content = r#"
435[server]
436port = 9090
437host = "0.0.0.0"
438data_dir = "/toml/data"
439"#;
440 let file_cfg: FileConfig = toml::from_str(toml_content).unwrap();
441 let cli = CliOverrides {
442 port: Some(3000),
443 ..Default::default()
445 };
446 let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
447
448 assert_eq!(cfg.port, 3000); assert_eq!(cfg.host, "0.0.0.0"); assert_eq!(cfg.data_dir, "/toml/data"); }
452}