use std::collections::HashMap;
use indexmap::IndexMap;
use super::{config_from_object, AdapterError};
use crate::value::{HoconValue, ScalarValue};
use crate::Config;
const SEPARATOR: &str = "__";
const MAX_DEPTH: usize = 64;
#[derive(Debug, Clone, Default)]
pub struct Options {
pub prefix: String,
pub origin: Option<String>,
}
pub fn load(opts: Options) -> Result<Config, AdapterError> {
if opts.prefix.is_empty() {
return Err(AdapterError::new(
"env: a prefix is required when mounting the environment (spec F1.1)",
));
}
let mut vars: HashMap<String, String> = HashMap::new();
for e in crate::sysenv::entries() {
if !e.name_bytes.starts_with(opts.prefix.as_bytes()) {
continue;
}
let undecodable = match (&e.name, &e.value) {
(None, _) => "name",
(Some(_), None) => "value",
(Some(name), Some(value)) => {
vars.insert(name.clone(), value.clone());
continue;
}
};
return Err(AdapterError::new(format!(
"env: {} matches the mount prefix {:?} but its {undecodable} is not valid UTF-8; \
a bulk mount cannot silently omit it (spec F1.9)",
e.display_name(),
opts.prefix,
)));
}
load_from(&vars, opts)
}
pub fn load_from(vars: &HashMap<String, String>, opts: Options) -> Result<Config, AdapterError> {
if opts.prefix.is_empty() {
return Err(AdapterError::new(
"env: a prefix is required when mounting the environment (spec F1.1)",
));
}
let mut names: Vec<&String> = vars.keys().collect();
names.sort();
let mut seen: HashMap<Vec<String>, &str> = HashMap::new();
let mut pairs: Vec<(Vec<String>, String)> = Vec::new();
for name in names {
let Some(rest) = name.strip_prefix(&opts.prefix) else {
continue;
};
let path = to_path(rest, name)?;
if let Some(prev) = seen.get(&path) {
return Err(AdapterError::new(format!(
"env: {prev} and {name} both map to {}",
display_path(&path)
)));
}
seen.insert(path.clone(), name);
pairs.push((path, vars[name].clone()));
}
let origin = opts.origin.as_deref().unwrap_or("environment variables");
Ok(config_from_object(nest(pairs), Some(origin)))
}
pub fn parse_dotenv(input: &str, opts: Options) -> Result<Config, AdapterError> {
let origin = opts.origin.clone().unwrap_or_else(|| ".env".to_string());
let mut pairs: Vec<(Vec<String>, String)> = Vec::new();
let normalized = super::strip_bom(input)
.replace("\r\n", "\n")
.replace('\r', "\n");
for (i, raw) in normalized.split('\n').enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let Some((name, rest)) = line.split_once('=') else {
return Err(AdapterError::new(format!(
"{origin}:{}: expected NAME=value",
i + 1
)));
};
let name = name.trim();
if name.is_empty() {
return Err(AdapterError::new(format!(
"{origin}:{}: empty variable name",
i + 1
)));
}
let value = dotenv_value(rest.trim_start_matches([' ', '\t']), &origin, i + 1, name)?;
let Some(stripped) = name.strip_prefix(&opts.prefix) else {
continue;
};
pairs.push((to_path(stripped, name)?, value));
}
Ok(config_from_object(nest(pairs), Some(&origin)))
}
fn to_path(rest: &str, name: &str) -> Result<Vec<String>, AdapterError> {
let segs: Vec<String> = rest
.split(SEPARATOR)
.map(|s| s.to_ascii_lowercase())
.collect();
if segs.iter().any(|s| s.is_empty()) {
return Err(AdapterError::new(format!(
"env: \"{name}\" produces an empty path segment"
)));
}
if segs.len() > MAX_DEPTH {
return Err(AdapterError::new(format!(
"env: \"{name}\" maps to a path {} segments deep, over the limit of {MAX_DEPTH}",
segs.len()
)));
}
Ok(segs)
}
fn display_path(segments: &[String]) -> String {
segments
.iter()
.map(|s| {
let bare = !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
if bare {
s.clone()
} else {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
})
.collect::<Vec<_>>()
.join(".")
}
fn nest(mut pairs: Vec<(Vec<String>, String)>) -> HoconValue {
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let mut root: IndexMap<String, HoconValue> = IndexMap::new();
for (path, value) in pairs {
set_nested(
&mut root,
&path,
HoconValue::Scalar(ScalarValue::string(value)),
);
}
HoconValue::Object(root)
}
fn set_nested(map: &mut IndexMap<String, HoconValue>, segments: &[String], value: HoconValue) {
if segments.is_empty() {
return;
}
if segments.len() == 1 {
if !matches!(map.get(&segments[0]), Some(HoconValue::Object(_))) {
map.insert(segments[0].clone(), value);
}
return;
}
let entry = map
.entry(segments[0].clone())
.or_insert_with(|| HoconValue::Object(IndexMap::new()));
if !matches!(entry, HoconValue::Object(_)) {
*entry = HoconValue::Object(IndexMap::new());
}
if let HoconValue::Object(inner) = entry {
set_nested(inner, &segments[1..], value);
}
}
fn dotenv_value(v: &str, origin: &str, line: usize, name: &str) -> Result<String, AdapterError> {
let fail = |msg: &str| AdapterError::new(format!("{origin}:{line}: {name}: {msg}"));
if let Some(rest) = v.strip_prefix('\'') {
let Some(end) = rest.find('\'') else {
return Err(fail(
"unterminated ' quote (multi-line values are not supported)",
));
};
if !rest[end + 1..].trim().is_empty() {
return Err(fail("unexpected text after the closing quote"));
}
return Ok(rest[..end].to_string());
}
if let Some(rest) = v.strip_prefix('"') {
let mut out = String::new();
let chars: Vec<char> = rest.chars().collect();
let mut i = 0;
while i < chars.len() {
match chars[i] {
'"' => {
let tail: String = chars[i + 1..].iter().collect();
if !tail.trim().is_empty() {
return Err(fail("unexpected text after the closing quote"));
}
return Ok(out);
}
'\\' => {
i += 1;
match chars.get(i) {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some(other) => {
return Err(fail(&format!(
"unknown escape \\{other} (supported: \\n \\r \\t \\\\ \\\")"
)))
}
None => return Err(fail("dangling \\ at end of line")),
}
}
c => out.push(c),
}
i += 1;
}
return Err(fail(
"unterminated \" quote (multi-line values are not supported)",
));
}
let trimmed = v.trim_end_matches([' ', '\t']);
let bytes = trimmed.as_bytes();
for i in 1..bytes.len() {
if bytes[i] == b'#' && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
return Err(fail(&format!(
"ambiguous value \"{trimmed}\": trailing comments are not supported, so quote the value if the # belongs to it"
)));
}
}
Ok(trimmed.to_string())
}