1use 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)]
44pub struct Config {
46 output_file: String,
48 vars: HashMap<String, String>,
50}
51
52impl Config {
53 pub fn output_file(&self) -> &str {
55 return &self.output_file;
56 }
57
58 fn sanitize(input: &str) -> String {
60 return input
61 .replace(['\n', '\r'], "")
62 .replace('\\', "\\\\")
63 .replace('"', "\\\"")
64 .trim()
65 .to_string();
66 }
67
68 pub fn is_key_valid(key: &str) -> bool {
70 return !key.is_empty() && key.chars().all(|c| c.is_alphanumeric() || c == '_');
71 }
72
73 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 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 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 let file_name: &OsStr = Path::new(raw_output)
190 .file_name()
191 .ok_or("Invalid output filename")?;
192 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 assert!(Config::is_key_valid("DATABASE_URL"));
233 assert!(Config::is_key_valid("PORT8080"));
234 assert!(Config::is_key_valid("secret_key"));
235
236 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 assert_eq!(Config::sanitize("hello\nworld\r"), "helloworld");
247
248 assert_eq!(
250 Config::sanitize(r#"path\to\"file""#),
251 r#"path\\to\\\"file\""#
252 );
253
254 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 assert_eq!(sanitized.len(), 2);
273 assert_eq!(sanitized[0].0, "KEY_1");
274 assert_eq!(sanitized[1].0, "KEY_2");
275
276 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}