1use std::{borrow::Cow, convert::TryFrom, fmt};
24
25#[cfg(feature = "tracing")]
26use tracing::{debug, trace};
27
28const COMMENT_PREFIX: &str = "#";
29const ASSIGNMENT_OPERATOR: &str = "=";
30
31#[derive(Debug, Clone, PartialEq, Default)]
36pub struct EnvFile<'a> {
37 pub entries: Vec<EnvEntry<'a>>,
38}
39
40impl<'a> fmt::Display for EnvFile<'a> {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 for entry in &self.entries {
43 write!(f, "{}", entry)?;
44 }
45 Ok(())
46 }
47}
48
49impl<'a> TryFrom<&'a str> for EnvFile<'a> {
50 type Error = ParseError;
51
52 fn try_from(s: &'a str) -> Result<Self, Self::Error> {
53 #[cfg(feature = "tracing")]
54 debug!("Parsing env file with {} lines", s.lines().count());
55
56 let mut entries = Vec::new();
57 let mut pending_comments = Vec::new();
58
59 for line in s.lines() {
60 #[cfg(feature = "tracing")]
61 trace!("Parsing line: {:?}", line);
62
63 let mut entry: EnvEntry = line.try_into()?;
64
65 if let EnvEntry::Variable(ref mut var) = entry {
66 #[cfg(feature = "tracing")]
67 trace!(
68 "Found variable: {} with {} pending comments",
69 var.key,
70 pending_comments.len()
71 );
72
73 var.preceding_comments = std::mem::take(&mut pending_comments);
74 } else if let EnvEntry::OrphanComment(comment) = entry {
75 #[cfg(feature = "tracing")]
76 trace!("Found comment, adding to pending");
77
78 pending_comments.push(comment);
79 continue;
80 } else if matches!(entry, EnvEntry::EmptyLine) && !pending_comments.is_empty() {
81 #[cfg(feature = "tracing")]
82 trace!(
83 "Empty line with {} pending comments, flushing",
84 pending_comments.len()
85 );
86
87 for comment in pending_comments.drain(..) {
88 entries.push(EnvEntry::OrphanComment(comment));
89 }
90 }
91
92 entries.push(entry);
93 }
94
95 for comment in pending_comments {
96 entries.push(EnvEntry::OrphanComment(comment));
97 }
98
99 #[cfg(feature = "tracing")]
100 debug!("Parsed {} entries", entries.len());
101
102 Ok(Self { entries })
103 }
104}
105
106impl<'a> EnvFile<'a> {
107 pub fn get(&self, key: &str) -> Option<&EnvVariable<'a>> {
111 self.entries.iter().find_map(|entry| {
112 if let EnvEntry::Variable(var) = entry {
113 if var.key == key { Some(var) } else { None }
114 } else {
115 None
116 }
117 })
118 }
119}
120
121#[derive(Debug, Clone, PartialEq)]
123pub enum EnvEntry<'a> {
124 Variable(EnvVariable<'a>),
126 OrphanComment(EnvComment<'a>),
128 EmptyLine,
130}
131
132impl<'a> fmt::Display for EnvEntry<'a> {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 match self {
135 EnvEntry::Variable(var) => {
136 write!(f, "{}", var)?;
137 writeln!(f)
138 }
139 EnvEntry::OrphanComment(comment) => {
140 writeln!(f, "{}", comment)
141 }
142 EnvEntry::EmptyLine => {
143 writeln!(f)
144 }
145 }
146 }
147}
148
149impl<'a> TryFrom<&'a str> for EnvEntry<'a> {
150 type Error = ParseError;
151
152 fn try_from(s: &'a str) -> Result<Self, Self::Error> {
153 let trimmed = s.trim();
154
155 if trimmed.is_empty() {
156 Ok(EnvEntry::EmptyLine)
157 } else if trimmed.starts_with(COMMENT_PREFIX) {
158 Ok(EnvEntry::OrphanComment(trimmed.try_into()?))
159 } else {
160 Ok(EnvEntry::Variable(trimmed.try_into()?))
161 }
162 }
163}
164
165#[derive(Debug, Clone, PartialEq)]
167pub struct EnvVariable<'a> {
168 pub key: Cow<'a, str>,
170 pub value: Cow<'a, str>,
172 pub preceding_comments: Vec<EnvComment<'a>>,
174 pub inline_comment: Option<EnvComment<'a>>,
176}
177
178impl<'a> fmt::Display for EnvVariable<'a> {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 for comment in &self.preceding_comments {
181 writeln!(f, "{}", comment)?;
182 }
183 write!(f, "{}{}{}", self.key, ASSIGNMENT_OPERATOR, self.value)?;
184 if let Some(comment) = &self.inline_comment {
185 write!(f, " {}", comment)?;
186 }
187 Ok(())
188 }
189}
190
191impl<'a> TryFrom<&'a str> for EnvVariable<'a> {
192 type Error = ParseError;
193
194 fn try_from(s: &'a str) -> Result<Self, Self::Error> {
195 #[cfg(feature = "tracing")]
196 trace!("Parsing variable from: {:?}", s);
197
198 if let Some((key, value_part)) = s.split_once(ASSIGNMENT_OPERATOR) {
199 let key = key.trim();
200
201 let (value, inline_comment) =
202 if let Some((value, comment)) = value_part.split_once(COMMENT_PREFIX) {
203 (value.trim(), Some(EnvComment(Cow::Borrowed(comment))))
204 } else {
205 (value_part.trim(), None)
206 };
207
208 #[cfg(feature = "tracing")]
209 trace!(
210 "Parsed variable: key={}, value={}, has_inline_comment={}",
211 key,
212 value,
213 inline_comment.is_some()
214 );
215
216 Ok(EnvVariable {
217 key: Cow::Borrowed(key),
218 value: Cow::Borrowed(value),
219 preceding_comments: Vec::new(),
220 inline_comment,
221 })
222 } else {
223 Err(ParseError::InvalidLine(s.to_string()))
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq)]
232pub struct EnvComment<'a>(Cow<'a, str>);
233
234impl<'a> fmt::Display for EnvComment<'a> {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 write!(f, "{}{}", COMMENT_PREFIX, self.0)
237 }
238}
239
240impl<'a> TryFrom<&'a str> for EnvComment<'a> {
241 type Error = ParseError;
242
243 fn try_from(s: &'a str) -> Result<Self, Self::Error> {
244 #[cfg(feature = "tracing")]
245 trace!("Parsing comment from: {:?}", s);
246
247 let trimmed = s.trim();
248 if let Some(content) = trimmed.strip_prefix(COMMENT_PREFIX) {
249 #[cfg(feature = "tracing")]
250 trace!("Parsed comment content: {:?}", content);
251
252 Ok(EnvComment(Cow::Borrowed(content)))
253 } else {
254 Err(ParseError::InvalidLine(s.to_string()))
255 }
256 }
257}
258
259#[derive(Debug, thiserror::Error)]
261pub enum ParseError {
262 #[error("Invalid line: {0}")]
264 InvalidLine(String),
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn test_parse_simple() {
273 let input = "KEY=value\nANOTHER=test";
274 let env: EnvFile = input.try_into().unwrap();
275
276 assert_eq!(env.entries.len(), 2);
277 match &env.entries[0] {
278 EnvEntry::Variable(var) => {
279 assert_eq!(var.key, "KEY");
280 assert_eq!(var.value, "value");
281 }
282 _ => panic!("Expected variable"),
283 }
284 match &env.entries[1] {
285 EnvEntry::Variable(var) => {
286 assert_eq!(var.key, "ANOTHER");
287 assert_eq!(var.value, "test");
288 }
289 _ => panic!("Expected variable"),
290 }
291 }
292
293 #[test]
294 fn test_parse_with_comments() {
295 let input = "# This is a comment\nKEY=value\n# Another comment\n# Multi line\nTEST=123";
296 let env: EnvFile = input.try_into().unwrap();
297
298 let mut iter = env.entries.iter();
299
300 match iter.next().unwrap() {
302 EnvEntry::Variable(var) => {
303 assert_eq!(var.key, "KEY");
304 assert_eq!(var.value, "value");
305 assert_eq!(var.preceding_comments.len(), 1);
306 assert_eq!(var.preceding_comments[0].to_string(), "# This is a comment");
307 }
308 _ => panic!("Expected variable"),
309 }
310
311 match iter.next().unwrap() {
313 EnvEntry::Variable(var) => {
314 assert_eq!(var.key, "TEST");
315 assert_eq!(var.value, "123");
316 assert_eq!(var.preceding_comments.len(), 2);
317 assert_eq!(var.preceding_comments[0].to_string(), "# Another comment");
318 assert_eq!(var.preceding_comments[1].to_string(), "# Multi line");
319 }
320 _ => panic!("Expected variable"),
321 }
322
323 assert!(iter.next().is_none());
324 }
325
326 #[test]
327 fn test_parse_inline_comments() {
328 let input = "KEY=value # This is inline\nTEST=123";
329 let env: EnvFile = input.try_into().unwrap();
330
331 match &env.entries[0] {
332 EnvEntry::Variable(var) => {
333 assert_eq!(var.key, "KEY");
334 assert_eq!(var.value, "value");
335 assert_eq!(
336 var.inline_comment,
337 Some(EnvComment(Cow::Owned(" This is inline".to_string())))
338 );
339 }
340 _ => panic!("Expected variable"),
341 }
342 }
343
344 #[test]
345 fn test_roundtrip() {
346 let input = "# Comment\nKEY=value\n\n# Orphan\nTEST=123 # inline";
347 let env: EnvFile = input.try_into().unwrap();
348 let output = env.to_string();
349
350 let env2: EnvFile = output.as_str().try_into().unwrap();
352 assert_eq!(env, env2);
353 }
354
355 #[test]
356 fn test_env_entry_from_str() {
357 let entry: EnvEntry = "".try_into().unwrap();
359 assert_eq!(entry, EnvEntry::EmptyLine);
360
361 let entry: EnvEntry = "# This is a comment".try_into().unwrap();
363 match entry {
364 EnvEntry::OrphanComment(comment) => assert_eq!(
365 comment,
366 EnvComment(Cow::Owned(" This is a comment".to_string()))
367 ),
368 _ => panic!("Expected OrphanComment"),
369 }
370
371 let entry: EnvEntry = "KEY=value".try_into().unwrap();
373 match entry {
374 EnvEntry::Variable(var) => {
375 assert_eq!(var.key, "KEY");
376 assert_eq!(var.value, "value");
377 assert!(var.preceding_comments.is_empty());
378 assert!(var.inline_comment.is_none());
379 }
380 _ => panic!("Expected Variable"),
381 }
382
383 let entry: EnvEntry = "KEY=value # comment".try_into().unwrap();
385 match entry {
386 EnvEntry::Variable(var) => {
387 assert_eq!(var.key, "KEY");
388 assert_eq!(var.value, "value");
389 assert_eq!(
390 var.inline_comment,
391 Some(EnvComment(Cow::Owned(" comment".to_string())))
392 );
393 }
394 _ => panic!("Expected Variable"),
395 }
396
397 assert!(EnvEntry::try_from("invalid line without equals").is_err());
399 }
400
401 #[test]
402 fn test_key_without_value() {
403 let entry: EnvEntry = "KEY=".try_into().unwrap();
405 match entry {
406 EnvEntry::Variable(var) => {
407 assert_eq!(var.key, "KEY");
408 assert_eq!(var.value, "");
409 assert!(var.inline_comment.is_none());
410 }
411 _ => panic!("Expected Variable"),
412 }
413
414 let entry: EnvEntry = "KEY= ".try_into().unwrap();
416 match entry {
417 EnvEntry::Variable(var) => {
418 assert_eq!(var.key, "KEY");
419 assert_eq!(var.value, "");
420 assert!(var.inline_comment.is_none());
421 }
422 _ => panic!("Expected Variable"),
423 }
424 }
425}