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
22pub const DEFAULT_HOST: &str = "127.0.0.1";
23pub const DEFAULT_PORT: u16 = 4747;
24pub const HOST_ENV: &str = "LORA_SERVER_HOST";
25pub const PORT_ENV: &str = "LORA_SERVER_PORT";
26pub const SNAPSHOT_PATH_ENV: &str = "LORA_SERVER_SNAPSHOT_PATH";
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ServerConfig {
30    pub host: String,
31    pub port: u16,
32    /// When set, the server mounts the `/admin/snapshot/{save,load}` routes
33    /// and wires them to this path. `None` means the admin surface is
34    /// disabled entirely — the default, so we never expose admin endpoints
35    /// on a network-reachable process unless the operator asks for it.
36    pub snapshot_path: Option<std::path::PathBuf>,
37    /// When `Some`, the server restores the graph from this path at boot.
38    /// Missing file at boot is treated as an empty graph (same as without
39    /// `--restore-from`). Independent of `snapshot_path` so operators can
40    /// restore from a read-only location and write back somewhere else.
41    pub restore_from: Option<std::path::PathBuf>,
42}
43
44impl Default for ServerConfig {
45    fn default() -> Self {
46        Self {
47            host: DEFAULT_HOST.to_string(),
48            port: DEFAULT_PORT,
49            snapshot_path: None,
50            restore_from: None,
51        }
52    }
53}
54
55impl ServerConfig {
56    pub fn bind_addr(&self) -> String {
57        if self.host.contains(':') && !self.host.starts_with('[') {
58            format!("[{}]:{}", self.host, self.port)
59        } else {
60            format!("{}:{}", self.host, self.port)
61        }
62    }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum ConfigOutcome {
67    Run(ServerConfig),
68    Help(String),
69    Version(String),
70}
71
72#[derive(Debug, PartialEq, Eq)]
73pub enum ConfigError {
74    UnknownArg(String),
75    MissingValue(&'static str),
76    EmptyValue(&'static str),
77    InvalidPort { value: String, reason: String },
78    UnexpectedPositional(String),
79}
80
81impl fmt::Display for ConfigError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            ConfigError::UnknownArg(a) => write!(f, "unknown argument: {a}"),
85            ConfigError::MissingValue(flag) => write!(f, "missing value for {flag}"),
86            ConfigError::EmptyValue(flag) => write!(f, "{flag} value must not be empty"),
87            ConfigError::InvalidPort { value, reason } => {
88                write!(f, "invalid port '{value}': {reason}")
89            }
90            ConfigError::UnexpectedPositional(a) => {
91                write!(f, "unexpected positional argument: {a}")
92            }
93        }
94    }
95}
96
97impl std::error::Error for ConfigError {}
98
99impl From<ParseIntError> for ConfigError {
100    fn from(_: ParseIntError) -> Self {
101        // Placeholder; we always build InvalidPort manually with the offending
102        // string so the user sees what they typed.
103        ConfigError::InvalidPort {
104            value: String::new(),
105            reason: "not a valid u16".into(),
106        }
107    }
108}
109
110pub fn help_text() -> String {
111    let version = env!("CARGO_PKG_VERSION");
112    format!(
113        "lora-server {version} — HTTP server for the Lora in-memory graph database
114
115USAGE:
116    lora-server [OPTIONS]
117
118OPTIONS:
119        --host <HOST>             Bind address. Default: {DEFAULT_HOST} (or ${HOST_ENV} if set).
120        --port <PORT>             TCP port.      Default: {DEFAULT_PORT} (or ${PORT_ENV} if set).
121        --snapshot-path <PATH>    Enable the admin surface. Mounts
122                                  POST /admin/snapshot/save and
123                                  POST /admin/snapshot/load against this file.
124                                  Also read from ${SNAPSHOT_PATH_ENV}.
125        --restore-from <PATH>     Restore the graph from this snapshot at boot.
126                                  Missing file is treated as empty.
127        --help                    Print this help and exit.
128        --version                 Print version and exit.
129
130ENVIRONMENT:
131    {HOST_ENV}     Bind address (overridden by --host).
132    {PORT_ENV}     TCP port      (overridden by --port).
133    {SNAPSHOT_PATH_ENV}  Path used by --snapshot-path.
134
135EXAMPLES:
136    lora-server
137    lora-server --host 0.0.0.0 --port 8080
138    lora-server --snapshot-path /var/lib/lora/graph.bin
139"
140    )
141}
142
143pub fn version_text() -> String {
144    format!("lora-server {}", env!("CARGO_PKG_VERSION"))
145}
146
147/// Resolve a [`ConfigOutcome`] from CLI args and explicit env values.
148///
149/// `args` includes the program name at position 0 (as produced by
150/// [`std::env::args`]); it is skipped internally.
151pub fn resolve<I>(
152    args: I,
153    env_host: Option<String>,
154    env_port: Option<String>,
155    env_snapshot_path: Option<String>,
156) -> Result<ConfigOutcome, ConfigError>
157where
158    I: IntoIterator<Item = String>,
159{
160    let mut iter = args.into_iter();
161    let _program = iter.next();
162
163    let mut cli_host: Option<String> = None;
164    let mut cli_port: Option<String> = None;
165    let mut cli_snapshot_path: Option<String> = None;
166    let mut cli_restore_from: Option<String> = None;
167
168    while let Some(arg) = iter.next() {
169        match arg.as_str() {
170            "--help" => return Ok(ConfigOutcome::Help(help_text())),
171            "--version" => return Ok(ConfigOutcome::Version(version_text())),
172            "--host" => {
173                let v = iter.next().ok_or(ConfigError::MissingValue("--host"))?;
174                cli_host = Some(v);
175            }
176            "--port" => {
177                let v = iter.next().ok_or(ConfigError::MissingValue("--port"))?;
178                cli_port = Some(v);
179            }
180            "--snapshot-path" => {
181                let v = iter
182                    .next()
183                    .ok_or(ConfigError::MissingValue("--snapshot-path"))?;
184                cli_snapshot_path = Some(v);
185            }
186            "--restore-from" => {
187                let v = iter
188                    .next()
189                    .ok_or(ConfigError::MissingValue("--restore-from"))?;
190                cli_restore_from = Some(v);
191            }
192            s if s.starts_with("--host=") => {
193                cli_host = Some(s["--host=".len()..].to_string());
194            }
195            s if s.starts_with("--port=") => {
196                cli_port = Some(s["--port=".len()..].to_string());
197            }
198            s if s.starts_with("--snapshot-path=") => {
199                cli_snapshot_path = Some(s["--snapshot-path=".len()..].to_string());
200            }
201            s if s.starts_with("--restore-from=") => {
202                cli_restore_from = Some(s["--restore-from=".len()..].to_string());
203            }
204            s if s.starts_with("--") => return Err(ConfigError::UnknownArg(arg)),
205            _ => return Err(ConfigError::UnexpectedPositional(arg)),
206        }
207    }
208
209    let host = cli_host
210        .or(env_host)
211        .unwrap_or_else(|| DEFAULT_HOST.to_string());
212    if host.trim().is_empty() {
213        return Err(ConfigError::EmptyValue("--host"));
214    }
215
216    let port = match cli_port.or(env_port) {
217        Some(raw) => parse_port(&raw)?,
218        None => DEFAULT_PORT,
219    };
220
221    let snapshot_path = cli_snapshot_path.or(env_snapshot_path).and_then(|p| {
222        if p.trim().is_empty() {
223            None
224        } else {
225            Some(std::path::PathBuf::from(p))
226        }
227    });
228
229    let restore_from = cli_restore_from.and_then(|p| {
230        if p.trim().is_empty() {
231            None
232        } else {
233            Some(std::path::PathBuf::from(p))
234        }
235    });
236
237    Ok(ConfigOutcome::Run(ServerConfig {
238        host,
239        port,
240        snapshot_path,
241        restore_from,
242    }))
243}
244
245/// Resolve using the process environment and `std::env::args`.
246pub fn resolve_from_process() -> Result<ConfigOutcome, ConfigError> {
247    resolve(
248        std::env::args(),
249        std::env::var(HOST_ENV).ok(),
250        std::env::var(PORT_ENV).ok(),
251        std::env::var(SNAPSHOT_PATH_ENV).ok(),
252    )
253}
254
255fn parse_port(raw: &str) -> Result<u16, ConfigError> {
256    let trimmed = raw.trim();
257    if trimmed.is_empty() {
258        return Err(ConfigError::EmptyValue("--port"));
259    }
260    trimmed
261        .parse::<u16>()
262        .map_err(|e| ConfigError::InvalidPort {
263            value: raw.to_string(),
264            reason: e.to_string(),
265        })
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn args(xs: &[&str]) -> Vec<String> {
273        std::iter::once("lora-server")
274            .chain(xs.iter().copied())
275            .map(String::from)
276            .collect()
277    }
278
279    #[test]
280    fn defaults_when_nothing_set() {
281        let out = resolve(args(&[]), None, None, None).unwrap();
282        assert_eq!(
283            out,
284            ConfigOutcome::Run(ServerConfig {
285                host: DEFAULT_HOST.into(),
286                port: DEFAULT_PORT,
287                snapshot_path: None,
288                restore_from: None,
289            })
290        );
291    }
292
293    #[test]
294    fn env_vars_apply_without_cli() {
295        let out = resolve(args(&[]), Some("0.0.0.0".into()), Some("9000".into()), None).unwrap();
296        assert_eq!(
297            out,
298            ConfigOutcome::Run(ServerConfig {
299                host: "0.0.0.0".into(),
300                port: 9000,
301                snapshot_path: None,
302                restore_from: None,
303            })
304        );
305    }
306
307    #[test]
308    fn cli_flags_override_env() {
309        let out = resolve(
310            args(&["--host", "10.0.0.1", "--port", "8080"]),
311            Some("0.0.0.0".into()),
312            Some("9000".into()),
313            None,
314        )
315        .unwrap();
316        assert_eq!(
317            out,
318            ConfigOutcome::Run(ServerConfig {
319                host: "10.0.0.1".into(),
320                port: 8080,
321                snapshot_path: None,
322                restore_from: None,
323            })
324        );
325    }
326
327    #[test]
328    fn cli_equals_form_works() {
329        let out = resolve(args(&["--host=::1", "--port=7000"]), None, None, None).unwrap();
330        assert_eq!(
331            out,
332            ConfigOutcome::Run(ServerConfig {
333                host: "::1".into(),
334                port: 7000,
335                snapshot_path: None,
336                restore_from: None,
337            })
338        );
339    }
340
341    #[test]
342    fn snapshot_path_from_cli() {
343        let out = resolve(
344            args(&["--snapshot-path", "/tmp/snap.bin"]),
345            None,
346            None,
347            None,
348        )
349        .unwrap();
350        let ConfigOutcome::Run(cfg) = out else {
351            panic!("expected Run");
352        };
353        assert_eq!(
354            cfg.snapshot_path,
355            Some(std::path::PathBuf::from("/tmp/snap.bin"))
356        );
357    }
358
359    #[test]
360    fn snapshot_path_from_env() {
361        let out = resolve(args(&[]), None, None, Some("/var/lora/snap.bin".into())).unwrap();
362        let ConfigOutcome::Run(cfg) = out else {
363            panic!("expected Run");
364        };
365        assert_eq!(
366            cfg.snapshot_path,
367            Some(std::path::PathBuf::from("/var/lora/snap.bin"))
368        );
369    }
370
371    #[test]
372    fn cli_snapshot_path_overrides_env() {
373        let out = resolve(
374            args(&["--snapshot-path", "/cli/snap.bin"]),
375            None,
376            None,
377            Some("/env/snap.bin".into()),
378        )
379        .unwrap();
380        let ConfigOutcome::Run(cfg) = out else {
381            panic!("expected Run");
382        };
383        assert_eq!(
384            cfg.snapshot_path,
385            Some(std::path::PathBuf::from("/cli/snap.bin"))
386        );
387    }
388
389    #[test]
390    fn help_flag_returns_help_outcome() {
391        match resolve(args(&["--help"]), None, None, None).unwrap() {
392            ConfigOutcome::Help(s) => assert!(s.contains("USAGE")),
393            other => panic!("expected Help, got {other:?}"),
394        }
395    }
396
397    #[test]
398    fn version_flag_returns_version_outcome() {
399        match resolve(args(&["--version"]), None, None, None).unwrap() {
400            ConfigOutcome::Version(s) => assert!(s.starts_with("lora-server ")),
401            other => panic!("expected Version, got {other:?}"),
402        }
403    }
404
405    #[test]
406    fn invalid_port_is_rejected() {
407        let err = resolve(args(&["--port", "notanumber"]), None, None, None).unwrap_err();
408        match err {
409            ConfigError::InvalidPort { value, .. } => assert_eq!(value, "notanumber"),
410            other => panic!("expected InvalidPort, got {other:?}"),
411        }
412    }
413
414    #[test]
415    fn port_out_of_range_is_rejected() {
416        let err = resolve(args(&["--port", "70000"]), None, None, None).unwrap_err();
417        assert!(matches!(err, ConfigError::InvalidPort { .. }));
418    }
419
420    #[test]
421    fn missing_value_is_rejected() {
422        let err = resolve(args(&["--host"]), None, None, None).unwrap_err();
423        assert_eq!(err, ConfigError::MissingValue("--host"));
424    }
425
426    #[test]
427    fn unknown_flag_is_rejected() {
428        let err = resolve(args(&["--nope"]), None, None, None).unwrap_err();
429        assert_eq!(err, ConfigError::UnknownArg("--nope".into()));
430    }
431
432    #[test]
433    fn positional_is_rejected() {
434        let err = resolve(args(&["something"]), None, None, None).unwrap_err();
435        assert_eq!(err, ConfigError::UnexpectedPositional("something".into()));
436    }
437
438    #[test]
439    fn ipv4_bind_addr_format() {
440        let cfg = ServerConfig {
441            host: "127.0.0.1".into(),
442            port: 3000,
443            snapshot_path: None,
444            restore_from: None,
445        };
446        assert_eq!(cfg.bind_addr(), "127.0.0.1:3000");
447    }
448
449    #[test]
450    fn ipv6_bind_addr_is_bracketed() {
451        let cfg = ServerConfig {
452            host: "::1".into(),
453            port: 3000,
454            snapshot_path: None,
455            restore_from: None,
456        };
457        assert_eq!(cfg.bind_addr(), "[::1]:3000");
458    }
459
460    #[test]
461    fn empty_host_rejected() {
462        let err = resolve(args(&["--host", "   "]), None, None, None).unwrap_err();
463        assert_eq!(err, ConfigError::EmptyValue("--host"));
464    }
465}