#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvVar {
pub name: String,
pub doc: Vec<String>,
pub default: Option<String>,
}
impl EnvVar {
pub fn optional(&self) -> bool {
self.default.is_none()
}
}
pub fn parse(text: &str) -> Vec<EnvVar> {
let mut vars: Vec<EnvVar> = Vec::new();
let mut doc: Vec<String> = Vec::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() {
doc.clear();
continue;
}
if let Some(comment) = line.strip_prefix('#') {
let comment = comment.trim();
match assignment(comment) {
Some((name, _)) => record(
&mut vars,
EnvVar {
name,
doc: std::mem::take(&mut doc),
default: None,
},
),
None if !comment.is_empty() => doc.push(comment.to_string()),
None => {}
}
continue;
}
if let Some((name, value)) = assignment(line) {
let (value, trailing) = split_inline_comment(&value);
if let Some(note) = trailing {
doc.push(note);
}
record(
&mut vars,
EnvVar {
name,
doc: std::mem::take(&mut doc),
default: Some(value),
},
);
}
}
vars
}
fn record(vars: &mut Vec<EnvVar>, var: EnvVar) {
match vars.iter_mut().find(|v| v.name == var.name) {
Some(existing) => {
if !var.doc.is_empty() {
existing.doc = var.doc;
}
if var.default.is_some() {
existing.default = var.default;
}
}
None => vars.push(var),
}
}
pub fn markdown_table(vars: &[EnvVar]) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(out, "| Variable | Default | Notes |");
let _ = writeln!(out, "| --- | --- | --- |");
for var in vars {
let default = match var.default.as_deref() {
Some("") | None => "_unset_".to_string(),
Some(value) => format!("`{value}`"),
};
let notes = var
.doc
.iter()
.map(|line| escape_cell(line))
.collect::<Vec<_>>()
.join(" ");
let _ = writeln!(out, "| `{}` | {default} | {notes} |", var.name);
}
out
}
fn escape_cell(text: &str) -> String {
text.replace('|', "\\|")
}
fn assignment(line: &str) -> Option<(String, String)> {
let (name, value) = line.split_once('=')?;
let name = name.trim_start_matches("export ").trim();
let ok = !name.is_empty()
&& !name.starts_with(|c: char| c.is_ascii_digit())
&& name
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
ok.then(|| (name.to_string(), value.trim().to_string()))
}
fn split_inline_comment(value: &str) -> (String, Option<String>) {
if let Some(inner) = unquote(value) {
return (inner, None);
}
match value.split_once('#') {
Some((value, note)) => {
let note = note.trim();
(
value.trim().to_string(),
(!note.is_empty()).then(|| note.to_string()),
)
}
None => (value.to_string(), None),
}
}
fn unquote(value: &str) -> Option<String> {
for quote in ['"', '\''] {
if let Some(rest) = value.strip_prefix(quote)
&& let Some(inner) = rest.strip_suffix(quote)
{
return Some(inner.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const TEMPLATE: &str = "\
# Server Configuration
PORT=8000
# Logging
# Options: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=INFO
# API Defaults
DEFAULT_INTERVAL=1d # Options: 1m, 5m, 1d
# FRED API Integration (optional)
# Free API key from https://fred.stlouisfed.org/
# FRED_API_KEY=your_fred_api_key_here
";
#[test]
fn comments_above_an_entry_become_its_documentation() {
let vars = parse(TEMPLATE);
let port = &vars[0];
assert_eq!(port.name, "PORT");
assert_eq!(port.doc, ["Server Configuration"]);
assert_eq!(port.default.as_deref(), Some("8000"));
let level = &vars[1];
assert_eq!(
level.doc,
["Logging", "Options: DEBUG, INFO, WARNING, ERROR"]
);
}
#[test]
fn a_commented_out_entry_is_a_knob_with_no_default() {
let vars = parse(TEMPLATE);
let fred = vars
.iter()
.find(|v| v.name == "FRED_API_KEY")
.expect("found");
assert!(fred.optional(), "commented out means unset by default");
assert_eq!(
fred.doc,
[
"FRED API Integration (optional)",
"Free API key from https://fred.stlouisfed.org/"
]
);
}
#[test]
fn an_inline_note_documents_rather_than_corrupts_the_default() {
let vars = parse(TEMPLATE);
let interval = vars
.iter()
.find(|v| v.name == "DEFAULT_INTERVAL")
.expect("found");
assert_eq!(interval.default.as_deref(), Some("1d"));
assert_eq!(interval.doc, ["API Defaults", "Options: 1m, 5m, 1d"]);
}
#[test]
fn a_blank_line_ends_a_comment_block() {
let vars = parse("# a heading\n\nKEY=value\n");
assert_eq!(vars[0].doc, Vec::<String>::new());
}
#[test]
fn prose_that_merely_contains_an_equals_sign_is_not_an_entry() {
let vars = parse("# note: set this when x = y\nKEY=value\n");
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].name, "KEY");
assert_eq!(vars[0].doc, ["note: set this when x = y"]);
}
#[test]
fn an_example_above_the_real_entry_names_one_knob() {
let vars = parse("# DATABASE_URL=postgres://user@host/db\nDATABASE_URL=\n");
assert_eq!(vars.len(), 1);
assert_eq!(vars[0].name, "DATABASE_URL");
assert_eq!(vars[0].default.as_deref(), Some(""));
}
#[test]
fn a_repeated_entry_keeps_its_first_position() {
let vars = parse("A=1\nB=2\nA=3\n");
assert_eq!(
vars.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(),
["A", "B"]
);
assert_eq!(vars[0].default.as_deref(), Some("3"));
}
#[test]
fn a_quoted_value_keeps_its_hashes() {
let vars = parse("KEY=\"a # b\"\n");
assert_eq!(vars[0].default.as_deref(), Some("a # b"));
}
}