Skip to main content

agent_first_data/document/format/
dotenv.rs

1//! Source-preserving dotenv backend.
2
3use crate::document::{DocumentError, DocumentResult, Value};
4use std::collections::BTreeMap;
5
6const MAX_DOTENV_VALUE_BYTES: usize = 1024 * 1024;
7
8#[derive(Debug)]
9pub struct DotenvDocument<'a> {
10    source: &'a str,
11    keys: Vec<String>,
12}
13
14impl<'a> DotenvDocument<'a> {
15    pub fn parse(source: &'a str) -> DocumentResult<Self> {
16        let value = parse_semantic(source)?;
17        let keys = value
18            .as_object()
19            .map(|object| object.keys().cloned().collect())
20            .unwrap_or_default();
21        Ok(Self { source, keys })
22    }
23
24    fn has_key(&self, key: &str) -> bool {
25        self.keys.iter().any(|candidate| candidate == key)
26    }
27
28    #[allow(dead_code)]
29    fn source(&self) -> &'a str {
30        self.source
31    }
32}
33
34/// Parse dotenv without shell execution, environment lookup, or variable expansion.
35pub fn load(content: &str) -> DocumentResult<Value> {
36    parse_semantic(content)
37}
38
39fn parse_semantic(content: &str) -> DocumentResult<Value> {
40    let mut values = BTreeMap::new();
41
42    for (line_number, raw_line) in logical_lines(content)? {
43        let line = raw_line
44            .strip_suffix('\r')
45            .unwrap_or(&raw_line)
46            .trim_start();
47        if line.is_empty() || line.starts_with('#') {
48            continue;
49        }
50
51        let assignment = line
52            .strip_prefix("export")
53            .and_then(|rest| {
54                rest.starts_with(char::is_whitespace)
55                    .then(|| rest.trim_start())
56            })
57            .unwrap_or(line);
58        let Some((raw_key, raw_value)) = assignment.split_once('=') else {
59            return Err(parse_error(line_number, "expected KEY=value assignment"));
60        };
61        let key = raw_key.trim();
62        if !valid_key(key) {
63            return Err(parse_error(line_number, "invalid variable name"));
64        }
65
66        let value = parse_value(raw_value.trim_start(), line_number)?;
67        if values.contains_key(key) {
68            return Err(parse_error(line_number, "duplicate variable name"));
69        }
70        values.insert(key.to_string(), Value::String(value));
71    }
72
73    Ok(Value::Object(values))
74}
75
76/// Split `content` into logical lines, joining the physical lines a quoted
77/// value spans.
78///
79/// A quote only opens a value where a value can start: after the `=`, before
80/// any other non-whitespace character, and never inside a comment. Tracking
81/// less than that made an ordinary English comment — `# set KEY=value if it
82/// isn't already there` — open a quote at the apostrophe that no later line
83/// closed, swallowing the rest of the file into one unparseable line and
84/// yielding zero keys with no error at all.
85fn logical_lines(content: &str) -> DocumentResult<Vec<(usize, String)>> {
86    let mut lines = Vec::new();
87    let mut buffer = String::new();
88    let mut first_line = 1;
89    let mut line_number = 1;
90    let mut quote = None;
91    let mut quote_opened_at = 0;
92    let mut escaped = false;
93    let mut value_may_open = false;
94    let mut in_comment = false;
95    let mut at_line_start = true;
96
97    for character in content.chars() {
98        if quote.is_none() {
99            if at_line_start && !character.is_whitespace() {
100                at_line_start = false;
101                in_comment = character == '#';
102            }
103            if !in_comment {
104                if value_may_open
105                    && character.is_whitespace()
106                    && character != '\n'
107                    && character != '\r'
108                {
109                    buffer.push(character);
110                    continue;
111                }
112                if value_may_open && (character == '\'' || character == '"') {
113                    quote = Some(character);
114                    quote_opened_at = line_number;
115                    value_may_open = false;
116                } else if character == '=' {
117                    value_may_open = true;
118                } else if !character.is_whitespace() {
119                    // The value has already started unquoted, so a quote from
120                    // here on is an ordinary character, not a delimiter.
121                    value_may_open = false;
122                }
123            }
124        } else if escaped {
125            escaped = false;
126        } else if character == '\\' {
127            escaped = true;
128        } else if Some(character) == quote {
129            quote = None;
130        }
131        if character == '\n' && quote.is_none() {
132            lines.push((
133                first_line,
134                buffer.strip_suffix('\n').unwrap_or(&buffer).to_string(),
135            ));
136            buffer.clear();
137            first_line = line_number + 1;
138        } else {
139            buffer.push(character);
140        }
141        if character == '\n' {
142            line_number += 1;
143            if quote.is_none() {
144                value_may_open = false;
145                in_comment = false;
146                at_line_start = true;
147            }
148        }
149    }
150    if quote.is_some() {
151        // Reaching end of input inside a quote means the file is malformed.
152        // Reporting zero keys instead let `value --default` hand a caller its
153        // fallback while the real value sat in the file, unread and unmentioned.
154        return Err(parse_error(quote_opened_at, "unterminated quoted value"));
155    }
156    if !buffer.is_empty() || content.is_empty() {
157        lines.push((first_line, buffer));
158    }
159    Ok(lines)
160}
161
162/// dotenv is intentionally read-only because the generic IR loses source formatting.
163pub fn save(_value: &Value) -> DocumentResult<String> {
164    Err(DocumentError::UnsupportedOperation {
165        format: "dotenv".to_string(),
166        operation: "save".to_string(),
167        detail: "dotenv files are read-only because rewriting would lose comments, ordering, and quoting"
168            .to_string(),
169    })
170}
171
172/// Replace one existing assignment while preserving every other source byte.
173pub fn set_preserving(content: &str, key: &str, value: &Value) -> DocumentResult<String> {
174    let Value::String(value) = value else {
175        return Err(unsupported("set", "dotenv values are strings"));
176    };
177    let replacement = encode_value(value);
178    edit_line(content, key, Some(&replacement))
179}
180
181/// Remove one existing assignment while preserving every other source byte.
182pub fn unset_preserving(content: &str, key: &str) -> DocumentResult<String> {
183    edit_line(content, key, None)
184}
185
186fn edit_line(content: &str, key: &str, replacement: Option<&str>) -> DocumentResult<String> {
187    if !valid_key(key) {
188        return Err(parse_error(0, "invalid variable name"));
189    }
190    let document = DotenvDocument::parse(content).map_err(|error| with_path(error, key))?;
191    let mut output = String::with_capacity(content.len());
192    let found = document.has_key(key);
193    for line in content.split_inclusive('\n') {
194        let body = line.strip_suffix('\n').unwrap_or(line);
195        let bare = body.strip_suffix('\r').unwrap_or(body);
196        let trimmed = bare.trim_start();
197        let assignment = trimmed
198            .strip_prefix("export")
199            .and_then(|rest| {
200                rest.starts_with(char::is_whitespace)
201                    .then(|| rest.trim_start())
202            })
203            .unwrap_or(trimmed);
204        let matches = assignment
205            .split_once('=')
206            .is_some_and(|(name, _)| name.trim() == key);
207        if matches {
208            if let Some(value) = replacement {
209                let prefix_len = body.len() - assignment.len();
210                let prefix = &body[..prefix_len];
211                let (name, old_value) = assignment.split_once('=').unwrap_or((key, ""));
212                let suffix = old_value
213                    .find(" #")
214                    .map(|index| &old_value[index..])
215                    .unwrap_or("");
216                output.push_str(prefix);
217                output.push_str(name);
218                output.push('=');
219                output.push_str(value);
220                output.push_str(suffix);
221                if body.ends_with('\r') {
222                    output.push('\r');
223                }
224                if line.ends_with('\n') {
225                    output.push('\n');
226                }
227            }
228        } else {
229            output.push_str(line);
230        }
231    }
232    if !found {
233        if let Some(value) = replacement {
234            if !output.is_empty() && !output.ends_with('\n') {
235                output.push('\n');
236            }
237            output.push_str(key);
238            output.push('=');
239            output.push_str(value);
240            if content.ends_with('\n') || !output.ends_with('\n') {
241                output.push('\n');
242            }
243            return Ok(output);
244        }
245        return Err(DocumentError::PathNotFound {
246            path: key.to_string(),
247        });
248    }
249    Ok(output)
250}
251
252fn encode_value(value: &str) -> String {
253    if !value.is_empty()
254        && value
255            .chars()
256            .all(|c| !c.is_whitespace() && c != '#' && c != '\\')
257    {
258        return value.to_string();
259    }
260    format!(
261        "\"{}\"",
262        value
263            .replace('\\', "\\\\")
264            .replace('"', "\\\"")
265            .replace('\n', "\\n")
266    )
267}
268
269fn unsupported(operation: &str, detail: &str) -> DocumentError {
270    DocumentError::UnsupportedOperation {
271        format: "dotenv".to_string(),
272        operation: operation.to_string(),
273        detail: detail.to_string(),
274    }
275}
276
277fn valid_key(key: &str) -> bool {
278    let mut chars = key.chars();
279    chars
280        .next()
281        .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
282        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
283}
284
285fn parse_value(input: &str, line_number: usize) -> DocumentResult<String> {
286    if input.len() > MAX_DOTENV_VALUE_BYTES {
287        return Err(parse_error(line_number, "value exceeds 1 MiB"));
288    }
289    match input.chars().next() {
290        Some('\'') => parse_quoted(input, '\'', line_number),
291        Some('"') => parse_quoted(input, '"', line_number),
292        _ => parse_unquoted(input),
293    }
294}
295
296fn parse_quoted(input: &str, quote: char, line_number: usize) -> DocumentResult<String> {
297    let mut output = String::new();
298    let mut escaped = false;
299
300    for (offset, character) in input[quote.len_utf8()..].char_indices() {
301        if escaped {
302            match (quote, character) {
303                ('"', 'n') => output.push('\n'),
304                ('"', 'r') => output.push('\r'),
305                ('"', 't') => output.push('\t'),
306                ('"', '\\') => output.push('\\'),
307                ('"', '"') => output.push('"'),
308                ('\'', '\'') => output.push('\''),
309                (_, other) => {
310                    output.push('\\');
311                    output.push(other);
312                }
313            }
314            escaped = false;
315            continue;
316        }
317        if character == '\\' {
318            escaped = true;
319            continue;
320        }
321        if character == quote {
322            let remainder_index = quote.len_utf8() + offset + character.len_utf8();
323            let remainder = input[remainder_index..].trim_start();
324            if !remainder.is_empty() && !remainder.starts_with('#') {
325                return Err(parse_error(
326                    line_number,
327                    "unexpected content after quoted value",
328                ));
329            }
330            return Ok(output);
331        }
332        output.push(character);
333    }
334
335    Err(parse_error(line_number, "unterminated quoted value"))
336}
337
338fn parse_unquoted(input: &str) -> DocumentResult<String> {
339    let mut output = String::new();
340    let mut escaped = false;
341
342    for character in input.chars() {
343        if escaped {
344            output.push(character);
345            escaped = false;
346        } else if character == '\\' {
347            escaped = true;
348        } else if character == '#' && output.chars().last().is_some_and(char::is_whitespace) {
349            break;
350        } else {
351            output.push(character);
352        }
353    }
354    if escaped {
355        output.push('\\');
356    }
357    Ok(output.trim_end().to_string())
358}
359
360fn parse_error(line_number: usize, detail: &str) -> DocumentError {
361    DocumentError::ParseError {
362        format: "dotenv".to_string(),
363        detail: format!("line {line_number}: {detail}"),
364    }
365}
366
367fn with_path(error: DocumentError, path: &str) -> DocumentError {
368    match error {
369        DocumentError::ParseError { format, detail } => DocumentError::ParseError {
370            format,
371            detail: format!("path `{path}`: {detail}"),
372        },
373        other => other,
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn a_comment_does_not_swallow_the_file() {
383        // The apostrophe in "isn't" follows an `=` earlier on the same comment
384        // line. Treating it as an opening quote consumed every later line, so
385        // the file parsed to zero keys and reported success — a caller using
386        // `--default` got its fallback while the real value sat in the file.
387        let source =
388            "# set KEY=value if it isn't already there\nAPI_HOST=example.com\nAPI_PORT=8080\n";
389        let Value::Object(values) = load(source).unwrap() else {
390            panic!("expected an object");
391        };
392        assert_eq!(values.len(), 2);
393        assert_eq!(
394            values.get("API_HOST"),
395            Some(&Value::String("example.com".to_string()))
396        );
397    }
398
399    #[test]
400    fn a_quote_after_the_value_started_is_literal() {
401        let Value::Object(values) = load("MESSAGE=it isn't quoted\n").unwrap() else {
402            panic!("expected an object");
403        };
404        assert_eq!(
405            values.get("MESSAGE"),
406            Some(&Value::String("it isn't quoted".to_string()))
407        );
408    }
409
410    #[test]
411    fn quoted_values_still_span_lines() {
412        let Value::Object(values) = load("A=\"line1\nline2\"\nB=after\n").unwrap() else {
413            panic!("expected an object");
414        };
415        assert_eq!(
416            values.get("A"),
417            Some(&Value::String("line1\nline2".to_string()))
418        );
419        assert_eq!(values.get("B"), Some(&Value::String("after".to_string())));
420    }
421
422    #[test]
423    fn an_unterminated_quote_is_a_parse_error() {
424        // Silence was the unacceptable answer here: an unreadable file has to
425        // say so rather than present itself as an empty one.
426        let error = load("A=\"unterminated\nB=after\n").unwrap_err();
427        assert_eq!(error.code(), "document_parse_failed");
428    }
429}