dotenv_space/core/
config.rs1use anyhow::{Context, Result};
45use serde::{Deserialize, Serialize};
46use std::fs;
47use std::path::{Path, PathBuf};
48
49#[derive(Debug, Deserialize, Serialize, Clone, Default)]
53pub struct Config {
54 #[serde(default)]
55 pub defaults: Defaults,
56
57 #[serde(default)]
58 pub validate: ValidateConfig,
59
60 #[serde(default)]
61 pub scan: ScanConfig,
62
63 #[serde(default)]
64 pub convert: ConvertConfig,
65
66 #[serde(default)]
67 pub aliases: Aliases,
68}
69
70#[derive(Debug, Deserialize, Serialize, Clone)]
72pub struct Defaults {
73 #[serde(default = "default_env_file")]
74 pub env_file: String,
75
76 #[serde(default = "default_example_file")]
77 pub example_file: String,
78
79 #[serde(default)]
80 pub verbose: bool,
81}
82
83#[derive(Debug, Deserialize, Serialize, Clone)]
85pub struct ValidateConfig {
86 #[serde(default)]
87 pub strict: bool,
88
89 #[serde(default)]
90 pub auto_fix: bool,
91
92 #[serde(default = "default_format")]
93 pub format: String,
94}
95
96#[derive(Debug, Deserialize, Serialize, Clone)]
98pub struct ScanConfig {
99 #[serde(default)]
100 pub ignore_placeholders: bool,
101
102 #[serde(default)]
103 pub exclude_patterns: Vec<String>,
104
105 #[serde(default = "default_format")]
106 pub format: String,
107}
108
109#[derive(Debug, Deserialize, Serialize, Clone)]
111pub struct ConvertConfig {
112 #[serde(default = "default_convert_format")]
113 pub default_format: String,
114
115 #[serde(default)]
116 pub base64: bool,
117
118 pub prefix: Option<String>,
119 pub transform: Option<String>,
120}
121
122#[derive(Debug, Deserialize, Serialize, Clone, Default)]
124pub struct Aliases {
125 #[serde(flatten)]
126 pub formats: std::collections::HashMap<String, String>,
127}
128
129impl Default for Defaults {
132 fn default() -> Self {
133 Self {
134 env_file: default_env_file(),
135 example_file: default_example_file(),
136 verbose: false,
137 }
138 }
139}
140
141impl Default for ValidateConfig {
142 fn default() -> Self {
143 Self {
144 strict: false,
145 auto_fix: false,
146 format: default_format(),
147 }
148 }
149}
150
151impl Default for ScanConfig {
152 fn default() -> Self {
153 Self {
154 ignore_placeholders: true,
155 exclude_patterns: vec![
156 "*.example".to_string(),
157 "*.sample".to_string(),
158 "*.template".to_string(),
159 ],
160 format: default_format(),
161 }
162 }
163}
164
165impl Default for ConvertConfig {
166 fn default() -> Self {
167 Self {
168 default_format: default_convert_format(),
169 base64: false,
170 prefix: None,
171 transform: None,
172 }
173 }
174}
175
176fn default_env_file() -> String {
177 ".env".to_string()
178}
179
180fn default_example_file() -> String {
181 ".env.example".to_string()
182}
183
184fn default_format() -> String {
185 "pretty".to_string()
186}
187
188fn default_convert_format() -> String {
189 "json".to_string()
190}
191
192impl Config {
193 pub fn load() -> Result<Self> {
195 let path = Self::find_config_file()?;
196 Self::load_from_path(&path)
197 }
198
199 pub fn load_from_path(path: &Path) -> Result<Self> {
201 let content = fs::read_to_string(path)
202 .with_context(|| format!("Failed to read config from {}", path.display()))?;
203
204 let config: Config = toml::from_str(&content)
205 .with_context(|| format!("Failed to parse config from {}", path.display()))?;
206
207 Ok(config)
208 }
209
210 pub fn find_config_file() -> Result<PathBuf> {
212 let config_names = [".evnx.toml", "evnx.toml"];
213
214 let mut current_dir = std::env::current_dir()?;
216
217 loop {
218 for name in &config_names {
219 let path = current_dir.join(name);
220 if path.exists() {
221 return Ok(path);
222 }
223 }
224
225 if !current_dir.pop() {
227 break;
228 }
229 }
230
231 if let Some(home) = dirs::home_dir() {
233 for name in &config_names {
234 let path = home.join(name);
235 if path.exists() {
236 return Ok(path);
237 }
238 }
239 }
240
241 Err(anyhow::anyhow!("No config file found"))
243 }
244
245 pub fn save(&self, path: &Path) -> Result<()> {
247 let toml = toml::to_string_pretty(self)?;
248 fs::write(path, toml)?;
249 Ok(())
250 }
251
252 pub fn create_example(path: &Path) -> Result<()> {
254 let example = r#"# evnx configuration file
255# Place this in your project root or home directory
256
257[defaults]
258env_file = ".env"
259example_file = ".env.example"
260verbose = false
261
262[validate]
263strict = false
264auto_fix = false
265format = "pretty" # Options: pretty, json, github-actions
266
267[scan]
268ignore_placeholders = true
269exclude_patterns = ["*.example", "*.sample", "*.template"]
270format = "pretty" # Options: pretty, json, sarif
271
272[convert]
273default_format = "json"
274base64 = false
275# prefix = "APP_"
276# transform = "uppercase"
277
278[aliases]
279# Format aliases for convert command
280gh = "github-actions"
281k8s = "kubernetes"
282tf = "terraform"
283"#;
284 fs::write(path, example)?;
285 Ok(())
286 }
287
288 pub fn merge_with_args(&self, cli_verbose: bool) -> Self {
290 let mut config = self.clone();
291 if cli_verbose {
292 config.defaults.verbose = true;
293 }
294 config
295 }
296
297 pub fn resolve_format_alias(&self, format: &str) -> String {
299 self.aliases
300 .formats
301 .get(format)
302 .cloned()
303 .unwrap_or_else(|| format.to_string())
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use std::io::Write;
311 use tempfile::NamedTempFile;
312
313 #[test]
314 fn test_default_config() {
315 let config = Config::default();
316 assert_eq!(config.defaults.env_file, ".env");
317 assert_eq!(config.defaults.example_file, ".env.example");
318 assert!(!config.defaults.verbose);
319 }
320
321 #[test]
322 fn test_load_from_toml() {
323 let toml = r#"
324[defaults]
325env_file = "custom.env"
326verbose = true
327
328[validate]
329strict = true
330
331[scan]
332ignore_placeholders = false
333
334[aliases]
335gh = "github-actions"
336"#;
337
338 let mut file = NamedTempFile::new().unwrap();
339 file.write_all(toml.as_bytes()).unwrap();
340
341 let config = Config::load_from_path(file.path()).unwrap();
342 assert_eq!(config.defaults.env_file, "custom.env");
343 assert!(config.defaults.verbose);
344 assert!(config.validate.strict);
345 assert!(!config.scan.ignore_placeholders);
346 assert_eq!(
347 config.aliases.formats.get("gh"),
348 Some(&"github-actions".to_string())
349 );
350 }
351
352 #[test]
353 fn test_resolve_alias() {
354 let mut config = Config::default();
355 config
356 .aliases
357 .formats
358 .insert("gh".to_string(), "github-actions".to_string());
359
360 assert_eq!(config.resolve_format_alias("gh"), "github-actions");
361 assert_eq!(config.resolve_format_alias("json"), "json");
362 }
363
364 #[test]
365 fn test_merge_with_args() {
366 let config = Config::default();
367 assert!(!config.defaults.verbose);
368
369 let merged = config.merge_with_args(true);
370 assert!(merged.defaults.verbose);
371 }
372
373 #[test]
374 fn test_create_example() {
375 let file = NamedTempFile::new().unwrap();
376 Config::create_example(file.path()).unwrap();
377
378 let content = fs::read_to_string(file.path()).unwrap();
379 assert!(content.contains("[defaults]"));
380 assert!(content.contains("[validate]"));
381 assert!(content.contains("[scan]"));
382 }
383}