Skip to main content

lora_server/config/
env.rs

1//! CLI / env resolution: turns argv + env into a [`ConfigOutcome`].
2
3use lora_database::SyncMode;
4
5use super::errors::ConfigError;
6use super::help::{help_text, version_text};
7use super::{
8    ConfigOutcome, ServerConfig, DEFAULT_HOST, DEFAULT_PORT, HOST_ENV, PORT_ENV, SNAPSHOT_PATH_ENV,
9    WAL_DIR_ENV, WAL_SYNC_MODE_ENV,
10};
11
12/// Inputs to [`resolve`]. Wrapping the env values in a struct keeps the
13/// caller-visible signature stable as new env-driven options are added.
14#[derive(Debug, Default, Clone)]
15pub struct EnvInputs {
16    pub host: Option<String>,
17    pub port: Option<String>,
18    pub snapshot_path: Option<String>,
19    pub wal_dir: Option<String>,
20    pub wal_sync_mode: Option<String>,
21}
22
23#[derive(Debug, Default)]
24struct CliInputs {
25    host: Option<String>,
26    port: Option<String>,
27    snapshot_path: Option<String>,
28    restore_from: Option<String>,
29    wal_dir: Option<String>,
30    wal_sync_mode: Option<String>,
31}
32
33impl CliInputs {
34    fn set(&mut self, field: CliField, value: String) {
35        match field {
36            CliField::Host => self.host = Some(value),
37            CliField::Port => self.port = Some(value),
38            CliField::SnapshotPath => self.snapshot_path = Some(value),
39            CliField::RestoreFrom => self.restore_from = Some(value),
40            CliField::WalDir => self.wal_dir = Some(value),
41            CliField::WalSyncMode => self.wal_sync_mode = Some(value),
42        }
43    }
44}
45
46#[derive(Debug, Clone, Copy)]
47enum CliField {
48    Host,
49    Port,
50    SnapshotPath,
51    RestoreFrom,
52    WalDir,
53    WalSyncMode,
54}
55
56enum ParsedCli {
57    Run(CliInputs),
58    Immediate(ConfigOutcome),
59}
60
61/// Resolve a [`ConfigOutcome`] from CLI args and env values.
62///
63/// `args` includes the program name at position 0 (as produced by
64/// [`std::env::args`]); it is skipped internally.
65pub fn resolve<I>(args: I, env: EnvInputs) -> Result<ConfigOutcome, ConfigError>
66where
67    I: IntoIterator<Item = String>,
68{
69    let cli = match parse_cli_args(args)? {
70        ParsedCli::Run(cli) => cli,
71        ParsedCli::Immediate(outcome) => return Ok(outcome),
72    };
73
74    let host = cli
75        .host
76        .or(env.host)
77        .unwrap_or_else(|| DEFAULT_HOST.to_string());
78    if host.trim().is_empty() {
79        return Err(ConfigError::EmptyValue("--host"));
80    }
81
82    let port = match cli.port.or(env.port) {
83        Some(raw) => parse_port(&raw)?,
84        None => DEFAULT_PORT,
85    };
86
87    let snapshot_path = cli
88        .snapshot_path
89        .or(env.snapshot_path)
90        .and_then(non_empty_path);
91    let restore_from = cli.restore_from.and_then(non_empty_path);
92    let wal_dir = cli.wal_dir.or(env.wal_dir).and_then(non_empty_path);
93    let wal_sync_mode = match cli.wal_sync_mode.or(env.wal_sync_mode) {
94        Some(raw) => parse_sync_mode(&raw)?,
95        None => SyncMode::default(),
96    };
97
98    Ok(ConfigOutcome::Run(ServerConfig {
99        host,
100        port,
101        snapshot_path,
102        restore_from,
103        wal_dir,
104        wal_sync_mode,
105    }))
106}
107
108fn parse_cli_args<I>(args: I) -> Result<ParsedCli, ConfigError>
109where
110    I: IntoIterator<Item = String>,
111{
112    let mut iter = args.into_iter();
113    let _program = iter.next();
114
115    let mut cli = CliInputs::default();
116
117    while let Some(arg) = iter.next() {
118        if arg == "--help" {
119            return Ok(ParsedCli::Immediate(ConfigOutcome::Help(help_text())));
120        }
121        if arg == "--version" {
122            return Ok(ParsedCli::Immediate(ConfigOutcome::Version(version_text())));
123        }
124
125        if let Some((field, flag)) = cli_field_for_flag(&arg) {
126            let value = iter.next().ok_or(ConfigError::MissingValue(flag))?;
127            cli.set(field, value);
128            continue;
129        }
130
131        if let Some((field, value)) = cli_field_for_equals(&arg) {
132            cli.set(field, value.to_string());
133            continue;
134        }
135
136        if arg.starts_with("--") {
137            return Err(ConfigError::UnknownArg(arg));
138        }
139        return Err(ConfigError::UnexpectedPositional(arg));
140    }
141
142    Ok(ParsedCli::Run(cli))
143}
144
145fn cli_field_for_flag(arg: &str) -> Option<(CliField, &'static str)> {
146    match arg {
147        "--host" => Some((CliField::Host, "--host")),
148        "--port" => Some((CliField::Port, "--port")),
149        "--snapshot-path" => Some((CliField::SnapshotPath, "--snapshot-path")),
150        "--restore-from" => Some((CliField::RestoreFrom, "--restore-from")),
151        "--wal-dir" => Some((CliField::WalDir, "--wal-dir")),
152        "--wal-sync-mode" => Some((CliField::WalSyncMode, "--wal-sync-mode")),
153        _ => None,
154    }
155}
156
157fn cli_field_for_equals(arg: &str) -> Option<(CliField, &str)> {
158    let (flag, value) = arg.split_once('=')?;
159    let (field, _) = cli_field_for_flag(flag)?;
160    Some((field, value))
161}
162
163/// Resolve using the process environment and `std::env::args`.
164pub fn resolve_from_process() -> Result<ConfigOutcome, ConfigError> {
165    resolve(
166        std::env::args(),
167        EnvInputs {
168            host: std::env::var(HOST_ENV).ok(),
169            port: std::env::var(PORT_ENV).ok(),
170            snapshot_path: std::env::var(SNAPSHOT_PATH_ENV).ok(),
171            wal_dir: std::env::var(WAL_DIR_ENV).ok(),
172            wal_sync_mode: std::env::var(WAL_SYNC_MODE_ENV).ok(),
173        },
174    )
175}
176
177fn non_empty_path(p: String) -> Option<std::path::PathBuf> {
178    if p.trim().is_empty() {
179        None
180    } else {
181        Some(std::path::PathBuf::from(p))
182    }
183}
184
185fn parse_port(raw: &str) -> Result<u16, ConfigError> {
186    let trimmed = raw.trim();
187    if trimmed.is_empty() {
188        return Err(ConfigError::EmptyValue("--port"));
189    }
190    trimmed
191        .parse::<u16>()
192        .map_err(|e| ConfigError::InvalidPort {
193            value: raw.to_string(),
194            reason: e.to_string(),
195        })
196}
197
198fn parse_sync_mode(raw: &str) -> Result<SyncMode, ConfigError> {
199    match raw.trim().to_ascii_lowercase().as_str() {
200        "group-sync" | "group_sync" | "groupsync" => Ok(SyncMode::GroupSync {
201            // 50 ms cadence is short enough that a crash window is
202            // bounded by the wallclock budget operators usually quote
203            // ("at most ~50 ms of writes lost") and long enough that
204            // the bg flusher does not tax disks under sustained load.
205            interval_ms: 50,
206        }),
207        other => Err(ConfigError::InvalidSyncMode(other.to_string())),
208    }
209}