1use serde::Deserialize;
2
3#[derive(Debug, Deserialize)]
4pub struct ServerConfig {
5 pub server: ServerSection,
6 pub typedb: TypeDBSection,
7 #[serde(default)]
8 pub schema: SchemaSection,
9 #[serde(default)]
10 pub interceptors: InterceptorsSection,
11 #[serde(default)]
12 pub logging: LoggingSection,
13}
14
15#[derive(Debug, Deserialize)]
16pub struct ServerSection {
17 #[serde(default = "default_host")]
18 pub host: String,
19 #[serde(default = "default_port")]
20 pub port: u16,
21}
22
23#[derive(Debug, Deserialize)]
24pub struct TypeDBSection {
25 pub address: String,
26 pub database: String,
27 #[serde(default = "default_username")]
28 pub username: String,
29 #[serde(default = "default_password")]
30 pub password: String,
31}
32
33#[derive(Debug, Default, Deserialize)]
34pub struct SchemaSection {
35 #[serde(default)]
36 pub source_file: String,
37}
38
39#[derive(Debug, Default, Deserialize)]
40pub struct InterceptorsSection {
41 #[serde(default)]
42 pub enabled: Vec<String>,
43 #[serde(default, rename = "audit-log")]
44 pub audit_log: Option<AuditLogConfig>,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48pub struct AuditLogConfig {
49 #[serde(default = "default_audit_output")]
50 pub output: String,
51 #[serde(default)]
52 pub file_path: String,
53}
54
55#[derive(Debug, Deserialize)]
56pub struct LoggingSection {
57 #[serde(default = "default_log_level")]
58 pub level: String,
59 #[serde(default = "default_log_format")]
60 pub format: String,
61}
62
63impl Default for LoggingSection {
64 fn default() -> Self {
65 Self {
66 level: default_log_level(),
67 format: default_log_format(),
68 }
69 }
70}
71
72fn default_host() -> String {
73 "0.0.0.0".to_string()
74}
75
76fn default_port() -> u16 {
77 8080
78}
79
80fn default_username() -> String {
81 "admin".to_string()
82}
83
84fn default_password() -> String {
85 "password".to_string()
86}
87
88fn default_log_level() -> String {
89 "info".to_string()
90}
91
92fn default_log_format() -> String {
93 "json".to_string()
94}
95
96fn default_audit_output() -> String {
97 "stdout".to_string()
98}
99
100impl ServerConfig {
101 pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
102 let content = std::fs::read_to_string(path)?;
103 let mut config: ServerConfig = toml::from_str(&content)?;
104 config.apply_env_overrides();
105 Ok(config)
106 }
107
108 fn apply_env_overrides(&mut self) {
109 self.apply_env_overrides_from(|name| std::env::var(name).ok());
110 }
111
112 fn apply_env_overrides_from<F>(&mut self, mut get_env: F)
113 where
114 F: FnMut(&str) -> Option<String>,
115 {
116 if let Some(address) = get_env("TYPEDB_ADDRESS") {
117 self.typedb.address = address;
118 }
119 if let Some(database) = get_env("TYPEDB_DATABASE") {
120 self.typedb.database = database;
121 }
122 if let Some(username) = get_env("TYPEDB_USERNAME") {
123 self.typedb.username = username;
124 }
125 if let Some(password) = get_env("TYPEDB_PASSWORD") {
126 self.typedb.password = password;
127 }
128 }
129}
130
131#[cfg(test)]
132#[cfg_attr(coverage_nightly, coverage(off))]
133mod tests {
134 use super::*;
135
136 const FULL_CONFIG: &str = r#"
137[server]
138host = "127.0.0.1"
139port = 9090
140
141[typedb]
142address = "localhost:1729"
143database = "mydb"
144username = "root"
145password = "secret"
146
147[schema]
148source_file = "schema.tql"
149
150[interceptors]
151enabled = ["audit-log"]
152
153[interceptors.audit-log]
154output = "file"
155file_path = "/tmp/audit.log"
156
157[logging]
158level = "debug"
159format = "text"
160"#;
161
162 const MINIMAL_CONFIG: &str = r#"
163[server]
164
165[typedb]
166address = "localhost:1729"
167database = "mydb"
168"#;
169
170 #[test]
173 fn from_file_valid_full_config() {
174 let dir = tempfile::tempdir().unwrap();
175 let path = dir.path().join("server.toml");
176 std::fs::write(&path, FULL_CONFIG).unwrap();
177
178 let config = ServerConfig::from_file(path.to_str().unwrap()).unwrap();
179 assert_eq!(config.server.host, "127.0.0.1");
180 assert_eq!(config.server.port, 9090);
181 assert_eq!(config.typedb.address, "localhost:1729");
182 assert_eq!(config.typedb.database, "mydb");
183 assert_eq!(config.typedb.username, "root");
184 assert_eq!(config.typedb.password, "secret");
185 assert_eq!(config.schema.source_file, "schema.tql");
186 assert_eq!(config.interceptors.enabled, vec!["audit-log"]);
187 let audit = config.interceptors.audit_log.unwrap();
188 assert_eq!(audit.output, "file");
189 assert_eq!(audit.file_path, "/tmp/audit.log");
190 assert_eq!(config.logging.level, "debug");
191 assert_eq!(config.logging.format, "text");
192 }
193
194 #[test]
195 fn env_overrides_typedb_section() {
196 let mut config: ServerConfig = toml::from_str(FULL_CONFIG).unwrap();
197 config.apply_env_overrides_from(|name| match name {
198 "TYPEDB_ADDRESS" => Some("typedb:1729".to_string()),
199 "TYPEDB_DATABASE" => Some("docker_db".to_string()),
200 "TYPEDB_USERNAME" => Some("docker_user".to_string()),
201 "TYPEDB_PASSWORD" => Some("docker_pass".to_string()),
202 _ => None,
203 });
204
205 assert_eq!(config.typedb.address, "typedb:1729");
206 assert_eq!(config.typedb.database, "docker_db");
207 assert_eq!(config.typedb.username, "docker_user");
208 assert_eq!(config.typedb.password, "docker_pass");
209 }
210
211 #[test]
212 fn from_file_valid_minimal_config() {
213 let dir = tempfile::tempdir().unwrap();
214 let path = dir.path().join("server.toml");
215 std::fs::write(&path, MINIMAL_CONFIG).unwrap();
216
217 let config = ServerConfig::from_file(path.to_str().unwrap()).unwrap();
218 assert_eq!(config.server.host, "0.0.0.0");
220 assert_eq!(config.server.port, 8080);
221 assert_eq!(config.typedb.username, "admin");
222 assert_eq!(config.typedb.password, "password");
223 assert_eq!(config.schema.source_file, "");
224 assert!(config.interceptors.enabled.is_empty());
225 assert!(config.interceptors.audit_log.is_none());
226 assert_eq!(config.logging.level, "info");
227 assert_eq!(config.logging.format, "json");
228 }
229
230 #[test]
231 fn from_file_missing_file() {
232 let result = ServerConfig::from_file("/nonexistent/path/server.toml");
233 assert!(result.is_err());
234 }
235
236 #[test]
237 fn from_file_invalid_toml() {
238 let dir = tempfile::tempdir().unwrap();
239 let path = dir.path().join("bad.toml");
240 std::fs::write(&path, "this is not valid toml {{{}}}").unwrap();
241
242 let result = ServerConfig::from_file(path.to_str().unwrap());
243 assert!(result.is_err());
244 }
245
246 #[test]
247 fn from_file_missing_required_typedb_section() {
248 let dir = tempfile::tempdir().unwrap();
249 let path = dir.path().join("incomplete.toml");
250 std::fs::write(&path, "[server]\n").unwrap();
251
252 let result = ServerConfig::from_file(path.to_str().unwrap());
253 assert!(result.is_err());
254 }
255
256 #[test]
257 fn from_file_missing_required_typedb_fields() {
258 let dir = tempfile::tempdir().unwrap();
259 let path = dir.path().join("incomplete.toml");
260 std::fs::write(&path, "[server]\n[typedb]\n").unwrap();
261
262 let result = ServerConfig::from_file(path.to_str().unwrap());
263 assert!(result.is_err()); }
265
266 #[test]
269 fn default_host_value() {
270 assert_eq!(default_host(), "0.0.0.0");
271 }
272
273 #[test]
274 fn default_port_value() {
275 assert_eq!(default_port(), 8080);
276 }
277
278 #[test]
279 fn default_username_value() {
280 assert_eq!(default_username(), "admin");
281 }
282
283 #[test]
284 fn default_password_value() {
285 assert_eq!(default_password(), "password");
286 }
287
288 #[test]
289 fn default_log_level_value() {
290 assert_eq!(default_log_level(), "info");
291 }
292
293 #[test]
294 fn default_log_format_value() {
295 assert_eq!(default_log_format(), "json");
296 }
297
298 #[test]
299 fn default_audit_output_value() {
300 assert_eq!(default_audit_output(), "stdout");
301 }
302
303 #[test]
306 fn logging_section_default() {
307 let logging = LoggingSection::default();
308 assert_eq!(logging.level, "info");
309 assert_eq!(logging.format, "json");
310 }
311
312 #[test]
315 fn server_section_custom_host_default_port() {
316 let toml = r#"
317[server]
318host = "192.168.1.1"
319
320[typedb]
321address = "localhost:1729"
322database = "db"
323"#;
324 let config: ServerConfig = toml::from_str(toml).unwrap();
325 assert_eq!(config.server.host, "192.168.1.1");
326 assert_eq!(config.server.port, 8080); }
328
329 #[test]
330 fn server_section_custom_port_default_host() {
331 let toml = r#"
332[server]
333port = 3000
334
335[typedb]
336address = "localhost:1729"
337database = "db"
338"#;
339 let config: ServerConfig = toml::from_str(toml).unwrap();
340 assert_eq!(config.server.host, "0.0.0.0"); assert_eq!(config.server.port, 3000);
342 }
343
344 #[test]
345 fn typedb_section_custom_credentials() {
346 let toml = r#"
347[server]
348
349[typedb]
350address = "remote:1729"
351database = "prod"
352username = "superuser"
353password = "hunter2"
354"#;
355 let config: ServerConfig = toml::from_str(toml).unwrap();
356 assert_eq!(config.typedb.username, "superuser");
357 assert_eq!(config.typedb.password, "hunter2");
358 }
359
360 #[test]
361 fn schema_section_default_when_missing() {
362 let config: ServerConfig = toml::from_str(MINIMAL_CONFIG).unwrap();
363 assert_eq!(config.schema.source_file, "");
364 }
365
366 #[test]
367 fn schema_section_with_file() {
368 let toml = r#"
369[server]
370[typedb]
371address = "localhost:1729"
372database = "db"
373[schema]
374source_file = "my_schema.tql"
375"#;
376 let config: ServerConfig = toml::from_str(toml).unwrap();
377 assert_eq!(config.schema.source_file, "my_schema.tql");
378 }
379
380 #[test]
381 fn interceptors_enabled_empty_by_default() {
382 let config: ServerConfig = toml::from_str(MINIMAL_CONFIG).unwrap();
383 assert!(config.interceptors.enabled.is_empty());
384 assert!(config.interceptors.audit_log.is_none());
385 }
386
387 #[test]
388 fn interceptors_enabled_without_audit_config() {
389 let toml = r#"
390[server]
391[typedb]
392address = "localhost:1729"
393database = "db"
394[interceptors]
395enabled = ["audit-log"]
396"#;
397 let config: ServerConfig = toml::from_str(toml).unwrap();
398 assert_eq!(config.interceptors.enabled, vec!["audit-log"]);
399 assert!(config.interceptors.audit_log.is_none());
400 }
401
402 #[test]
403 fn interceptors_with_audit_config() {
404 let toml = r#"
405[server]
406[typedb]
407address = "localhost:1729"
408database = "db"
409[interceptors]
410enabled = ["audit-log"]
411[interceptors.audit-log]
412output = "file"
413file_path = "/var/log/audit.jsonl"
414"#;
415 let config: ServerConfig = toml::from_str(toml).unwrap();
416 let audit = config.interceptors.audit_log.unwrap();
417 assert_eq!(audit.output, "file");
418 assert_eq!(audit.file_path, "/var/log/audit.jsonl");
419 }
420
421 #[test]
422 fn audit_log_config_defaults() {
423 let toml = r#"
424[server]
425[typedb]
426address = "localhost:1729"
427database = "db"
428[interceptors]
429enabled = ["audit-log"]
430[interceptors.audit-log]
431"#;
432 let config: ServerConfig = toml::from_str(toml).unwrap();
433 let audit = config.interceptors.audit_log.unwrap();
434 assert_eq!(audit.output, "stdout"); assert_eq!(audit.file_path, ""); }
437
438 #[test]
439 fn extra_fields_ignored() {
440 let toml = r#"
441[server]
442host = "0.0.0.0"
443unknown_field = "ignored"
444
445[typedb]
446address = "localhost:1729"
447database = "db"
448"#;
449 let result: Result<ServerConfig, _> = toml::from_str(toml);
451 assert!(result.is_ok());
452 }
453
454 #[test]
455 fn multiple_interceptors_enabled() {
456 let toml = r#"
457[server]
458[typedb]
459address = "localhost:1729"
460database = "db"
461[interceptors]
462enabled = ["audit-log", "rate-limiter", "custom"]
463"#;
464 let config: ServerConfig = toml::from_str(toml).unwrap();
465 assert_eq!(config.interceptors.enabled.len(), 3);
466 }
467}