1use 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 pub snapshot_path: Option<std::path::PathBuf>,
37 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 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
147pub 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
245pub 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}