Skip to main content

velesdb_server/
config.rs

1//! Server configuration module.
2//!
3//! Loads configuration from multiple sources with priority:
4//! CLI flags > environment variables > velesdb.toml > defaults.
5
6use serde::Deserialize;
7use std::path::{Path, PathBuf};
8
9// ============================================================================
10// TOML file configuration (all fields optional)
11// ============================================================================
12
13/// Root structure for `velesdb.toml`.
14#[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// ============================================================================
41// Resolved configuration
42// ============================================================================
43
44/// Final resolved server configuration.
45#[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
70// ============================================================================
71// Loading logic
72// ============================================================================
73
74impl ServerConfig {
75    /// Load configuration with priority: CLI > env > TOML file > defaults.
76    ///
77    /// `cli` contains values from clap (which merges CLI flags + env vars).
78    /// `cli_sources` indicates which fields were explicitly set via CLI/env
79    /// (as opposed to falling back to clap defaults).
80    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        // Layer: TOML over defaults
92        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        // Layer: CLI/env over TOML (only override when explicitly set)
103        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    /// Validate the configuration at startup.
122    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        // TLS: both cert and key must be provided together
131        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    /// Returns `true` when API key authentication is enabled.
153    pub fn auth_enabled(&self) -> bool {
154        !self.api_keys.is_empty()
155    }
156
157    /// Returns `true` when TLS is configured.
158    pub fn tls_enabled(&self) -> bool {
159        self.tls_cert.is_some() && self.tls_key.is_some()
160    }
161}
162
163// ============================================================================
164// CLI overrides (filled by clap in main.rs)
165// ============================================================================
166
167/// Values explicitly provided via CLI flags or environment variables.
168/// `None` means "not provided — fall through to TOML or default".
169#[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
180// ============================================================================
181// TOML file loader
182// ============================================================================
183
184fn 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
210// ============================================================================
211// Helper: parse comma-separated API keys from env var
212// ============================================================================
213
214/// Parse `VELESDB_API_KEYS` env var (comma-separated) into a `Vec<String>`.
215pub 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// ============================================================================
230// Tests
231// ============================================================================
232
233#[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        // CLI wins over TOML
299        assert_eq!(cfg.host, "10.0.0.1");
300        assert_eq!(cfg.port, 3000);
301        // TOML didn't set data_dir, so default applies
302        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"); // default
317        assert_eq!(cfg.data_dir, "./velesdb_data"); // default
318    }
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        // Simulate by directly testing the parsing logic
405        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        // When no explicit path and no velesdb.toml in cwd, returns defaults
427        let result = load_toml_file(&None);
428        assert!(result.is_ok());
429    }
430
431    #[test]
432    fn test_full_priority_chain() {
433        // Scenario: default=8080, TOML=9090, CLI=3000 → expect 3000
434        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            // host not set in CLI → TOML should win
444            ..Default::default()
445        };
446        let cfg = ServerConfig::merge(ServerConfig::default(), file_cfg, cli);
447
448        assert_eq!(cfg.port, 3000); // CLI wins
449        assert_eq!(cfg.host, "0.0.0.0"); // TOML wins (no CLI override)
450        assert_eq!(cfg.data_dir, "/toml/data"); // TOML wins (no CLI override)
451    }
452}