use std::collections::HashMap;
use indexmap::IndexMap;
use super::{config_from_object, AdapterError};
use crate::value::{HoconValue, ScalarValue};
use crate::Config;
const SEPARATOR: &str = "__";
#[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 vars: HashMap<String, String> = std::env::vars()
.filter(|(name, _)| name.starts_with(&opts.prefix))
.collect();
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<String, &str> = HashMap::new();
let mut pairs: 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 \"{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<(String, String)> = Vec::new();
let normalized = 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<String, AdapterError> {
let segs: Vec<String> = rest.split(SEPARATOR).map(|s| s.to_lowercase()).collect();
if segs.iter().any(|s| s.is_empty()) {
return Err(AdapterError::new(format!(
"env: \"{name}\" produces an empty path segment"
)));
}
Ok(segs.join("."))
}
fn nest(mut pairs: 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 {
let segments: Vec<&str> = path.split('.').collect();
set_nested(
&mut root,
&segments,
HoconValue::Scalar(ScalarValue::string(value)),
);
}
HoconValue::Object(root)
}
fn set_nested(map: &mut IndexMap<String, HoconValue>, segments: &[&str], value: HoconValue) {
if segments.is_empty() {
return;
}
if segments.len() == 1 {
if !matches!(map.get(segments[0]), Some(HoconValue::Object(_))) {
map.insert(segments[0].to_string(), value);
}
return;
}
let entry = map
.entry(segments[0].to_string())
.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())
}