const COMMENT_PREFIX: char = '#';
const KEY_VALUE_SEPARATOR: char = '=';
const EXPORT_PREFIX: &str = "export ";
const QUOTE_CHARS: [char; 2] = ['"', '\''];
const NUL: char = '\0';
const FIRST_LINE_NUMBER: usize = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry<'a> {
pub key: &'a str,
pub value: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Parsed<'a> {
pub entries: Vec<Entry<'a>>,
pub skipped: Vec<usize>,
}
pub fn parse(content: &str) -> Parsed<'_> {
let mut entries = Vec::new();
let mut skipped = Vec::new();
for (index, line) in content.lines().enumerate() {
match classify_line(line) {
LineKind::Ignored => {}
LineKind::Assignment(entry) => entries.push(entry),
LineKind::Unusable => skipped.push(index + FIRST_LINE_NUMBER),
}
}
Parsed { entries, skipped }
}
enum LineKind<'a> {
Ignored,
Assignment(Entry<'a>),
Unusable,
}
fn classify_line(line: &str) -> LineKind<'_> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with(COMMENT_PREFIX) {
return LineKind::Ignored;
}
let assignment = match trimmed.strip_prefix(EXPORT_PREFIX) {
Some(rest) => rest,
None => trimmed,
};
let (key, value) = match assignment.split_once(KEY_VALUE_SEPARATOR) {
Some(pair) => pair,
None => return LineKind::Unusable,
};
let key = key.trim();
let value = strip_surrounding_quotes(value.trim());
if key.is_empty() || key.contains(NUL) || value.contains(NUL) {
return LineKind::Unusable;
}
LineKind::Assignment(Entry { key, value })
}
fn strip_surrounding_quotes(value: &str) -> &str {
for quote in QUOTE_CHARS {
if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
return &value[1..value.len() - 1];
}
}
value
}
#[cfg(test)]
mod tests {
use super::*;
fn entry<'a>(key: &'a str, value: &'a str) -> Entry<'a> {
Entry { key, value }
}
#[test]
fn keeps_unquoted_spaces_in_a_value() {
let parsed = parse("BASE_ADDRESSES=http://a http://b application");
assert_eq!(
parsed.entries,
vec![entry("BASE_ADDRESSES", "http://a http://b application")]
);
assert!(parsed.skipped.is_empty());
}
#[test]
fn a_malformed_line_does_not_stop_the_rest_of_the_file() {
let parsed = parse("A=1\nnosep\nB=2\n");
assert_eq!(parsed.entries, vec![entry("A", "1"), entry("B", "2")]);
assert_eq!(parsed.skipped, vec![2]);
}
#[test]
fn does_not_expand_dollar_signs() {
let parsed = parse("PLAIN=abc\nSECRET=p$word${PLAIN}x");
assert_eq!(
parsed.entries,
vec![entry("PLAIN", "abc"), entry("SECRET", "p$word${PLAIN}x")]
);
}
#[test]
fn does_not_treat_a_hash_inside_a_value_as_a_comment() {
assert_eq!(parse("HASH=a#b").entries, vec![entry("HASH", "a#b")]);
}
#[test]
fn strips_quotes_and_export_prefix() {
assert_eq!(
parse("export NAME=\"quoted value\"").entries,
vec![entry("NAME", "quoted value")]
);
assert_eq!(parse("S='single'").entries, vec![entry("S", "single")]);
}
#[test]
fn keeps_everything_after_the_first_separator() {
assert_eq!(parse("KEY=a=b=c").entries, vec![entry("KEY", "a=b=c")]);
}
#[test]
fn skips_comments_blanks_and_malformed_lines() {
let parsed = parse("# comment\n \nno separator\n=value-without-key\n");
assert!(parsed.entries.is_empty());
assert_eq!(parsed.skipped, vec![3, 4]);
}
#[test]
fn reads_crlf_line_endings() {
let parsed = parse("A=1\r\nB=2\r\n");
assert_eq!(parsed.entries, vec![entry("A", "1"), entry("B", "2")]);
assert!(parsed.skipped.is_empty());
}
#[test]
fn skips_a_nul_in_the_key_or_the_value() {
let parsed = parse("K\0EY=1\nVAL=a\0b\nOK=2\n");
assert_eq!(parsed.entries, vec![entry("OK", "2")]);
assert_eq!(parsed.skipped, vec![1, 2]);
}
}