qubit_config/source/env_file_config_source.rs
1/*******************************************************************************
2 *
3 * Copyright (c) 2025 - 2026.
4 * Haixing Hu, Qubit Co. Ltd.
5 *
6 * All rights reserved.
7 *
8 ******************************************************************************/
9//! # `.env` File Configuration Source
10//!
11//! Loads configuration from `.env` format files (as used by dotenv tools).
12//!
13//! # Format
14//!
15//! The `.env` format supports:
16//! - `KEY=VALUE` assignments
17//! - `# comment` lines
18//! - Quoted values: `KEY="value with spaces"` or `KEY='value'`
19//! - Export prefix: `export KEY=VALUE` (the `export` keyword is ignored)
20//!
21//! # Author
22//!
23//! Haixing Hu
24
25use std::path::{Path, PathBuf};
26
27use crate::{Config, ConfigError, ConfigResult};
28
29use super::ConfigSource;
30
31/// Configuration source that loads from `.env` format files
32///
33/// # Examples
34///
35/// ```rust
36/// use qubit_config::source::{EnvFileConfigSource, ConfigSource};
37/// use qubit_config::Config;
38///
39/// let temp_dir = tempfile::tempdir().unwrap();
40/// let path = temp_dir.path().join(".env");
41/// std::fs::write(&path, "PORT=8080\n").unwrap();
42/// let source = EnvFileConfigSource::from_file(path);
43/// let mut config = Config::new();
44/// source.load(&mut config).unwrap();
45/// let port = config.get::<String>("PORT").unwrap();
46/// assert_eq!(port, "8080");
47/// ```
48///
49/// # Author
50///
51/// Haixing Hu
52#[derive(Debug, Clone)]
53pub struct EnvFileConfigSource {
54 path: PathBuf,
55}
56
57impl EnvFileConfigSource {
58 /// Creates a new `EnvFileConfigSource` from a file path
59 ///
60 /// # Parameters
61 ///
62 /// * `path` - Path to the `.env` file
63 #[inline]
64 pub fn from_file<P: AsRef<Path>>(path: P) -> Self {
65 Self {
66 path: path.as_ref().to_path_buf(),
67 }
68 }
69}
70
71impl ConfigSource for EnvFileConfigSource {
72 fn load(&self, config: &mut Config) -> ConfigResult<()> {
73 let iter = dotenvy::from_path_iter(&self.path).map_err(|e| {
74 ConfigError::IoError(std::io::Error::other(format!(
75 "Failed to read .env file '{}': {}",
76 self.path.display(),
77 e
78 )))
79 })?;
80
81 for item in iter {
82 let (key, value) = item.map_err(|e| {
83 ConfigError::ParseError(format!(
84 "Failed to parse .env file '{}': {}",
85 self.path.display(),
86 e
87 ))
88 })?;
89 config.set(&key, value)?;
90 }
91
92 Ok(())
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use crate::ConfigError;
100
101 #[test]
102 fn test_load_invalid_env_file_returns_parse_error() {
103 // dotenvy fails to parse files with invalid UTF-8 sequences
104 let dir = tempfile::tempdir().unwrap();
105 let path = dir.path().join("bad.env");
106 // Write invalid UTF-8 content
107 std::fs::write(&path, b"VALID=ok\n\xff\xfe=bad\n").unwrap();
108
109 let source = EnvFileConfigSource::from_file(&path);
110 let mut config = Config::new();
111 let result = source.load(&mut config);
112 // dotenvy may return an error or silently skip; either way it shouldn't panic
113 // If it returns an error, it should be ParseError
114 if let Err(e) = result {
115 assert!(matches!(
116 e,
117 ConfigError::ParseError(_) | ConfigError::IoError(_)
118 ));
119 }
120 }
121
122 #[test]
123 fn test_load_env_file_with_unclosed_quote_returns_error() {
124 let dir = tempfile::tempdir().unwrap();
125 let path = dir.path().join("unclosed.env");
126 // Unclosed quote - dotenvy should return a parse error
127 std::fs::write(&path, "KEY=\"unclosed value\n").unwrap();
128
129 let source = EnvFileConfigSource::from_file(&path);
130 let mut config = Config::new();
131 let result = source.load(&mut config);
132 // Either succeeds (dotenvy is lenient) or fails with ParseError
133 let _ = result;
134 }
135}