1use std::fmt;
20use std::num::ParseIntError;
21
22use lora_database::SyncMode;
23
24pub const DEFAULT_HOST: &str = "127.0.0.1";
25pub const DEFAULT_PORT: u16 = 4747;
26pub const HOST_ENV: &str = "LORA_SERVER_HOST";
27pub const PORT_ENV: &str = "LORA_SERVER_PORT";
28pub const SNAPSHOT_PATH_ENV: &str = "LORA_SERVER_SNAPSHOT_PATH";
29pub const WAL_DIR_ENV: &str = "LORA_SERVER_WAL_DIR";
30pub const WAL_SYNC_MODE_ENV: &str = "LORA_SERVER_WAL_SYNC_MODE";
31
32pub const DEFAULT_WAL_SEGMENT_TARGET_BYTES: u64 = 8 * 1024 * 1024;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ServerConfig {
38 pub host: String,
39 pub port: u16,
40 pub snapshot_path: Option<std::path::PathBuf>,
45 pub restore_from: Option<std::path::PathBuf>,
50 pub wal_dir: Option<std::path::PathBuf>,
56 pub wal_sync_mode: SyncMode,
58}
59
60impl Default for ServerConfig {
61 fn default() -> Self {
62 Self {
63 host: DEFAULT_HOST.to_string(),
64 port: DEFAULT_PORT,
65 snapshot_path: None,
66 restore_from: None,
67 wal_dir: None,
68 wal_sync_mode: SyncMode::PerCommit,
69 }
70 }
71}
72
73impl ServerConfig {
74 pub fn bind_addr(&self) -> String {
75 if self.host.contains(':') && !self.host.starts_with('[') {
76 format!("[{}]:{}", self.host, self.port)
77 } else {
78 format!("{}:{}", self.host, self.port)
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ConfigOutcome {
85 Run(ServerConfig),
86 Help(String),
87 Version(String),
88}
89
90#[derive(Debug, PartialEq, Eq)]
91pub enum ConfigError {
92 UnknownArg(String),
93 MissingValue(&'static str),
94 EmptyValue(&'static str),
95 InvalidPort { value: String, reason: String },
96 InvalidSyncMode(String),
97 UnexpectedPositional(String),
98}
99
100impl fmt::Display for ConfigError {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 match self {
103 ConfigError::UnknownArg(a) => write!(f, "unknown argument: {a}"),
104 ConfigError::MissingValue(flag) => write!(f, "missing value for {flag}"),
105 ConfigError::EmptyValue(flag) => write!(f, "{flag} value must not be empty"),
106 ConfigError::InvalidPort { value, reason } => {
107 write!(f, "invalid port '{value}': {reason}")
108 }
109 ConfigError::InvalidSyncMode(value) => {
110 write!(
111 f,
112 "invalid --wal-sync-mode '{value}': expected per-commit, group, or none"
113 )
114 }
115 ConfigError::UnexpectedPositional(a) => {
116 write!(f, "unexpected positional argument: {a}")
117 }
118 }
119 }
120}
121
122impl std::error::Error for ConfigError {}
123
124impl From<ParseIntError> for ConfigError {
125 fn from(_: ParseIntError) -> Self {
126 ConfigError::InvalidPort {
129 value: String::new(),
130 reason: "not a valid u16".into(),
131 }
132 }
133}
134
135pub fn help_text() -> String {
136 let version = env!("CARGO_PKG_VERSION");
137 format!(
138 "lora-server {version} — HTTP server for the Lora in-memory graph database
139
140USAGE:
141 lora-server [OPTIONS]
142
143OPTIONS:
144 --host <HOST> Bind address. Default: {DEFAULT_HOST} (or ${HOST_ENV} if set).
145 --port <PORT> TCP port. Default: {DEFAULT_PORT} (or ${PORT_ENV} if set).
146 --snapshot-path <PATH> Enable the snapshot admin surface. Mounts
147 POST /admin/snapshot/save and
148 POST /admin/snapshot/load against this file.
149 Also acts as the default target for
150 POST /admin/checkpoint when --wal-dir is set.
151 Also read from ${SNAPSHOT_PATH_ENV}.
152 --restore-from <PATH> Restore the graph from this snapshot at boot.
153 Missing file is treated as empty. When
154 --wal-dir is also set, the WAL is replayed
155 on top of the snapshot.
156 --wal-dir <DIR> Attach a write-ahead log at this directory.
157 Every mutating query is bracketed by
158 begin/commit; a crashed process recovers
159 committed writes on next boot. Read-only
160 queries do not touch the WAL.
161 Also enables the WAL admin routes
162 (POST /admin/wal/status,
163 POST /admin/wal/truncate,
164 POST /admin/checkpoint) — independent of
165 --snapshot-path. /admin/checkpoint requires
166 `path` in the request body when no
167 --snapshot-path default is configured.
168 Also read from ${WAL_DIR_ENV}.
169 --wal-sync-mode <MODE> WAL durability cadence. One of:
170 per-commit fsync before each commit returns (default).
171 group buffer commits, fsync periodically.
172 none no fsync; rely on OS / external durability.
173 Also read from ${WAL_SYNC_MODE_ENV}.
174 --help Print this help and exit.
175 --version Print version and exit.
176
177ENVIRONMENT:
178 {HOST_ENV} Bind address (overridden by --host).
179 {PORT_ENV} TCP port (overridden by --port).
180 {SNAPSHOT_PATH_ENV} Path used by --snapshot-path.
181 {WAL_DIR_ENV} Directory used by --wal-dir.
182 {WAL_SYNC_MODE_ENV} Mode used by --wal-sync-mode.
183
184EXAMPLES:
185 lora-server
186 lora-server --host 0.0.0.0 --port 8080
187 lora-server --snapshot-path /var/lib/lora/graph.bin
188 lora-server --wal-dir /var/lib/lora/wal --snapshot-path /var/lib/lora/graph.bin \\
189 --restore-from /var/lib/lora/graph.bin
190"
191 )
192}
193
194pub fn version_text() -> String {
195 format!("lora-server {}", env!("CARGO_PKG_VERSION"))
196}
197
198#[derive(Debug, Default, Clone)]
201pub struct EnvInputs {
202 pub host: Option<String>,
203 pub port: Option<String>,
204 pub snapshot_path: Option<String>,
205 pub wal_dir: Option<String>,
206 pub wal_sync_mode: Option<String>,
207}
208
209pub fn resolve<I>(args: I, env: EnvInputs) -> Result<ConfigOutcome, ConfigError>
214where
215 I: IntoIterator<Item = String>,
216{
217 let mut iter = args.into_iter();
218 let _program = iter.next();
219
220 let mut cli_host: Option<String> = None;
221 let mut cli_port: Option<String> = None;
222 let mut cli_snapshot_path: Option<String> = None;
223 let mut cli_restore_from: Option<String> = None;
224 let mut cli_wal_dir: Option<String> = None;
225 let mut cli_wal_sync_mode: Option<String> = None;
226
227 while let Some(arg) = iter.next() {
228 match arg.as_str() {
229 "--help" => return Ok(ConfigOutcome::Help(help_text())),
230 "--version" => return Ok(ConfigOutcome::Version(version_text())),
231 "--host" => {
232 let v = iter.next().ok_or(ConfigError::MissingValue("--host"))?;
233 cli_host = Some(v);
234 }
235 "--port" => {
236 let v = iter.next().ok_or(ConfigError::MissingValue("--port"))?;
237 cli_port = Some(v);
238 }
239 "--snapshot-path" => {
240 let v = iter
241 .next()
242 .ok_or(ConfigError::MissingValue("--snapshot-path"))?;
243 cli_snapshot_path = Some(v);
244 }
245 "--restore-from" => {
246 let v = iter
247 .next()
248 .ok_or(ConfigError::MissingValue("--restore-from"))?;
249 cli_restore_from = Some(v);
250 }
251 "--wal-dir" => {
252 let v = iter.next().ok_or(ConfigError::MissingValue("--wal-dir"))?;
253 cli_wal_dir = Some(v);
254 }
255 "--wal-sync-mode" => {
256 let v = iter
257 .next()
258 .ok_or(ConfigError::MissingValue("--wal-sync-mode"))?;
259 cli_wal_sync_mode = Some(v);
260 }
261 s if s.starts_with("--host=") => {
262 cli_host = Some(s["--host=".len()..].to_string());
263 }
264 s if s.starts_with("--port=") => {
265 cli_port = Some(s["--port=".len()..].to_string());
266 }
267 s if s.starts_with("--snapshot-path=") => {
268 cli_snapshot_path = Some(s["--snapshot-path=".len()..].to_string());
269 }
270 s if s.starts_with("--restore-from=") => {
271 cli_restore_from = Some(s["--restore-from=".len()..].to_string());
272 }
273 s if s.starts_with("--wal-dir=") => {
274 cli_wal_dir = Some(s["--wal-dir=".len()..].to_string());
275 }
276 s if s.starts_with("--wal-sync-mode=") => {
277 cli_wal_sync_mode = Some(s["--wal-sync-mode=".len()..].to_string());
278 }
279 s if s.starts_with("--") => return Err(ConfigError::UnknownArg(arg)),
280 _ => return Err(ConfigError::UnexpectedPositional(arg)),
281 }
282 }
283
284 let host = cli_host
285 .or(env.host)
286 .unwrap_or_else(|| DEFAULT_HOST.to_string());
287 if host.trim().is_empty() {
288 return Err(ConfigError::EmptyValue("--host"));
289 }
290
291 let port = match cli_port.or(env.port) {
292 Some(raw) => parse_port(&raw)?,
293 None => DEFAULT_PORT,
294 };
295
296 let snapshot_path = cli_snapshot_path
297 .or(env.snapshot_path)
298 .and_then(non_empty_path);
299 let restore_from = cli_restore_from.and_then(non_empty_path);
300 let wal_dir = cli_wal_dir.or(env.wal_dir).and_then(non_empty_path);
301 let wal_sync_mode = match cli_wal_sync_mode.or(env.wal_sync_mode) {
302 Some(raw) => parse_sync_mode(&raw)?,
303 None => SyncMode::PerCommit,
304 };
305
306 Ok(ConfigOutcome::Run(ServerConfig {
307 host,
308 port,
309 snapshot_path,
310 restore_from,
311 wal_dir,
312 wal_sync_mode,
313 }))
314}
315
316pub fn resolve_from_process() -> Result<ConfigOutcome, ConfigError> {
318 resolve(
319 std::env::args(),
320 EnvInputs {
321 host: std::env::var(HOST_ENV).ok(),
322 port: std::env::var(PORT_ENV).ok(),
323 snapshot_path: std::env::var(SNAPSHOT_PATH_ENV).ok(),
324 wal_dir: std::env::var(WAL_DIR_ENV).ok(),
325 wal_sync_mode: std::env::var(WAL_SYNC_MODE_ENV).ok(),
326 },
327 )
328}
329
330fn non_empty_path(p: String) -> Option<std::path::PathBuf> {
331 if p.trim().is_empty() {
332 None
333 } else {
334 Some(std::path::PathBuf::from(p))
335 }
336}
337
338fn parse_port(raw: &str) -> Result<u16, ConfigError> {
339 let trimmed = raw.trim();
340 if trimmed.is_empty() {
341 return Err(ConfigError::EmptyValue("--port"));
342 }
343 trimmed
344 .parse::<u16>()
345 .map_err(|e| ConfigError::InvalidPort {
346 value: raw.to_string(),
347 reason: e.to_string(),
348 })
349}
350
351fn parse_sync_mode(raw: &str) -> Result<SyncMode, ConfigError> {
352 match raw.trim().to_ascii_lowercase().as_str() {
353 "per-commit" | "per_commit" | "percommit" => Ok(SyncMode::PerCommit),
354 "group" => Ok(SyncMode::Group {
355 interval_ms: 50,
360 }),
361 "none" | "off" => Ok(SyncMode::None),
362 other => Err(ConfigError::InvalidSyncMode(other.to_string())),
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 fn args(xs: &[&str]) -> Vec<String> {
371 std::iter::once("lora-server")
372 .chain(xs.iter().copied())
373 .map(String::from)
374 .collect()
375 }
376
377 fn run_cfg(out: ConfigOutcome) -> ServerConfig {
378 match out {
379 ConfigOutcome::Run(c) => c,
380 other => panic!("expected Run, got {other:?}"),
381 }
382 }
383
384 #[test]
385 fn defaults_when_nothing_set() {
386 let cfg = run_cfg(resolve(args(&[]), EnvInputs::default()).unwrap());
387 assert_eq!(cfg, ServerConfig::default());
388 }
389
390 #[test]
391 fn env_vars_apply_without_cli() {
392 let cfg = run_cfg(
393 resolve(
394 args(&[]),
395 EnvInputs {
396 host: Some("0.0.0.0".into()),
397 port: Some("9000".into()),
398 ..EnvInputs::default()
399 },
400 )
401 .unwrap(),
402 );
403 assert_eq!(cfg.host, "0.0.0.0");
404 assert_eq!(cfg.port, 9000);
405 }
406
407 #[test]
408 fn cli_flags_override_env() {
409 let cfg = run_cfg(
410 resolve(
411 args(&["--host", "10.0.0.1", "--port", "8080"]),
412 EnvInputs {
413 host: Some("0.0.0.0".into()),
414 port: Some("9000".into()),
415 ..EnvInputs::default()
416 },
417 )
418 .unwrap(),
419 );
420 assert_eq!(cfg.host, "10.0.0.1");
421 assert_eq!(cfg.port, 8080);
422 }
423
424 #[test]
425 fn cli_equals_form_works() {
426 let cfg =
427 run_cfg(resolve(args(&["--host=::1", "--port=7000"]), EnvInputs::default()).unwrap());
428 assert_eq!(cfg.host, "::1");
429 assert_eq!(cfg.port, 7000);
430 }
431
432 #[test]
433 fn snapshot_path_from_cli() {
434 let cfg = run_cfg(
435 resolve(
436 args(&["--snapshot-path", "/tmp/snap.bin"]),
437 EnvInputs::default(),
438 )
439 .unwrap(),
440 );
441 assert_eq!(
442 cfg.snapshot_path,
443 Some(std::path::PathBuf::from("/tmp/snap.bin"))
444 );
445 }
446
447 #[test]
448 fn snapshot_path_from_env() {
449 let cfg = run_cfg(
450 resolve(
451 args(&[]),
452 EnvInputs {
453 snapshot_path: Some("/var/lora/snap.bin".into()),
454 ..EnvInputs::default()
455 },
456 )
457 .unwrap(),
458 );
459 assert_eq!(
460 cfg.snapshot_path,
461 Some(std::path::PathBuf::from("/var/lora/snap.bin"))
462 );
463 }
464
465 #[test]
466 fn cli_snapshot_path_overrides_env() {
467 let cfg = run_cfg(
468 resolve(
469 args(&["--snapshot-path", "/cli/snap.bin"]),
470 EnvInputs {
471 snapshot_path: Some("/env/snap.bin".into()),
472 ..EnvInputs::default()
473 },
474 )
475 .unwrap(),
476 );
477 assert_eq!(
478 cfg.snapshot_path,
479 Some(std::path::PathBuf::from("/cli/snap.bin"))
480 );
481 }
482
483 #[test]
484 fn wal_dir_from_cli_and_env() {
485 let cfg = run_cfg(resolve(args(&["--wal-dir", "/tmp/wal"]), EnvInputs::default()).unwrap());
486 assert_eq!(cfg.wal_dir, Some(std::path::PathBuf::from("/tmp/wal")));
487
488 let cfg = run_cfg(
489 resolve(
490 args(&[]),
491 EnvInputs {
492 wal_dir: Some("/env/wal".into()),
493 ..EnvInputs::default()
494 },
495 )
496 .unwrap(),
497 );
498 assert_eq!(cfg.wal_dir, Some(std::path::PathBuf::from("/env/wal")));
499
500 let cfg = run_cfg(
502 resolve(
503 args(&["--wal-dir=/cli/wal"]),
504 EnvInputs {
505 wal_dir: Some("/env/wal".into()),
506 ..EnvInputs::default()
507 },
508 )
509 .unwrap(),
510 );
511 assert_eq!(cfg.wal_dir, Some(std::path::PathBuf::from("/cli/wal")));
512 }
513
514 #[test]
515 fn wal_sync_mode_parses_known_strings() {
516 for (raw, expected) in [
517 ("per-commit", SyncMode::PerCommit),
518 ("PER_COMMIT", SyncMode::PerCommit),
519 ("none", SyncMode::None),
520 ("OFF", SyncMode::None),
521 ] {
522 let cfg =
523 run_cfg(resolve(args(&["--wal-sync-mode", raw]), EnvInputs::default()).unwrap());
524 assert_eq!(cfg.wal_sync_mode, expected, "raw={raw}");
525 }
526
527 let cfg =
529 run_cfg(resolve(args(&["--wal-sync-mode", "group"]), EnvInputs::default()).unwrap());
530 assert!(matches!(cfg.wal_sync_mode, SyncMode::Group { .. }));
531 }
532
533 #[test]
534 fn invalid_wal_sync_mode_rejected() {
535 let err = resolve(args(&["--wal-sync-mode", "yolo"]), EnvInputs::default()).unwrap_err();
536 assert!(matches!(err, ConfigError::InvalidSyncMode(_)));
537 }
538
539 #[test]
540 fn help_flag_returns_help_outcome() {
541 match resolve(args(&["--help"]), EnvInputs::default()).unwrap() {
542 ConfigOutcome::Help(s) => assert!(s.contains("USAGE")),
543 other => panic!("expected Help, got {other:?}"),
544 }
545 }
546
547 #[test]
548 fn version_flag_returns_version_outcome() {
549 match resolve(args(&["--version"]), EnvInputs::default()).unwrap() {
550 ConfigOutcome::Version(s) => assert!(s.starts_with("lora-server ")),
551 other => panic!("expected Version, got {other:?}"),
552 }
553 }
554
555 #[test]
556 fn invalid_port_is_rejected() {
557 let err = resolve(args(&["--port", "notanumber"]), EnvInputs::default()).unwrap_err();
558 match err {
559 ConfigError::InvalidPort { value, .. } => assert_eq!(value, "notanumber"),
560 other => panic!("expected InvalidPort, got {other:?}"),
561 }
562 }
563
564 #[test]
565 fn port_out_of_range_is_rejected() {
566 let err = resolve(args(&["--port", "70000"]), EnvInputs::default()).unwrap_err();
567 assert!(matches!(err, ConfigError::InvalidPort { .. }));
568 }
569
570 #[test]
571 fn missing_value_is_rejected() {
572 let err = resolve(args(&["--host"]), EnvInputs::default()).unwrap_err();
573 assert_eq!(err, ConfigError::MissingValue("--host"));
574 }
575
576 #[test]
577 fn unknown_flag_is_rejected() {
578 let err = resolve(args(&["--nope"]), EnvInputs::default()).unwrap_err();
579 assert_eq!(err, ConfigError::UnknownArg("--nope".into()));
580 }
581
582 #[test]
583 fn positional_is_rejected() {
584 let err = resolve(args(&["something"]), EnvInputs::default()).unwrap_err();
585 assert_eq!(err, ConfigError::UnexpectedPositional("something".into()));
586 }
587
588 #[test]
589 fn ipv4_bind_addr_format() {
590 let cfg = ServerConfig {
591 host: "127.0.0.1".into(),
592 port: 3000,
593 ..ServerConfig::default()
594 };
595 assert_eq!(cfg.bind_addr(), "127.0.0.1:3000");
596 }
597
598 #[test]
599 fn ipv6_bind_addr_is_bracketed() {
600 let cfg = ServerConfig {
601 host: "::1".into(),
602 port: 3000,
603 ..ServerConfig::default()
604 };
605 assert_eq!(cfg.bind_addr(), "[::1]:3000");
606 }
607
608 #[test]
609 fn empty_host_rejected() {
610 let err = resolve(args(&["--host", " "]), EnvInputs::default()).unwrap_err();
611 assert_eq!(err, ConfigError::EmptyValue("--host"));
612 }
613}