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";
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 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
127pub 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
183pub 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}