Skip to main content

auto_env_generator/
lib.rs

1//! Auto Environment Generator Library
2//!
3//! A fast Rust library for scanning .rs files to detect environment variable usage
4//! and generating .env files with parallel processing and efficient pattern matching.
5
6use aho_corasick::AhoCorasick;
7use anyhow::{Context, Result};
8use rayon::prelude::*;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use std::collections::HashSet;
12use std::fs::{self, File};
13use std::io::Write;
14use std::path::{Path, PathBuf};
15use std::sync::Mutex;
16
17/// Configuration for the environment generator
18#[derive(Debug, Deserialize, Serialize, Clone)]
19pub struct Config {
20    /// Name of the output file (default: ".env")
21    pub output: Option<String>,
22    /// Whether to merge with existing file without overwriting values
23    pub merge_existing: Option<bool>,
24    /// List of variable names to ignore
25    pub ignore: Option<Vec<String>>,
26}
27
28impl Default for Config {
29    fn default() -> Self {
30        Self {
31            output: Some(".env".to_string()),
32            merge_existing: Some(true),
33            ignore: Some(vec![]),
34        }
35    }
36}
37
38/// Environment variable scanner with efficient pattern matching
39pub struct EnvScanner {
40    patterns: AhoCorasick,
41    extract_regex: Regex,
42    config: Config,
43}
44
45impl EnvScanner {
46    /// Create a new scanner with default configuration
47    pub fn new() -> Result<Self> {
48        Self::with_config(Config::default())
49    }
50
51    /// Create a scanner with custom configuration
52    pub fn with_config(config: Config) -> Result<Self> {
53        // Patterns to search for environment variable calls
54        let patterns = vec![
55            "std::env::var(",
56            "env::var(",
57            "dotenv::var(",
58            "std::env::var_os(",
59            "env::var_os(",
60            "dotenv::var_os(",
61        ];
62
63        let ac = AhoCorasick::new(patterns).context("Failed to create Aho-Corasick automaton")?;
64
65        // Regex to extract string literals from env var calls (more strict)
66        let extract_regex = Regex::new(
67            r#"(?:std::env::var|env::var|dotenv::var)(?:_os)?\s*\(\s*"([^"\n\r]*)"\s*\)"#,
68        )
69        .context("Failed to compile extraction regex")?;
70
71        Ok(Self {
72            patterns: ac,
73            extract_regex,
74            config,
75        })
76    }
77
78    /// Load configuration from a TOML file
79    pub fn load_config<P: AsRef<Path>>(config_path: P) -> Result<Config> {
80        let content = fs::read_to_string(config_path).context("Failed to read config file")?;
81        let config: Config = toml::from_str(&content).context("Failed to parse TOML config")?;
82        Ok(config)
83    }
84
85    /// Scan a single file for environment variable usage
86    fn scan_file<P: AsRef<Path>>(&self, path: P) -> Result<HashSet<String>> {
87        let content = fs::read_to_string(&path)
88            .with_context(|| format!("Failed to read file: {:?}", path.as_ref()))?;
89
90        let mut variables = HashSet::new();
91
92        // Process the entire file content to handle multiline cases
93        for line in content.lines() {
94            let trimmed_line = line.trim();
95
96            // Skip comments and empty lines
97            if trimmed_line.starts_with("//") || trimmed_line.is_empty() {
98                continue;
99            }
100
101            // Check if this is inside a string literal (basic check)
102            if let Some(comment_pos) = line.find("//") {
103                let before_comment = &line[..comment_pos];
104                // Only process the part before the comment
105                if self.patterns.is_match(before_comment) {
106                    for cap in self.extract_regex.captures_iter(before_comment) {
107                        if let Some(var_name) = cap.get(1) {
108                            let var_name = var_name.as_str().to_string();
109
110                            // Check if variable should be ignored
111                            if let Some(ignore_list) = &self.config.ignore {
112                                if !ignore_list.contains(&var_name) {
113                                    variables.insert(var_name);
114                                }
115                            } else {
116                                variables.insert(var_name);
117                            }
118                        }
119                    }
120                }
121            } else {
122                // Fast pattern search using Aho-Corasick
123                if self.patterns.is_match(&line) {
124                    // Extract variable names using regex
125                    for cap in self.extract_regex.captures_iter(&line) {
126                        if let Some(var_name) = cap.get(1) {
127                            let var_name = var_name.as_str().to_string();
128
129                            // Check if variable should be ignored
130                            if let Some(ignore_list) = &self.config.ignore {
131                                if !ignore_list.contains(&var_name) {
132                                    variables.insert(var_name);
133                                }
134                            } else {
135                                variables.insert(var_name);
136                            }
137                        }
138                    }
139                }
140            }
141        }
142
143        // Handle multiline patterns by normalizing whitespace
144        let normalized_content = content
145            .lines()
146            .map(|line| line.trim())
147            .filter(|line| !line.starts_with("//") && !line.is_empty())
148            .collect::<Vec<_>>()
149            .join(" ");
150
151        if self.patterns.is_match(&normalized_content) {
152            for cap in self.extract_regex.captures_iter(&normalized_content) {
153                if let Some(var_name) = cap.get(1) {
154                    let var_name = var_name.as_str().to_string();
155
156                    // Check if variable should be ignored
157                    if let Some(ignore_list) = &self.config.ignore {
158                        if !ignore_list.contains(&var_name) {
159                            variables.insert(var_name);
160                        }
161                    } else {
162                        variables.insert(var_name);
163                    }
164                }
165            }
166        }
167
168        Ok(variables)
169    }
170
171    /// Find all .rs files in a directory recursively
172    fn find_rust_files<P: AsRef<Path>>(&self, dir: P) -> Result<Vec<PathBuf>> {
173        let mut rust_files = Vec::new();
174
175        fn walk_dir(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
176            for entry in fs::read_dir(dir)? {
177                let entry = entry?;
178                let path = entry.path();
179
180                if path.is_dir() {
181                    // Skip target and hidden directories
182                    if let Some(name) = path.file_name() {
183                        if let Some(name_str) = name.to_str() {
184                            if name_str.starts_with('.') || name_str == "target" {
185                                continue;
186                            }
187                        }
188                    }
189                    walk_dir(&path, files)?;
190                } else if path.extension().map_or(false, |ext| ext == "rs") {
191                    files.push(path);
192                }
193            }
194            Ok(())
195        }
196
197        walk_dir(dir.as_ref(), &mut rust_files)?;
198        Ok(rust_files)
199    }
200
201    /// Scan all .rs files in parallel and collect environment variables
202    pub fn scan_directory<P: AsRef<Path>>(&self, dir: P) -> Result<HashSet<String>> {
203        let rust_files = self.find_rust_files(dir)?;
204
205        if rust_files.is_empty() {
206            return Ok(HashSet::new());
207        }
208
209        // Use Mutex to safely collect results from parallel threads
210        let all_variables = Mutex::new(HashSet::new());
211
212        // Parallel processing of files
213        rust_files.par_iter().try_for_each(|file| -> Result<()> {
214            let variables = self.scan_file(file)?;
215
216            if !variables.is_empty() {
217                let mut all_vars = all_variables.lock().unwrap();
218                all_vars.extend(variables);
219            }
220
221            Ok(())
222        })?;
223
224        Ok(all_variables.into_inner().unwrap())
225    }
226
227    /// Read existing .env file and return variables as HashMap
228    fn read_existing_env<P: AsRef<Path>>(
229        &self,
230        path: P,
231    ) -> Result<std::collections::HashMap<String, String>> {
232        let mut existing = std::collections::HashMap::new();
233
234        if path.as_ref().exists() {
235            let content = fs::read_to_string(path)?;
236            for line in content.lines() {
237                let line = line.trim();
238                if line.is_empty() || line.starts_with('#') {
239                    continue;
240                }
241
242                if let Some(eq_pos) = line.find('=') {
243                    let key = line[..eq_pos].trim().to_string();
244                    let value = line[eq_pos + 1..].trim().to_string();
245                    existing.insert(key, value);
246                }
247            }
248        }
249
250        Ok(existing)
251    }
252
253    /// Generate .env file with detected variables
254    pub fn generate_env_file<P: AsRef<Path>>(
255        &self,
256        variables: &HashSet<String>,
257        output_path: P,
258    ) -> Result<()> {
259        let output_path = output_path.as_ref();
260        let merge_existing = self.config.merge_existing.unwrap_or(true);
261
262        let mut existing_vars = if merge_existing {
263            self.read_existing_env(output_path)?
264        } else {
265            std::collections::HashMap::new()
266        };
267
268        // Add new variables with empty values if they don't exist
269        for var in variables {
270            existing_vars.entry(var.clone()).or_insert_with(String::new);
271        }
272
273        // Sort variables for consistent output
274        let mut sorted_vars: Vec<_> = existing_vars.iter().collect();
275        sorted_vars.sort_by(|a, b| a.0.cmp(b.0));
276
277        // Write to file
278        let mut file = File::create(output_path)
279            .with_context(|| format!("Failed to create file: {:?}", output_path))?;
280
281        writeln!(file, "# Auto-generated environment variables")?;
282        writeln!(file, "# Add your values below")?;
283        writeln!(file)?;
284
285        for (key, value) in sorted_vars {
286            if value.is_empty() {
287                writeln!(file, "{}=", key)?;
288            } else {
289                writeln!(file, "{}={}", key, value)?;
290            }
291        }
292
293        Ok(())
294    }
295}
296
297impl Default for EnvScanner {
298    fn default() -> Self {
299        Self::new().expect("Failed to create default EnvScanner")
300    }
301}
302
303/// Main API function for programmatic use
304pub fn generate_env_file<P: AsRef<Path>>(path: P) -> Result<()> {
305    generate_env_file_with_config(path, Config::default())
306}
307
308/// Generate .env file with custom configuration
309pub fn generate_env_file_with_config<P: AsRef<Path>>(path: P, config: Config) -> Result<()> {
310    let scanner = EnvScanner::with_config(config.clone())?;
311    let variables = scanner.scan_directory(&path)?;
312
313    let output_file = config.output.unwrap_or_else(|| ".env".to_string());
314    let output_path = path.as_ref().join(output_file);
315
316    scanner.generate_env_file(&variables, output_path)?;
317    Ok(())
318}
319
320/// Generate .env file with custom output path
321pub fn generate_env_file_to<P: AsRef<Path>, O: AsRef<Path>>(
322    scan_path: P,
323    output_path: O,
324) -> Result<()> {
325    let scanner = EnvScanner::new()?;
326    let variables = scanner.scan_directory(scan_path)?;
327    scanner.generate_env_file(&variables, output_path)?;
328    Ok(())
329}
330
331/// Scan directory and return found environment variables
332pub fn scan_for_env_vars<P: AsRef<Path>>(path: P) -> Result<HashSet<String>> {
333    let scanner = EnvScanner::new()?;
334    scanner.scan_directory(path)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use std::fs;
341    use tempfile::TempDir;
342
343    fn create_test_file(dir: &Path, name: &str, content: &str) -> Result<()> {
344        let file_path = dir.join(name);
345        if let Some(parent) = file_path.parent() {
346            fs::create_dir_all(parent)?;
347        }
348        fs::write(file_path, content)?;
349        Ok(())
350    }
351
352    #[test]
353    fn test_scan_single_file() -> Result<()> {
354        let temp_dir = TempDir::new()?;
355        let content = r#"
356use std::env;
357
358fn main() {
359    let db_url = std::env::var("DATABASE_URL").unwrap();
360    let api_key = env::var("API_KEY").unwrap();
361    let debug = dotenv::var("DEBUG_MODE").unwrap_or_default();
362}
363"#;
364
365        create_test_file(temp_dir.path(), "main.rs", content)?;
366
367        let scanner = EnvScanner::new()?;
368        let variables = scanner.scan_file(temp_dir.path().join("main.rs"))?;
369
370        assert_eq!(variables.len(), 3);
371        assert!(variables.contains("DATABASE_URL"));
372        assert!(variables.contains("API_KEY"));
373        assert!(variables.contains("DEBUG_MODE"));
374
375        Ok(())
376    }
377
378    #[test]
379    fn test_ignore_variables() -> Result<()> {
380        let temp_dir = TempDir::new()?;
381        let content = r#"
382fn main() {
383    let db_url = std::env::var("DATABASE_URL").unwrap();
384    let api_key = std::env::var("API_KEY").unwrap();
385}
386"#;
387
388        create_test_file(temp_dir.path(), "main.rs", content)?;
389
390        let config = Config {
391            ignore: Some(vec!["API_KEY".to_string()]),
392            ..Default::default()
393        };
394
395        let scanner = EnvScanner::with_config(config)?;
396        let variables = scanner.scan_directory(temp_dir.path())?;
397
398        assert_eq!(variables.len(), 1);
399        assert!(variables.contains("DATABASE_URL"));
400        assert!(!variables.contains("API_KEY"));
401
402        Ok(())
403    }
404
405    #[test]
406    fn test_merge_existing_env() -> Result<()> {
407        let temp_dir = TempDir::new()?;
408
409        // Create existing .env file
410        let existing_env = "EXISTING_VAR=existing_value\nDATABASE_URL=\n";
411        fs::write(temp_dir.path().join(".env"), existing_env)?;
412
413        // Create Rust file with new variable
414        let content = r#"
415fn main() {
416    let db_url = std::env::var("DATABASE_URL").unwrap();
417    let new_var = std::env::var("NEW_VARIABLE").unwrap();
418}
419"#;
420        create_test_file(temp_dir.path(), "main.rs", content)?;
421
422        let scanner = EnvScanner::new()?;
423        let variables = scanner.scan_directory(temp_dir.path())?;
424        scanner.generate_env_file(&variables, temp_dir.path().join(".env"))?;
425
426        let result = fs::read_to_string(temp_dir.path().join(".env"))?;
427
428        // Should contain existing value and new empty variable
429        assert!(result.contains("EXISTING_VAR=existing_value"));
430        assert!(result.contains("NEW_VARIABLE="));
431        assert!(result.contains("DATABASE_URL="));
432
433        Ok(())
434    }
435
436    #[test]
437    fn test_parallel_scanning() -> Result<()> {
438        let temp_dir = TempDir::new()?;
439
440        // Create multiple files in different directories
441        create_test_file(
442            temp_dir.path(),
443            "src/main.rs",
444            r#"
445fn main() {
446    let var1 = std::env::var("VAR_1").unwrap();
447}
448"#,
449        )?;
450
451        create_test_file(
452            temp_dir.path(),
453            "src/lib.rs",
454            r#"
455pub fn test() {
456    let var2 = env::var("VAR_2").unwrap();
457}
458"#,
459        )?;
460
461        create_test_file(
462            temp_dir.path(),
463            "tests/integration.rs",
464            r#"
465#[test]
466fn test_something() {
467    let var3 = dotenv::var("VAR_3").unwrap();
468}
469"#,
470        )?;
471
472        let scanner = EnvScanner::new()?;
473        let variables = scanner.scan_directory(temp_dir.path())?;
474
475        assert_eq!(variables.len(), 3);
476        assert!(variables.contains("VAR_1"));
477        assert!(variables.contains("VAR_2"));
478        assert!(variables.contains("VAR_3"));
479
480        Ok(())
481    }
482
483    #[test]
484    fn test_skip_target_directory() -> Result<()> {
485        let temp_dir = TempDir::new()?;
486
487        // Create file in target directory (should be ignored)
488        create_test_file(
489            temp_dir.path(),
490            "target/debug/build/main.rs",
491            r#"
492fn main() {
493    let var1 = std::env::var("SHOULD_BE_IGNORED").unwrap();
494}
495"#,
496        )?;
497
498        // Create normal file
499        create_test_file(
500            temp_dir.path(),
501            "src/main.rs",
502            r#"
503fn main() {
504    let var2 = std::env::var("SHOULD_BE_FOUND").unwrap();
505}
506"#,
507        )?;
508
509        let scanner = EnvScanner::new()?;
510        let variables = scanner.scan_directory(temp_dir.path())?;
511
512        assert_eq!(variables.len(), 1);
513        assert!(variables.contains("SHOULD_BE_FOUND"));
514        assert!(!variables.contains("SHOULD_BE_IGNORED"));
515
516        Ok(())
517    }
518}