confroid 0.0.4

The n+1-st config reader for your environment-based configs.
Documentation
//! The environment snapshot ([`Env`]) and the naming context ([`Ctx`]) that
//! is threaded through [`FromEnv`](crate::FromEnv) as fields are read.

use std::collections::{BTreeSet, HashMap};

/// A snapshot of environment variables.
///
/// Taking a snapshot up front keeps a single [`from_env`](crate::from_env)
/// call internally consistent and makes testing trivial via
/// [`Env::from_pairs`].
#[derive(Debug, Clone, Default)]
pub struct Env {
    vars: HashMap<String, String>,
}

impl Env {
    /// Snapshot the current process environment.
    pub fn from_os() -> Self {
        Env {
            vars: std::env::vars().collect(),
        }
    }

    /// Build an environment from an explicit set of key/value pairs. Primarily
    /// useful for tests, where touching the real process environment is racy.
    pub fn from_pairs<I, K, V>(pairs: I) -> Self
    where
        I: IntoIterator<Item = (K, V)>,
        K: Into<String>,
        V: Into<String>,
    {
        Env {
            vars: pairs
                .into_iter()
                .map(|(k, v)| (k.into(), v.into()))
                .collect(),
        }
    }

    /// Look up a variable by its exact name.
    pub fn get(&self, key: &str) -> Option<&str> {
        self.vars.get(key).map(String::as_str)
    }

    /// Return the distinct immediate child segments below `prefix`.
    ///
    /// A segment is the portion of a key immediately following `prefix__`, up
    /// to the next `__`. For example, with keys `PEOPLE__alice__NAME` and
    /// `PEOPLE__bob__AGE`, `immediate_children("PEOPLE")` yields `{alice, bob}`.
    ///
    /// When `prefix` is empty, segments are taken from the start of every key.
    /// Results are sorted for deterministic iteration.
    pub fn immediate_children(&self, prefix: &str) -> BTreeSet<String> {
        let full = if prefix.is_empty() {
            String::new()
        } else {
            format!("{prefix}__")
        };

        let mut out = BTreeSet::new();
        for key in self.vars.keys() {
            let rest = if full.is_empty() {
                key.as_str()
            } else {
                match key.strip_prefix(&full) {
                    Some(rest) => rest,
                    None => continue,
                }
            };
            if rest.is_empty() {
                continue;
            }
            let seg = match rest.find("__") {
                Some(idx) => &rest[..idx],
                None => rest,
            };
            out.insert(seg.to_string());
        }
        out
    }
}

/// The naming context for the field currently being read.
///
/// It carries two parallel names that are extended together as the reader
/// descends into nested structs, vectors and maps:
///
/// * [`var`](Ctx::var) — the environment variable name in
///   `SCREAMING_SNAKE_CASE`, joined with `__` (e.g. `HTTP__PORT`).
/// * [`path`](Ctx::path) — a human-readable dotted field path used in error
///   messages (e.g. `http.port`).
#[derive(Debug, Clone, Default)]
pub struct Ctx {
    /// Environment variable name / prefix, e.g. `HTTP__PORT`.
    pub var: String,
    /// Dotted field path, e.g. `http.port`.
    pub path: String,
}

impl Ctx {
    /// The root context, used for the top-level config struct.
    pub fn root() -> Self {
        Ctx::default()
    }

    /// Add an environment-variable prefix without changing the field path.
    ///
    /// This is used by container-level prefixes such as
    /// `#[confroid(prefix = "APP")]`.
    pub fn prefix(&self, prefix: &str) -> Ctx {
        Ctx {
            var: if self.var.is_empty() {
                prefix.to_string()
            } else if prefix.is_empty() {
                self.var.clone()
            } else {
                format!("{}__{}", self.var, prefix)
            },
            path: self.path.clone(),
        }
    }

    /// Descend into a named struct field.
    ///
    /// `var_seg` is the (already-cased) environment segment for the field and
    /// `name` is the human-readable field name for the error path.
    pub fn field(&self, var_seg: &str, name: &str) -> Ctx {
        Ctx {
            var: if self.var.is_empty() {
                var_seg.to_string()
            } else {
                format!("{}__{}", self.var, var_seg)
            },
            path: if self.path.is_empty() {
                name.to_string()
            } else {
                format!("{}.{}", self.path, name)
            },
        }
    }

    /// Descend into a vector element at `index`.
    pub fn index(&self, index: usize) -> Ctx {
        Ctx {
            var: format!("{}__{}", self.var, index),
            path: format!("{}[{}]", self.path, index),
        }
    }

    /// Descend into a map entry with the given `key` (case preserved).
    pub fn key(&self, key: &str) -> Ctx {
        Ctx {
            var: format!("{}__{}", self.var, key),
            path: format!("{}.{}", self.path, key),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn immediate_children_extracts_distinct_first_segments() {
        let env = Env::from_pairs([
            ("PEOPLE__alice__NAME", "Alice"),
            ("PEOPLE__alice__AGE", "30"),
            ("PEOPLE__bob__NAME", "Bob"),
            ("OTHER", "x"),
        ]);
        let children: Vec<_> = env.immediate_children("PEOPLE").into_iter().collect();
        assert_eq!(children, vec!["alice".to_string(), "bob".to_string()]);
    }

    #[test]
    fn immediate_children_scalar_values() {
        let env = Env::from_pairs([("NAMES__0", "a"), ("NAMES__1", "b")]);
        let children: Vec<_> = env.immediate_children("NAMES").into_iter().collect();
        assert_eq!(children, vec!["0".to_string(), "1".to_string()]);
    }

    #[test]
    fn immediate_children_of_missing_prefix_is_empty() {
        let env = Env::from_pairs([("A__B", "x")]);
        assert!(env.immediate_children("NOPE").is_empty());
    }

    #[test]
    fn root_prefix_returns_top_level_segments() {
        let env = Env::from_pairs([("HTTP__PORT", "8080"), ("GREETING", "hi")]);
        let children: Vec<_> = env.immediate_children("").into_iter().collect();
        assert_eq!(children, vec!["GREETING".to_string(), "HTTP".to_string()]);
    }

    #[test]
    fn ctx_field_join() {
        let root = Ctx::root();
        let http = root.field("HTTP", "http");
        assert_eq!(http.var, "HTTP");
        assert_eq!(http.path, "http");
        let port = http.field("PORT", "port");
        assert_eq!(port.var, "HTTP__PORT");
        assert_eq!(port.path, "http.port");
    }

    #[test]
    fn ctx_index_and_key() {
        let base = Ctx {
            var: "SERVERS".into(),
            path: "servers".into(),
        };
        let idx = base.index(2);
        assert_eq!(idx.var, "SERVERS__2");
        assert_eq!(idx.path, "servers[2]");

        let keyed = base.key("alice");
        assert_eq!(keyed.var, "SERVERS__alice");
        assert_eq!(keyed.path, "servers.alice");
    }
}