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 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
124pub 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
134pub 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
143pub 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}