1const COMMENT_PREFIX: char = '#';
2const KEY_VALUE_SEPARATOR: char = '=';
3const EXPORT_PREFIX: &str = "export ";
4const QUOTE_CHARS: [char; 2] = ['"', '\''];
5const NUL: char = '\0';
7const FIRST_LINE_NUMBER: usize = 1;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Entry<'a> {
13 pub key: &'a str,
15 pub value: &'a str,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Parsed<'a> {
22 pub entries: Vec<Entry<'a>>,
24 pub skipped: Vec<usize>,
26}
27
28pub fn parse(content: &str) -> Parsed<'_> {
30 let mut entries = Vec::new();
31 let mut skipped = Vec::new();
32 for (index, line) in content.lines().enumerate() {
33 match classify_line(line) {
34 LineKind::Ignored => {}
35 LineKind::Assignment(entry) => entries.push(entry),
36 LineKind::Unusable => skipped.push(index + FIRST_LINE_NUMBER),
37 }
38 }
39 Parsed { entries, skipped }
40}
41
42enum LineKind<'a> {
44 Ignored,
46 Assignment(Entry<'a>),
47 Unusable,
49}
50
51fn classify_line(line: &str) -> LineKind<'_> {
52 let trimmed = line.trim();
53 if trimmed.is_empty() || trimmed.starts_with(COMMENT_PREFIX) {
54 return LineKind::Ignored;
55 }
56 let assignment = match trimmed.strip_prefix(EXPORT_PREFIX) {
57 Some(rest) => rest,
58 None => trimmed,
59 };
60 let (key, value) = match assignment.split_once(KEY_VALUE_SEPARATOR) {
61 Some(pair) => pair,
62 None => return LineKind::Unusable,
63 };
64 let key = key.trim();
65 let value = strip_surrounding_quotes(value.trim());
66 if key.is_empty() || key.contains(NUL) || value.contains(NUL) {
67 return LineKind::Unusable;
68 }
69 LineKind::Assignment(Entry { key, value })
70}
71
72fn strip_surrounding_quotes(value: &str) -> &str {
74 for quote in QUOTE_CHARS {
75 if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
76 return &value[1..value.len() - 1];
77 }
78 }
79 value
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 fn entry<'a>(key: &'a str, value: &'a str) -> Entry<'a> {
87 Entry { key, value }
88 }
89
90 #[test]
91 fn keeps_unquoted_spaces_in_a_value() {
92 let parsed = parse("BASE_ADDRESSES=http://a http://b application");
93 assert_eq!(
94 parsed.entries,
95 vec![entry("BASE_ADDRESSES", "http://a http://b application")]
96 );
97 assert!(parsed.skipped.is_empty());
98 }
99
100 #[test]
101 fn a_malformed_line_does_not_stop_the_rest_of_the_file() {
102 let parsed = parse("A=1\nnosep\nB=2\n");
103 assert_eq!(parsed.entries, vec![entry("A", "1"), entry("B", "2")]);
104 assert_eq!(parsed.skipped, vec![2]);
105 }
106
107 #[test]
108 fn does_not_expand_dollar_signs() {
109 let parsed = parse("PLAIN=abc\nSECRET=p$word${PLAIN}x");
110 assert_eq!(
111 parsed.entries,
112 vec![entry("PLAIN", "abc"), entry("SECRET", "p$word${PLAIN}x")]
113 );
114 }
115
116 #[test]
117 fn does_not_treat_a_hash_inside_a_value_as_a_comment() {
118 assert_eq!(parse("HASH=a#b").entries, vec![entry("HASH", "a#b")]);
119 }
120
121 #[test]
122 fn strips_quotes_and_export_prefix() {
123 assert_eq!(
124 parse("export NAME=\"quoted value\"").entries,
125 vec![entry("NAME", "quoted value")]
126 );
127 assert_eq!(parse("S='single'").entries, vec![entry("S", "single")]);
128 }
129
130 #[test]
131 fn keeps_everything_after_the_first_separator() {
132 assert_eq!(parse("KEY=a=b=c").entries, vec![entry("KEY", "a=b=c")]);
133 }
134
135 #[test]
136 fn skips_comments_blanks_and_malformed_lines() {
137 let parsed = parse("# comment\n \nno separator\n=value-without-key\n");
138 assert!(parsed.entries.is_empty());
139 assert_eq!(parsed.skipped, vec![3, 4]);
140 }
141
142 #[test]
143 fn reads_crlf_line_endings() {
144 let parsed = parse("A=1\r\nB=2\r\n");
145 assert_eq!(parsed.entries, vec![entry("A", "1"), entry("B", "2")]);
146 assert!(parsed.skipped.is_empty());
147 }
148
149 #[test]
150 fn skips_a_nul_in_the_key_or_the_value() {
151 let parsed = parse("K\0EY=1\nVAL=a\0b\nOK=2\n");
152 assert_eq!(parsed.entries, vec![entry("OK", "2")]);
153 assert_eq!(parsed.skipped, vec![1, 2]);
154 }
155}