Skip to main content

lora_server/
config.rs

1//! Runtime configuration for `lora-server`.
2//!
3//! Resolves the bind address (`host` + `port`) from, in order of precedence:
4//!
5//! 1. CLI flags: `--host <HOST>`, `--port <PORT>` (also accepts `--host=<HOST>`).
6//! 2. Environment variables: `LORA_SERVER_HOST`, `LORA_SERVER_PORT`.
7//! 3. Built-in defaults: `127.0.0.1:4747`.
8//!
9//! The default HTTP port for the local LoraDB server is `4747` — short,
10//! memorable, and outside the most common local development ports
11//! (3000/4000/5000/8000/8080/8443/9000) and standard database ports
12//! (Postgres 5432, Redis 6379, MongoDB 27017, Elasticsearch 9200, MySQL
13//! 3306) so it does not collide with typical side projects.
14//!
15//! The parser also understands `--help` / `--version`, which return a
16//! [`ConfigOutcome`] variant instead of a [`ServerConfig`] so the binary
17//! can print and exit before booting the runtime.
18
19use 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
32/// Default segment target for WAL-enabled deployments. Matches the
33/// in-tree `WalConfig::enabled` constructor.
34pub 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    /// When set, the server mounts the `/admin/snapshot/{save,load}` routes
41    /// and wires them to this path. `None` means the admin surface is
42    /// disabled entirely — the default, so we never expose admin endpoints
43    /// on a network-reachable process unless the operator asks for it.
44    pub snapshot_path: Option<std::path::PathBuf>,
45    /// When `Some`, the server restores the graph from this path at boot.
46    /// Missing file at boot is treated as an empty graph (same as without
47    /// `--restore-from`). Independent of `snapshot_path` so operators can
48    /// restore from a read-only location and write back somewhere else.
49    pub restore_from: Option<std::path::PathBuf>,
50    /// When `Some`, the server attaches a WAL at this directory and
51    /// brackets every query with begin/commit/abort. Also unlocks the
52    /// `/admin/checkpoint`, `/admin/wal/status`, and
53    /// `/admin/wal/truncate` admin routes (only when `snapshot_path`
54    /// is also configured).
55    pub wal_dir: Option<std::path::PathBuf>,
56    /// Durability cadence for the WAL. Ignored when `wal_dir` is `None`.
57    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        // Placeholder; we always build InvalidPort manually with the offending
127        // string so the user sees what they typed.
128        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/// Inputs to [`resolve`]. Wrapping the env values in a struct keeps the
199/// caller-visible signature stable as new env-driven options are added.
200#[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
209/// Resolve a [`ConfigOutcome`] from CLI args and env values.
210///
211/// `args` includes the program name at position 0 (as produced by
212/// [`std::env::args`]); it is skipped internally.
213pub 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
316/// Resolve using the process environment and `std::env::args`.
317pub 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            // 50 ms cadence is short enough that a crash window is
356            // bounded by the wallclock budget operators usually quote
357            // ("at most ~50 ms of writes lost") and long enough that
358            // the bg flusher does not tax disks under sustained load.
359            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        // CLI overrides env.
501        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        // group parses to Group { .. } with v1 defaults.
528        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}