Skip to main content

philiprehberger_dotenv/
lib.rs

1//! Fast `.env` file parser with variable interpolation, multi-file layering, and type-safe loading.
2//!
3//! # Quick Start
4//!
5//! ```no_run
6//! use philiprehberger_dotenv::DotEnv;
7//!
8//! let env = DotEnv::load().expect("failed to load .env");
9//! let port: u16 = env.get_as("PORT").expect("invalid PORT");
10//! let debug: bool = env.get_bool("DEBUG").expect("invalid DEBUG");
11//! ```
12//!
13//! # Features
14//!
15//! - Parse `.env` files with quoted values, comments, and escape sequences
16//! - Variable interpolation using `${VAR_NAME}` syntax
17//! - Multi-file layering with priority ordering
18//! - Type-safe accessors for common types
19//! - Required variable validation
20
21use std::collections::{HashMap, HashSet};
22use std::fs;
23use std::path::Path;
24use std::str::FromStr;
25
26/// Errors that can occur when loading or accessing environment variables.
27#[derive(Debug)]
28pub enum DotEnvError {
29    /// An I/O error occurred while reading a file.
30    Io(std::io::Error),
31    /// A parse error occurred at a specific line.
32    Parse {
33        /// The 1-based line number where the error occurred.
34        line: usize,
35        /// A description of the parse error.
36        message: String,
37    },
38    /// One or more required variables are missing.
39    MissingVars(Vec<String>),
40    /// A value could not be converted to the requested type.
41    TypeConversion {
42        /// The environment variable key.
43        key: String,
44        /// The expected type name.
45        expected: &'static str,
46        /// The actual value that failed conversion.
47        value: String,
48    },
49    /// A variable interpolation error occurred (e.g., circular reference).
50    InterpolationError {
51        /// The key being resolved.
52        key: String,
53        /// The reference that caused the error.
54        references: String,
55    },
56}
57
58impl std::fmt::Display for DotEnvError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            DotEnvError::Io(err) => write!(f, "I/O error: {err}"),
62            DotEnvError::Parse { line, message } => {
63                write!(f, "parse error at line {line}: {message}")
64            }
65            DotEnvError::MissingVars(vars) => {
66                write!(f, "missing required variables: {}", vars.join(", "))
67            }
68            DotEnvError::TypeConversion {
69                key,
70                expected,
71                value,
72            } => {
73                write!(
74                    f,
75                    "cannot convert {key}={value:?} to type {expected}"
76                )
77            }
78            DotEnvError::InterpolationError { key, references } => {
79                write!(
80                    f,
81                    "circular reference resolving {key}: references {references}"
82                )
83            }
84        }
85    }
86}
87
88impl std::error::Error for DotEnvError {
89    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
90        match self {
91            DotEnvError::Io(err) => Some(err),
92            _ => None,
93        }
94    }
95}
96
97impl From<std::io::Error> for DotEnvError {
98    fn from(err: std::io::Error) -> Self {
99        DotEnvError::Io(err)
100    }
101}
102
103/// Parse raw `.env` file content into key-value pairs.
104///
105/// Supports:
106/// - `KEY=value` (unquoted, trims trailing whitespace)
107/// - `KEY="value"` (double-quoted, supports `\n`, `\t`, `\\`, `\"`)
108/// - `KEY='value'` (single-quoted, literal)
109/// - `KEY=` (empty value)
110/// - `export KEY=value` (optional export prefix)
111/// - Comments: lines starting with `#`, inline `#` after unquoted values
112/// - Lines without `=` are silently skipped
113fn parse_env_content(content: &str) -> Result<Vec<(String, String)>, DotEnvError> {
114    let mut pairs = Vec::new();
115
116    for (line_idx, raw_line) in content.lines().enumerate() {
117        let line = raw_line.trim();
118
119        // Skip empty lines and comment lines
120        if line.is_empty() || line.starts_with('#') {
121            continue;
122        }
123
124        // Strip optional `export ` prefix
125        let line = if let Some(rest) = line.strip_prefix("export ") {
126            rest.trim_start()
127        } else {
128            line
129        };
130
131        // Find the `=` separator
132        let eq_pos = match line.find('=') {
133            Some(pos) => pos,
134            None => continue, // skip lines without =
135        };
136
137        let key = line[..eq_pos].trim().to_string();
138        if key.is_empty() {
139            continue;
140        }
141
142        let raw_value = &line[eq_pos + 1..];
143        let value = parse_value(raw_value, line_idx + 1)?;
144
145        pairs.push((key, value));
146    }
147
148    Ok(pairs)
149}
150
151/// Parse a value portion of a KEY=value line.
152fn parse_value(raw: &str, line_number: usize) -> Result<String, DotEnvError> {
153    let trimmed = raw.trim_start();
154
155    if trimmed.is_empty() {
156        return Ok(String::new());
157    }
158
159    if trimmed.starts_with('"') {
160        // Double-quoted value
161        parse_double_quoted(trimmed, line_number)
162    } else if trimmed.starts_with('\'') {
163        // Single-quoted value (literal, no escapes)
164        parse_single_quoted(trimmed, line_number)
165    } else {
166        // Unquoted value — strip inline comments and trailing whitespace
167        let value = if let Some(comment_pos) = find_inline_comment(trimmed) {
168            trimmed[..comment_pos].trim_end()
169        } else {
170            trimmed.trim_end()
171        };
172        Ok(value.to_string())
173    }
174}
175
176/// Find the position of an inline `#` comment in an unquoted value.
177fn find_inline_comment(s: &str) -> Option<usize> {
178    for (i, c) in s.char_indices() {
179        if c == '#' && (i == 0 || s.as_bytes()[i - 1] == b' ') {
180            return Some(i);
181        }
182    }
183    None
184}
185
186/// Parse a double-quoted string, handling escape sequences.
187fn parse_double_quoted(s: &str, line_number: usize) -> Result<String, DotEnvError> {
188    let inner = &s[1..]; // skip opening quote
189    let mut result = String::new();
190    let mut chars = inner.chars();
191
192    loop {
193        match chars.next() {
194            None => {
195                return Err(DotEnvError::Parse {
196                    line: line_number,
197                    message: "unterminated double-quoted string".to_string(),
198                });
199            }
200            Some('"') => {
201                // End of quoted string
202                return Ok(result);
203            }
204            Some('\\') => {
205                match chars.next() {
206                    Some('n') => result.push('\n'),
207                    Some('t') => result.push('\t'),
208                    Some('\\') => result.push('\\'),
209                    Some('"') => result.push('"'),
210                    Some(c) => {
211                        // Unknown escape — keep literal
212                        result.push('\\');
213                        result.push(c);
214                    }
215                    None => {
216                        return Err(DotEnvError::Parse {
217                            line: line_number,
218                            message: "unterminated escape sequence".to_string(),
219                        });
220                    }
221                }
222            }
223            Some(c) => result.push(c),
224        }
225    }
226}
227
228/// Parse a single-quoted string (literal, no escape processing).
229fn parse_single_quoted(s: &str, line_number: usize) -> Result<String, DotEnvError> {
230    let inner = &s[1..]; // skip opening quote
231    match inner.find('\'') {
232        Some(end) => Ok(inner[..end].to_string()),
233        None => Err(DotEnvError::Parse {
234            line: line_number,
235            message: "unterminated single-quoted string".to_string(),
236        }),
237    }
238}
239
240/// Resolve `${VAR_NAME}` interpolation in parsed values.
241///
242/// Looks up references in the parsed vars first, then falls back to `std::env::var`.
243/// Detects circular references.
244fn interpolate(
245    pairs: Vec<(String, String)>,
246) -> Result<Vec<(String, String)>, DotEnvError> {
247    let raw_map: HashMap<String, String> = pairs.iter().cloned().collect();
248    let keys: Vec<String> = pairs.iter().map(|(k, _)| k.clone()).collect();
249    let mut resolved: HashMap<String, String> = HashMap::new();
250
251    for key in &keys {
252        if !resolved.contains_key(key) {
253            resolve_key(key, &raw_map, &mut resolved, &mut HashSet::new())?;
254        }
255    }
256
257    // Preserve insertion order
258    let result: Vec<(String, String)> = pairs
259        .into_iter()
260        .map(|(k, _)| {
261            let v = resolved.get(&k).cloned().unwrap_or_default();
262            (k, v)
263        })
264        .collect();
265
266    Ok(result)
267}
268
269/// Recursively resolve a single key's value, detecting circular references.
270fn resolve_key(
271    key: &str,
272    raw_map: &HashMap<String, String>,
273    resolved: &mut HashMap<String, String>,
274    in_progress: &mut HashSet<String>,
275) -> Result<String, DotEnvError> {
276    if let Some(val) = resolved.get(key) {
277        return Ok(val.clone());
278    }
279
280    if in_progress.contains(key) {
281        return Err(DotEnvError::InterpolationError {
282            key: key.to_string(),
283            references: key.to_string(),
284        });
285    }
286
287    let raw_value = match raw_map.get(key) {
288        Some(v) => v.clone(),
289        None => {
290            // Fall back to process env
291            return Ok(std::env::var(key).unwrap_or_default());
292        }
293    };
294
295    in_progress.insert(key.to_string());
296
297    let result = expand_references(&raw_value, key, raw_map, resolved, in_progress)?;
298
299    in_progress.remove(key);
300    resolved.insert(key.to_string(), result.clone());
301    Ok(result)
302}
303
304/// Expand `${VAR}` references within a string value.
305fn expand_references(
306    value: &str,
307    parent_key: &str,
308    raw_map: &HashMap<String, String>,
309    resolved: &mut HashMap<String, String>,
310    in_progress: &mut HashSet<String>,
311) -> Result<String, DotEnvError> {
312    let mut result = String::new();
313    let mut chars = value.chars().peekable();
314
315    while let Some(c) = chars.next() {
316        if c == '$' && chars.peek() == Some(&'{') {
317            chars.next(); // consume '{'
318            let mut ref_name = String::new();
319            let mut found_close = false;
320            for c2 in chars.by_ref() {
321                if c2 == '}' {
322                    found_close = true;
323                    break;
324                }
325                ref_name.push(c2);
326            }
327            if !found_close {
328                // No closing brace, treat as literal
329                result.push('$');
330                result.push('{');
331                result.push_str(&ref_name);
332                continue;
333            }
334            // Resolve the referenced variable
335            let resolved_val =
336                resolve_key(&ref_name, raw_map, resolved, in_progress).map_err(|_| {
337                    DotEnvError::InterpolationError {
338                        key: parent_key.to_string(),
339                        references: ref_name.clone(),
340                    }
341                })?;
342            result.push_str(&resolved_val);
343        } else {
344            result.push(c);
345        }
346    }
347
348    Ok(result)
349}
350
351/// A loaded set of environment variables parsed from `.env` files.
352///
353/// Use [`DotEnv::load`] to load from the default `.env` file, or
354/// [`DotEnv::load_from`] for a specific path.
355pub struct DotEnv {
356    vars: HashMap<String, String>,
357}
358
359impl DotEnv {
360    /// Load environment variables from a `.env` file in the current directory.
361    ///
362    /// # Errors
363    ///
364    /// Returns a [`DotEnvError`] if the file cannot be read or parsed.
365    pub fn load() -> Result<DotEnv, DotEnvError> {
366        DotEnv::load_from(".env")
367    }
368
369    /// Load environment variables from a specific file path.
370    ///
371    /// # Errors
372    ///
373    /// Returns a [`DotEnvError`] if the file cannot be read or parsed.
374    pub fn load_from(path: impl AsRef<Path>) -> Result<DotEnv, DotEnvError> {
375        let content = fs::read_to_string(path.as_ref())?;
376        DotEnv::from_str(&content)
377    }
378
379    /// Load environment variables from multiple files, with later files overriding earlier ones.
380    ///
381    /// Files that do not exist are silently skipped.
382    ///
383    /// # Errors
384    ///
385    /// Returns a [`DotEnvError`] if any existing file cannot be parsed.
386    pub fn load_layered(paths: &[impl AsRef<Path>]) -> Result<DotEnv, DotEnvError> {
387        let mut all_pairs: Vec<(String, String)> = Vec::new();
388
389        for path in paths {
390            let path = path.as_ref();
391            if !path.exists() {
392                continue;
393            }
394            let content = fs::read_to_string(path)?;
395            let pairs = parse_env_content(&content)?;
396            all_pairs.extend(pairs);
397        }
398
399        let resolved = interpolate(all_pairs)?;
400        let vars: HashMap<String, String> = resolved.into_iter().collect();
401        Ok(DotEnv { vars })
402    }
403
404    /// Create a `DotEnv` from a string of `.env`-formatted content.
405    fn from_str(content: &str) -> Result<DotEnv, DotEnvError> {
406        let pairs = parse_env_content(content)?;
407        let resolved = interpolate(pairs)?;
408        let vars: HashMap<String, String> = resolved.into_iter().collect();
409        Ok(DotEnv { vars })
410    }
411
412    /// Get the raw string value for a key.
413    pub fn get(&self, key: &str) -> Option<&str> {
414        self.vars.get(key).map(|s| s.as_str())
415    }
416
417    /// Get the value for a key, or return a default if the key is not present.
418    pub fn get_or(&self, key: &str, default: &str) -> String {
419        self.vars
420            .get(key)
421            .cloned()
422            .unwrap_or_else(|| default.to_string())
423    }
424
425    /// Get a value parsed as a specific type.
426    ///
427    /// # Errors
428    ///
429    /// Returns [`DotEnvError::MissingVars`] if the key is not found, or
430    /// [`DotEnvError::TypeConversion`] if the value cannot be parsed.
431    pub fn get_as<T: FromStr>(&self, key: &str) -> Result<T, DotEnvError> {
432        let value = self.vars.get(key).ok_or_else(|| {
433            DotEnvError::MissingVars(vec![key.to_string()])
434        })?;
435        value.parse::<T>().map_err(|_| DotEnvError::TypeConversion {
436            key: key.to_string(),
437            expected: std::any::type_name::<T>(),
438            value: value.clone(),
439        })
440    }
441
442    /// Parse a boolean value from common representations.
443    ///
444    /// Accepted values (case-insensitive): `true`, `false`, `1`, `0`, `yes`, `no`.
445    ///
446    /// # Errors
447    ///
448    /// Returns [`DotEnvError::MissingVars`] if the key is not found, or
449    /// [`DotEnvError::TypeConversion`] if the value is not a recognized boolean.
450    pub fn get_bool(&self, key: &str) -> Result<bool, DotEnvError> {
451        let value = self.vars.get(key).ok_or_else(|| {
452            DotEnvError::MissingVars(vec![key.to_string()])
453        })?;
454        match value.to_lowercase().as_str() {
455            "true" | "1" | "yes" => Ok(true),
456            "false" | "0" | "no" => Ok(false),
457            _ => Err(DotEnvError::TypeConversion {
458                key: key.to_string(),
459                expected: "bool",
460                value: value.clone(),
461            }),
462        }
463    }
464
465    /// Split a value by the given separator into a list of strings.
466    ///
467    /// Returns an empty vector if the key is not found.
468    pub fn get_list(&self, key: &str, separator: char) -> Vec<String> {
469        match self.vars.get(key) {
470            Some(value) => value
471                .split(separator)
472                .map(|s| s.trim().to_string())
473                .collect(),
474            None => Vec::new(),
475        }
476    }
477
478    /// Validate that all specified keys are present.
479    ///
480    /// # Errors
481    ///
482    /// Returns [`DotEnvError::MissingVars`] listing all missing keys.
483    pub fn require(&self, keys: &[&str]) -> Result<(), DotEnvError> {
484        let missing: Vec<String> = keys
485            .iter()
486            .filter(|k| !self.vars.contains_key(**k))
487            .map(|k| k.to_string())
488            .collect();
489
490        if missing.is_empty() {
491            Ok(())
492        } else {
493            Err(DotEnvError::MissingVars(missing))
494        }
495    }
496
497    /// Set all loaded variables into the process environment via [`std::env::set_var`].
498    pub fn apply(&self) {
499        for (key, value) in &self.vars {
500            // SAFETY: We are setting env vars in a controlled context.
501            // In production use, callers should ensure this is called
502            // before spawning threads.
503            unsafe {
504                std::env::set_var(key, value);
505            }
506        }
507    }
508
509    /// Return an iterator over all keys.
510    pub fn keys(&self) -> impl Iterator<Item = &str> {
511        self.vars.keys().map(|s| s.as_str())
512    }
513
514    /// Return an iterator over all key-value pairs.
515    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
516        self.vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))
517    }
518}
519
520/// Load environment variables from a `.env` file in the current directory.
521///
522/// Shorthand for [`DotEnv::load`].
523///
524/// # Errors
525///
526/// Returns a [`DotEnvError`] if the file cannot be read or parsed.
527pub fn load() -> Result<DotEnv, DotEnvError> {
528    DotEnv::load()
529}
530
531/// Load environment variables from `.env` and set them into the process environment.
532///
533/// # Errors
534///
535/// Returns a [`DotEnvError`] if the file cannot be read or parsed.
536pub fn load_and_apply() -> Result<(), DotEnvError> {
537    let env = DotEnv::load()?;
538    env.apply();
539    Ok(())
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use std::io::Write;
546
547    fn parse(content: &str) -> DotEnv {
548        DotEnv::from_str(content).expect("failed to parse")
549    }
550
551    #[test]
552    fn test_basic_key_value() {
553        let env = parse("FOO=bar\nBAZ=qux");
554        assert_eq!(env.get("FOO"), Some("bar"));
555        assert_eq!(env.get("BAZ"), Some("qux"));
556    }
557
558    #[test]
559    fn test_double_quoted_value() {
560        let env = parse(r#"GREETING="hello world""#);
561        assert_eq!(env.get("GREETING"), Some("hello world"));
562    }
563
564    #[test]
565    fn test_single_quoted_value() {
566        let env = parse("PATH_VAR='/usr/local/bin'");
567        assert_eq!(env.get("PATH_VAR"), Some("/usr/local/bin"));
568    }
569
570    #[test]
571    fn test_escape_sequences_in_double_quotes() {
572        let env = parse(r#"MSG="line1\nline2\ttab\\slash\"quote""#);
573        assert_eq!(env.get("MSG"), Some("line1\nline2\ttab\\slash\"quote"));
574    }
575
576    #[test]
577    fn test_single_quotes_no_escapes() {
578        let env = parse(r"LITERAL='hello\nworld'");
579        assert_eq!(env.get("LITERAL"), Some(r"hello\nworld"));
580    }
581
582    #[test]
583    fn test_full_line_comment() {
584        let env = parse("# this is a comment\nKEY=value");
585        assert_eq!(env.get("KEY"), Some("value"));
586        assert!(env.vars.len() == 1);
587    }
588
589    #[test]
590    fn test_inline_comment() {
591        let env = parse("KEY=value # this is a comment");
592        assert_eq!(env.get("KEY"), Some("value"));
593    }
594
595    #[test]
596    fn test_export_prefix() {
597        let env = parse("export SECRET=hunter2");
598        assert_eq!(env.get("SECRET"), Some("hunter2"));
599    }
600
601    #[test]
602    fn test_empty_value() {
603        let env = parse("EMPTY=\nALSO_EMPTY=");
604        assert_eq!(env.get("EMPTY"), Some(""));
605        assert_eq!(env.get("ALSO_EMPTY"), Some(""));
606    }
607
608    #[test]
609    fn test_lines_without_equals_skipped() {
610        let env = parse("VALID=yes\nINVALID_LINE\nALSO_VALID=true");
611        assert_eq!(env.vars.len(), 2);
612        assert_eq!(env.get("VALID"), Some("yes"));
613        assert_eq!(env.get("ALSO_VALID"), Some("true"));
614    }
615
616    #[test]
617    fn test_variable_interpolation_simple() {
618        let env = parse("HOST=localhost\nURL=http://${HOST}/api");
619        assert_eq!(env.get("URL"), Some("http://localhost/api"));
620    }
621
622    #[test]
623    fn test_variable_interpolation_nested() {
624        let env = parse("A=hello\nB=${A}_world\nC=${B}!");
625        assert_eq!(env.get("C"), Some("hello_world!"));
626    }
627
628    #[test]
629    fn test_variable_interpolation_fallback_to_env() {
630        // Set a process env var and reference it
631        unsafe { std::env::set_var("DOTENV_TEST_FALLBACK", "from_env"); }
632        let env = parse("REF=${DOTENV_TEST_FALLBACK}");
633        assert_eq!(env.get("REF"), Some("from_env"));
634        unsafe { std::env::remove_var("DOTENV_TEST_FALLBACK"); }
635    }
636
637    #[test]
638    fn test_dollar_without_braces_not_expanded() {
639        let env = parse("VAL=hello\nREF=$VAL");
640        assert_eq!(env.get("REF"), Some("$VAL"));
641    }
642
643    #[test]
644    fn test_circular_reference_detection() {
645        let result = DotEnv::from_str("A=${B}\nB=${A}");
646        assert!(result.is_err());
647        if let Err(DotEnvError::InterpolationError { .. }) = result {
648            // expected
649        } else {
650            panic!("expected InterpolationError");
651        }
652    }
653
654    #[test]
655    fn test_layered_loading() {
656        let dir = std::env::temp_dir().join("dotenv_test_layered");
657        let _ = fs::create_dir_all(&dir);
658
659        let base_path = dir.join("base.env");
660        let override_path = dir.join("override.env");
661
662        let mut f1 = fs::File::create(&base_path).unwrap();
663        writeln!(f1, "A=base_a\nB=base_b").unwrap();
664
665        let mut f2 = fs::File::create(&override_path).unwrap();
666        writeln!(f2, "B=override_b\nC=new_c").unwrap();
667
668        let env = DotEnv::load_layered(&[&base_path, &override_path]).unwrap();
669        assert_eq!(env.get("A"), Some("base_a"));
670        assert_eq!(env.get("B"), Some("override_b"));
671        assert_eq!(env.get("C"), Some("new_c"));
672
673        let _ = fs::remove_dir_all(&dir);
674    }
675
676    #[test]
677    fn test_get_as_u16() {
678        let env = parse("PORT=8080");
679        let port: u16 = env.get_as("PORT").unwrap();
680        assert_eq!(port, 8080);
681    }
682
683    #[test]
684    fn test_get_as_invalid_type() {
685        let env = parse("PORT=not_a_number");
686        let result: Result<u16, _> = env.get_as("PORT");
687        assert!(result.is_err());
688    }
689
690    #[test]
691    fn test_get_bool_variants() {
692        let env = parse("A=true\nB=false\nC=1\nD=0\nE=yes\nF=no\nG=TRUE\nH=Yes");
693        assert!(env.get_bool("A").unwrap());
694        assert!(!env.get_bool("B").unwrap());
695        assert!(env.get_bool("C").unwrap());
696        assert!(!env.get_bool("D").unwrap());
697        assert!(env.get_bool("E").unwrap());
698        assert!(!env.get_bool("F").unwrap());
699        assert!(env.get_bool("G").unwrap());
700        assert!(env.get_bool("H").unwrap());
701    }
702
703    #[test]
704    fn test_get_bool_invalid() {
705        let env = parse("VAL=maybe");
706        assert!(env.get_bool("VAL").is_err());
707    }
708
709    #[test]
710    fn test_require_all_present() {
711        let env = parse("A=1\nB=2\nC=3");
712        assert!(env.require(&["A", "B", "C"]).is_ok());
713    }
714
715    #[test]
716    fn test_require_missing() {
717        let env = parse("A=1");
718        let result = env.require(&["A", "B", "C"]);
719        match result {
720            Err(DotEnvError::MissingVars(vars)) => {
721                assert!(vars.contains(&"B".to_string()));
722                assert!(vars.contains(&"C".to_string()));
723                assert_eq!(vars.len(), 2);
724            }
725            _ => panic!("expected MissingVars error"),
726        }
727    }
728
729    #[test]
730    fn test_get_list() {
731        let env = parse("HOSTS=a,b,c");
732        let list = env.get_list("HOSTS", ',');
733        assert_eq!(list, vec!["a", "b", "c"]);
734    }
735
736    #[test]
737    fn test_get_list_with_spaces() {
738        let env = parse("ITEMS=one , two , three");
739        let list = env.get_list("ITEMS", ',');
740        assert_eq!(list, vec!["one", "two", "three"]);
741    }
742
743    #[test]
744    fn test_get_list_missing_key() {
745        let env = parse("OTHER=val");
746        let list = env.get_list("MISSING", ',');
747        assert!(list.is_empty());
748    }
749
750    #[test]
751    fn test_get_or_default() {
752        let env = parse("A=hello");
753        assert_eq!(env.get_or("A", "default"), "hello");
754        assert_eq!(env.get_or("MISSING", "fallback"), "fallback");
755    }
756
757    #[test]
758    fn test_apply_sets_env_vars() {
759        let env = parse("DOTENV_TEST_APPLY=applied_value");
760        env.apply();
761        assert_eq!(
762            std::env::var("DOTENV_TEST_APPLY").unwrap(),
763            "applied_value"
764        );
765        unsafe { std::env::remove_var("DOTENV_TEST_APPLY"); }
766    }
767
768    #[test]
769    fn test_keys_and_iter() {
770        let env = parse("X=1\nY=2");
771        let mut keys: Vec<&str> = env.keys().collect();
772        keys.sort();
773        assert_eq!(keys, vec!["X", "Y"]);
774
775        let mut pairs: Vec<(&str, &str)> = env.iter().collect();
776        pairs.sort();
777        assert_eq!(pairs, vec![("X", "1"), ("Y", "2")]);
778    }
779
780    #[test]
781    fn test_load_from_file() {
782        let dir = std::env::temp_dir().join("dotenv_test_load_from");
783        let _ = fs::create_dir_all(&dir);
784        let path = dir.join("test.env");
785
786        let mut f = fs::File::create(&path).unwrap();
787        writeln!(f, "LOADED=yes").unwrap();
788
789        let env = DotEnv::load_from(&path).unwrap();
790        assert_eq!(env.get("LOADED"), Some("yes"));
791
792        let _ = fs::remove_dir_all(&dir);
793    }
794
795    #[test]
796    fn test_whitespace_around_key() {
797        let env = parse("  KEY  =value");
798        assert_eq!(env.get("KEY"), Some("value"));
799    }
800
801    #[test]
802    fn test_interpolation_multiple_refs() {
803        let env = parse("HOST=localhost\nPORT=5432\nURL=postgres://${HOST}:${PORT}/db");
804        assert_eq!(env.get("URL"), Some("postgres://localhost:5432/db"));
805    }
806}