agent_first_data/document/format/
dotenv.rs1use 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
34pub 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
74fn 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 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 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
157pub 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
167pub 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
176pub 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
340fn 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 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 let error = load("A=\"unterminated\nB=after\n").unwrap_err();
509 assert_eq!(error.code(), "document_parse_failed");
510 }
511}