1use ahash::AHashMap as HashMap;
2use clap::Args;
3use color_eyre::Result;
4use color_eyre::eyre::Context;
5use color_eyre::eyre::eyre;
6use envx_core::ProjectConfig;
7use std::fmt::Write;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11#[derive(Args)]
12pub struct DocsArgs {
13 #[arg(short, long)]
15 pub output: Option<PathBuf>,
16
17 #[arg(long, default_value = "Environment Variables")]
19 pub title: String,
20
21 #[arg(long)]
23 pub required_only: bool,
24}
25
26pub fn handle_docs(args: DocsArgs) -> Result<()> {
36 let config_path = Path::new(".envx").join("config.yaml");
38
39 if !config_path.exists() {
40 return Err(eyre!(
41 "No .envx/config.yaml found in the current directory.\n\
42 Please run 'envx project init' to initialize a project first."
43 ));
44 }
45
46 let config =
48 ProjectConfig::load(&config_path).context("Failed to load project configuration from .envx/config.yaml")?;
49
50 let markdown = generate_markdown(&config, &args).context("Failed to generate markdown documentation")?;
52
53 if let Some(output_path) = args.output {
55 fs::write(&output_path, markdown)
56 .with_context(|| format!("Failed to write documentation to '{}'", output_path.display()))?;
57 println!("✅ Documentation generated: {}", output_path.display());
58 } else {
59 print!("{markdown}");
60 }
61
62 Ok(())
63}
64
65fn generate_markdown(config: &ProjectConfig, args: &DocsArgs) -> Result<String> {
66 let mut output = String::new();
67
68 writeln!(&mut output, "# {}", args.title)?;
70 writeln!(&mut output)?;
71
72 let mut all_vars: HashMap<String, (String, String, String, bool)> = HashMap::new();
74
75 for req_var in &config.required {
77 all_vars.insert(
78 req_var.name.clone(),
79 (
80 req_var
81 .description
82 .clone()
83 .unwrap_or_else(|| "_No description_".to_string()),
84 req_var
85 .example
86 .clone()
87 .map_or_else(|| "_None_".to_string(), |e| mask_sensitive_value(&req_var.name, &e)),
88 config
89 .defaults
90 .get(&req_var.name)
91 .map_or_else(|| "_None_".to_string(), |d| mask_sensitive_value(&req_var.name, d)),
92 true, ),
94 );
95 }
96
97 for (name, default_value) in &config.defaults {
99 all_vars.entry(name.clone()).or_insert((
100 "_No description_".to_string(),
101 mask_sensitive_value(name, default_value),
102 mask_sensitive_value(name, default_value),
103 false, ));
105 }
106
107 for file_path in &config.auto_load {
109 if let Ok(env_vars) = parse_env_file(file_path) {
110 for (name, value) in env_vars {
111 all_vars.entry(name.clone()).or_insert((
113 "_No description_".to_string(),
114 mask_sensitive_value(&name, &value),
115 "_None_".to_string(),
116 false, ));
118 }
119 }
120 }
121
122 let mut sorted_vars: Vec<(String, String, String, String, bool)> = all_vars
124 .into_iter()
125 .map(|(name, (desc, example, default, is_required))| (name, desc, example, default, is_required))
126 .collect();
127
128 if args.required_only {
130 sorted_vars.retain(|(_, _, _, _, is_required)| *is_required);
131 }
132
133 sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
135
136 writeln!(&mut output, "| Variable | Description | Example | Default |")?;
138 writeln!(&mut output, "|----------|-------------|---------|---------|")?;
139
140 for (name, description, example, default, is_required) in sorted_vars {
141 let var_name = if is_required { format!("**{name}**") } else { name };
142
143 writeln!(
144 &mut output,
145 "| {var_name} | {description} | `{example}` | `{default}` |"
146 )?;
147 }
148
149 Ok(output)
150}
151
152fn parse_env_file(path: &str) -> Result<HashMap<String, String>> {
153 let mut vars = HashMap::new();
154
155 if !Path::new(path).exists() {
156 return Ok(vars);
157 }
158
159 let content = fs::read_to_string(path)?;
160
161 for line in content.lines() {
162 let line = line.trim();
163
164 if line.is_empty() || line.starts_with('#') {
166 continue;
167 }
168
169 if let Some((key, value)) = line.split_once('=') {
171 let key = key.trim();
172 let value = value.trim().trim_matches('"').trim_matches('\'');
173 vars.insert(key.to_string(), value.to_string());
174 }
175 }
176
177 Ok(vars)
178}
179
180fn mask_sensitive_value(name: &str, value: &str) -> String {
181 let sensitive_patterns = [
182 "KEY",
183 "SECRET",
184 "PASSWORD",
185 "TOKEN",
186 "PRIVATE",
187 "CREDENTIAL",
188 "AUTH",
189 "CERT",
190 "CERTIFICATE",
191 ];
192
193 let name_upper = name.to_uppercase();
194 if sensitive_patterns.iter().any(|pattern| name_upper.contains(pattern)) {
195 if value.len() > 4 {
196 format!("{}****", &value[..4])
197 } else {
198 "****".to_string()
199 }
200 } else {
201 value.to_string()
202 }
203}
204
205#[cfg(test)]
208mod tests {
209 use super::*;
210 use ahash::AHashMap as HashMap;
211 use envx_core::{ProjectConfig, RequiredVar, project_config::ValidationRules};
212 use tempfile::TempDir;
213
214 fn create_test_config() -> ProjectConfig {
215 ProjectConfig {
216 name: Some("test-project".to_string()),
217 description: Some("Test project description".to_string()),
218 required: vec![
219 RequiredVar {
220 name: "DATABASE_URL".to_string(),
221 description: Some("PostgreSQL connection string".to_string()),
222 example: Some("postgresql://user:pass@localhost:5432/dbname".to_string()),
223 pattern: None,
224 },
225 RequiredVar {
226 name: "API_KEY".to_string(),
227 description: Some("API key for external service".to_string()),
228 example: Some("sk-1234567890abcdef".to_string()),
229 pattern: None,
230 },
231 RequiredVar {
232 name: "JWT_SECRET".to_string(),
233 description: None,
234 example: None,
235 pattern: None,
236 },
237 ],
238 defaults: HashMap::from([
239 ("NODE_ENV".to_string(), "development".to_string()),
240 ("PORT".to_string(), "3000".to_string()),
241 ("API_KEY".to_string(), "default-api-key".to_string()),
242 ("SECRET_TOKEN".to_string(), "secret123456".to_string()),
243 ]),
244 auto_load: vec![".env".to_string(), ".env.local".to_string()],
245 profile: None,
246 scripts: HashMap::new(),
247 validation: ValidationRules::default(),
248 inherit: true,
249 }
250 }
251
252 #[test]
253 fn test_mask_sensitive_value() {
254 assert_eq!(mask_sensitive_value("API_KEY", "sk-1234567890"), "sk-1****");
256 assert_eq!(mask_sensitive_value("SECRET", "mysecret"), "myse****");
257 assert_eq!(mask_sensitive_value("PASSWORD", "pass123"), "pass****");
258 assert_eq!(mask_sensitive_value("AUTH_TOKEN", "token"), "toke****");
259 assert_eq!(mask_sensitive_value("PRIVATE_KEY", "key"), "****");
260 assert_eq!(mask_sensitive_value("DB_PASSWORD", "dbpass"), "dbpa****");
261 assert_eq!(mask_sensitive_value("CERTIFICATE", "cert123"), "cert****");
262
263 assert_eq!(mask_sensitive_value("PORT", "3000"), "3000");
265 assert_eq!(mask_sensitive_value("NODE_ENV", "production"), "production");
266 assert_eq!(
267 mask_sensitive_value("DATABASE_URL", "postgres://localhost"),
268 "postgres://localhost"
269 );
270
271 assert_eq!(mask_sensitive_value("KEY", ""), "****");
273 assert_eq!(mask_sensitive_value("TOKEN", "abc"), "****");
274 assert_eq!(mask_sensitive_value("MIXED_SECRET_VAR", "value"), "valu****"); }
276
277 #[test]
278 fn test_parse_env_file() {
279 let temp_dir = TempDir::new().unwrap();
280 let env_file = temp_dir.path().join(".env");
281
282 let content = r#"
284# Comment line
285DATABASE_URL=postgres://localhost:5432/mydb
286API_KEY=test-api-key
287PORT=3000
288
289# Another comment
290EMPTY_VALUE=
291QUOTED_VALUE="quoted value"
292SINGLE_QUOTED='single quoted'
293SPACES_AROUND = value with spaces
294 "#;
295 fs::write(&env_file, content).unwrap();
296
297 let result = parse_env_file(env_file.to_str().unwrap()).unwrap();
298
299 assert_eq!(
300 result.get("DATABASE_URL"),
301 Some(&"postgres://localhost:5432/mydb".to_string())
302 );
303 assert_eq!(result.get("API_KEY"), Some(&"test-api-key".to_string()));
304 assert_eq!(result.get("PORT"), Some(&"3000".to_string()));
305 assert_eq!(result.get("EMPTY_VALUE"), Some(&String::new()));
306 assert_eq!(result.get("QUOTED_VALUE"), Some(&"quoted value".to_string()));
307 assert_eq!(result.get("SINGLE_QUOTED"), Some(&"single quoted".to_string()));
308 assert_eq!(result.get("SPACES_AROUND"), Some(&"value with spaces".to_string()));
309
310 assert!(!result.contains_key("# Comment line"));
312 assert!(!result.contains_key("# Another comment"));
313 }
314
315 #[test]
316 fn test_parse_env_file_nonexistent() {
317 let result = parse_env_file("nonexistent.env").unwrap();
318 assert!(result.is_empty());
319 }
320
321 #[test]
322 fn test_parse_env_file_edge_cases() {
323 let temp_dir = TempDir::new().unwrap();
324 let env_file = temp_dir.path().join(".env");
325
326 let content = r"
328# Empty lines and various formats
329KEY1=value1
330
331KEY2 = value2
332KEY3= value3
333KEY4 =value4
334
335# No equals sign
336INVALID_LINE
337
338# Multiple equals signs
339KEY5=value=with=equals
340
341# Unicode
342UNICODE_KEY=值
343KEY_UNICODE=hello世界
344
345# Special characters
346SPECIAL!@#$%^&*()=value
347 ";
348 fs::write(&env_file, content).unwrap();
349
350 let result = parse_env_file(env_file.to_str().unwrap()).unwrap();
351
352 assert_eq!(result.get("KEY1"), Some(&"value1".to_string()));
353 assert_eq!(result.get("KEY2"), Some(&"value2".to_string()));
354 assert_eq!(result.get("KEY3"), Some(&"value3".to_string()));
355 assert_eq!(result.get("KEY4"), Some(&"value4".to_string()));
356 assert_eq!(result.get("KEY5"), Some(&"value=with=equals".to_string()));
357 assert_eq!(result.get("UNICODE_KEY"), Some(&"值".to_string()));
358 assert_eq!(result.get("KEY_UNICODE"), Some(&"hello世界".to_string()));
359 assert_eq!(result.get("SPECIAL!@#$%^&*()"), Some(&"value".to_string()));
360
361 assert!(!result.contains_key("INVALID_LINE"));
363 }
364
365 #[test]
366 fn test_generate_markdown_basic() {
367 let config = create_test_config();
368 let args = DocsArgs {
369 output: None,
370 title: "Test Environment Variables".to_string(),
371 required_only: false,
372 };
373
374 let markdown = generate_markdown(&config, &args).unwrap();
375
376 assert!(markdown.contains("# Test Environment Variables"));
378
379 assert!(markdown.contains("| Variable | Description | Example | Default |"));
381 assert!(markdown.contains("|----------|-------------|---------|---------|"));
382
383 assert!(markdown.contains("| **DATABASE_URL** |"));
385 assert!(markdown.contains("| **API_KEY** |"));
386 assert!(markdown.contains("| **JWT_SECRET** |"));
387
388 assert!(markdown.contains("PostgreSQL connection string"));
390 assert!(markdown.contains("API key for external service"));
391
392 assert!(markdown.contains("`postgresql://user:pass@localhost:5432/dbname`"));
394
395 assert!(markdown.contains("`sk-1****`")); assert!(markdown.contains("`defa****`")); assert!(markdown.contains("`secr****`")); assert!(markdown.contains("| NODE_ENV |"));
402 assert!(markdown.contains("`development`"));
403 assert!(markdown.contains("| PORT |"));
404 assert!(markdown.contains("`3000`"));
405 }
406
407 #[test]
408 fn test_generate_markdown_required_only() {
409 let config = create_test_config();
410 let args = DocsArgs {
411 output: None,
412 title: "Environment Variables".to_string(),
413 required_only: true,
414 };
415
416 let markdown = generate_markdown(&config, &args).unwrap();
417
418 assert!(markdown.contains("**DATABASE_URL**"));
420 assert!(markdown.contains("**API_KEY**"));
421 assert!(markdown.contains("**JWT_SECRET**"));
422
423 assert!(!markdown.contains("| NODE_ENV |"));
425 assert!(!markdown.contains("| PORT |"));
426 assert!(!markdown.contains("| SECRET_TOKEN |"));
427 }
428
429 #[test]
430 fn test_generate_markdown_with_env_files() {
431 let temp_dir = TempDir::new().unwrap();
432 let env_file = temp_dir.path().join(".env");
433
434 let content = r"
436REDIS_URL=redis://localhost:6379
437CACHE_PASSWORD=cachepass123
438LOG_LEVEL=debug
439NEW_VAR=new_value
440 ";
441 fs::write(&env_file, content).unwrap();
442
443 let mut config = create_test_config();
445 config.auto_load = vec![env_file.to_str().unwrap().to_string()];
446
447 let args = DocsArgs {
448 output: None,
449 title: "Environment Variables".to_string(),
450 required_only: false,
451 };
452
453 let markdown = generate_markdown(&config, &args).unwrap();
454
455 assert!(markdown.contains("| REDIS_URL |"));
457 assert!(markdown.contains("`redis://localhost:6379`"));
458
459 assert!(markdown.contains("| CACHE_PASSWORD |"));
461 assert!(markdown.contains("`cach****`")); assert!(markdown.contains("| LOG_LEVEL |"));
465 assert!(markdown.contains("`debug`"));
466 assert!(markdown.contains("| NEW_VAR |"));
467 assert!(markdown.contains("`new_value`"));
468 }
469
470 #[test]
471 fn test_generate_markdown_sorting() {
472 let config = ProjectConfig {
473 name: None,
474 description: None,
475 required: vec![
476 RequiredVar {
477 name: "ZEBRA".to_string(),
478 description: None,
479 example: None,
480 pattern: None,
481 },
482 RequiredVar {
483 name: "APPLE".to_string(),
484 description: None,
485 example: None,
486 pattern: None,
487 },
488 ],
489 defaults: HashMap::from([
490 ("BANANA".to_string(), "yellow".to_string()),
491 ("MANGO".to_string(), "orange".to_string()),
492 ]),
493 auto_load: vec![],
494 profile: None,
495 scripts: HashMap::new(),
496 validation: ValidationRules::default(),
497 inherit: true,
498 };
499
500 let args = DocsArgs {
501 output: None,
502 title: "Test".to_string(),
503 required_only: false,
504 };
505
506 let markdown = generate_markdown(&config, &args).unwrap();
507
508 let lines: Vec<&str> = markdown.lines().collect();
510 let var_lines: Vec<&str> = lines
511 .iter()
512 .filter(|line| line.starts_with("| ") && !line.contains("Variable") && !line.contains("----"))
513 .copied()
514 .collect();
515
516 assert!(var_lines[0].contains("APPLE"));
518 assert!(var_lines[1].contains("BANANA"));
519 assert!(var_lines[2].contains("MANGO"));
520 assert!(var_lines[3].contains("ZEBRA"));
521 }
522
523 fn handle_docs_with_config(args: DocsArgs, config: &ProjectConfig) -> Result<()> {
524 let markdown = generate_markdown(config, &args)?;
526
527 if let Some(output_path) = args.output {
529 fs::write(&output_path, markdown)?;
530 println!("✅ Documentation generated: {}", output_path.display());
531 } else {
532 print!("{markdown}");
533 }
534
535 Ok(())
536 }
537
538 #[test]
539 fn test_handle_docs_stdout() {
540 let config = create_test_config();
541
542 let args = DocsArgs {
543 output: None,
544 title: "Test".to_string(),
545 required_only: false,
546 };
547
548 let result = handle_docs_with_config(args, &config);
550
551 assert!(result.is_ok());
552 }
553
554 #[test]
555 fn test_handle_docs_file_output() {
556 let temp_dir = TempDir::new().unwrap();
557 let output_file = temp_dir.path().join("output.md");
558
559 let config = create_test_config();
560
561 let args = DocsArgs {
562 output: Some(output_file.clone()),
563 title: "Test Output".to_string(),
564 required_only: false,
565 };
566
567 let result = handle_docs_with_config(args, &config);
568
569 assert!(result.is_ok());
570 assert!(output_file.exists());
571
572 let content = fs::read_to_string(&output_file).unwrap();
573 assert!(content.contains("# Test Output"));
574 assert!(content.contains("**API_KEY**"));
575 assert!(content.contains("PORT"));
576 }
577
578 #[test]
579 fn test_markdown_content_structure() {
580 let config = create_test_config();
581 let args = DocsArgs {
582 output: None,
583 title: "My Variables".to_string(),
584 required_only: false,
585 };
586
587 let markdown = generate_markdown(&config, &args).unwrap();
588 let lines: Vec<&str> = markdown.lines().collect();
589
590 assert_eq!(lines[0], "# My Variables");
592 assert_eq!(lines[1], "");
593 assert_eq!(lines[2], "| Variable | Description | Example | Default |");
594 assert_eq!(lines[3], "|----------|-------------|---------|---------|");
595
596 let table_rows = lines.iter().skip(4).filter(|line| line.starts_with('|')).count();
598
599 assert!(table_rows >= 4); }
602}