1use std::{collections::HashMap, env, fs, path::Path};
2
3use thiserror::Error;
4
5#[derive(Debug, Error)]
6pub enum ConfigError {
7 #[error("Failed to read env file `{path}`: {source}")]
8 ReadEnvFile {
9 path: String,
10 #[source]
11 source: std::io::Error,
12 },
13 #[error("Missing required config key: {key}")]
14 MissingKey { key: String },
15 #[error("Environment validation failed")]
16 Validation {
17 issues: Vec<EnvValidationIssue>,
18 },
19}
20
21#[derive(Clone, Debug)]
22pub struct EnvValidationIssue {
23 pub key: String,
24 pub message: String,
25}
26
27#[derive(Clone, Debug)]
28pub struct ConfigOptions {
29 pub env_file_path: String,
30 pub include_process_env: bool,
31 pub schema: Option<EnvSchema>,
32}
33
34impl Default for ConfigOptions {
35 fn default() -> Self {
36 Self {
37 env_file_path: ".env".to_string(),
38 include_process_env: true,
39 schema: None,
40 }
41 }
42}
43
44impl ConfigOptions {
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 pub fn env_file(mut self, path: impl Into<String>) -> Self {
50 self.env_file_path = path.into();
51 self
52 }
53
54 pub fn without_process_env(mut self) -> Self {
55 self.include_process_env = false;
56 self
57 }
58
59 pub fn validate_with(mut self, schema: EnvSchema) -> Self {
60 self.schema = Some(schema);
61 self
62 }
63}
64
65#[derive(Clone, Debug, Default)]
66pub struct EnvSchema {
67 rules: HashMap<String, Vec<EnvRule>>,
68}
69
70#[derive(Clone, Debug)]
71enum EnvRule {
72 Required,
73 MinLen(usize),
74 OneOf(Vec<String>),
75}
76
77impl EnvSchema {
78 pub fn new() -> Self {
79 Self::default()
80 }
81
82 pub fn required(mut self, key: impl Into<String>) -> Self {
83 self.rules
84 .entry(key.into())
85 .or_default()
86 .push(EnvRule::Required);
87 self
88 }
89
90 pub fn min_len(mut self, key: impl Into<String>, min: usize) -> Self {
91 self.rules
92 .entry(key.into())
93 .or_default()
94 .push(EnvRule::MinLen(min));
95 self
96 }
97
98 pub fn one_of(mut self, key: impl Into<String>, values: &[&str]) -> Self {
99 self.rules.entry(key.into()).or_default().push(EnvRule::OneOf(
100 values.iter().map(|v| (*v).to_string()).collect(),
101 ));
102 self
103 }
104
105 fn validate(&self, env: &EnvStore) -> Result<(), ConfigError> {
106 let mut issues = Vec::new();
107
108 for (key, rules) in &self.rules {
109 let value = env.get(key);
110 for rule in rules {
111 match rule {
112 EnvRule::Required => {
113 if value.map(|v| v.trim().is_empty()).unwrap_or(true) {
114 issues.push(EnvValidationIssue {
115 key: key.clone(),
116 message: format!("{} is required", key),
117 });
118 }
119 }
120 EnvRule::MinLen(min) => {
121 if let Some(v) = value {
122 if v.len() < *min {
123 issues.push(EnvValidationIssue {
124 key: key.clone(),
125 message: format!("{} must be at least {} chars", key, min),
126 });
127 }
128 }
129 }
130 EnvRule::OneOf(allowed) => {
131 if let Some(v) = value {
132 if !allowed.iter().any(|entry| entry == v) {
133 issues.push(EnvValidationIssue {
134 key: key.clone(),
135 message: format!(
136 "{} must be one of [{}]",
137 key,
138 allowed.join(", ")
139 ),
140 });
141 }
142 }
143 }
144 }
145 }
146 }
147
148 if issues.is_empty() {
149 Ok(())
150 } else {
151 Err(ConfigError::Validation { issues })
152 }
153 }
154}
155
156#[derive(Clone, Debug, Default)]
157pub struct EnvStore {
158 values: HashMap<String, String>,
159}
160
161impl EnvStore {
162 pub fn load() -> Result<Self, ConfigError> {
163 Self::load_with_options(&ConfigOptions::default())
164 }
165
166 pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
167 Self::load_with_options(&ConfigOptions::new().env_file(path.as_ref().display().to_string()))
168 }
169
170 pub fn load_with_options(options: &ConfigOptions) -> Result<Self, ConfigError> {
171 let path_ref = Path::new(&options.env_file_path);
172 let mut values = if options.include_process_env {
173 env::vars().collect::<HashMap<_, _>>()
174 } else {
175 HashMap::new()
176 };
177
178 if path_ref.exists() {
179 let content =
180 fs::read_to_string(path_ref).map_err(|source| ConfigError::ReadEnvFile {
181 path: path_ref.display().to_string(),
182 source,
183 })?;
184 for line in content.lines() {
185 let trimmed = line.trim();
186 if trimmed.is_empty() || trimmed.starts_with('#') {
187 continue;
188 }
189 if let Some((key, value)) = trimmed.split_once('=') {
190 values.entry(key.trim().to_string()).or_insert_with(|| {
191 value
192 .trim()
193 .trim_matches('"')
194 .trim_matches('\'')
195 .to_string()
196 });
197 }
198 }
199 }
200
201 Ok(Self { values })
202 }
203
204 pub fn from_pairs(pairs: impl IntoIterator<Item = (String, String)>) -> Self {
205 Self {
206 values: pairs.into_iter().collect(),
207 }
208 }
209
210 pub fn get(&self, key: &str) -> Option<&str> {
211 self.values.get(key).map(String::as_str)
212 }
213
214 pub fn require(&self, key: &str) -> Result<&str, ConfigError> {
215 self.get(key).ok_or_else(|| ConfigError::MissingKey {
216 key: key.to_string(),
217 })
218 }
219}
220
221pub trait FromEnv: Sized {
222 fn from_env(env: &EnvStore) -> Result<Self, ConfigError>;
223}
224
225pub fn load_config<T: FromEnv>() -> Result<T, ConfigError> {
226 let env = EnvStore::load()?;
227 T::from_env(&env)
228}
229
230pub struct ConfigModule;
231
232impl ConfigModule {
233 pub fn for_root<T: FromEnv>(options: ConfigOptions) -> Result<T, ConfigError> {
234 let env = EnvStore::load_with_options(&options)?;
235 if let Some(schema) = &options.schema {
236 schema.validate(&env)?;
237 }
238 T::from_env(&env)
239 }
240
241 pub fn env(options: ConfigOptions) -> Result<EnvStore, ConfigError> {
242 EnvStore::load_with_options(&options)
243 }
244}