spark_server/
env_config.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Strict parsing for the process-scoped `ATLAS_*` configuration variables.
4//!
5//! ## What this exists to stop
6//!
7//! The process-scoped config — rate limits, the response store, the
8//! conversation store — was read like this:
9//!
10//! ```ignore
11//! let rpm = std::env::var("ATLAS_RATE_LIMIT_RPM")
12//!     .ok()
13//!     .and_then(|s| s.parse().ok())   // ← a typo lands here
14//!     .unwrap_or(0);                  // ← and silently becomes "off"
15//! ```
16//!
17//! `ATLAS_RATE_LIMIT_RPM=1oo` (letter o) parses as nothing, falls through to
18//! the default, and the default for a rate limit is **0, which means the limit
19//! is not enforced at all**. The operator set a limit, the server started
20//! cleanly, printed nothing, and served unlimited. Every variable in this
21//! family had the same shape: `ATLAS_STORE_TTL_SECONDS=1h` is a 24-hour TTL,
22//! `ATLAS_CONVERSATION_MAX_ENTRIES=10_000` (the spelling the doc comment uses!)
23//! is the default 10 000 by luck rather than by parse.
24//!
25//! This is the repo's PCND rule — production code must not silently default; it
26//! must require explicit config or fail fast naming the key — and the repo
27//! already applies it elsewhere: `ATLAS_VISION_MAX_PIXELS` hard-errors with
28//! "must be a positive integer, got …". These variables did not.
29//!
30//! ## Shape
31//!
32//! [`parse_min`] is pure — it takes the raw value rather than reading the
33//! environment — so the decision is separable from the I/O (SBIO) and testable
34//! without `set_var`, which is process-global and races every other test in the
35//! binary. Each `from_env` does the reading and hands the strings here.
36//!
37//! Empty and whitespace-only are treated as unset, not as errors: exporting
38//! `ATLAS_STORE_DIR=` to mean "off" is an established habit, and the previous
39//! code already fell back for them.
40
41use std::fmt::Display;
42use std::str::FromStr;
43
44/// Parse an optional numeric override, refusing a malformed or out-of-range
45/// value instead of silently substituting the default.
46///
47/// `min` is the smallest value that means anything for this key; `meaning`
48/// describes what the key controls and is quoted back in the error, because
49/// "invalid value" without saying what a valid one would be leaves the reader
50/// exactly where they started.
51///
52/// Returns `Ok(None)` when the variable is unset or blank — the caller applies
53/// its own documented default, which is the one case where defaulting is right
54/// because nobody asked for anything else.
55pub fn parse_min<T>(
56    key: &str,
57    raw: Option<&str>,
58    min: T,
59    meaning: &str,
60) -> Result<Option<T>, String>
61where
62    T: FromStr + PartialOrd + Display + Copy,
63{
64    let Some(raw) = raw else { return Ok(None) };
65    let trimmed = raw.trim();
66    if trimmed.is_empty() {
67        return Ok(None);
68    }
69    let parsed: T = trimmed
70        .parse()
71        .map_err(|_| describe(key, raw, min, meaning, "is not a whole number"))?;
72    if parsed < min {
73        return Err(describe(
74            key,
75            raw,
76            min,
77            meaning,
78            "is below the smallest value this setting accepts",
79        ));
80    }
81    Ok(Some(parsed))
82}
83
84/// The one place the wording of these errors is decided.
85///
86/// Shaped like `cli::validate`'s `Violation` — what, why, fix — because the
87/// repo already holds that a diagnostic without a `fix` is half of one, and an
88/// operator reading this has a shell open and wants to know what to type.
89fn describe<T: Display>(key: &str, raw: &str, min: T, meaning: &str, problem: &str) -> String {
90    format!(
91        "{key}={raw:?} {problem}.\n      \
92         why: {meaning} — expected a whole number >= {min}.\n      \
93         fix: correct the value, or unset {key} to use the built-in default. \
94         It is NOT ignored: the server refuses to start rather than serve a \
95         configuration you did not ask for."
96    )
97}
98
99#[cfg(test)]
100#[path = "env_config_tests.rs"]
101mod tests;