envx_cli/
docs.rs

1use clap::Args;
2use color_eyre::Result;
3use color_eyre::eyre::Context;
4use color_eyre::eyre::eyre;
5use envx_core::ProjectConfig;
6use std::collections::HashMap;
7use std::fmt::Write;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11#[derive(Args)]
12pub struct DocsArgs {
13    /// Output file path (outputs to stdout if not specified)
14    #[arg(short, long)]
15    pub output: Option<PathBuf>,
16
17    /// Custom title for the documentation
18    #[arg(long, default_value = "Environment Variables")]
19    pub title: String,
20
21    /// Include only required variables
22    #[arg(long)]
23    pub required_only: bool,
24}
25
26/// Handles the documentation generation command.
27///
28/// # Errors
29///
30/// Returns an error if:
31/// - The .envx/config.yaml file does not exist
32/// - The project configuration cannot be loaded
33/// - The output file cannot be written to
34/// - Markdown generation fails
35pub fn handle_docs(args: DocsArgs) -> Result<()> {
36    // Check if .envx/config.yaml exists
37    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    // Load project configuration
47    let config =
48        ProjectConfig::load(&config_path).context("Failed to load project configuration from .envx/config.yaml")?;
49
50    // Generate markdown documentation
51    let markdown = generate_markdown(&config, &args).context("Failed to generate markdown documentation")?;
52
53    // Output to file or stdout
54    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    // Title
69    writeln!(&mut output, "# {}", args.title)?;
70    writeln!(&mut output)?;
71
72    // Collect all variables
73    let mut all_vars: HashMap<String, (String, String, String, bool)> = HashMap::new();
74
75    // 1. Add required variables from config
76    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, // is_required
93            ),
94        );
95    }
96
97    // 2. Add defaults from config (that aren't already in required)
98    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, // is_required
104        ));
105    }
106
107    // 3. Parse auto-loaded .env files to find more variables
108    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                // Only add if not already documented
112                all_vars.entry(name.clone()).or_insert((
113                    "_No description_".to_string(),
114                    mask_sensitive_value(&name, &value),
115                    "_None_".to_string(),
116                    false, // is_required
117                ));
118            }
119        }
120    }
121
122    // Convert to sorted vec for output
123    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    // Filter if required_only
129    if args.required_only {
130        sorted_vars.retain(|(_, _, _, _, is_required)| *is_required);
131    }
132
133    // Sort by name
134    sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
135
136    // Generate table
137    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        // Skip empty lines and comments
165        if line.is_empty() || line.starts_with('#') {
166            continue;
167        }
168
169        // Parse KEY=VALUE format
170        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// Add this at the end of the file
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use envx_core::{ProjectConfig, RequiredVar, project_config::ValidationRules};
211    use std::collections::HashMap;
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        // Test sensitive patterns
255        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        // Test non-sensitive values
264        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        // Test edge cases
272        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****"); // # spellchecker:disable-line
275    }
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        // Create test .env file
283        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        // Comments should not be parsed
311        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        // Test edge cases
327        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        // Invalid line should not be parsed
362        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        // Check title
377        assert!(markdown.contains("# Test Environment Variables"));
378
379        // Check table header
380        assert!(markdown.contains("| Variable | Description | Example | Default |"));
381        assert!(markdown.contains("|----------|-------------|---------|---------|"));
382
383        // Check required variables are bold
384        assert!(markdown.contains("| **DATABASE_URL** |"));
385        assert!(markdown.contains("| **API_KEY** |"));
386        assert!(markdown.contains("| **JWT_SECRET** |"));
387
388        // Check descriptions
389        assert!(markdown.contains("PostgreSQL connection string"));
390        assert!(markdown.contains("API key for external service"));
391
392        // Check examples
393        assert!(markdown.contains("`postgresql://user:pass@localhost:5432/dbname`"));
394
395        // Check sensitive values are masked
396        assert!(markdown.contains("`sk-1****`")); // API_KEY example
397        assert!(markdown.contains("`defa****`")); // API_KEY default
398        assert!(markdown.contains("`secr****`")); // SECRET_TOKEN
399
400        // Check non-sensitive defaults
401        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        // Should contain required variables
419        assert!(markdown.contains("**DATABASE_URL**"));
420        assert!(markdown.contains("**API_KEY**"));
421        assert!(markdown.contains("**JWT_SECRET**"));
422
423        // Should NOT contain optional variables
424        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        // Create .env file with additional variables
435        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        // Create config with auto_load pointing to our test file
444        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        // Should include variables from .env file
456        assert!(markdown.contains("| REDIS_URL |"));
457        assert!(markdown.contains("`redis://localhost:6379`"));
458
459        // Password should be masked
460        assert!(markdown.contains("| CACHE_PASSWORD |"));
461        assert!(markdown.contains("`cach****`")); // # spellchecker:disable-line
462
463        // Regular variables should not be masked
464        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        // Extract variable names from markdown to check order
509        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        // Variables should be in alphabetical order
517        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        // Generate markdown documentation
525        let markdown = generate_markdown(config, &args)?;
526
527        // Output to file or stdout
528        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        // Use the test helper function that doesn't load from disk
549        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        // Check structure
591        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        // Count table rows (excluding header and separator)
597        let table_rows = lines.iter().skip(4).filter(|line| line.starts_with('|')).count();
598
599        // Should have rows for all required vars + defaults
600        assert!(table_rows >= 4); // At least API_KEY, DATABASE_URL, JWT_SECRET, NODE_ENV, PORT, SECRET_TOKEN
601    }
602}