use std::{collections::BTreeMap, io::Write as _, path::Path};
use anyhow::{Context, Result};
pub fn read(path: &Path) -> Result<BTreeMap<String, String>> {
let mut out = BTreeMap::new();
if !path.exists() {
return Ok(out);
}
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((k, v)) = trimmed.split_once('=') else {
tracing::warn!(line_num = i + 1, line, "malformed line in .env, skipping");
continue;
};
let k = k.trim();
let k = k.strip_prefix("export ").map(str::trim).unwrap_or(k);
if !is_valid_key(k) {
tracing::warn!(
line_num = i + 1,
key = k,
"invalid env var name in .env, skipping"
);
continue;
}
out.insert(k.to_owned(), v.to_owned());
}
Ok(out)
}
pub fn write(path: &Path, vars: &BTreeMap<String, String>) -> Result<()> {
let parent = path.parent().context("env file has no parent directory")?;
std::fs::create_dir_all(parent).with_context(|| format!("create dir {}", parent.display()))?;
let tmp = parent.join(format!(".env.tmp.{}", std::process::id()));
{
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
opts.mode(0o600);
}
let mut f = opts
.open(&tmp)
.with_context(|| format!("create {}", tmp.display()))?;
writeln!(
f,
"# Auto-managed by rsclaw. Missing vars are captured from your shell."
)?;
writeln!(
f,
"# This file is the source of truth: hand-edits are respected and are"
)?;
writeln!(
f,
"# NOT overwritten by your shell on startup. To pull a rotated value"
)?;
writeln!(
f,
"# in from the shell, run `rsclaw env sync` (see docs/env.md)."
)?;
writeln!(f)?;
for (k, v) in vars {
if v.contains('\n') {
writeln!(
f,
"# SKIPPED: {k} value contained newline (not supported in .env)"
)?;
continue;
}
writeln!(f, "{k}={v}")?;
}
f.flush()?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
fn is_valid_key(s: &str) -> bool {
if s.is_empty() {
return false;
}
let bytes = s.as_bytes();
if !(bytes[0].is_ascii_alphabetic() || bytes[0] == b'_') {
return false;
}
bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || *b == b'_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_preserves_values() {
let tmp = tempfile::tempdir().expect("tmpdir");
let path = tmp.path().join(".env");
let mut vars = BTreeMap::new();
vars.insert("FOO".to_owned(), "bar".to_owned());
vars.insert("RSCLAW_API_KEY".to_owned(), "sk-abc=def/ghi+jkl".to_owned());
write(&path, &vars).expect("write");
let got = read(&path).expect("read");
assert_eq!(got, vars);
}
#[test]
fn read_skips_malformed_lines() {
let tmp = tempfile::tempdir().expect("tmpdir");
let path = tmp.path().join(".env");
std::fs::write(
&path,
"# comment\nGOOD=ok\nno_equals_sign\n=missing_key\nBAD KEY=x\nFOO=bar\n",
)
.expect("write");
let got = read(&path).expect("read");
assert_eq!(got.get("GOOD").map(String::as_str), Some("ok"));
assert_eq!(got.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(got.len(), 2, "expected only valid keys");
}
#[test]
fn read_accepts_export_prefix() {
let tmp = tempfile::tempdir().expect("tmpdir");
let path = tmp.path().join(".env");
std::fs::write(
&path,
"export OPENAI_KEY=sk-abc\nexport FOO=bar\nBAREKEY=baz\n",
)
.expect("write");
let got = read(&path).expect("read");
assert_eq!(got.get("OPENAI_KEY").map(String::as_str), Some("sk-abc"));
assert_eq!(got.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(got.get("BAREKEY").map(String::as_str), Some("baz"));
assert_eq!(got.len(), 3);
}
#[test]
fn read_returns_empty_when_missing() {
let tmp = tempfile::tempdir().expect("tmpdir");
let got = read(&tmp.path().join("does-not-exist.env")).expect("read");
assert!(got.is_empty());
}
#[test]
#[cfg(unix)]
fn write_sets_mode_0600() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir().expect("tmpdir");
let path = tmp.path().join(".env");
let mut vars = BTreeMap::new();
vars.insert("KEY".to_owned(), "val".to_owned());
write(&path, &vars).expect("write");
let meta = std::fs::metadata(&path).expect("metadata");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
}
#[test]
fn write_skips_values_with_newlines() {
let tmp = tempfile::tempdir().expect("tmpdir");
let path = tmp.path().join(".env");
let mut vars = BTreeMap::new();
vars.insert("MULTILINE".to_owned(), "line1\nline2".to_owned());
vars.insert("GOOD".to_owned(), "fine".to_owned());
write(&path, &vars).expect("write");
let got = read(&path).expect("read");
assert!(!got.contains_key("MULTILINE"));
assert_eq!(got.get("GOOD").map(String::as_str), Some("fine"));
}
}