systemprompt_models/env.rs
1//! Environment-variable reading and `${VAR}` / `${VAR:-default}` interpolation.
2//!
3//! A single primitive shared by every config surface that expands placeholders:
4//! the profile loader interpolates a whole YAML document against the process
5//! environment, and the services config layer drives [`interpolate`] in a
6//! multi-pass loop over a secrets→env→vars source chain. Both reuse the one
7//! regex and the one unresolved-placeholder rule defined here, so the syntax
8//! never drifts between surfaces.
9
10use std::sync::LazyLock;
11
12use regex::Regex;
13
14#[expect(
15 clippy::expect_used,
16 reason = "compile-time-constant regex; failure is a programmer bug, not runtime input"
17)]
18static INTERPOLATION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
19 Regex::new(r"\$\{([^}:]+)(?::-(.*?))?\}")
20 .expect("INTERPOLATION_REGEX is a valid regex - this is a compile-time constant")
21});
22
23/// Reads an environment variable, treating empty as absent.
24///
25/// Returns `Some` only when the variable is present and non-empty, so a
26/// blank override never masks a downstream default.
27#[must_use]
28pub fn read_env_optional(name: &str) -> Option<String> {
29 match std::env::var(name) {
30 Ok(v) if !v.is_empty() => Some(v),
31 Ok(_) | Err(_) => None,
32 }
33}
34
35/// Reports whether `input` still contains a `${VAR}` / `${VAR:-default}`
36/// placeholder. Used by multi-pass resolvers to detect non-convergence.
37#[must_use]
38pub fn contains_placeholder(input: &str) -> bool {
39 INTERPOLATION_REGEX.is_match(input)
40}
41
42/// Replaces every `${VAR}` / `${VAR:-default}` occurrence in `input` using
43/// `lookup`.
44///
45/// Resolution order per placeholder: `lookup(var)`, then the inline `:-default`
46/// if present, otherwise the literal placeholder is left untouched. A single
47/// pass; transitive resolution (a resolved value that itself contains a
48/// placeholder) is the caller's concern.
49#[must_use]
50pub fn interpolate(input: &str, lookup: &impl Fn(&str) -> Option<String>) -> String {
51 INTERPOLATION_REGEX
52 .replace_all(input, |caps: ®ex::Captures| {
53 let full = caps[0].to_owned();
54 let var_name = &caps[1];
55 let default_value = caps.get(2).map(|m| m.as_str());
56 lookup(var_name).unwrap_or_else(|| default_value.map_or(full, str::to_owned))
57 })
58 .into_owned()
59}