1use anyhow::{Context, Result};
11use colored::*;
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15
16use crate::core::{Parser, ParserConfig};
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct ValidationResult {
20 pub status: String,
21 pub required_present: usize,
22 pub required_total: usize,
23 pub issues: Vec<Issue>,
24 pub summary: Summary,
25}
26
27#[derive(Debug, Serialize, Deserialize, Clone)]
28pub struct Issue {
29 pub severity: String,
30 #[serde(rename = "type")]
31 pub issue_type: String,
32 pub variable: String,
33 pub message: String,
34 pub location: String,
35 pub suggestion: Option<String>,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
39pub struct Summary {
40 pub errors: usize,
41 pub warnings: usize,
42 pub style: usize,
43}
44
45pub fn run(
46 env: String,
47 example: String,
48 strict: bool,
49 fix: bool,
50 format: String,
51 exit_zero: bool,
52 verbose: bool,
53) -> Result<()> {
54 if verbose {
55 println!("{}", "Running validate in verbose mode".dimmed());
56 }
57
58 if format == "pretty" {
59 println!(
60 "\n{}",
61 "┌─ Validating environment variables ──────────────────┐".cyan()
62 );
63 println!(
64 "{}",
65 "│ Comparing .env against .env.example │".cyan()
66 );
67 println!(
68 "{}\n",
69 "└──────────────────────────────────────────────────────┘".cyan()
70 );
71 }
72
73 let mut parser_config = ParserConfig::default();
74 if strict {
75 parser_config.strict = true;
76 }
77 let parser = Parser::new(parser_config);
78
79 let example_file = parser
80 .parse_file(&example)
81 .with_context(|| format!("Failed to parse {}", example))?;
82
83 let env_file = parser
84 .parse_file(&env)
85 .with_context(|| format!("Failed to parse {}", env))?;
86
87 if verbose {
88 println!(
89 "Parsed {} variables from {}",
90 example_file.vars.len(),
91 example
92 );
93 println!("Parsed {} variables from {}", env_file.vars.len(), env);
94 }
95
96 let mut issues = Vec::new();
97
98 let example_keys: HashSet<_> = example_file.vars.keys().collect();
100 let env_keys: HashSet<_> = env_file.vars.keys().collect();
101
102 let missing: Vec<_> = example_keys.difference(&env_keys).collect();
103 for key in &missing {
104 issues.push(Issue {
105 severity: "error".to_string(),
106 issue_type: "missing_variable".to_string(),
107 variable: key.to_string(),
108 message: format!("Missing required variable: {}", key),
109 location: format!("{}:?", env),
110 suggestion: Some(format!("Add {}=<value> to {}", key, env)),
111 });
112 }
113
114 if strict {
116 let extra: Vec<_> = env_keys.difference(&example_keys).collect();
117 for key in &extra {
118 issues.push(Issue {
119 severity: "warning".to_string(),
120 issue_type: "extra_variable".to_string(),
121 variable: key.to_string(),
122 message: format!("Extra variable not in .env.example: {}", key),
123 location: format!("{}:?", env),
124 suggestion: Some(format!("Add {} to {} or remove from {}", key, example, env)),
125 });
126 }
127 }
128
129 for (key, value) in &env_file.vars {
131 if is_placeholder(value) {
132 let suggestion = match key.as_str() {
133 "SECRET_KEY" => Some("Run: openssl rand -hex 32".to_string()),
134 k if k.contains("AWS") => Some("Get from AWS Console".to_string()),
135 k if k.contains("STRIPE") => Some("Get from Stripe Dashboard".to_string()),
136 _ => None,
137 };
138
139 issues.push(Issue {
140 severity: "error".to_string(),
141 issue_type: "placeholder_value".to_string(),
142 variable: key.clone(),
143 message: format!("{} looks like a placeholder", key),
144 location: format!("{}:?", env),
145 suggestion,
146 });
147 }
148 }
149
150 for (key, value) in &env_file.vars {
152 if value == "False" || value == "True" {
153 issues.push(Issue {
154 severity: "warning".to_string(),
155 issue_type: "boolean_trap".to_string(),
156 variable: key.clone(),
157 message: format!("{} is set to \"{}\" (string)", key, value),
158 location: format!("{}:?", env),
159 suggestion: Some(format!(
160 "This is truthy in Python — use {} or 0 instead",
161 if value == "False" { "False" } else { "True" }
162 )),
163 });
164 }
165 }
166
167 if let Some(secret_key) = env_file.vars.get("SECRET_KEY") {
169 if is_weak_secret_key(secret_key) {
170 issues.push(Issue {
171 severity: "error".to_string(),
172 issue_type: "weak_secret".to_string(),
173 variable: "SECRET_KEY".to_string(),
174 message: "SECRET_KEY is too weak".to_string(),
175 location: format!("{}:?", env),
176 suggestion: Some("Run: openssl rand -hex 32".to_string()),
177 });
178 }
179 }
180
181 let has_docker = Path::new("docker-compose.yml").exists()
183 || Path::new("docker-compose.yaml").exists()
184 || Path::new("Dockerfile").exists();
185
186 if has_docker {
187 for (key, value) in &env_file.vars {
188 if value.contains("localhost") && (key.contains("URL") || key.contains("HOST")) {
189 issues.push(Issue {
190 severity: "warning".to_string(),
191 issue_type: "localhost_in_docker".to_string(),
192 variable: key.clone(),
193 message: format!("{} uses localhost", key),
194 location: format!("{}:?", env),
195 suggestion: Some(
196 "In Docker, use service name instead (e.g., db:5432)".to_string(),
197 ),
198 });
199 }
200 }
201 }
202
203 let errors = issues.iter().filter(|i| i.severity == "error").count();
205 let warnings = issues.iter().filter(|i| i.severity == "warning").count();
206 let style = issues.iter().filter(|i| i.severity == "style").count();
207
208 let result = ValidationResult {
209 status: if errors > 0 {
210 "failed".to_string()
211 } else {
212 "passed".to_string()
213 },
214 required_present: env_file.vars.len().min(example_file.vars.len()),
215 required_total: example_file.vars.len(),
216 issues,
217 summary: Summary {
218 errors,
219 warnings,
220 style,
221 },
222 };
223
224 match format.as_str() {
226 "json" => output_json(&result)?,
227 "github-actions" => output_github_actions(&result)?,
228 _ => output_pretty(&result, &env_file.vars, &example_file.vars)?,
229 }
230
231 if fix && result.summary.errors > 0 {
233 println!("\n{} Auto-fix is not yet implemented", "ℹ️".cyan());
234 println!(" This will be added in a future version");
235 }
236
237 if !exit_zero && result.summary.errors > 0 {
239 std::process::exit(1);
240 }
241
242 Ok(())
243}
244
245fn output_pretty(
246 result: &ValidationResult,
247 _env_vars: &HashMap<String, String>,
248 _example_vars: &HashMap<String, String>,
249) -> Result<()> {
250 if result.issues.is_empty() {
251 println!(
252 "{} All required variables present ({}/{})",
253 "✓".green(),
254 result.required_present,
255 result.required_total
256 );
257 println!("{} No issues found", "✓".green());
258 return Ok(());
259 }
260
261 if result.required_present == result.required_total {
262 println!(
263 "{} All required variables present ({}/{})",
264 "✓".green(),
265 result.required_present,
266 result.required_total
267 );
268 } else {
269 println!(
270 "{} Missing {} required variables",
271 "✗".red(),
272 result.required_total - result.required_present
273 );
274 }
275
276 println!("{} Found {} issues\n", "✗".red(), result.issues.len());
277
278 println!("{}", "Issues:".bold());
279 for (i, issue) in result.issues.iter().enumerate() {
280 let icon = match issue.severity.as_str() {
281 "error" => "🚨",
282 "warning" => "⚠️ ",
283 _ => "ℹ️ ",
284 };
285
286 println!(" {}. {} {}", i + 1, icon, issue.message);
287 if let Some(suggestion) = &issue.suggestion {
288 println!(" → {}", suggestion.dimmed());
289 }
290 println!(" Location: {}", issue.location.dimmed());
291 }
292
293 println!("\n{}", "Summary:".bold());
294 if result.summary.errors > 0 {
295 println!(" 🚨 {} critical issues", result.summary.errors);
296 }
297 if result.summary.warnings > 0 {
298 println!(" ⚠️ {} warnings", result.summary.warnings);
299 }
300 if result.summary.errors == 0 && result.summary.warnings == 0 {
301 println!(" {} 0 issues found", "✓".green());
302 }
303
304 Ok(())
305}
306
307fn output_json(result: &ValidationResult) -> Result<()> {
308 let json = serde_json::to_string_pretty(result)?;
309 println!("{}", json);
310 Ok(())
311}
312
313fn output_github_actions(result: &ValidationResult) -> Result<()> {
314 for issue in &result.issues {
315 let level = match issue.severity.as_str() {
316 "error" => "error",
317 "warning" => "warning",
318 _ => "notice",
319 };
320
321 let location = issue.location.replace(":?", "");
322
323 println!("::{} file={},line=1::{}", level, location, issue.message);
324
325 if let Some(suggestion) = &issue.suggestion {
326 println!(
327 "::{} file={},line=1::Suggestion: {}",
328 level, location, suggestion
329 );
330 }
331 }
332 Ok(())
333}
334
335fn is_placeholder(value: &str) -> bool {
336 let lower = value.to_lowercase();
337
338 let placeholders = [
339 "your_key_here",
340 "your_secret_here",
341 "your_token_here",
342 "change_me",
343 "changeme",
344 "replace_me",
345 "example",
346 "xxx",
347 "todo",
348 "generate-with",
349 ];
350
351 placeholders.iter().any(|p| lower.contains(p))
352}
353
354fn is_weak_secret_key(key: &str) -> bool {
355 if key.len() < 32 {
357 return true;
358 }
359
360 let weak = [
362 "secret", "password", "dev", "test", "1234", "abcd", "changeme",
363 ];
364
365 let lower = key.to_lowercase();
366 weak.iter().any(|w| lower.contains(w))
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn test_is_placeholder() {
375 assert!(is_placeholder("YOUR_KEY_HERE"));
376 assert!(is_placeholder("generate-with-openssl"));
377 assert!(!is_placeholder("sk_live_51Hrealkey"));
378 }
379
380 #[test]
381 fn test_is_weak_secret_key() {
382 assert!(is_weak_secret_key("short"));
383 assert!(is_weak_secret_key("this-is-a-test-secret-key-do-not-use"));
384 assert!(is_weak_secret_key("devkeysecretpassword1234567890"));
385 assert!(!is_weak_secret_key(
386 "a7b9c4d1e8f2g5h3i6j0k9l8m7n6o5p4q3r2s1t0u9v8w7x6y5z4"
387 ));
388 }
389}