Skip to main content

dotenv_space/core/
config.rs

1//! Config file support for .evnx.toml
2//!
3//! # Overview
4//!
5//! Provides configuration file support allowing users to set defaults and preferences
6//! that persist across command invocations. Configuration files are searched in:
7//!
8//! 1. Current directory (`.evnx.toml` or `evnx.toml`)
9//! 2. Parent directories (recursively up to root)
10//! 3. Home directory (`~/.evnx.toml` or `~/evnx.toml`)
11//!
12//! # Configuration Sections
13//!
14//! - **defaults** - Default file paths and behavior
15//! - **validate** - Validation command defaults
16//! - **scan** - Secret scanning defaults
17//! - **convert** - Format conversion defaults
18//! - **aliases** - Custom format aliases
19//!
20//! # Example Configuration
21//!
22//! ```toml
23//! [defaults]
24//! env_file = ".env"
25//! example_file = ".env.example"
26//! verbose = false
27//!
28//! [validate]
29//! strict = true
30//! format = "pretty"
31//!
32//! [scan]
33//! ignore_placeholders = true
34//! exclude_patterns = ["*.example", "*.template"]
35//!
36//! [convert]
37//! default_format = "json"
38//!
39//! [aliases]
40//! gh = "github-actions"
41//! k8s = "kubernetes"
42//! ```
43
44use anyhow::{Context, Result};
45use serde::{Deserialize, Serialize};
46use std::fs;
47use std::path::{Path, PathBuf};
48
49/// Main configuration struct
50///
51/// ✅ CLIPPY FIX: Uses `#[derive(Default)]` instead of manual implementation
52#[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/// Default settings for file paths and general behavior
71#[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/// Configuration for validation command
84#[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/// Configuration for secret scanning command
97#[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/// Configuration for format conversion command
110#[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/// Custom format aliases for the convert command
123#[derive(Debug, Deserialize, Serialize, Clone, Default)]
124pub struct Aliases {
125    #[serde(flatten)]
126    pub formats: std::collections::HashMap<String, String>,
127}
128
129// ✅ CLIPPY FIX: Removed manual impl Default for Config (using #[derive(Default)])
130
131impl 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    /// Load config from file
194    pub fn load() -> Result<Self> {
195        let path = Self::find_config_file()?;
196        Self::load_from_path(&path)
197    }
198
199    /// Load config from specific path
200    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    /// Find config file by searching up the directory tree
211    pub fn find_config_file() -> Result<PathBuf> {
212        let config_names = [".evnx.toml", "evnx.toml"];
213
214        // Start from current directory
215        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            // Move up one directory
226            if !current_dir.pop() {
227                break;
228            }
229        }
230
231        // Check home directory
232        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        // Return default config if no file found
242        Err(anyhow::anyhow!("No config file found"))
243    }
244
245    /// Save config to file
246    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    /// Create example config file
253    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    /// Merge with CLI arguments (CLI args take precedence)
289    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    /// Resolve format alias
298    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}