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";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ServerConfig {
29    pub host: String,
30    pub port: u16,
31}
32
33impl Default for ServerConfig {
34    fn default() -> Self {
35        Self {
36            host: DEFAULT_HOST.to_string(),
37            port: DEFAULT_PORT,
38        }
39    }
40}
41
42impl ServerConfig {
43    pub fn bind_addr(&self) -> String {
44        if self.host.contains(':') && !self.host.starts_with('[') {
45            format!("[{}]:{}", self.host, self.port)
46        } else {
47            format!("{}:{}", self.host, self.port)
48        }
49    }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum ConfigOutcome {
54    Run(ServerConfig),
55    Help(String),
56    Version(String),
57}
58
59#[derive(Debug, PartialEq, Eq)]
60pub enum ConfigError {
61    UnknownArg(String),
62    MissingValue(&'static str),
63    EmptyValue(&'static str),
64    InvalidPort { value: String, reason: String },
65    UnexpectedPositional(String),
66}
67
68impl fmt::Display for ConfigError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            ConfigError::UnknownArg(a) => write!(f, "unknown argument: {a}"),
72            ConfigError::MissingValue(flag) => write!(f, "missing value for {flag}"),
73            ConfigError::EmptyValue(flag) => write!(f, "{flag} value must not be empty"),
74            ConfigError::InvalidPort { value, reason } => {
75                write!(f, "invalid port '{value}': {reason}")
76            }
77            ConfigError::UnexpectedPositional(a) => {
78                write!(f, "unexpected positional argument: {a}")
79            }
80        }
81    }
82}
83
84impl std::error::Error for ConfigError {}
85
86impl From<ParseIntError> for ConfigError {
87    fn from(_: ParseIntError) -> Self {
88        // Placeholder; we always build InvalidPort manually with the offending
89        // string so the user sees what they typed.
90        ConfigError::InvalidPort {
91            value: String::new(),
92            reason: "not a valid u16".into(),
93        }
94    }
95}
96
97pub fn help_text() -> String {
98    let version = env!("CARGO_PKG_VERSION");
99    format!(
100        "lora-server {version} — HTTP server for the Lora in-memory graph database
101
102USAGE:
103    lora-server [OPTIONS]
104
105OPTIONS:
106        --host <HOST>    Bind address. Default: {DEFAULT_HOST} (or ${HOST_ENV} if set).
107        --port <PORT>    TCP port.      Default: {DEFAULT_PORT} (or ${PORT_ENV} if set).
108        --help           Print this help and exit.
109        --version        Print version and exit.
110
111ENVIRONMENT:
112    {HOST_ENV}     Bind address (overridden by --host).
113    {PORT_ENV}     TCP port      (overridden by --port).
114
115EXAMPLES:
116    lora-server
117    lora-server --host 0.0.0.0 --port 8080
118    {HOST_ENV}=0.0.0.0 {PORT_ENV}=8080 lora-server
119"
120    )
121}
122
123pub fn version_text() -> String {
124    format!("lora-server {}", env!("CARGO_PKG_VERSION"))
125}
126
127/// Resolve a [`ConfigOutcome`] from CLI args and explicit env values.
128///
129/// `args` includes the program name at position 0 (as produced by
130/// [`std::env::args`]); it is skipped internally.
131pub fn resolve<I>(
132    args: I,
133    env_host: Option<String>,
134    env_port: Option<String>,
135) -> Result<ConfigOutcome, ConfigError>
136where
137    I: IntoIterator<Item = String>,
138{
139    let mut iter = args.into_iter();
140    let _program = iter.next();
141
142    let mut cli_host: Option<String> = None;
143    let mut cli_port: Option<String> = None;
144
145    while let Some(arg) = iter.next() {
146        match arg.as_str() {
147            "--help" => return Ok(ConfigOutcome::Help(help_text())),
148            "--version" => return Ok(ConfigOutcome::Version(version_text())),
149            "--host" => {
150                let v = iter.next().ok_or(ConfigError::MissingValue("--host"))?;
151                cli_host = Some(v);
152            }
153            "--port" => {
154                let v = iter.next().ok_or(ConfigError::MissingValue("--port"))?;
155                cli_port = Some(v);
156            }
157            s if s.starts_with("--host=") => {
158                cli_host = Some(s["--host=".len()..].to_string());
159            }
160            s if s.starts_with("--port=") => {
161                cli_port = Some(s["--port=".len()..].to_string());
162            }
163            s if s.starts_with("--") => return Err(ConfigError::UnknownArg(arg)),
164            _ => return Err(ConfigError::UnexpectedPositional(arg)),
165        }
166    }
167
168    let host = cli_host
169        .or(env_host)
170        .unwrap_or_else(|| DEFAULT_HOST.to_string());
171    if host.trim().is_empty() {
172        return Err(ConfigError::EmptyValue("--host"));
173    }
174
175    let port = match cli_port.or(env_port) {
176        Some(raw) => parse_port(&raw)?,
177        None => DEFAULT_PORT,
178    };
179
180    Ok(ConfigOutcome::Run(ServerConfig { host, port }))
181}
182
183/// Resolve using the process environment and `std::env::args`.
184pub fn resolve_from_process() -> Result<ConfigOutcome, ConfigError> {
185    resolve(
186        std::env::args(),
187        std::env::var(HOST_ENV).ok(),
188        std::env::var(PORT_ENV).ok(),
189    )
190}
191
192fn parse_port(raw: &str) -> Result<u16, ConfigError> {
193    let trimmed = raw.trim();
194    if trimmed.is_empty() {
195        return Err(ConfigError::EmptyValue("--port"));
196    }
197    trimmed
198        .parse::<u16>()
199        .map_err(|e| ConfigError::InvalidPort {
200            value: raw.to_string(),
201            reason: e.to_string(),
202        })
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn args(xs: &[&str]) -> Vec<String> {
210        std::iter::once("lora-server")
211            .chain(xs.iter().copied())
212            .map(String::from)
213            .collect()
214    }
215
216    #[test]
217    fn defaults_when_nothing_set() {
218        let out = resolve(args(&[]), None, None).unwrap();
219        assert_eq!(
220            out,
221            ConfigOutcome::Run(ServerConfig {
222                host: DEFAULT_HOST.into(),
223                port: DEFAULT_PORT,
224            })
225        );
226    }
227
228    #[test]
229    fn env_vars_apply_without_cli() {
230        let out = resolve(args(&[]), Some("0.0.0.0".into()), Some("9000".into())).unwrap();
231        assert_eq!(
232            out,
233            ConfigOutcome::Run(ServerConfig {
234                host: "0.0.0.0".into(),
235                port: 9000,
236            })
237        );
238    }
239
240    #[test]
241    fn cli_flags_override_env() {
242        let out = resolve(
243            args(&["--host", "10.0.0.1", "--port", "8080"]),
244            Some("0.0.0.0".into()),
245            Some("9000".into()),
246        )
247        .unwrap();
248        assert_eq!(
249            out,
250            ConfigOutcome::Run(ServerConfig {
251                host: "10.0.0.1".into(),
252                port: 8080,
253            })
254        );
255    }
256
257    #[test]
258    fn cli_equals_form_works() {
259        let out = resolve(args(&["--host=::1", "--port=7000"]), None, None).unwrap();
260        assert_eq!(
261            out,
262            ConfigOutcome::Run(ServerConfig {
263                host: "::1".into(),
264                port: 7000,
265            })
266        );
267    }
268
269    #[test]
270    fn help_flag_returns_help_outcome() {
271        match resolve(args(&["--help"]), None, None).unwrap() {
272            ConfigOutcome::Help(s) => assert!(s.contains("USAGE")),
273            other => panic!("expected Help, got {other:?}"),
274        }
275    }
276
277    #[test]
278    fn version_flag_returns_version_outcome() {
279        match resolve(args(&["--version"]), None, None).unwrap() {
280            ConfigOutcome::Version(s) => assert!(s.starts_with("lora-server ")),
281            other => panic!("expected Version, got {other:?}"),
282        }
283    }
284
285    #[test]
286    fn invalid_port_is_rejected() {
287        let err = resolve(args(&["--port", "notanumber"]), None, None).unwrap_err();
288        match err {
289            ConfigError::InvalidPort { value, .. } => assert_eq!(value, "notanumber"),
290            other => panic!("expected InvalidPort, got {other:?}"),
291        }
292    }
293
294    #[test]
295    fn port_out_of_range_is_rejected() {
296        let err = resolve(args(&["--port", "70000"]), None, None).unwrap_err();
297        assert!(matches!(err, ConfigError::InvalidPort { .. }));
298    }
299
300    #[test]
301    fn missing_value_is_rejected() {
302        let err = resolve(args(&["--host"]), None, None).unwrap_err();
303        assert_eq!(err, ConfigError::MissingValue("--host"));
304    }
305
306    #[test]
307    fn unknown_flag_is_rejected() {
308        let err = resolve(args(&["--nope"]), None, None).unwrap_err();
309        assert_eq!(err, ConfigError::UnknownArg("--nope".into()));
310    }
311
312    #[test]
313    fn positional_is_rejected() {
314        let err = resolve(args(&["something"]), None, None).unwrap_err();
315        assert_eq!(err, ConfigError::UnexpectedPositional("something".into()));
316    }
317
318    #[test]
319    fn ipv4_bind_addr_format() {
320        let cfg = ServerConfig {
321            host: "127.0.0.1".into(),
322            port: 3000,
323        };
324        assert_eq!(cfg.bind_addr(), "127.0.0.1:3000");
325    }
326
327    #[test]
328    fn ipv6_bind_addr_is_bracketed() {
329        let cfg = ServerConfig {
330            host: "::1".into(),
331            port: 3000,
332        };
333        assert_eq!(cfg.bind_addr(), "[::1]:3000");
334    }
335
336    #[test]
337    fn empty_host_rejected() {
338        let err = resolve(args(&["--host", "   "]), None, None).unwrap_err();
339        assert_eq!(err, ConfigError::EmptyValue("--host"));
340    }
341}