env_sync/
parse.rs

1//! Environment file parsing with comment preservation.
2//!
3//! This module provides zero-copy parsing of `.env` files that preserves comments
4//! and associates them with environment variables. Comments can appear before
5//! variables (preceding comments) or on the same line (inline comments).
6//!
7//! # Examples
8//!
9//! ```
10//! use env_sync::parse::EnvFile;
11//! use std::convert::TryFrom;
12//!
13//! let content = r#"
14//! # Database configuration
15//! DB_HOST=localhost
16//! DB_PORT=5432 # Default PostgreSQL port
17//! "#;
18//!
19//! let env_file = EnvFile::try_from(content).unwrap();
20//! println!("{}", env_file);
21//! ```
22
23use 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/// Represents a parsed environment file with preserved comments.
32///
33/// The `EnvFile` contains a sequence of entries that can be variables,
34/// comments, or empty lines, maintaining the original file structure.
35#[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  /// Finds an environment variable by its key.
108  ///
109  /// Returns the first variable with the matching key, or `None` if not found.
110  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/// Represents a single entry in an environment file.
122#[derive(Debug, Clone, PartialEq)]
123pub enum EnvEntry<'a> {
124  /// A variable assignment with optional comments
125  Variable(EnvVariable<'a>),
126  /// A comment not associated with a variable
127  OrphanComment(EnvComment<'a>),
128  /// An empty line
129  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/// Represents an environment variable with its value and associated comments.
166#[derive(Debug, Clone, PartialEq)]
167pub struct EnvVariable<'a> {
168  /// The variable name
169  pub key: Cow<'a, str>,
170  /// The variable value
171  pub value: Cow<'a, str>,
172  /// Comments that appear before this variable
173  pub preceding_comments: Vec<EnvComment<'a>>,
174  /// Comment that appears on the same line as the variable
175  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/// Represents a comment in an environment file.
229///
230/// The comment content excludes the leading `#` character.
231#[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/// Errors that can occur during parsing.
260#[derive(Debug, thiserror::Error)]
261pub enum ParseError {
262  /// A line that cannot be parsed as a variable, comment, or empty line
263  #[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    // First entry should be KEY variable with one preceding comment
301    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    // Second entry should be TEST variable with two preceding comments
312    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    // Parse the output again and compare
351    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    // Test empty line
358    let entry: EnvEntry = "".try_into().unwrap();
359    assert_eq!(entry, EnvEntry::EmptyLine);
360
361    // Test comment
362    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    // Test variable
372    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    // Test variable with inline comment
384    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    // Test invalid line
398    assert!(EnvEntry::try_from("invalid line without equals").is_err());
399  }
400
401  #[test]
402  fn test_key_without_value() {
403    // Test key with equals but no value
404    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    // Test key with equals and whitespace
415    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}