dotenv-verbatim 0.3.1

A .env loader that takes the value verbatim: no expansion, no escapes, no inline comments; one malformed line is skipped, not the rest of the file
Documentation
const COMMENT_PREFIX: char = '#';
const KEY_VALUE_SEPARATOR: char = '=';
const EXPORT_KEYWORD: &str = "export";
const QUOTE_CHARS: [char; 2] = ['"', '\''];
/// `env::set_var` panics on a NUL in the key or the value, so such a line is unusable.
const NUL: char = '\0';
/// Line numbers are reported the way an editor shows them.
const FIRST_LINE_NUMBER: usize = 1;
/// A UTF-8 BOM is an encoding artefact of the file, not part of the first key.
const BYTE_ORDER_MARK: char = '\u{feff}';

/// One parsed key/value pair, borrowed from the parsed content.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry<'a> {
    /// The name left of the first `=`, trimmed and never empty.
    pub key: &'a str,
    /// Everything right of the first `=`, trimmed, with at most one pair of quotes removed.
    pub value: &'a str,
}

/// The result of a parse: the entries in file order plus the line numbers that were skipped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Parsed<'a> {
    /// The pairs that parsed, in file order.
    pub entries: Vec<Entry<'a>>,
    /// Line numbers, counted from 1, that were neither a pair nor a comment or blank.
    pub skipped: Vec<usize>,
}

/// Parse `.env` content. Pure: touches neither the environment nor the filesystem.
pub fn parse(content: &str) -> Parsed<'_> {
    let content = strip_byte_order_mark(content);
    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 }
}

/// What one line of the file turned out to be.
enum LineKind<'a> {
    /// Blank line or whole-line comment: expected, not reported.
    Ignored,
    Assignment(Entry<'a>),
    /// Neither, so the line is reported by number and parsing continues.
    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 = strip_export_prefix(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 })
}

/// Drop a leading UTF-8 BOM so it does not become part of the first key.
fn strip_byte_order_mark(content: &str) -> &str {
    match content.strip_prefix(BYTE_ORDER_MARK) {
        Some(rest) => rest,
        None => content,
    }
}

/// Strip the optional `export` prefix; whitespace must follow, so `exported=1` keeps its key.
fn strip_export_prefix(line: &str) -> &str {
    match line.strip_prefix(EXPORT_KEYWORD) {
        Some(rest) if rest.starts_with(char::is_whitespace) => rest,
        _ => line,
    }
}

/// Strip one pair of matching surrounding quotes (double or single), if any.
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 ignores_a_leading_byte_order_mark() {
        let parsed = parse("\u{feff}A=1\nB=2\n");
        assert_eq!(parsed.entries, vec![entry("A", "1"), entry("B", "2")]);
        assert!(parsed.skipped.is_empty());
    }

    #[test]
    fn accepts_any_whitespace_after_export() {
        let parsed = parse("export\tTAB=1\nexport  WIDE=2\nexported=3\nexport=4\n");
        assert_eq!(
            parsed.entries,
            vec![
                entry("TAB", "1"),
                entry("WIDE", "2"),
                entry("exported", "3"),
                entry("export", "4"),
            ]
        );
        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]);
    }
}