Skip to main content

env_gen/
lib.rs

1//! > **A command line tool for generating an environment variable file.**
2//!
3//! ## Install
4//! ```console
5//! $ cargo install env-gen
6//! ```
7//!
8//! ## Usage
9//! ```console
10//! $ env-gen
11//! ```
12//! This will prompt you to create a new configuration file, optionally using an existing template file, and add key-value pairs for your environment variables. The generated `.env` file will be created in the current working directory.
13//!
14//! The following template files are supported (searched in the current working directory):
15//! - `example.env`
16//! - `.example.env`
17//! - `env.example`
18//! - `.env.example`
19//! - `env.sample`
20//! - `.env.sample`
21//! - `.env-dist`  
22
23use std::collections::HashMap;
24use std::error::Error;
25use std::ffi::OsStr;
26use std::fs::File;
27use std::io::{BufWriter, Write};
28use std::path::{Path, PathBuf};
29
30use chroma_print::{print_info, print_success};
31use dialoguer::{Confirm, Input, Select, console::Style, theme::ColorfulTheme};
32
33const ENV_TEMPLATE_FILES: &[&str] = &[
34    "example.env",
35    ".example.env",
36    "env.example",
37    ".env.example",
38    "env.sample",
39    ".env.sample",
40    ".env-dist",
41];
42
43#[derive(Debug)]
44/// Configuration struct
45pub struct Config {
46    /// The name of the output file (e.g., .env)
47    output_file: String,
48    /// A hash map to store key-value pairs of environment variables
49    vars: HashMap<String, String>,
50}
51
52impl Config {
53    /// Getter for `output_file`
54    pub fn output_file(&self) -> &str {
55        return &self.output_file;
56    }
57
58    /// Sanitizes input by removing newlines, escaping backslashes and quotes, and trimming whitespace
59    fn sanitize(input: &str) -> String {
60        return input
61            .replace(['\n', '\r'], "")
62            .replace('\\', "\\\\")
63            .replace('"', "\\\"")
64            .trim()
65            .to_string();
66    }
67
68    /// Validates that a variable key follows standard ENV naming conventions
69    pub fn is_key_valid(key: &str) -> bool {
70        return !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_');
71    }
72
73    /// Returns a vector of sanitized (key, value) variables
74    pub fn sanitized_vars(&self) -> Vec<(String, String)> {
75        let mut sanitized: Vec<(String, String)> = self
76            .vars
77            .iter()
78            .map(|(k, v)| (Self::sanitize(k), Self::sanitize(v)))
79            .collect();
80
81        sanitized.sort_by(|a, b| a.0.cmp(&b.0));
82
83        return sanitized;
84    }
85
86    /// Initializes the configuration by prompting the user for input and optionally reading from template files
87    fn init_config() -> Result<Option<Config>, Box<dyn Error>> {
88        let theme: ColorfulTheme = ColorfulTheme {
89            values_style: Style::new().yellow().dim(),
90            ..ColorfulTheme::default()
91        };
92        print_success!("Welcome to env-gen! Let's create your configuration file:\n");
93
94        let mut is_creating_new_config_from_template: bool = false;
95        let mut vars: HashMap<String, String> = HashMap::new();
96        let template_files_found: Vec<&str> = ENV_TEMPLATE_FILES
97            .iter()
98            .copied()
99            .filter(|file| Path::new(file).exists())
100            .collect();
101
102        if !template_files_found.is_empty() {
103            print_info!(
104                "Found existing env template file(s): {}",
105                template_files_found.join(", ")
106            );
107            is_creating_new_config_from_template = Confirm::with_theme(&theme)
108            .with_prompt(
109                "Do you want to create a new configuration file using one of the found template file(s) (If No/n, a blank file will be created)?",
110            )
111            .default(false)
112            .interact()?;
113        }
114
115        if is_creating_new_config_from_template {
116            let template_file_index = Select::with_theme(&theme)
117                .with_prompt("Which template file do you want to use?")
118                .default(0)
119                .items(&template_files_found)
120                .interact()?;
121
122            let mut has_invalid_keys: bool = false;
123            let template_file: &str = template_files_found[template_file_index];
124            let contents: String = std::fs::read_to_string(template_file)?;
125            for line in contents.lines() {
126                if let Some((key, value)) = line.split_once('=') {
127                    if Config::is_key_valid(key) {
128                        vars.entry(key.to_string()).or_insert(value.to_string());
129                    } else {
130                        has_invalid_keys = true;
131                        print_info!("Skipping invalid key in template file: {}", key);
132                    }
133                }
134            }
135
136            if has_invalid_keys {
137                print_info!(
138                    "Keys must be non-empty and contain only alphanumeric characters or underscores."
139                );
140            }
141        }
142
143        let output_file: String = Input::with_theme(&theme)
144            .with_prompt("Enter the name of the output file (default: .env):")
145            .default(".env".into())
146            .interact()?;
147
148        let is_adding_vars_to_file: bool = Confirm::with_theme(&theme)
149            .with_prompt("Do you want to add (key/value) variables?")
150            .default(!is_creating_new_config_from_template)
151            .interact()?;
152
153        if is_adding_vars_to_file {
154            loop {
155                let key: String = Input::new().with_prompt("Key").interact_text()?;
156                let value: String = Input::new().with_prompt("Value").interact_text()?;
157
158                if !Config::is_key_valid(&key) {
159                    print_info!(
160                        "Invalid key: {}. Keys must be non-empty and contain only alphanumeric characters or underscores. Please try again.",
161                        key
162                    );
163                    continue;
164                }
165
166                vars.entry(key).or_insert(value);
167
168                let continue_input: bool = Confirm::new()
169                    .with_prompt("Add another?")
170                    .default(true)
171                    .interact()?;
172
173                if !continue_input {
174                    break;
175                }
176            }
177        }
178
179        return Ok(Some(Config { output_file, vars }));
180    }
181
182    /// Main function to run the configuration initialization and create output file
183    pub fn run() -> Result<(), Box<dyn Error>> {
184        match Self::init_config() {
185            Ok(Some(config)) => {
186                let raw_output: &str = config.output_file();
187
188                // Get the filename from the raw output path
189                let file_name: &OsStr = Path::new(raw_output)
190                    .file_name()
191                    .ok_or("Invalid output filename")?;
192                // Anchor the filename to CWD (for sanity)
193                let mut full_path: PathBuf = std::env::current_dir()?;
194                full_path.push(file_name);
195
196                let file: File = File::create(&full_path)?;
197                let mut writer: BufWriter<File> = BufWriter::new(file);
198
199                for (key, value) in config.sanitized_vars() {
200                    writeln!(writer, "{}=\"{}\"", key, value)?;
201                }
202
203                writer.flush()?;
204
205                print_success!(
206                    "\n{} file created successfully!",
207                    file_name.to_string_lossy()
208                );
209
210                return Ok(());
211            }
212            Ok(None) => {
213                print_info!("Aborted. No configuration file created.");
214            }
215            Err(error) => {
216                return Err(error);
217            }
218        }
219
220        return Ok(());
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use std::collections::HashMap;
228
229    #[test]
230    fn test_is_key_valid() {
231        // Valid keys
232        assert!(Config::is_key_valid("DATABASE_URL"));
233        assert!(Config::is_key_valid("PORT8080"));
234        assert!(Config::is_key_valid("secret_key"));
235
236        // Invalid keys
237        assert!(!Config::is_key_valid(""));
238        assert!(!Config::is_key_valid("KEY-WITH-DASH"));
239        assert!(!Config::is_key_valid("KEY!@#"));
240        assert!(!Config::is_key_valid("KEY SPACE"));
241    }
242
243    #[test]
244    fn test_sanitize() {
245        // Newline removal
246        assert_eq!(Config::sanitize("hello\nworld\r"), "helloworld");
247
248        // Escaping quotes and backslashes
249        assert_eq!(
250            Config::sanitize(r#"path\to\"file""#),
251            r#"path\\to\\\"file\""#
252        );
253
254        // Trimming whitespace
255        assert_eq!(Config::sanitize("  clean me  "), "clean me");
256    }
257
258    #[test]
259    fn test_sanitized_vars_sorting_and_logic() {
260        let mut vars: HashMap<String, String> = HashMap::new();
261        vars.insert("KEY_1".to_string(), "value".to_string());
262        vars.insert("KEY_2".to_string(), "value\nwith_newline".to_string());
263
264        let config = Config {
265            output_file: ".env".to_string(),
266            vars,
267        };
268
269        let sanitized: Vec<(String, String)> = config.sanitized_vars();
270
271        // Check sorted
272        assert_eq!(sanitized.len(), 2);
273        assert_eq!(sanitized[0].0, "KEY_1");
274        assert_eq!(sanitized[1].0, "KEY_2");
275
276        // Check if value was sanitized
277        assert_eq!(sanitized[0].1, "value");
278        assert_eq!(sanitized[1].1, "valuewith_newline");
279    }
280
281    #[test]
282    fn test_output_file_getter() {
283        let config = Config {
284            output_file: "test.env".to_string(),
285            vars: HashMap::new(),
286        };
287        assert_eq!(config.output_file(), "test.env");
288    }
289}