use std::collections::BTreeMap;
use std::path::Path;
use figment::value::{Dict, Value};
use figment::{Metadata, Profile, Provider};
use crate::error::{Error, ErrorKind, Origin};
pub(crate) const PREFIX: &str = "the file ";
pub(crate) fn read(path: &Path) -> Result<BTreeMap<String, String>, Error> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(error) => {
return Err(Error::new(ErrorKind::Io, error.to_string())
.with_origin(Origin::File(path.to_owned())))
}
};
parse(&text).map_err(|line| {
Error::new(
ErrorKind::Parse,
format!("line {line} is not a comment, blank, or `KEY=value`"),
)
.with_origin(Origin::File(path.to_owned()))
})
}
fn parse(text: &str) -> Result<BTreeMap<String, String>, usize> {
let mut entries = BTreeMap::new();
for (number, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
let Some((name, value)) = line.split_once('=') else {
return Err(number + 1);
};
let name = name.trim();
if name.is_empty() {
return Err(number + 1);
}
entries.insert(name.to_owned(), unquote(value.trim()).to_owned());
}
Ok(entries)
}
fn unquote(value: &str) -> &str {
for quote in ['"', '\''] {
if let Some(inner) = value
.strip_prefix(quote)
.and_then(|v| v.strip_suffix(quote))
{
return inner;
}
}
value
}
pub(crate) struct DotenvProvider {
entries: BTreeMap<String, String>,
named: String,
prefix: String,
key: String,
nest: String,
allow_empty: bool,
}
impl DotenvProvider {
pub(crate) fn new(
entries: BTreeMap<String, String>,
path: &Path,
prefix: &str,
key: &str,
nest: &str,
allow_empty: bool,
) -> Self {
Self {
entries,
named: format!("{PREFIX}{}", path.display()),
prefix: prefix.to_owned(),
key: key.to_owned(),
nest: nest.to_owned(),
allow_empty,
}
}
}
impl Provider for DotenvProvider {
fn metadata(&self) -> Metadata {
Metadata::named(self.named.clone())
}
fn data(&self) -> figment::Result<figment::value::Map<Profile, Dict>> {
let mut values = Dict::new();
for (name, text) in &self.entries {
let Some(rest) = strip_prefix_ignoring_case(name, &self.prefix) else {
continue;
};
if text.is_empty() && !self.allow_empty {
continue;
}
let path = rest.to_ascii_lowercase().replace(&self.nest, ".");
if path.is_empty() || path.split('.').any(str::is_empty) {
continue;
}
let value = text
.parse::<Value>()
.unwrap_or_else(|_| Value::from(text.clone()));
crate::layer::insert_path(&mut values, &path, value);
}
let mut map = figment::value::Map::new();
map.insert(Profile::from(self.key.clone()), values);
Ok(map)
}
}
fn strip_prefix_ignoring_case<'a>(name: &'a str, prefix: &str) -> Option<&'a str> {
name.get(..prefix.len())
.filter(|start| start.eq_ignore_ascii_case(prefix))
.and_then(|_| name.get(prefix.len()..))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comments_and_blank_lines_are_skipped() {
let entries = parse("# a note\n\nAPP_HOST=localhost\n \n").unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries["APP_HOST"], "localhost");
}
#[test]
fn export_is_accepted_because_a_shell_often_reads_the_same_file() {
let entries = parse("export APP_HOST=localhost").unwrap();
assert_eq!(entries["APP_HOST"], "localhost");
}
#[test]
fn quotes_keep_what_trimming_would_take() {
let entries = parse("A=\" spaced \"\nB='# not a comment'\nC=plain").unwrap();
assert_eq!(entries["A"], " spaced ");
assert_eq!(entries["B"], "# not a comment");
assert_eq!(entries["C"], "plain");
}
#[test]
fn a_mismatched_quote_is_part_of_the_value() {
let entries = parse("A=\"unbalanced").unwrap();
assert_eq!(entries["A"], "\"unbalanced");
}
#[test]
fn a_value_may_contain_an_equals_sign() {
let entries = parse("DSN=postgres://u:p@h/db?opt=1").unwrap();
assert_eq!(entries["DSN"], "postgres://u:p@h/db?opt=1");
}
#[test]
fn a_line_that_is_not_an_assignment_names_itself() {
assert_eq!(parse("APP_HOST=ok\nnonsense\n").unwrap_err(), 2);
assert_eq!(parse("=novalue").unwrap_err(), 1);
}
#[test]
fn interpolation_is_left_alone_rather_than_half_implemented() {
let entries = parse("A=${OTHER}").unwrap();
assert_eq!(
entries["A"], "${OTHER}",
"a value whose meaning depends on which library read it is worse \
than one that does nothing surprising"
);
}
#[test]
fn a_missing_file_is_not_an_error() {
assert!(read(Path::new("/no/such/.env")).unwrap().is_empty());
}
}