use std::collections::{BTreeSet, HashMap};
#[derive(Debug, Clone, Default)]
pub struct Env {
vars: HashMap<String, String>,
}
impl Env {
pub fn from_os() -> Self {
Env {
vars: std::env::vars().collect(),
}
}
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(),
}
}
pub fn get(&self, key: &str) -> Option<&str> {
self.vars.get(key).map(String::as_str)
}
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
}
}
#[derive(Debug, Clone, Default)]
pub struct Ctx {
pub var: String,
pub path: String,
}
impl Ctx {
pub fn root() -> Self {
Ctx::default()
}
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(),
}
}
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)
},
}
}
pub fn index(&self, index: usize) -> Ctx {
Ctx {
var: format!("{}__{}", self.var, index),
path: format!("{}[{}]", self.path, index),
}
}
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");
}
}