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
76fn logical_lines(content: &str) -> Vec<(usize, String)> {
77    let mut lines = Vec::new();
78    let mut buffer = String::new();
79    let mut first_line = 1;
80    let mut line_number = 1;
81    let mut quote = None;
82    let mut escaped = false;
83    let mut after_equals = false;
84
85    for character in content.chars() {
86        if quote.is_none() {
87            if after_equals && character.is_whitespace() && character != '\n' && character != '\r' {
88                buffer.push(character);
89                continue;
90            }
91            if after_equals && (character == '\'' || character == '"') {
92                quote = Some(character);
93                after_equals = false;
94            } else if character == '=' {
95                after_equals = true;
96            }
97        } else if escaped {
98            escaped = false;
99        } else if character == '\\' {
100            escaped = true;
101        } else if Some(character) == quote {
102            quote = None;
103        }
104        if character == '\n' && quote.is_none() {
105            lines.push((
106                first_line,
107                buffer.strip_suffix('\n').unwrap_or(&buffer).to_string(),
108            ));
109            buffer.clear();
110            first_line = line_number + 1;
111        } else {
112            buffer.push(character);
113        }
114        if character == '\n' {
115            line_number += 1;
116        }
117    }
118    if !buffer.is_empty() || content.is_empty() {
119        lines.push((first_line, buffer));
120    }
121    lines
122}
123
124/// dotenv is intentionally read-only because the generic IR loses source formatting.
125pub fn save(_value: &Value) -> DocumentResult<String> {
126    Err(DocumentError::UnsupportedOperation {
127        format: "dotenv".to_string(),
128        operation: "save".to_string(),
129        detail: "dotenv files are read-only because rewriting would lose comments, ordering, and quoting"
130            .to_string(),
131    })
132}
133
134/// Replace one existing assignment while preserving every other source byte.
135pub fn set_preserving(content: &str, key: &str, value: &Value) -> DocumentResult<String> {
136    let Value::String(value) = value else {
137        return Err(unsupported("set", "dotenv values are strings"));
138    };
139    let replacement = encode_value(value);
140    edit_line(content, key, Some(&replacement))
141}
142
143/// Remove one existing assignment while preserving every other source byte.
144pub fn unset_preserving(content: &str, key: &str) -> DocumentResult<String> {
145    edit_line(content, key, None)
146}
147
148fn edit_line(content: &str, key: &str, replacement: Option<&str>) -> DocumentResult<String> {
149    if !valid_key(key) {
150        return Err(parse_error(0, "invalid variable name"));
151    }
152    let document = DotenvDocument::parse(content).map_err(|error| with_path(error, key))?;
153    let mut output = String::with_capacity(content.len());
154    let found = document.has_key(key);
155    for line in content.split_inclusive('\n') {
156        let body = line.strip_suffix('\n').unwrap_or(line);
157        let bare = body.strip_suffix('\r').unwrap_or(body);
158        let trimmed = bare.trim_start();
159        let assignment = trimmed
160            .strip_prefix("export")
161            .and_then(|rest| {
162                rest.starts_with(char::is_whitespace)
163                    .then(|| rest.trim_start())
164            })
165            .unwrap_or(trimmed);
166        let matches = assignment
167            .split_once('=')
168            .is_some_and(|(name, _)| name.trim() == key);
169        if matches {
170            if let Some(value) = replacement {
171                let prefix_len = body.len() - assignment.len();
172                let prefix = &body[..prefix_len];
173                let (name, old_value) = assignment.split_once('=').unwrap_or((key, ""));
174                let suffix = old_value
175                    .find(" #")
176                    .map(|index| &old_value[index..])
177                    .unwrap_or("");
178                output.push_str(prefix);
179                output.push_str(name);
180                output.push('=');
181                output.push_str(value);
182                output.push_str(suffix);
183                if body.ends_with('\r') {
184                    output.push('\r');
185                }
186                if line.ends_with('\n') {
187                    output.push('\n');
188                }
189            }
190        } else {
191            output.push_str(line);
192        }
193    }
194    if !found {
195        if let Some(value) = replacement {
196            if !output.is_empty() && !output.ends_with('\n') {
197                output.push('\n');
198            }
199            output.push_str(key);
200            output.push('=');
201            output.push_str(value);
202            if content.ends_with('\n') || !output.ends_with('\n') {
203                output.push('\n');
204            }
205            return Ok(output);
206        }
207        return Err(DocumentError::PathNotFound {
208            path: key.to_string(),
209        });
210    }
211    Ok(output)
212}
213
214fn encode_value(value: &str) -> String {
215    if !value.is_empty()
216        && value
217            .chars()
218            .all(|c| !c.is_whitespace() && c != '#' && c != '\\')
219    {
220        return value.to_string();
221    }
222    format!(
223        "\"{}\"",
224        value
225            .replace('\\', "\\\\")
226            .replace('"', "\\\"")
227            .replace('\n', "\\n")
228    )
229}
230
231fn unsupported(operation: &str, detail: &str) -> DocumentError {
232    DocumentError::UnsupportedOperation {
233        format: "dotenv".to_string(),
234        operation: operation.to_string(),
235        detail: detail.to_string(),
236    }
237}
238
239fn valid_key(key: &str) -> bool {
240    let mut chars = key.chars();
241    chars
242        .next()
243        .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
244        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
245}
246
247fn parse_value(input: &str, line_number: usize) -> DocumentResult<String> {
248    if input.len() > MAX_DOTENV_VALUE_BYTES {
249        return Err(parse_error(line_number, "value exceeds 1 MiB"));
250    }
251    match input.chars().next() {
252        Some('\'') => parse_quoted(input, '\'', line_number),
253        Some('"') => parse_quoted(input, '"', line_number),
254        _ => parse_unquoted(input),
255    }
256}
257
258fn parse_quoted(input: &str, quote: char, line_number: usize) -> DocumentResult<String> {
259    let mut output = String::new();
260    let mut escaped = false;
261
262    for (offset, character) in input[quote.len_utf8()..].char_indices() {
263        if escaped {
264            match (quote, character) {
265                ('"', 'n') => output.push('\n'),
266                ('"', 'r') => output.push('\r'),
267                ('"', 't') => output.push('\t'),
268                ('"', '\\') => output.push('\\'),
269                ('"', '"') => output.push('"'),
270                ('\'', '\'') => output.push('\''),
271                (_, other) => {
272                    output.push('\\');
273                    output.push(other);
274                }
275            }
276            escaped = false;
277            continue;
278        }
279        if character == '\\' {
280            escaped = true;
281            continue;
282        }
283        if character == quote {
284            let remainder_index = quote.len_utf8() + offset + character.len_utf8();
285            let remainder = input[remainder_index..].trim_start();
286            if !remainder.is_empty() && !remainder.starts_with('#') {
287                return Err(parse_error(
288                    line_number,
289                    "unexpected content after quoted value",
290                ));
291            }
292            return Ok(output);
293        }
294        output.push(character);
295    }
296
297    Err(parse_error(line_number, "unterminated quoted value"))
298}
299
300fn parse_unquoted(input: &str) -> DocumentResult<String> {
301    let mut output = String::new();
302    let mut escaped = false;
303
304    for character in input.chars() {
305        if escaped {
306            output.push(character);
307            escaped = false;
308        } else if character == '\\' {
309            escaped = true;
310        } else if character == '#' && output.chars().last().is_some_and(char::is_whitespace) {
311            break;
312        } else {
313            output.push(character);
314        }
315    }
316    if escaped {
317        output.push('\\');
318    }
319    Ok(output.trim_end().to_string())
320}
321
322fn parse_error(line_number: usize, detail: &str) -> DocumentError {
323    DocumentError::ParseError {
324        format: "dotenv".to_string(),
325        detail: format!("line {line_number}: {detail}"),
326    }
327}
328
329fn with_path(error: DocumentError, path: &str) -> DocumentError {
330    match error {
331        DocumentError::ParseError { format, detail } => DocumentError::ParseError {
332            format,
333            detail: format!("path `{path}`: {detail}"),
334        },
335        other => other,
336    }
337}