1use 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#[derive(Debug, Deserialize, Serialize, Clone)]
19pub struct Config {
20 pub output: Option<String>,
22 pub merge_existing: Option<bool>,
24 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
38pub struct EnvScanner {
40 patterns: AhoCorasick,
41 extract_regex: Regex,
42 config: Config,
43}
44
45impl EnvScanner {
46 pub fn new() -> Result<Self> {
48 Self::with_config(Config::default())
49 }
50
51 pub fn with_config(config: Config) -> Result<Self> {
53 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 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 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 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 for line in content.lines() {
94 let trimmed_line = line.trim();
95
96 if trimmed_line.starts_with("//") || trimmed_line.is_empty() {
98 continue;
99 }
100
101 if let Some(comment_pos) = line.find("//") {
103 let before_comment = &line[..comment_pos];
104 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 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 if self.patterns.is_match(&line) {
124 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 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 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 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 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 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 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 let all_variables = Mutex::new(HashSet::new());
211
212 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 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 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 for var in variables {
270 existing_vars.entry(var.clone()).or_insert_with(String::new);
271 }
272
273 let mut sorted_vars: Vec<_> = existing_vars.iter().collect();
275 sorted_vars.sort_by(|a, b| a.0.cmp(b.0));
276
277 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
303pub fn generate_env_file<P: AsRef<Path>>(path: P) -> Result<()> {
305 generate_env_file_with_config(path, Config::default())
306}
307
308pub 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
320pub 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
331pub 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 let existing_env = "EXISTING_VAR=existing_value\nDATABASE_URL=\n";
411 fs::write(temp_dir.path().join(".env"), existing_env)?;
412
413 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 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_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_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_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}