use std::path::{Path, PathBuf};
fn find_env_file(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
let candidate = d.join(".env");
if candidate.is_file() {
return Some(candidate);
}
dir = d.parent();
}
None
}
fn parse_line(line: &str) -> Option<(String, String)> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (key, val) = line.split_once('=')?;
let key = key.trim();
if key.is_empty() {
return None;
}
let mut val = val.trim();
if val.len() >= 2
&& ((val.starts_with('"') && val.ends_with('"'))
|| (val.starts_with('\'') && val.ends_with('\'')))
{
val = &val[1..val.len() - 1];
}
Some((key.to_string(), val.to_string()))
}
pub fn load() -> usize {
let Ok(cwd) = std::env::current_dir() else {
return 0;
};
load_from(&cwd)
}
fn load_from(start: &Path) -> usize {
let Some(path) = find_env_file(start) else {
return 0;
};
let Ok(content) = std::fs::read_to_string(&path) else {
return 0;
};
let mut n = 0;
for line in content.lines() {
if let Some((k, v)) = parse_line(line) {
if std::env::var_os(&k).is_none() {
std::env::set_var(&k, &v);
n += 1;
}
}
}
if n > 0 {
eprintln!("kibble: loaded {n} env var(s) from {}", path.display());
}
n
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic() {
assert_eq!(parse_line("FOO=bar"), Some(("FOO".into(), "bar".into())));
}
#[test]
fn parse_trims_spaces_around_equals() {
assert_eq!(parse_line(" FOO = bar "), Some(("FOO".into(), "bar".into())));
}
#[test]
fn parse_export_prefix() {
assert_eq!(parse_line("export TOKEN=abc"), Some(("TOKEN".into(), "abc".into())));
}
#[test]
fn parse_strips_matching_quotes() {
assert_eq!(parse_line(r#"K="a b""#), Some(("K".into(), "a b".into())));
assert_eq!(parse_line("K='a b'"), Some(("K".into(), "a b".into())));
assert_eq!(parse_line(r#"K="a b'"#), Some(("K".into(), r#""a b'"#.into())));
}
#[test]
fn parse_value_may_contain_equals() {
assert_eq!(parse_line("URL=a=b=c"), Some(("URL".into(), "a=b=c".into())));
}
#[test]
fn parse_skips_blank_and_comment() {
assert_eq!(parse_line(""), None);
assert_eq!(parse_line(" "), None);
assert_eq!(parse_line("# a comment"), None);
assert_eq!(parse_line("=novalue"), None);
}
#[test]
fn find_walks_up_to_parent() {
let base = std::env::temp_dir().join(format!("kibble_dotenv_find_{}", std::process::id()));
let nested = base.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(base.join(".env"), "X=1\n").unwrap();
let found = find_env_file(&nested).unwrap();
assert_eq!(found, base.join(".env"));
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn load_sets_and_respects_existing() {
let dir = std::env::temp_dir().join(format!("kibble_dotenv_load_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(".env"),
"KIBBLE_DOTENV_NEW=fromfile\nKIBBLE_DOTENV_PRESET=fromfile\n",
)
.unwrap();
std::env::set_var("KIBBLE_DOTENV_PRESET", "fromenv");
let n = load_from(&dir);
assert_eq!(n, 1); assert_eq!(std::env::var("KIBBLE_DOTENV_NEW").unwrap(), "fromfile");
assert_eq!(std::env::var("KIBBLE_DOTENV_PRESET").unwrap(), "fromenv");
std::env::remove_var("KIBBLE_DOTENV_NEW");
std::env::remove_var("KIBBLE_DOTENV_PRESET");
std::fs::remove_dir_all(&dir).ok();
}
}