Skip to main content

dotenv/
dotenv.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! Loads environment variables from `.env` files.
4//!
5//! # Quick start
6//!
7//! ```rust,ignore
8//! dotenv::load()?;
9//! ```
10//!
11//! Call [`load`] near the start of your program to load a `.env` file
12//! from the current working directory.
13//!
14//! # Precedence
15//!
16//! - **Existing environment variables are never overwritten.** A variable
17//!   already set in the environment takes priority over anything in `.env`.
18//! - **First declaration wins in `.env`.** If the same key appears multiple
19//!   times, only the first is used.
20//!
21//! # Supported syntax
22//!
23//! ```env
24//! HELLO=world
25//! HELLO="world"
26//! HELLO='world'
27//! HELLO='"nested"'
28//! HELLO=world  # inline comment
29//! # full-line comment
30//! ```
31//!
32//! ## Key names
33//!
34//! Keys may only contain ASCII letters, digits, `_`, `.`, and `-`.
35//!
36//! ## Limitations
37//!
38//! - Multi-line values are not supported.
39//! - Variable substitution (e.g. `${FOO}`) is not supported.
40//! - Export syntax (`export KEY=value`) is not supported.
41//!
42//! # Deserializing into structs with `FromEnv`
43//!
44//! This crate provides a [`FromEnv`] trait (and a [`#[derive(FromEnv)]`](FromEnv)
45//! proc-macro) for constructing typed structs directly from environment
46//! variables.
47//!
48//! ## Basic usage
49//!
50//! ```rust,ignore
51//! use dotenv::FromEnv;
52//!
53//! #[derive(FromEnv)]
54//! struct Config {
55//!     #[env(rename = "MY_HOST")]
56//!     host: String,
57//!     #[env(rename = "MY_PORT")]
58//!     port: u16,
59//! }
60//!
61//! let cfg = Config::from_env().unwrap();
62//! ```
63//!
64//! ## Default values
65//!
66//! Use `#[env(default)]` for any type that implements [`Default`] (yields the
67//! default when unset e.g. `None` for [`Option<T>`], `0` for numbers, `""`
68//! for [`String`]) or `#[env(default = expr)]` for any type:
69//!
70//! ```rust,ignore
71//! #[derive(FromEnv)]
72//! struct Config {
73//!     #[env(rename = "MY_HOST")]
74//!     host: String,
75//!     #[env(default)]
76//!     verbose: Option<bool>,
77//!     #[env(default)]
78//!     timeout: u64,
79//!     #[env(rename = "MY_PORT", default = 8080)]
80//!     port: u16,
81//! }
82//! ```
83//!
84//! In all cases the environment variable is checked first. The default
85//! value is only used when the variable is not set.
86//!
87//! ## Custom parsers
88//!
89//! When the standard [`FromStr`] parsing is insufficient, provide a custom
90//! parser via `#[env(with = "func")]`. The function signature must be
91//! `fn(&str, &str) -> Result<T, FromEnvError>`:
92//!
93//! ```rust,ignore
94//! use dotenv::{FromEnv, FromEnvError};
95//!
96//! fn parse_port(_var: &str, val: &str) -> Result<u16, FromEnvError> {
97//!     val.parse().map_err(|e| FromEnvError::invalid("MY_PORT", val, e))
98//! }
99//!
100//! #[derive(FromEnv)]
101//! struct Config {
102//!     #[env(rename = "MY_PORT", with = "parse_port")]
103//!     port: u16,
104//! }
105//! ```
106//!
107//! ## Nested structs
108//!
109//! Fields whose type also implements [`FromEnv`] are automatically detected
110//! and populated as nested structs. The parent's field name (in
111//! `SCREAMING_SNAKE_CASE`) is used as a prefix so that child fields are read
112//! from prefixed variable names:
113//!
114//! ```rust,ignore
115//! #[derive(FromEnv)]
116//! struct AppConfig {
117//!     database: Database,
118//!     debug: bool,       // reads DEBUG
119//! }
120//!
121//! #[derive(FromEnv)]
122//! struct Database {
123//!     url: String,       // reads DATABASE_URL
124//!     pool_size: u32,    // reads DATABASE_POOL_SIZE
125//! }
126//! ```
127//!
128//! ## Custom `FromEnvValue` implementations
129//!
130//! For leaf types that need special parsing logic, implement [`FromEnvValue`]
131//! directly on your type. Types that implement [`FromStr`] get a blanket impl
132//! automatically.
133//!
134//! ## Convenience function
135//!
136//! The function [`from_env`] lets you avoid importing the trait:
137//!
138//! ```rust,ignore
139//! let cfg: Config = dotenv::from_env().unwrap();
140//! ```
141
142use std::{collections::HashSet, env, fmt, fmt::Display, fs, io, str::FromStr};
143
144use memchr::memchr;
145
146/// Errors that can occur when loading a `.env` file.
147#[derive(Debug)]
148pub enum Error {
149    /// An I/O error (file not found, permissions, etc.).
150    Io(io::Error),
151    /// A parse error on a specific line.
152    Parse(ParseError),
153}
154
155impl fmt::Display for Error {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        match self {
158            Error::Io(e) => write!(f, "dotenv I/O error: {e}"),
159            Error::Parse(e) => write!(f, "dotenv parse error at line {}: {}", e.line, e.kind),
160        }
161    }
162}
163
164impl std::error::Error for Error {
165    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
166        match self {
167            Error::Io(e) => Some(e),
168            Error::Parse(_) => None,
169        }
170    }
171}
172
173impl From<io::Error> for Error {
174    fn from(e: io::Error) -> Self {
175        Error::Io(e)
176    }
177}
178
179/// A parse error with the line number and kind.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct ParseError {
182    /// The 1-indexed line number where the error occurred.
183    pub line: usize,
184    /// The kind of parse error.
185    pub kind: ParseErrorKind,
186}
187
188/// The specific kind of parse error.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum ParseErrorKind {
191    /// A line without an `=` sign.
192    MissingEquals,
193    /// A quoted value (`"..."` or `'...'`) without a closing quote.
194    UnmatchedQuote,
195    /// A line with an empty key before the `=` sign.
196    EmptyKey,
197    /// A key containing characters outside the allowed set
198    /// (alphanumeric, `_`, `.`, `-`).
199    InvalidKey,
200    /// Extra content found after a closing quote.
201    TrailingContent,
202}
203
204impl fmt::Display for ParseErrorKind {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        match self {
207            ParseErrorKind::MissingEquals => f.write_str("missing equals sign"),
208            ParseErrorKind::UnmatchedQuote => f.write_str("unmatched quote"),
209            ParseErrorKind::EmptyKey => f.write_str("empty key"),
210            ParseErrorKind::InvalidKey => f.write_str("invalid key character"),
211            ParseErrorKind::TrailingContent => f.write_str("trailing content after closing quote"),
212        }
213    }
214}
215
216/// Loads the `.env` file from the current working directory.
217///
218/// Each key-value pair found in the file is set as an environment variable
219/// for the current process, subject to these rules:
220///
221/// 1. A variable already present in the environment is not overwritten.
222/// 2. When the same key appears multiple times in `.env`, the first
223///    declaration takes effect.
224///
225/// # Errors
226///
227/// Returns [`Error`] if the file cannot be read (missing, permissions,
228/// etc.) or if the `.env` file is malformed.
229///
230/// # Example
231///
232/// ```rust,ignore
233/// fn main() {
234///     if let Err(e) = dotenv::load() {
235///         eprintln!("Failed to load .env: {e}");
236///     }
237/// }
238/// ```
239pub fn load() -> Result<(), Error> {
240    let mut path = env::current_dir()?;
241    path.push(".env");
242    let content = fs::read_to_string(&path)?;
243    let pairs = parse(&content)?;
244
245    let existing: HashSet<String> = env::vars().map(|(k, _)| k).collect();
246
247    let mut seen = HashSet::new();
248    for (key, value) in &pairs {
249        if seen.insert(key.clone()) && !existing.contains(key.as_str()) {
250            // SAFETY: single-threaded at startup, no concurrent access to env
251            unsafe { env::set_var(key, value) };
252        }
253    }
254    Ok(())
255}
256
257/// Parse a `.env` file string into a list of `(key, value)` pairs.
258fn parse(input: &str) -> Result<Vec<(String, String)>, Error> {
259    let mut pairs = Vec::new();
260
261    for (line_idx, raw_line) in input.lines().enumerate() {
262        let line = raw_line.trim_start();
263
264        if line.is_empty() || line.starts_with('#') {
265            continue;
266        }
267
268        let eq_pos = memchr(b'=', line.as_bytes()).ok_or_else(|| {
269            Error::Parse(ParseError {
270                line: line_idx + 1,
271                kind: ParseErrorKind::MissingEquals,
272            })
273        })?;
274
275        let key = line[..eq_pos].trim_end();
276        let value_str = &line[eq_pos + 1..];
277
278        if key.is_empty() {
279            return Err(Error::Parse(ParseError {
280                line: line_idx + 1,
281                kind: ParseErrorKind::EmptyKey,
282            }));
283        }
284
285        if !key
286            .chars()
287            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
288        {
289            return Err(Error::Parse(ParseError {
290                line: line_idx + 1,
291                kind: ParseErrorKind::InvalidKey,
292            }));
293        }
294
295        let value = parse_value(value_str, line_idx + 1)?;
296        pairs.push((key.to_string(), value));
297    }
298
299    Ok(pairs)
300}
301
302/// Find the first `#` preceded by whitespace (indicating a comment start).
303fn find_comment_start(s: &str) -> Option<usize> {
304    let bytes = s.as_bytes();
305    let mut offset = 0;
306    while let Some(pos) = memchr(b'#', &bytes[offset..]) {
307        let abs = offset + pos;
308        if abs > 0 && bytes[abs - 1].is_ascii_whitespace() {
309            return Some(abs);
310        }
311        offset = abs + 1;
312    }
313    None
314}
315
316/// Parse a single value string (everything after `=`).
317fn parse_value(s: &str, line: usize) -> Result<String, Error> {
318    let trimmed = s.trim();
319
320    if trimmed.is_empty() {
321        return Ok(String::new());
322    }
323
324    match trimmed.as_bytes()[0] {
325        b'"' => {
326            let rest = &trimmed[1..];
327            let close = memchr(b'"', rest.as_bytes()).ok_or(Error::Parse(ParseError {
328                line,
329                kind: ParseErrorKind::UnmatchedQuote,
330            }))?;
331            let after = rest[close + 1..].trim();
332            if !after.is_empty() && !after.starts_with('#') {
333                return Err(Error::Parse(ParseError {
334                    line,
335                    kind: ParseErrorKind::TrailingContent,
336                }));
337            }
338            Ok(rest[..close].to_string())
339        }
340        b'\'' => {
341            let rest = &trimmed[1..];
342            let close = memchr(b'\'', rest.as_bytes()).ok_or(Error::Parse(ParseError {
343                line,
344                kind: ParseErrorKind::UnmatchedQuote,
345            }))?;
346            let after = rest[close + 1..].trim();
347            if !after.is_empty() && !after.starts_with('#') {
348                return Err(Error::Parse(ParseError {
349                    line,
350                    kind: ParseErrorKind::TrailingContent,
351                }));
352            }
353            Ok(rest[..close].to_string())
354        }
355        _ => {
356            let comment_start = find_comment_start(s);
357            let val = match comment_start {
358                Some(pos) => &s[..pos],
359                None => s,
360            };
361            Ok(val.trim().to_string())
362        }
363    }
364}
365
366// ---------------------------------------------------------------------------
367// FromEnv trait and derive support
368// ---------------------------------------------------------------------------
369
370pub use dotenv_derive::FromEnv;
371
372/// Error returned by [`FromEnv::from_env`].
373///
374/// # Example
375///
376/// ```
377/// # use dotenv::FromEnvError;
378/// let err = FromEnvError::missing("MY_VAR");
379/// assert_eq!(err.to_string(), "environment variable `MY_VAR` is not set");
380///
381/// let err = FromEnvError::invalid("PORT", "abc", "invalid digit");
382/// assert_eq!(
383///     err.to_string(),
384///     "environment variable `PORT` has invalid value `abc`: invalid digit"
385/// );
386/// ```
387#[derive(Debug, Clone)]
388pub enum FromEnvError {
389    /// An environment variable was not set.
390    Missing(String),
391    /// An environment variable was set but could not be parsed.
392    Invalid {
393        /// The name of the environment variable.
394        var: String,
395        /// The raw value of the environment variable.
396        value: String,
397        /// A description of why parsing failed.
398        message: String,
399    },
400}
401
402impl fmt::Display for FromEnvError {
403    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
404        match self {
405            FromEnvError::Missing(var) => write!(f, "environment variable `{var}` is not set"),
406            FromEnvError::Invalid {
407                var,
408                value,
409                message,
410            } => {
411                write!(f, "environment variable `{var}` has invalid value `{value}`: {message}")
412            }
413        }
414    }
415}
416
417impl std::error::Error for FromEnvError {}
418
419impl FromEnvError {
420    pub fn missing(var: impl Into<String>) -> Self {
421        FromEnvError::Missing(var.into())
422    }
423
424    pub fn invalid(var: impl Into<String>, value: impl Into<String>, message: impl Into<String>) -> Self {
425        FromEnvError::Invalid {
426            var: var.into(),
427            value: value.into(),
428            message: message.into(),
429        }
430    }
431}
432
433/// Trait for types that can be constructed from environment variables.
434///
435/// Usually derived with [`#[derive(FromEnv)]`](FromEnv).
436pub trait FromEnv: Sized {
437    /// Load `Self` from environment variables using an empty prefix.
438    fn from_env() -> Result<Self, FromEnvError> {
439        Self::from_env_with_prefix("")
440    }
441
442    /// Load `Self` from environment variables, prepending `prefix` to each
443    /// env-var name derived from field names.
444    ///
445    /// This is used internally to support nested structs. Each parent field
446    /// passes its own SCREAMING_SNAKE name plus `_` as the child's prefix.
447    fn from_env_with_prefix(prefix: &str) -> Result<Self, FromEnvError>;
448}
449
450/// Trait for converting a raw env-var string into a typed value.
451///
452/// Implementations are provided for all [`FromStr`] types via a blanket impl.
453/// You can implement this trait for custom types that need special parsing.
454///
455/// # Example
456///
457/// ```
458/// use dotenv::FromEnvValue;
459///
460/// let n = <u16 as FromEnvValue>::from_env_value("42".into()).unwrap();
461/// assert_eq!(n, 42);
462///
463/// let b = <bool as FromEnvValue>::from_env_value("true".into()).unwrap();
464/// assert!(b);
465///
466/// let err = <u16 as FromEnvValue>::from_env_value("abc".into()).unwrap_err();
467/// assert!(!err.is_empty());
468/// ```
469pub trait FromEnvValue: Sized {
470    /// Convert the raw string value into `Self`.
471    fn from_env_value(s: String) -> Result<Self, String>;
472}
473
474impl<T: FromStr> FromEnvValue for T
475where
476    T::Err: Display,
477{
478    fn from_env_value(s: String) -> Result<Self, String> {
479        s.parse::<T>().map_err(|e| e.to_string())
480    }
481}
482
483/// Auto-dispatch trait for un-attributed `#[derive(FromEnv)]` fields.
484///
485/// For types that implement [`FromEnv`] (nested structs), calls
486/// `from_env_with_prefix`. For leaf types listed in the built-in impls,
487/// reads the env var and parses via [`FromStr`].
488///
489/// You should not need to implement this trait directly.
490pub trait FromEnvAuto: Sized {
491    fn from_env_auto(prefix: &str, var_name: &str) -> Result<Self, FromEnvError>;
492}
493
494impl<T: FromEnv> FromEnvAuto for T {
495    fn from_env_auto(prefix: &str, _var_name: &str) -> Result<Self, FromEnvError> {
496        Self::from_env_with_prefix(prefix)
497    }
498}
499
500macro_rules! impl_from_env_auto_leaf {
501    ($($t:ty),* $(,)?) => {
502        $(impl FromEnvAuto for $t {
503            fn from_env_auto(_prefix: &str, var_name: &str) -> Result<Self, FromEnvError> {
504                let val = ::std::env::var(var_name)
505                    .map_err(|_| FromEnvError::missing(var_name))?;
506                <Self as FromEnvValue>::from_env_value(val.clone())
507                    .map_err(|e| FromEnvError::invalid(var_name, val, e))
508            }
509        })*
510    };
511}
512
513impl_from_env_auto_leaf!(String, bool, u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, f32, f64,);
514
515/// Convenience function to load a `FromEnv` type from environment variables.
516///
517/// Equivalent to `<T as FromEnv>::from_env()` but doesn't require importing
518/// the `FromEnv` trait.
519///
520/// # Example
521///
522/// ```rust,ignore
523/// let config: Config = dotenv::from_env().unwrap();
524/// ```
525pub fn from_env<T: FromEnv>() -> Result<T, FromEnvError> {
526    T::from_env()
527}
528
529// ---------------------------------------------------------------------------
530// Tests
531// ---------------------------------------------------------------------------
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    fn parse_ok(input: &str) -> Vec<(String, String)> {
538        parse(input).unwrap()
539    }
540
541    fn parse_kind(input: &str) -> ParseErrorKind {
542        match parse(input).unwrap_err() {
543            Error::Parse(e) => e.kind,
544            _ => panic!("expected Parse error"),
545        }
546    }
547
548    fn parse_line(input: &str) -> usize {
549        match parse(input).unwrap_err() {
550            Error::Parse(e) => e.line,
551            _ => panic!("expected Parse error"),
552        }
553    }
554
555    // Unsafe helper for tests. Tests are single-threaded.
556    // Only used by the `load_*` tests, which are skipped on wasm32.
557    #[cfg(not(target_arch = "wasm32"))]
558    unsafe fn set_env(k: &str, v: &str) {
559        unsafe { env::set_var(k, v) };
560    }
561
562    #[cfg(not(target_arch = "wasm32"))]
563    unsafe fn remove_env(k: &str) {
564        unsafe { env::remove_var(k) };
565    }
566
567    // ── Basic parsing ──────────────────────────────────────────────────────
568
569    #[test]
570    fn simple_key_value() {
571        assert_eq!(parse_ok("K=v"), vec![("K".into(), "v".into())]);
572    }
573
574    #[test]
575    fn multiple_pairs() {
576        let pairs = parse_ok("A=1\nB=2\nC=3");
577        assert_eq!(
578            pairs,
579            vec![
580                ("A".into(), "1".into()),
581                ("B".into(), "2".into()),
582                ("C".into(), "3".into()),
583            ]
584        );
585    }
586
587    #[test]
588    fn value_with_equals() {
589        assert_eq!(parse_ok("K=a=b=c"), vec![("K".into(), "a=b=c".into())]);
590    }
591
592    #[test]
593    fn key_with_underscore() {
594        assert_eq!(parse_ok("MY_KEY=val"), vec![("MY_KEY".into(), "val".into())]);
595    }
596
597    #[test]
598    fn key_with_dot() {
599        assert_eq!(parse_ok("my.key=val"), vec![("my.key".into(), "val".into())]);
600    }
601
602    #[test]
603    fn key_with_hyphen() {
604        assert_eq!(parse_ok("my-key=val"), vec![("my-key".into(), "val".into())]);
605    }
606
607    #[test]
608    fn key_with_digits() {
609        assert_eq!(parse_ok("KEY123=val"), vec![("KEY123".into(), "val".into())]);
610    }
611
612    #[test]
613    fn key_mixed() {
614        assert_eq!(parse_ok("A1.b-C_2=val"), vec![("A1.b-C_2".into(), "val".into())]);
615    }
616
617    #[test]
618    fn key_starting_with_hyphen() {
619        assert_eq!(parse_ok("-KEY=v"), vec![("-KEY".into(), "v".into())]);
620    }
621
622    #[test]
623    fn key_starting_with_dot() {
624        assert_eq!(parse_ok(".KEY=v"), vec![(".KEY".into(), "v".into())]);
625    }
626
627    #[test]
628    fn key_starting_with_underscore() {
629        assert_eq!(parse_ok("_KEY=v"), vec![("_KEY".into(), "v".into())]);
630    }
631
632    #[test]
633    fn key_only_dots() {
634        assert_eq!(parse_ok("...=value"), vec![("...".into(), "value".into())]);
635    }
636
637    #[test]
638    fn key_only_hyphens() {
639        assert_eq!(parse_ok("---=value"), vec![("---".into(), "value".into())]);
640    }
641
642    // ── Double-quoted values ───────────────────────────────────────────────
643
644    #[test]
645    fn double_quoted_value() {
646        assert_eq!(parse_ok("K=\"hello\""), vec![("K".into(), "hello".into())]);
647    }
648
649    #[test]
650    fn double_quoted_with_spaces() {
651        assert_eq!(parse_ok("K=\"hello world\""), vec![("K".into(), "hello world".into())]);
652    }
653
654    #[test]
655    fn double_quoted_empty() {
656        assert_eq!(parse_ok("K=\"\""), vec![("K".into(), "".into())]);
657    }
658
659    #[test]
660    fn double_quoted_hash_preserved() {
661        assert_eq!(parse_ok("K=\"a#b\""), vec![("K".into(), "a#b".into())]);
662    }
663
664    #[test]
665    fn double_quoted_equals_inside() {
666        assert_eq!(parse_ok("K=\"a=b\""), vec![("K".into(), "a=b".into())]);
667    }
668
669    #[test]
670    fn double_quoted_single_quotes_inside() {
671        assert_eq!(parse_ok("K=\"it's ok\""), vec![("K".into(), "it's ok".into())]);
672    }
673
674    #[test]
675    fn double_quoted_whitespace_preserved() {
676        assert_eq!(parse_ok("K=\" hello \""), vec![("K".into(), " hello ".into())]);
677    }
678
679    #[test]
680    fn double_quoted_trailing_content_error() {
681        assert_eq!(parse_kind("K=\"hello\"extra"), ParseErrorKind::TrailingContent);
682    }
683
684    #[test]
685    fn double_quoted_trailing_comment_allowed() {
686        assert_eq!(parse_ok("K=\"hello\" # comment"), vec![("K".into(), "hello".into())]);
687    }
688
689    // ── Single-quoted values ───────────────────────────────────────────────
690
691    #[test]
692    fn single_quoted_value() {
693        assert_eq!(parse_ok("K='hello'"), vec![("K".into(), "hello".into())]);
694    }
695
696    #[test]
697    fn single_quoted_with_spaces() {
698        assert_eq!(parse_ok("K='hello world'"), vec![("K".into(), "hello world".into())]);
699    }
700
701    #[test]
702    fn single_quoted_empty() {
703        assert_eq!(parse_ok("K=''"), vec![("K".into(), "".into())]);
704    }
705
706    #[test]
707    fn single_quoted_hash_preserved() {
708        assert_eq!(parse_ok("K='a#b'"), vec![("K".into(), "a#b".into())]);
709    }
710
711    #[test]
712    fn single_quoted_double_quotes_inside() {
713        assert_eq!(parse_ok(r#"K='"hello"'"#), vec![("K".into(), r#""hello""#.into())]);
714    }
715
716    #[test]
717    fn single_quoted_whitespace_preserved() {
718        assert_eq!(parse_ok("K=' hello '"), vec![("K".into(), " hello ".into())]);
719    }
720
721    #[test]
722    fn single_quoted_trailing_content_error() {
723        assert_eq!(parse_kind("K='hello'extra"), ParseErrorKind::TrailingContent);
724    }
725
726    #[test]
727    fn single_quoted_trailing_comment_allowed() {
728        assert_eq!(parse_ok("K='hello' # comment"), vec![("K".into(), "hello".into())]);
729    }
730
731    // ── Quoted example from the spec ────────────────────────────────────────
732
733    #[test]
734    fn quoted_nested_example() {
735        assert_eq!(parse_ok("HELLO='\"hello\"'"), vec![("HELLO".into(), "\"hello\"".into())]);
736    }
737
738    // ── Unquoted values ────────────────────────────────────────────────────
739
740    #[test]
741    fn unquoted_hash_is_comment() {
742        assert_eq!(parse_ok("K=val # comment"), vec![("K".into(), "val".into())]);
743    }
744
745    #[test]
746    fn unquoted_hash_no_space_not_comment() {
747        assert_eq!(parse_ok("K=val#comment"), vec![("K".into(), "val#comment".into())]);
748    }
749
750    #[test]
751    fn unquoted_trimmed() {
752        assert_eq!(parse_ok("K=  val  "), vec![("K".into(), "val".into())]);
753    }
754
755    #[test]
756    fn unquoted_trailing_spaces_before_comment() {
757        assert_eq!(parse_ok("K=val   # comment"), vec![("K".into(), "val".into())]);
758    }
759
760    #[test]
761    fn unquoted_value_with_numbers() {
762        assert_eq!(parse_ok("PORT=8080"), vec![("PORT".into(), "8080".into())]);
763    }
764
765    #[test]
766    fn unquoted_value_with_dots() {
767        assert_eq!(parse_ok("HOST=192.168.1.1"), vec![("HOST".into(), "192.168.1.1".into())]);
768    }
769
770    #[test]
771    fn unquoted_value_containing_quote() {
772        assert_eq!(parse_ok("K=hello\"there"), vec![("K".into(), "hello\"there".into())]);
773    }
774
775    #[test]
776    fn unquoted_value_containing_only_hash() {
777        assert_eq!(parse_ok("K=#"), vec![("K".into(), "#".into())]);
778    }
779
780    #[test]
781    fn unquoted_value_hash_without_preceding_space() {
782        assert_eq!(parse_ok("K=val#ue"), vec![("K".into(), "val#ue".into())]);
783    }
784
785    #[test]
786    fn unquoted_hash_with_preceding_space_is_comment() {
787        assert_eq!(parse_ok("K=val #ue"), vec![("K".into(), "val".into())]);
788    }
789
790    // ── Empty values ───────────────────────────────────────────────────────
791
792    #[test]
793    fn empty_value_no_quotes() {
794        assert_eq!(parse_ok("K="), vec![("K".into(), "".into())]);
795    }
796
797    #[test]
798    fn empty_value_trailing_spaces() {
799        assert_eq!(parse_ok("K=   "), vec![("K".into(), "".into())]);
800    }
801
802    #[test]
803    fn empty_value_spaces_before_comment() {
804        assert_eq!(parse_ok("K=   # comment"), vec![("K".into(), "".into())]);
805    }
806
807    #[test]
808    fn empty_double_quoted_value_with_comment() {
809        assert_eq!(parse_ok("K=\"\" # comment"), vec![("K".into(), "".into())]);
810    }
811
812    // ── Whitespace handling ────────────────────────────────────────────────
813
814    #[test]
815    fn leading_whitespace_on_line() {
816        assert_eq!(parse_ok("  K=v"), vec![("K".into(), "v".into())]);
817    }
818
819    #[test]
820    fn trailing_whitespace_before_equals() {
821        assert_eq!(parse_ok("K  =v"), vec![("K".into(), "v".into())]);
822    }
823
824    #[test]
825    fn whitespace_around_equals() {
826        assert_eq!(parse_ok("K = v"), vec![("K".into(), "v".into())]);
827    }
828
829    #[test]
830    fn tabs_as_whitespace() {
831        assert_eq!(parse_ok("\tK\t=\tv"), vec![("K".into(), "v".into())]);
832    }
833
834    #[test]
835    fn tab_after_equals() {
836        assert_eq!(parse_ok("K=\tval"), vec![("K".into(), "val".into())]);
837    }
838
839    #[test]
840    fn double_equals_value() {
841        assert_eq!(parse_ok("K==v"), vec![("K".into(), "=v".into())]);
842    }
843
844    // ── Comments ───────────────────────────────────────────────────────────
845
846    #[test]
847    fn full_line_comment() {
848        assert!(parse_ok("# this is a comment").is_empty());
849    }
850
851    #[test]
852    fn comment_with_leading_spaces() {
853        assert!(parse_ok("  # indented comment").is_empty());
854    }
855
856    #[test]
857    fn empty_lines_skipped() {
858        assert!(parse_ok("\n\n\n").is_empty());
859    }
860
861    #[test]
862    fn mixed_comments_and_values() {
863        let pairs = parse_ok("# header\nA=1\n\nB=2 # inline\n");
864        assert_eq!(pairs, vec![("A".into(), "1".into()), ("B".into(), "2".into())]);
865    }
866
867    // ── Line endings ───────────────────────────────────────────────────────
868
869    #[test]
870    fn unix_line_endings() {
871        assert_eq!(parse_ok("A=1\nB=2"), vec![("A".into(), "1".into()), ("B".into(), "2".into())]);
872    }
873
874    #[test]
875    fn windows_line_endings() {
876        assert_eq!(parse_ok("A=1\r\nB=2"), vec![("A".into(), "1".into()), ("B".into(), "2".into())]);
877    }
878
879    #[test]
880    fn no_trailing_newline() {
881        assert_eq!(parse_ok("A=1"), vec![("A".into(), "1".into())]);
882    }
883
884    #[test]
885    fn single_line_no_newline() {
886        assert_eq!(parse_ok("K=v"), vec![("K".into(), "v".into())]);
887    }
888
889    // ── Edge cases: empty / comment-only files ─────────────────────────────
890
891    #[test]
892    fn empty_file() {
893        assert!(parse_ok("").is_empty());
894    }
895
896    #[test]
897    fn only_comments() {
898        assert!(parse_ok("# a\n# b\n# c").is_empty());
899    }
900
901    #[test]
902    fn only_blank_lines() {
903        assert!(parse_ok("\n\n \n\t\n").is_empty());
904    }
905
906    // ── Error cases ────────────────────────────────────────────────────────
907
908    #[test]
909    fn error_missing_equals() {
910        assert_eq!(parse_kind("INVALID"), ParseErrorKind::MissingEquals);
911    }
912
913    #[test]
914    fn error_missing_equals_with_comment() {
915        assert_eq!(parse_kind("K # comment"), ParseErrorKind::MissingEquals);
916    }
917
918    #[test]
919    fn error_empty_key() {
920        assert_eq!(parse_kind("=value"), ParseErrorKind::EmptyKey);
921    }
922
923    #[test]
924    fn error_empty_key_with_spaces() {
925        assert_eq!(parse_kind("   =value"), ParseErrorKind::EmptyKey);
926    }
927
928    #[test]
929    fn error_unmatched_double_quote() {
930        assert_eq!(parse_kind("K=\"hello"), ParseErrorKind::UnmatchedQuote);
931    }
932
933    #[test]
934    fn error_unmatched_single_quote() {
935        assert_eq!(parse_kind("K='hello"), ParseErrorKind::UnmatchedQuote);
936    }
937
938    #[test]
939    fn error_unmatched_double_quote_with_hash() {
940        assert_eq!(parse_kind("K=\"hello#more"), ParseErrorKind::UnmatchedQuote);
941    }
942
943    #[test]
944    fn error_trailing_content_double_quote() {
945        assert_eq!(parse_kind("K=\"hello\"extra"), ParseErrorKind::TrailingContent);
946    }
947
948    #[test]
949    fn error_trailing_content_single_quote() {
950        assert_eq!(parse_kind("K='hello'extra"), ParseErrorKind::TrailingContent);
951    }
952
953    #[test]
954    fn error_trailing_content_line_number() {
955        assert_eq!(parse_line("A=1\nK=\"v\"x\nB=2"), 2);
956    }
957
958    #[test]
959    fn error_invalid_key_exclamation() {
960        assert_eq!(parse_kind("K!EY=v"), ParseErrorKind::InvalidKey);
961    }
962
963    #[test]
964    fn error_invalid_key_dollar() {
965        assert_eq!(parse_kind("\u{0024}KEY=v"), ParseErrorKind::InvalidKey);
966    }
967
968    #[test]
969    fn error_invalid_key_at() {
970        assert_eq!(parse_kind("KEY@=v"), ParseErrorKind::InvalidKey);
971    }
972
973    #[test]
974    fn error_invalid_key_space() {
975        assert_eq!(parse_kind("K EY=v"), ParseErrorKind::InvalidKey);
976    }
977
978    #[test]
979    fn error_invalid_key_slash() {
980        assert_eq!(parse_kind("KEY/VAL=v"), ParseErrorKind::InvalidKey);
981    }
982
983    #[test]
984    fn error_invalid_key_unicode() {
985        assert_eq!(parse_kind("K\u{00C9}Y=v"), ParseErrorKind::InvalidKey);
986    }
987
988    #[test]
989    fn error_line_number_missing_equals() {
990        assert_eq!(parse_line("A=1\nINVALID\nB=2"), 2);
991    }
992
993    #[test]
994    fn error_line_number_invalid_key() {
995        assert_eq!(parse_line("A=1\n\"$\"BAD=v\nB=2"), 2);
996    }
997
998    #[test]
999    fn error_line_number_unmatched_quote() {
1000        assert_eq!(parse_line("A=1\nK=\"unclosed\nB=2"), 2);
1001    }
1002
1003    // ── Unicode values ─────────────────────────────────────────────────────
1004
1005    #[test]
1006    fn unicode_value_unquoted() {
1007        assert_eq!(parse_ok("K=h\u{00E9}llo"), vec![("K".into(), "h\u{00E9}llo".into())]);
1008    }
1009
1010    #[test]
1011    fn unicode_value_double_quoted() {
1012        assert_eq!(parse_ok("K=\"h\u{00E9}llo\""), vec![("K".into(), "h\u{00E9}llo".into())]);
1013    }
1014
1015    #[test]
1016    fn unicode_value_single_quoted() {
1017        assert_eq!(parse_ok("K='h\u{00E9}llo'"), vec![("K".into(), "h\u{00E9}llo".into())]);
1018    }
1019
1020    // ── `load()` integration tests ─────────────────────────────────────────
1021
1022    // `env::temp_dir()` panics on WASI, so this test cannot run on wasm32.
1023    #[cfg(not(target_arch = "wasm32"))]
1024    #[test]
1025    fn load_sets_vars() {
1026        let dir = env::temp_dir().join(format!("dotenv_test_{}", std::process::id()));
1027        let _ = fs::create_dir_all(&dir);
1028        let env_path = dir.join(".env");
1029        fs::write(&env_path, "DOTENV_TEST_FOO=bar\nDOTENV_TEST_BAZ=qux").unwrap();
1030
1031        let old = env::current_dir().ok();
1032        env::set_current_dir(&dir).unwrap();
1033
1034        let result = load();
1035
1036        if let Some(p) = old {
1037            let _ = env::set_current_dir(p);
1038        }
1039        let _ = fs::remove_file(&env_path);
1040        let _ = fs::remove_dir(&dir);
1041
1042        assert!(result.is_ok());
1043        assert_eq!(env::var("DOTENV_TEST_FOO").unwrap(), "bar");
1044        assert_eq!(env::var("DOTENV_TEST_BAZ").unwrap(), "qux");
1045
1046        unsafe { remove_env("DOTENV_TEST_FOO") };
1047        unsafe { remove_env("DOTENV_TEST_BAZ") };
1048    }
1049
1050    // `env::temp_dir()` panics on WASI, so this test cannot run on wasm32.
1051    #[cfg(not(target_arch = "wasm32"))]
1052    #[test]
1053    fn load_preserves_existing_env_vars() {
1054        unsafe { set_env("DOTENV_EXISTING", "original") };
1055
1056        let dir = env::temp_dir().join(format!("dotenv_test_existing_{}", std::process::id()));
1057        let _ = fs::create_dir_all(&dir);
1058        let env_path = dir.join(".env");
1059        fs::write(&env_path, "DOTENV_EXISTING=from_file").unwrap();
1060
1061        let old = env::current_dir().ok();
1062        env::set_current_dir(&dir).unwrap();
1063
1064        let result = load();
1065
1066        if let Some(p) = old {
1067            let _ = env::set_current_dir(p);
1068        }
1069        let _ = fs::remove_file(&env_path);
1070        let _ = fs::remove_dir(&dir);
1071
1072        assert!(result.is_ok());
1073        assert_eq!(env::var("DOTENV_EXISTING").unwrap(), "original");
1074
1075        unsafe { remove_env("DOTENV_EXISTING") };
1076    }
1077
1078    // `env::temp_dir()` panics on WASI, so this test cannot run on wasm32.
1079    #[cfg(not(target_arch = "wasm32"))]
1080    #[test]
1081    fn load_first_declaration_wins() {
1082        let dir = env::temp_dir().join(format!("dotenv_test_first_{}", std::process::id()));
1083        let _ = fs::create_dir_all(&dir);
1084        let env_path = dir.join(".env");
1085        fs::write(&env_path, "DOTENV_DUP=first\nDOTENV_DUP=second").unwrap();
1086
1087        let old = env::current_dir().ok();
1088        env::set_current_dir(&dir).unwrap();
1089
1090        let result = load();
1091
1092        if let Some(p) = old {
1093            let _ = env::set_current_dir(p);
1094        }
1095        let _ = fs::remove_file(&env_path);
1096        let _ = fs::remove_dir(&dir);
1097
1098        assert!(result.is_ok());
1099        assert_eq!(env::var("DOTENV_DUP").unwrap(), "first");
1100
1101        unsafe { remove_env("DOTENV_DUP") };
1102    }
1103
1104    // `env::temp_dir()` panics on WASI, so this test cannot run on wasm32.
1105    #[cfg(not(target_arch = "wasm32"))]
1106    #[test]
1107    fn load_file_not_found() {
1108        let dir = env::temp_dir().join(format!("dotenv_test_missing_{}", std::process::id()));
1109        let _ = fs::create_dir_all(&dir);
1110
1111        let old = env::current_dir().ok();
1112        env::set_current_dir(&dir).unwrap();
1113
1114        let result = load();
1115
1116        if let Some(p) = old {
1117            let _ = env::set_current_dir(p);
1118        }
1119        let _ = fs::remove_dir(&dir);
1120
1121        match result.unwrap_err() {
1122            Error::Io(_) => {}
1123            _ => panic!("expected Io error"),
1124        }
1125    }
1126
1127    // `env::temp_dir()` panics on WASI, so this test cannot run on wasm32.
1128    #[cfg(not(target_arch = "wasm32"))]
1129    #[test]
1130    fn load_parse_error() {
1131        let dir = env::temp_dir().join(format!("dotenv_test_parse_err_{}", std::process::id()));
1132        let _ = fs::create_dir_all(&dir);
1133        let env_path = dir.join(".env");
1134        fs::write(&env_path, "A=1\nMALFORMED\nB=2").unwrap();
1135
1136        let old = env::current_dir().ok();
1137        env::set_current_dir(&dir).unwrap();
1138
1139        let result = load();
1140
1141        if let Some(p) = old {
1142            let _ = env::set_current_dir(p);
1143        }
1144        let _ = fs::remove_file(&env_path);
1145        let _ = fs::remove_dir(&dir);
1146
1147        match result.unwrap_err() {
1148            Error::Parse(e) => assert_eq!(e.line, 2),
1149            _ => panic!("expected Parse error"),
1150        }
1151    }
1152}