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 raw_line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
44        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line).trim_start();
45        if line.is_empty() || line.starts_with('#') {
46            continue;
47        }
48
49        let assignment = line
50            .strip_prefix("export")
51            .and_then(|rest| {
52                rest.starts_with(char::is_whitespace)
53                    .then(|| rest.trim_start())
54            })
55            .unwrap_or(line);
56        let Some((raw_key, raw_value)) = assignment.split_once('=') else {
57            return Err(parse_error(line_number, "expected KEY=value assignment"));
58        };
59        let key = raw_key.trim();
60        if !valid_key(key) {
61            return Err(parse_error(line_number, "invalid variable name"));
62        }
63
64        let value = parse_value(raw_value.trim_start(), line_number)?;
65        if values.contains_key(key) {
66            return Err(parse_error(line_number, "duplicate variable name"));
67        }
68        values.insert(key.to_string(), Value::String(value));
69    }
70
71    Ok(Value::Object(values))
72}
73
74/// Split `content` into logical lines, joining the physical lines a quoted
75/// value spans.
76///
77/// A quote only opens a value where a value can start: after the `=`, before
78/// any other non-whitespace character, and never inside a comment. Tracking
79/// less than that made an ordinary English comment — `# set KEY=value if it
80/// isn't already there` — open a quote at the apostrophe that no later line
81/// closed, swallowing the rest of the file into one unparseable line and
82/// yielding zero keys with no error at all.
83fn logical_lines(content: &str) -> DocumentResult<Vec<(usize, &str)>> {
84    let mut lines = Vec::new();
85    let mut start = 0;
86    let mut first_line = 1;
87    let mut line_number = 1;
88    let mut quote = None;
89    let mut quote_opened_at = 0;
90    let mut escaped = false;
91    let mut value_may_open = false;
92    let mut assignment_started = false;
93    let mut in_comment = false;
94    let mut at_line_start = true;
95
96    for (offset, character) in content.char_indices() {
97        if quote.is_none() {
98            if at_line_start && !character.is_whitespace() {
99                at_line_start = false;
100                in_comment = character == '#';
101            }
102            if !in_comment {
103                if value_may_open
104                    && character.is_whitespace()
105                    && character != '\n'
106                    && character != '\r'
107                {
108                    continue;
109                }
110                if value_may_open && (character == '\'' || character == '"') {
111                    quote = Some(character);
112                    quote_opened_at = line_number;
113                    value_may_open = false;
114                } else if character == '=' && !assignment_started {
115                    value_may_open = true;
116                    assignment_started = true;
117                } else if !character.is_whitespace() {
118                    // The value has already started unquoted, so a quote from
119                    // here on is an ordinary character, not a delimiter.
120                    value_may_open = false;
121                }
122            }
123        } else if escaped {
124            escaped = false;
125        } else if character == '\\' {
126            escaped = true;
127        } else if Some(character) == quote {
128            quote = None;
129        }
130        if character == '\n' && quote.is_none() {
131            lines.push((first_line, &content[start..offset + 1]));
132            start = offset + 1;
133            first_line = line_number + 1;
134        }
135        if character == '\n' {
136            line_number += 1;
137            if quote.is_none() {
138                value_may_open = false;
139                assignment_started = false;
140                in_comment = false;
141                at_line_start = true;
142            }
143        }
144    }
145    if quote.is_some() {
146        // Reaching end of input inside a quote means the file is malformed.
147        // Reporting zero keys instead let `value --default` hand a caller its
148        // fallback while the real value sat in the file, unread and unmentioned.
149        return Err(parse_error(quote_opened_at, "unterminated quoted value"));
150    }
151    if start < content.len() || content.is_empty() {
152        lines.push((first_line, &content[start..]));
153    }
154    Ok(lines)
155}
156
157/// dotenv is intentionally read-only because the generic IR loses source formatting.
158pub fn save(_value: &Value) -> DocumentResult<String> {
159    Err(DocumentError::UnsupportedOperation {
160        format: "dotenv".to_string(),
161        operation: "save".to_string(),
162        detail: "dotenv files are read-only because rewriting would lose comments, ordering, and quoting"
163            .to_string(),
164    })
165}
166
167/// Replace one existing assignment while preserving every other source byte.
168pub fn set_preserving(content: &str, key: &str, value: &Value) -> DocumentResult<String> {
169    let Value::String(value) = value else {
170        return Err(unsupported("set", "dotenv values are strings"));
171    };
172    let replacement = encode_value(value);
173    edit_line(content, key, Some(&replacement))
174}
175
176/// Remove one existing assignment while preserving every other source byte.
177pub fn unset_preserving(content: &str, key: &str) -> DocumentResult<String> {
178    edit_line(content, key, None)
179}
180
181fn edit_line(content: &str, key: &str, replacement: Option<&str>) -> DocumentResult<String> {
182    if !valid_key(key) {
183        return Err(parse_error(0, "invalid variable name"));
184    }
185    let document = DotenvDocument::parse(content).map_err(|error| with_path(error, key))?;
186    let mut output = String::with_capacity(content.len());
187    let found = document.has_key(key);
188    for (line_number, line) in logical_lines(content)? {
189        let body = line.strip_suffix('\n').unwrap_or(line);
190        let bare = body.strip_suffix('\r').unwrap_or(body);
191        let trimmed = bare.trim_start();
192        let assignment = trimmed
193            .strip_prefix("export")
194            .and_then(|rest| {
195                rest.starts_with(char::is_whitespace)
196                    .then(|| rest.trim_start())
197            })
198            .unwrap_or(trimmed);
199        let matches = assignment
200            .split_once('=')
201            .is_some_and(|(name, _)| name.trim() == key);
202        if matches {
203            if let Some(value) = replacement {
204                let prefix_len = bare.len() - assignment.len();
205                let prefix = &body[..prefix_len];
206                let (name, old_value) = assignment.split_once('=').unwrap_or((key, ""));
207                let old_trimmed = old_value.trim_start();
208                let spacing = &old_value[..old_value.len() - old_trimmed.len()];
209                let suffix = value_suffix(old_trimmed, line_number)?;
210                output.push_str(prefix);
211                output.push_str(name);
212                output.push('=');
213                output.push_str(spacing);
214                output.push_str(value);
215                output.push_str(suffix);
216                if body.ends_with('\r') {
217                    output.push('\r');
218                }
219                if line.ends_with('\n') {
220                    output.push('\n');
221                }
222            }
223        } else {
224            output.push_str(line);
225        }
226    }
227    if !found {
228        if let Some(value) = replacement {
229            let newline = if content.contains("\r\n") {
230                "\r\n"
231            } else {
232                "\n"
233            };
234            if !output.is_empty() && !output.ends_with('\n') {
235                output.push_str(newline);
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_str(newline);
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() && !matches!(c, '#' | '\\' | '\'' | '"'))
257    {
258        return value.to_string();
259    }
260    format!(
261        "\"{}\"",
262        value
263            .replace('\\', "\\\\")
264            .replace('"', "\\\"")
265            .replace('\n', "\\n")
266            .replace('\r', "\\r")
267            .replace('\t', "\\t")
268    )
269}
270
271fn unsupported(operation: &str, detail: &str) -> DocumentError {
272    DocumentError::UnsupportedOperation {
273        format: "dotenv".to_string(),
274        operation: operation.to_string(),
275        detail: detail.to_string(),
276    }
277}
278
279fn valid_key(key: &str) -> bool {
280    let mut chars = key.chars();
281    chars
282        .next()
283        .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
284        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
285}
286
287fn parse_value(input: &str, line_number: usize) -> DocumentResult<String> {
288    if input.len() > MAX_DOTENV_VALUE_BYTES {
289        return Err(parse_error(line_number, "value exceeds 1 MiB"));
290    }
291    match input.chars().next() {
292        Some('\'') => parse_quoted(input, '\'', line_number).map(|(value, _)| value),
293        Some('"') => parse_quoted(input, '"', line_number).map(|(value, _)| value),
294        _ => parse_unquoted(input),
295    }
296}
297
298fn parse_quoted(input: &str, quote: char, line_number: usize) -> DocumentResult<(String, usize)> {
299    let mut output = String::new();
300    let mut escaped = false;
301
302    for (offset, character) in input[quote.len_utf8()..].char_indices() {
303        if escaped {
304            match (quote, character) {
305                ('"', 'n') => output.push('\n'),
306                ('"', 'r') => output.push('\r'),
307                ('"', 't') => output.push('\t'),
308                ('"', '\\') => output.push('\\'),
309                ('"', '"') => output.push('"'),
310                ('\'', '\'') => output.push('\''),
311                (_, other) => {
312                    output.push('\\');
313                    output.push(other);
314                }
315            }
316            escaped = false;
317            continue;
318        }
319        if character == '\\' {
320            escaped = true;
321            continue;
322        }
323        if character == quote {
324            let remainder_index = quote.len_utf8() + offset + character.len_utf8();
325            let remainder = input[remainder_index..].trim_start();
326            if !remainder.is_empty() && !remainder.starts_with('#') {
327                return Err(parse_error(
328                    line_number,
329                    "unexpected content after quoted value",
330                ));
331            }
332            return Ok((output, remainder_index));
333        }
334        output.push(character);
335    }
336
337    Err(parse_error(line_number, "unterminated quoted value"))
338}
339
340/// Keep only an actual trailing comment or whitespace, never a ` #` inside a
341/// quoted value. The parser and editor share the quote boundary.
342fn value_suffix(input: &str, line_number: usize) -> DocumentResult<&str> {
343    if let Some(quote @ ('\'' | '"')) = input.chars().next() {
344        let (_, end) = parse_quoted(input, quote, line_number)?;
345        return Ok(&input[end..]);
346    }
347    let mut escaped = false;
348    let mut whitespace = None;
349    for (offset, character) in input.char_indices() {
350        if escaped {
351            escaped = false;
352        } else if character == '\\' {
353            escaped = true;
354            continue;
355        } else if character == '#'
356            && let Some(start) = whitespace
357        {
358            return Ok(&input[start..]);
359        }
360        if character.is_whitespace() {
361            whitespace.get_or_insert(offset);
362        } else {
363            whitespace = None;
364        }
365    }
366    Ok(&input[whitespace.unwrap_or(input.len())..])
367}
368
369fn parse_unquoted(input: &str) -> DocumentResult<String> {
370    let mut output = String::new();
371    let mut escaped = false;
372
373    for character in input.chars() {
374        if escaped {
375            output.push(character);
376            escaped = false;
377        } else if character == '\\' {
378            escaped = true;
379        } else if character == '#' && output.chars().last().is_some_and(char::is_whitespace) {
380            break;
381        } else {
382            output.push(character);
383        }
384    }
385    if escaped {
386        output.push('\\');
387    }
388    Ok(output.trim_end().to_string())
389}
390
391fn parse_error(line_number: usize, detail: &str) -> DocumentError {
392    DocumentError::ParseError {
393        format: "dotenv".to_string(),
394        detail: format!("line {line_number}: {detail}"),
395    }
396}
397
398fn with_path(error: DocumentError, path: &str) -> DocumentError {
399    match error {
400        DocumentError::ParseError { format, detail } => DocumentError::ParseError {
401            format,
402            detail: format!("path `{path}`: {detail}"),
403        },
404        other => other,
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn a_comment_does_not_swallow_the_file() {
414        // The apostrophe in "isn't" follows an `=` earlier on the same comment
415        // line. Treating it as an opening quote consumed every later line, so
416        // the file parsed to zero keys and reported success — a caller using
417        // `--default` got its fallback while the real value sat in the file.
418        let source =
419            "# set KEY=value if it isn't already there\nAPI_HOST=example.com\nAPI_PORT=8080\n";
420        let Value::Object(values) = load(source).unwrap() else {
421            panic!("expected an object");
422        };
423        assert_eq!(values.len(), 2);
424        assert_eq!(
425            values.get("API_HOST"),
426            Some(&Value::String("example.com".to_string()))
427        );
428    }
429
430    #[test]
431    fn a_quote_after_the_value_started_is_literal() {
432        let Value::Object(values) = load("MESSAGE=it isn't quoted\n").unwrap() else {
433            panic!("expected an object");
434        };
435        assert_eq!(
436            values.get("MESSAGE"),
437            Some(&Value::String("it isn't quoted".to_string()))
438        );
439    }
440
441    #[test]
442    fn quoted_values_still_span_lines() {
443        let Value::Object(values) = load("A=\"line1\nline2\"\nB=after\n").unwrap() else {
444            panic!("expected an object");
445        };
446        assert_eq!(
447            values.get("A"),
448            Some(&Value::String("line1\nline2".to_string()))
449        );
450        assert_eq!(values.get("B"), Some(&Value::String("after".to_string())));
451    }
452
453    #[test]
454    fn editing_a_multiline_value_replaces_its_whole_assignment() {
455        let source = "# before\r\n export A = \"first # value\r\nB=inside\"\t# keep\r\nB=after\r\n";
456        let value = Value::String("\"quoted\"\r\t\n".into());
457        let edited = set_preserving(source, "A", &value).unwrap();
458        assert!(edited.starts_with("# before\r\n export A = "));
459        assert!(edited.ends_with("\t# keep\r\nB=after\r\n"));
460        assert!(!edited.contains("B=inside"));
461        let Value::Object(values) = load(&edited).unwrap() else {
462            panic!("expected an object");
463        };
464        assert_eq!(values["A"], value);
465        assert_eq!(values["B"], Value::String("after".into()));
466        assert_eq!(
467            unset_preserving(source, "A").unwrap(),
468            "# before\r\nB=after\r\n"
469        );
470    }
471
472    #[test]
473    fn written_quotes_and_comment_like_values_round_trip() {
474        for value in [
475            "\"quoted\"",
476            "'quoted'",
477            "x=\"literal",
478            "x # value",
479            "a\r\nb\tc",
480        ] {
481            let value = Value::String(value.into());
482            let edited = set_preserving("A=old # keep\n", "A", &value).unwrap();
483            let Value::Object(values) = load(&edited).unwrap() else {
484                panic!("expected an object");
485            };
486            assert_eq!(values["A"], value, "{edited}");
487            assert!(edited.ends_with(" # keep\n"));
488        }
489        assert_eq!(
490            set_preserving("A=\"old # value\"\n", "A", &Value::String("new".into())).unwrap(),
491            "A=new\n"
492        );
493    }
494
495    #[test]
496    fn later_equals_signs_cannot_open_an_unquoted_value() {
497        let Value::Object(values) = load("A=query=\"literal\nB=after\n").unwrap() else {
498            panic!("expected an object");
499        };
500        assert_eq!(values["A"], Value::String("query=\"literal".into()));
501        assert_eq!(values["B"], Value::String("after".into()));
502    }
503
504    #[test]
505    fn an_unterminated_quote_is_a_parse_error() {
506        // Silence was the unacceptable answer here: an unreadable file has to
507        // say so rather than present itself as an empty one.
508        let error = load("A=\"unterminated\nB=after\n").unwrap_err();
509        assert_eq!(error.code(), "document_parse_failed");
510    }
511}