Skip to main content

nestforge_config/
lib.rs

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}