1#![allow(clippy::format_push_string)]
2use color_eyre::Result;
3use color_eyre::eyre::eyre;
4use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme};
5use std::{
6 fs,
7 path::{Path, PathBuf},
8};
9
10use ahash::AHashMap as HashMap;
11use colored::Colorize;
12use glob::glob;
13use serde::{Deserialize, Serialize};
14
15use crate::{ProfileManager, ProjectConfig, RequiredVar, ValidationRules as ConfigValidationRules};
16
17#[derive(Debug)]
19struct EscPressed;
20
21impl std::fmt::Display for EscPressed {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 write!(f, "User pressed ESC")
24 }
25}
26
27impl std::error::Error for EscPressed {}
28
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct WizardConfig {
31 pub skip_system_check: bool,
32 pub auto_detect_project: bool,
33 pub default_profiles: Vec<String>,
34 pub template_path: Option<PathBuf>,
35 pub selected_vars: Vec<SelectedVariable>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SelectedVariable {
40 pub name: String,
41 pub value: String,
42 pub description: String,
43 pub required: bool,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ProjectType {
48 pub name: String,
49 pub category: ProjectCategory,
50 pub suggested_vars: Vec<SuggestedVariable>,
51 pub suggested_profiles: Vec<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub enum ProjectCategory {
56 WebApp,
57 Python,
58 Rust,
59 Go,
60 NextJs,
61 Docker,
62 Microservices,
63 Custom,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct SuggestedVariable {
68 pub name: String,
69 pub description: String,
70 pub example: String,
71 pub required: bool,
72 pub sensitive: bool,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct TeamConfig {
77 pub config_path: PathBuf,
78 pub git_hooks: bool,
79 pub ci_integration: bool,
80 pub shared_profiles: bool,
81}
82
83#[allow(clippy::struct_excessive_bools)]
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ValidationRules {
86 pub require_all_defined: bool,
87 pub validate_urls: bool,
88 pub validate_numbers: bool,
89 pub warn_missing: bool,
90 pub strict_mode: bool,
91 pub custom_patterns: HashMap<String, String>,
92}
93
94#[allow(clippy::struct_excessive_bools)]
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Integrations {
97 pub shell_aliases: bool,
98 pub auto_completion: bool,
99 pub vscode_extension: bool,
100 pub git_hooks: bool,
101 pub docker_integration: bool,
102}
103
104#[derive(Debug, Clone)]
105pub struct SystemInfo {
106 pub os: String,
107 pub shell: String,
108 pub terminal: String,
109 pub home_dir: PathBuf,
110 pub config_dir: PathBuf,
111}
112
113impl SystemInfo {
114 pub fn detect() -> Result<Self> {
122 let os = if cfg!(windows) {
123 "Windows".to_string()
124 } else if cfg!(target_os = "macos") {
125 "macOS".to_string()
126 } else {
127 "Linux".to_string()
128 };
129
130 let shell = std::env::var("SHELL").unwrap_or_else(|_| {
131 if cfg!(windows) {
132 "PowerShell".to_string()
133 } else {
134 "bash".to_string()
135 }
136 });
137
138 let terminal = std::env::var("TERM_PROGRAM")
139 .or_else(|_| std::env::var("TERMINAL"))
140 .unwrap_or_else(|_| "Unknown".to_string());
141
142 let home_dir = dirs::home_dir().ok_or_else(|| eyre!("Could not find home directory"))?;
143 let config_dir = dirs::config_dir().ok_or_else(|| eyre!("Could not find config directory"))?;
144
145 Ok(Self {
146 os,
147 shell,
148 terminal,
149 home_dir,
150 config_dir,
151 })
152 }
153}
154
155#[derive(Default)]
156pub struct SetupWizard {
157 theme: ColorfulTheme,
158 config: WizardConfig,
159}
160
161#[derive(Debug, Clone)]
162pub struct SetupResult {
163 pub project_type: ProjectType,
164 pub profiles: Vec<String>,
165 pub profile_configs: HashMap<String, HashMap<String, String>>,
166 pub team_config: Option<TeamConfig>,
167 pub validation_rules: ValidationRules,
168 pub imported_files: Vec<PathBuf>,
169 pub create_env_files: bool,
170 pub selected_vars: Vec<SelectedVariable>,
171}
172
173impl SetupWizard {
174 #[must_use]
175 pub fn new() -> Self {
176 Self::default()
177 }
178
179 pub fn run(&mut self) -> Result<SetupResult> {
190 match self.run_wizard() {
192 Ok(result) => Ok(result),
193 Err(e) => {
194 if e.downcast_ref::<EscPressed>().is_some() {
195 Self::show_goodbye();
196 std::process::exit(0);
197 } else {
198 Err(e)
199 }
200 }
201 }
202 }
203
204 fn run_wizard(&mut self) -> Result<SetupResult> {
205 Self::show_welcome()?;
207
208 let system_info = Self::detect_system()?;
210 Self::show_system_info(&system_info);
211
212 let project_type = self.select_project_type()?;
214
215 let imported_files = if let Some(existing_files) = self.scan_existing_files()? {
217 self.import_existing(existing_files)?
218 } else {
219 Vec::new()
220 };
221
222 let selected_vars = self.configure_variables(&project_type)?;
224
225 let (profiles, profile_configs) = self.create_and_configure_profiles(&project_type, &selected_vars)?;
227
228 let create_env_files = self.ask_create_env_files()?;
230
231 let team_config = if self.ask_team_setup()? {
233 Some(self.configure_team_features()?)
234 } else {
235 None
236 };
237
238 let validation_rules = self.configure_validation(&project_type)?;
240
241 let result = SetupResult {
243 project_type: project_type.clone(),
244 profiles,
245 profile_configs,
246 team_config,
247 validation_rules,
248 imported_files,
249 create_env_files,
250 selected_vars,
251 };
252
253 self.review_and_apply(&result)?;
254
255 Self::check_required_variables(&result);
257
258 Ok(result)
259 }
260
261 fn show_goodbye() {
262 println!("\n{}", "ā".repeat(65).bright_black());
263 println!(
264 "\n{} {}",
265 "š".bright_yellow(),
266 "Setup cancelled. No worries!".bright_cyan().bold()
267 );
268 println!(
269 "\n{}",
270 "You can run 'envx init' anytime to start the setup wizard again.".bright_white()
271 );
272 println!("{}", "Your project files remain unchanged.".bright_white());
273 println!("\n{}", "ā".repeat(65).bright_black());
274 println!("\n{}", "Happy coding! š".bright_magenta());
275 }
276
277 #[allow(clippy::too_many_lines)]
278 fn show_welcome() -> Result<()> {
279 print!("\x1B[2J\x1B[1;1H");
281
282 println!(
284 "{}",
285 "āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā®".bright_cyan()
286 );
287 println!(
288 "{}",
289 "ā ā".bright_cyan()
290 );
291 println!(
292 "{}",
293 "ā āāāāāāāāāāāā āāāāāā āāāāāā āāā ā".bright_cyan()
294 );
295 println!(
296 "{}",
297 "ā āāāāāāāāāāāāā āāāāāā āāāāāāāāāāā ā".bright_cyan()
298 );
299 println!(
300 "{}",
301 "ā āāāāāā āāāāāā āāāāāā āāā āāāāāā ā".bright_cyan()
302 );
303 println!(
304 "{}",
305 "ā āāāāāā āāāāāāāāāāāāāā āāāā āāāāāā ā".bright_cyan()
306 );
307 println!(
308 "{}",
309 "ā āāāāāāāāāāā āāāāāā āāāāāāā āāāā āāā ā".bright_cyan()
310 );
311 println!(
312 "{}",
313 "ā āāāāāāāāāāā āāāāā āāāāā āāā āāā ā".bright_cyan()
314 );
315 println!(
316 "{}",
317 "ā ā".bright_cyan()
318 );
319 println!(
320 "{}",
321 format!(
322 "ā Environment Variable Manager v{:<8} ā",
323 env!("CARGO_PKG_VERSION")
324 )
325 .bright_cyan()
326 );
327 println!(
328 "{}",
329 "ā°āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāÆ".bright_cyan()
330 );
331
332 println!(
333 "\n{} {} {}",
334 "āØ".bright_yellow(),
335 "Welcome to envx!".bright_white().bold(),
336 "Your intelligent environment variable companion".bright_blue()
337 );
338
339 println!("\n{}", "ā".repeat(65).bright_black());
340
341 println!("\n{}", "This setup wizard will help you:".bright_white());
343
344 let features = vec![
345 (
346 "š",
347 "Define environment variables",
348 "Set up your project's environment",
349 ),
350 (
351 "š",
352 "Create profiles",
353 "Configure dev, test, and production environments",
354 ),
355 ("š¦", "Import existing files", "Seamlessly migrate from .env files"),
356 ("š", "Generate .env files", "Create .env files for each profile"),
357 ("š„", "Enable team features", "Share configurations with your team"),
358 ];
359
360 for (icon, title, desc) in features {
361 println!(
362 " {} {} {}",
363 icon,
364 format!("{title:<22}").bright_green().bold(),
365 format!("ā {desc}").bright_black()
366 );
367 }
368
369 println!("\n{}", "ā".repeat(65).bright_black());
370
371 println!(
373 "\n{} {} {}",
374 "ā±ļø ".bright_blue(),
375 "Estimated time:".bright_white(),
376 "1-3 minutes".bright_yellow().bold()
377 );
378
379 println!(
381 "\n{} {}",
382 "š”".bright_yellow(),
383 "Tip: Press ESC at any time to exit the wizard".bright_white().italic()
384 );
385
386 println!(
388 "\n{}",
389 "Let's create the perfect setup for your project! šÆ".bright_magenta()
390 );
391
392 let continue_prompt = format!(
393 "\n{} {} {} {} {}",
394 "Type".bright_black(),
395 "[y (yes)]".bright_green().bold(),
396 "to begin your journey,".bright_black(),
397 "[n (no)]".bright_red().bold(),
398 "to skip, or [ESC] to exit".bright_black()
399 );
400
401 print!("\n{}", "Initializing".bright_cyan());
403 for _ in 0..3 {
404 std::thread::sleep(std::time::Duration::from_millis(400));
405 print!("{}", ".".bright_cyan());
406 std::io::Write::flush(&mut std::io::stdout())?;
407 }
408 println!(" {}", "Ready!".bright_green().bold());
409
410 println!("{continue_prompt}");
411
412 let welcome_theme = ColorfulTheme {
414 prompt_style: dialoguer::console::Style::new().cyan().bold(),
415 ..ColorfulTheme::default()
416 };
417
418 let result = Confirm::with_theme(&welcome_theme)
419 .with_prompt("")
420 .default(true)
421 .show_default(false)
422 .wait_for_newline(true)
423 .interact_opt()?;
424
425 match result {
426 Some(true) => {
427 println!(
429 "\n{} {}",
430 "š".bright_yellow(),
431 "Great choice! Let's build something amazing together."
432 .bright_green()
433 .bold()
434 );
435 std::thread::sleep(std::time::Duration::from_millis(1000));
436 Ok(())
437 }
438 Some(false) => {
439 println!(
440 "\n{} {}",
441 "š".bright_yellow(),
442 "No problem! You can run 'envx init' anytime to set up.".bright_blue()
443 );
444 std::process::exit(0);
445 }
446 None => Err(EscPressed.into()),
447 }
448 }
449
450 fn detect_system() -> Result<SystemInfo> {
451 println!("\nš Detecting your system...");
452
453 let info = SystemInfo::detect()?;
454 Ok(info)
455 }
456
457 fn show_system_info(info: &SystemInfo) {
458 println!("ā OS: {}", info.os);
459 println!("ā Shell: {}", info.shell);
460 println!("ā Terminal: {}", info.terminal);
461 println!("ā Envx Version: {}\n", env!("CARGO_PKG_VERSION"));
462 }
463
464 fn ask_team_setup(&self) -> Result<bool> {
465 match Confirm::with_theme(&self.theme)
466 .with_prompt("Are you working in a team?")
467 .default(false)
468 .interact_opt()?
469 {
470 Some(value) => Ok(value),
471 None => Err(EscPressed.into()),
472 }
473 }
474
475 fn ask_create_env_files(&self) -> Result<bool> {
476 match Confirm::with_theme(&self.theme)
477 .with_prompt("\nWould you like to create .env files for your profiles?")
478 .default(true)
479 .interact_opt()?
480 {
481 Some(value) => Ok(value),
482 None => Err(EscPressed.into()),
483 }
484 }
485
486 pub fn select_project_type(&self) -> Result<ProjectType> {
495 let options = vec![
496 "Web Application (Node.js, React, etc.)",
497 "Python Application",
498 "Rust Application",
499 "Go Application",
500 "Next.js/Full-Stack Application",
501 "Docker/Container-based",
502 "Multi-service/Microservices",
503 "Other/Custom",
504 ];
505
506 let Some(selection) = Select::with_theme(&self.theme)
507 .with_prompt("What type of project are you working on?")
508 .items(&options)
509 .interact_opt()?
510 else {
511 return Err(EscPressed.into());
512 };
513
514 let project_type = match selection {
515 0 => Self::create_web_app_type(),
516 1 => Self::create_python_type(),
517 2 => Self::create_rust_type(),
518 3 => Self::create_go_type(),
519 4 => Self::create_nextjs_type(),
520 5 => Self::create_docker_type(),
521 6 => Self::create_microservices_type(),
522 _ => self.create_custom_type()?,
523 };
524
525 Ok(project_type)
526 }
527
528 fn configure_variables(&mut self, project_type: &ProjectType) -> Result<Vec<SelectedVariable>> {
529 let mut selected_vars = Vec::new();
530
531 if !project_type.suggested_vars.is_empty() {
533 println!("\nš Let's configure variables for your {} project:", project_type.name);
534
535 let options: Vec<String> = project_type
536 .suggested_vars
537 .iter()
538 .map(|var| {
539 let required_marker = if var.required { " (required)" } else { "" };
540 format!("{} - {}{}", var.name, var.description, required_marker)
541 })
542 .collect();
543
544 let defaults: Vec<bool> = project_type.suggested_vars.iter().map(|var| var.required).collect();
545
546 let Some(selections) = MultiSelect::with_theme(&self.theme)
547 .with_prompt("Select variables to configure (Space to toggle, Enter to continue)")
548 .items(&options)
549 .defaults(&defaults)
550 .interact_opt()?
551 else {
552 return Err(EscPressed.into());
553 };
554
555 if !selections.is_empty() {
557 println!("\nš§ Configure variable values:");
558 for &idx in &selections {
559 let var = &project_type.suggested_vars[idx];
560
561 let value = Input::<String>::with_theme(&self.theme)
562 .with_prompt(format!("{} ({})", var.name, var.description))
563 .default(var.example.clone())
564 .interact()?;
565
566 selected_vars.push(SelectedVariable {
567 name: var.name.clone(),
568 value,
569 description: var.description.clone(),
570 required: var.required,
571 });
572 }
573 }
574 }
575
576 println!("\nā Custom Environment Variables");
578 let Some(add_custom) = Confirm::with_theme(&self.theme)
579 .with_prompt("Would you like to add custom environment variables?")
580 .default(true)
581 .interact_opt()?
582 else {
583 return Err(EscPressed.into());
584 };
585
586 if add_custom {
587 loop {
588 println!("\nš Add a custom variable:");
589
590 let var_name = Input::<String>::with_theme(&self.theme)
591 .with_prompt("Variable name (or press Enter to finish)")
592 .allow_empty(true)
593 .interact()?;
594
595 if var_name.is_empty() {
596 break;
597 }
598
599 let description = Input::<String>::with_theme(&self.theme)
600 .with_prompt("Description")
601 .default(format!("{var_name} configuration"))
602 .interact()?;
603
604 let value = Input::<String>::with_theme(&self.theme)
605 .with_prompt("Value")
606 .default("your-value-here".to_string())
607 .interact()?;
608
609 let Some(required) = Confirm::with_theme(&self.theme)
610 .with_prompt("Is this variable required?")
611 .default(false)
612 .interact_opt()?
613 else {
614 return Err(EscPressed.into());
615 };
616
617 selected_vars.push(SelectedVariable {
618 name: var_name,
619 value,
620 description,
621 required,
622 });
623
624 let Some(add_more) = Confirm::with_theme(&self.theme)
625 .with_prompt("Add another custom variable?")
626 .default(true)
627 .interact_opt()?
628 else {
629 return Err(EscPressed.into());
630 };
631
632 if !add_more {
633 break;
634 }
635 }
636 }
637
638 self.config.selected_vars.clone_from(&selected_vars);
639 Ok(selected_vars)
640 }
641
642 fn create_and_configure_profiles(
643 &self,
644 project_type: &ProjectType,
645 selected_vars: &[SelectedVariable],
646 ) -> Result<(Vec<String>, HashMap<String, HashMap<String, String>>)> {
647 println!("\nš Let's create environment profiles:");
648
649 let mut profiles = Vec::new();
650 let mut profile_configs = HashMap::new();
651
652 let suggested = &project_type.suggested_profiles;
654 let mut options: Vec<String> = suggested
655 .iter()
656 .map(|p| {
657 format!(
658 "{} ({})",
659 p,
660 match p.as_str() {
661 "development" => "local development",
662 "testing" => "running tests",
663 "staging" => "pre-production",
664 "production" => "live environment",
665 _ => "custom",
666 }
667 )
668 })
669 .collect();
670
671 options.push("Add custom profile".to_string());
672
673 let defaults: Vec<bool> = vec![true, false, false, false]; let Some(selections) = MultiSelect::with_theme(&self.theme)
676 .with_prompt("Select profiles to create")
677 .items(&options)
678 .defaults(&defaults)
679 .interact_opt()?
680 else {
681 return Err(EscPressed.into());
682 };
683
684 for &idx in &selections {
686 if idx < suggested.len() {
687 profiles.push(suggested[idx].clone());
688 } else if idx == options.len() - 1 {
689 let custom_name = Input::<String>::with_theme(&self.theme)
691 .with_prompt("Enter custom profile name")
692 .interact()?;
693
694 if !custom_name.is_empty() {
695 profiles.push(custom_name);
696 }
697 }
698 }
699
700 loop {
702 let Some(add_more) = Confirm::with_theme(&self.theme)
703 .with_prompt("Add another custom profile?")
704 .default(false)
705 .interact_opt()?
706 else {
707 return Err(EscPressed.into());
708 };
709
710 if !add_more {
711 break;
712 }
713
714 let custom_name = Input::<String>::with_theme(&self.theme)
715 .with_prompt("Enter profile name")
716 .interact()?;
717
718 if !custom_name.is_empty() && !profiles.contains(&custom_name) {
719 profiles.push(custom_name);
720 }
721 }
722
723 for profile in &profiles {
725 println!("\nāļø Configuring '{profile}' profile:");
726 let mut profile_config = HashMap::new();
727
728 for var in selected_vars {
730 let default_value = Self::get_profile_default_value(profile, &var.name, &var.value);
731
732 let value = Input::<String>::with_theme(&self.theme)
733 .with_prompt(format!(" {}", var.name))
734 .default(default_value)
735 .interact()?;
736
737 profile_config.insert(var.name.clone(), value);
738 }
739
740 profile_configs.insert(profile.clone(), profile_config);
741 }
742
743 Ok((profiles, profile_configs))
744 }
745
746 fn get_profile_default_value(profile: &str, var_name: &str, base_value: &str) -> String {
747 match (profile, var_name) {
748 ("development", "NODE_ENV") => "development".to_string(),
749 ("testing", "NODE_ENV") => "test".to_string(),
750 ("staging", "NODE_ENV") => "staging".to_string(),
751 ("production", "NODE_ENV") => "production".to_string(),
752
753 ("development", "DATABASE_URL") => base_value.replace("myapp", "myapp_dev"),
754 ("testing", "DATABASE_URL") => base_value.replace("myapp", "myapp_test"),
755 ("staging", "DATABASE_URL") => base_value.replace("myapp", "myapp_staging"),
756
757 ("development", "LOG_LEVEL") => "debug".to_string(),
758 ("testing", "LOG_LEVEL") => "error".to_string(),
759 ("production", "LOG_LEVEL") => "info".to_string(),
760
761 ("development", "DEBUG") => "true".to_string(),
762 (_, "DEBUG") => "false".to_string(),
763
764 _ => base_value.to_string(),
765 }
766 }
767
768 pub fn scan_existing_files(&self) -> Result<Option<Vec<PathBuf>>> {
777 println!("\nš Scanning for existing environment files...");
778
779 let patterns = vec![".env", ".env.*", "docker-compose.yml", "docker-compose.yaml"];
780 let mut found_files = Vec::new();
781
782 for pattern in patterns {
783 if let Ok(paths) = glob(pattern) {
784 for path in paths.flatten() {
785 found_files.push(path);
786 }
787 }
788 }
789
790 if found_files.is_empty() {
791 return Ok(None);
792 }
793
794 println!("Found existing environment files:");
795 for (i, file) in found_files.iter().enumerate() {
796 let var_count = Self::count_env_vars(file).unwrap_or(0);
797 println!(
798 " {} {} ({} variables)",
799 if i == 0 { "ā" } else { " " },
800 file.display(),
801 var_count
802 );
803 }
804
805 let Some(import) = Confirm::with_theme(&self.theme)
806 .with_prompt("\nWould you like to import these?")
807 .default(true)
808 .interact_opt()?
809 else {
810 return Err(EscPressed.into());
811 };
812
813 if import { Ok(Some(found_files)) } else { Ok(None) }
814 }
815
816 fn count_env_vars(path: &Path) -> Result<usize> {
817 let content = fs::read_to_string(path)?;
818 let count = content
819 .lines()
820 .filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#'))
821 .filter(|line| line.contains('='))
822 .count();
823 Ok(count)
824 }
825
826 pub fn import_existing(&self, files: Vec<PathBuf>) -> Result<Vec<PathBuf>> {
834 let options: Vec<&str> = vec!["Import all", "Select files to import", "Skip import"];
835
836 let Some(selection) = Select::with_theme(&self.theme)
837 .with_prompt("Import option")
838 .items(&options)
839 .interact_opt()?
840 else {
841 return Err(EscPressed.into());
842 };
843
844 match selection {
845 0 => Ok(files),
846 1 => {
847 let file_names: Vec<String> = files.iter().map(|p| p.display().to_string()).collect();
848
849 let Some(selections) = MultiSelect::with_theme(&self.theme)
850 .with_prompt("Select files to import")
851 .items(&file_names)
852 .interact_opt()?
853 else {
854 return Err(EscPressed.into());
855 };
856
857 Ok(selections.into_iter().map(|i| files[i].clone()).collect())
858 }
859 _ => Ok(Vec::new()),
860 }
861 }
862
863 pub fn configure_team_features(&self) -> Result<TeamConfig> {
872 println!("\nš„ Team Collaboration Setup:");
873
874 let Some(create_config) = Confirm::with_theme(&self.theme)
875 .with_prompt("Create .envx/config.yaml for team?")
876 .default(true)
877 .interact_opt()?
878 else {
879 return Err(EscPressed.into());
880 };
881
882 let git_hooks = false;
883
884 let ci_integration = false;
885
886 let Some(shared_profiles) = Confirm::with_theme(&self.theme)
887 .with_prompt("Enable shared profiles?")
888 .default(true)
889 .interact_opt()?
890 else {
891 return Err(EscPressed.into());
892 };
893
894 let config_path = if create_config {
895 let repo_root = Self::find_repo_root().unwrap_or_else(|_| PathBuf::from("."));
896 repo_root.join(".envx").join("config.yaml")
897 } else {
898 PathBuf::from(".envx/config.yaml")
899 };
900
901 Ok(TeamConfig {
902 config_path,
903 git_hooks,
904 ci_integration,
905 shared_profiles,
906 })
907 }
908
909 fn find_repo_root() -> Result<PathBuf> {
910 let current = std::env::current_dir()?;
911 let mut dir = current.as_path();
912
913 loop {
914 if dir.join(".git").exists() {
915 return Ok(dir.to_path_buf());
916 }
917 match dir.parent() {
918 Some(parent) => dir = parent,
919 None => return Err(eyre!("No git repository found")),
920 }
921 }
922 }
923
924 pub fn configure_validation(&self, project_type: &ProjectType) -> Result<ValidationRules> {
933 println!("\nā
Configure Validation Rules:");
934
935 let options = vec![
936 "Require all variables in .envx/config.yaml",
937 "Validate URLs are properly formatted",
938 "Check numeric values are in valid ranges",
939 "Warn about missing required variables",
940 "Strict mode (fail on any validation error)",
941 ];
942
943 let defaults = vec![true, true, true, true, false]; let Some(selections) = MultiSelect::with_theme(&self.theme)
946 .with_prompt("Select validation rules")
947 .items(&options)
948 .defaults(&defaults)
949 .interact_opt()?
950 else {
951 return Err(EscPressed.into());
952 };
953
954 let rules = ValidationRules {
955 require_all_defined: selections.contains(&0),
956 validate_urls: selections.contains(&1),
957 validate_numbers: selections.contains(&2),
958 warn_missing: selections.contains(&3),
959 strict_mode: selections.contains(&4),
960 custom_patterns: self.get_custom_patterns(project_type)?,
961 };
962
963 Ok(rules)
964 }
965
966 fn get_custom_patterns(&self, project_type: &ProjectType) -> Result<HashMap<String, String>> {
967 let mut patterns = HashMap::new();
968
969 match &project_type.category {
970 ProjectCategory::WebApp => {
971 patterns.insert("*_URL".to_string(), r"^https?://.*".to_string());
972 patterns.insert("*_PORT".to_string(), r"^[0-9]{1,5}$".to_string());
973 }
974 ProjectCategory::Docker => {
975 patterns.insert("*_IMAGE".to_string(), r"^[a-z0-9\-_/:.]+$".to_string());
976 }
977 _ => {}
978 }
979
980 let Some(add_custom) = Confirm::with_theme(&self.theme)
981 .with_prompt("\nAdd custom validation pattern?")
982 .default(false)
983 .interact_opt()?
984 else {
985 return Err(EscPressed.into());
986 };
987
988 if add_custom {
989 let pattern_name = Input::<String>::with_theme(&self.theme)
990 .with_prompt("Pattern name (e.g., *_EMAIL)")
991 .interact()?;
992
993 let pattern_regex = Input::<String>::with_theme(&self.theme)
994 .with_prompt("Regex pattern")
995 .interact()?;
996
997 patterns.insert(pattern_name, pattern_regex);
998 }
999
1000 Ok(patterns)
1001 }
1002
1003 pub fn review_and_apply(&self, result: &SetupResult) -> Result<()> {
1013 println!("\nš Setup Summary:");
1014 println!("{}", "ā".repeat(50));
1015 println!("Project Type: {}", result.project_type.name);
1016 println!("Profiles: {}", result.profiles.join(", "));
1017 println!("Variables: {} configured", result.selected_vars.len());
1018 println!(
1019 "Create .env files: {}",
1020 if result.create_env_files { "Yes" } else { "No" }
1021 );
1022 println!(
1023 "Team Setup: {}",
1024 if result.team_config.is_some() {
1025 "Enabled"
1026 } else {
1027 "Disabled"
1028 }
1029 );
1030
1031 if !result.imported_files.is_empty() {
1032 println!("Imported Files: {}", result.imported_files.len());
1033 }
1034
1035 println!("{}", "ā".repeat(50));
1036
1037 let Some(confirm) = Confirm::with_theme(&self.theme)
1038 .with_prompt("\nReady to apply configuration?")
1039 .default(true)
1040 .interact_opt()?
1041 else {
1042 return Err(EscPressed.into());
1043 };
1044
1045 if !confirm {
1046 return Err(eyre!("Setup cancelled by user"));
1047 }
1048
1049 self.apply_configuration(result)?;
1051
1052 Ok(())
1053 }
1054
1055 #[allow(clippy::too_many_lines)]
1056 fn apply_configuration(&self, result: &SetupResult) -> Result<()> {
1057 println!("\nš Applying configuration...");
1058
1059 if let Some(team_config) = &result.team_config {
1061 Self::create_project_config(result, &team_config.config_path)?;
1062 println!("ā Created project configuration");
1063 }
1064
1065 for file in &result.imported_files {
1067 println!("ā Imported {}", file.display());
1068 }
1070
1071 let mut profile_manager = ProfileManager::new()?;
1073
1074 let mut profile_mappings: HashMap<String, String> = HashMap::new();
1076 for profile_name in &result.profiles {
1077 profile_mappings.insert(profile_name.clone(), profile_name.clone());
1078 }
1079
1080 for (profile_name, _) in &result.profile_configs {
1081 if profile_manager.get(profile_name).is_some() {
1082 println!("\nā ļø Profile '{profile_name}' already exists!");
1083
1084 let options = vec![
1085 format!("Rename new profile (current: {})", profile_name),
1086 format!("Delete existing '{}' profile and replace", profile_name),
1087 "Skip this profile".to_string(),
1088 ];
1089
1090 let Some(choice) = Select::with_theme(&self.theme)
1091 .with_prompt("How would you like to proceed?")
1092 .items(&options)
1093 .interact_opt()?
1094 else {
1095 return Err(EscPressed.into());
1096 };
1097
1098 match choice {
1099 0 => {
1100 loop {
1102 let new_name = Input::<String>::with_theme(&self.theme)
1103 .with_prompt("Enter new profile name")
1104 .default(format!("{profile_name}_new"))
1105 .interact()?;
1106
1107 if new_name.is_empty() {
1108 println!("Profile name cannot be empty!");
1109 continue;
1110 }
1111
1112 if profile_manager.get(&new_name).is_none() {
1113 profile_mappings.insert(profile_name.clone(), new_name);
1114 break;
1115 }
1116 println!("Profile '{new_name}' also exists! Please choose another name.");
1117 }
1118 }
1119 1 => {
1120 let Some(confirm_delete) = Confirm::with_theme(&self.theme)
1122 .with_prompt(format!(
1123 "Are you sure you want to delete the existing '{profile_name}' profile?"
1124 ))
1125 .default(false)
1126 .interact_opt()?
1127 else {
1128 return Err(EscPressed.into());
1129 };
1130
1131 if confirm_delete {
1132 profile_manager.delete(profile_name)?;
1133 println!("ā Deleted existing profile: {profile_name}");
1134 } else {
1135 println!("Skipping profile: {profile_name}");
1136 profile_mappings.remove(profile_name);
1137 }
1138 }
1139 2 => {
1140 println!("Skipping profile: {profile_name}");
1142 profile_mappings.remove(profile_name);
1143 }
1144 _ => unreachable!(),
1145 }
1146 }
1147 }
1148
1149 for (original_name, actual_name) in &profile_mappings {
1151 if let Some(profile_vars) = result.profile_configs.get(original_name) {
1152 profile_manager.create(actual_name.clone(), Some(format!("{actual_name} environment")))?;
1154
1155 if let Some(profile) = profile_manager.get_mut(actual_name) {
1157 for (var_name, var_value) in profile_vars {
1158 profile.add_var(var_name.clone(), var_value.clone(), false);
1159 }
1160 }
1161
1162 if original_name == actual_name {
1163 println!("ā Created profile: {actual_name}");
1164 } else {
1165 println!("ā Created profile: {actual_name} (renamed from {original_name})");
1166 }
1167 }
1168 }
1169
1170 if let Some(first_profile) = result.profiles.first() {
1173 if let Some(actual_name) = profile_mappings.get(first_profile) {
1174 profile_manager.switch(actual_name)?;
1175 println!("ā Set active profile: {actual_name}");
1176 }
1177 }
1178
1179 if result.create_env_files {
1181 Self::create_env_files_with_mappings(result, &profile_mappings)?;
1182 }
1183
1184 for var in &result.selected_vars {
1186 unsafe { std::env::set_var(&var.name, &var.value) };
1187 println!("ā Set {} in current session", var.name);
1188 }
1189
1190 Ok(())
1191 }
1192
1193 fn create_env_files_with_mappings(result: &SetupResult, mappings: &HashMap<String, String>) -> Result<()> {
1194 println!("\nš Creating .env files...");
1195
1196 for (original_name, config) in &result.profile_configs {
1197 if let Some(actual_name) = mappings.get(original_name) {
1198 let filename = if actual_name == "development" {
1199 ".env".to_string()
1200 } else {
1201 format!(".env.{actual_name}")
1202 };
1203
1204 let mut content = String::new();
1205 content.push_str(&format!("# Environment variables for {actual_name} profile\n"));
1206 if original_name != actual_name {
1207 content.push_str(&format!("# (originally configured as {original_name})\n"));
1208 }
1209 content.push_str(&format!(
1210 "# Generated by envx on {}\n\n",
1211 chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
1212 ));
1213
1214 for (key, value) in config {
1215 content.push_str(&format!("{key}={value}\n"));
1216 }
1217
1218 fs::write(&filename, content)?;
1219 println!("ā Created {filename}");
1220 }
1221 }
1222
1223 Ok(())
1224 }
1225
1226 fn check_required_variables(result: &SetupResult) {
1227 println!("\nš Checking environment variables...");
1228
1229 let mut required_set = Vec::new();
1230 let mut required_missing = Vec::new();
1231 let mut optional_set = Vec::new();
1232 let mut optional_missing = Vec::new();
1233
1234 for var in &result.selected_vars {
1235 match std::env::var(&var.name) {
1236 Ok(value) => {
1237 if value == var.value {
1238 if var.required {
1239 required_set.push(&var.name);
1240 } else {
1241 optional_set.push(&var.name);
1242 }
1243 } else if var.required {
1244 required_missing.push(&var.name);
1245 } else {
1246 optional_missing.push(&var.name);
1247 }
1248 }
1249 Err(_) => {
1250 if var.required {
1251 required_missing.push(&var.name);
1252 } else {
1253 optional_missing.push(&var.name);
1254 }
1255 }
1256 }
1257 }
1258
1259 if !required_set.is_empty() || !optional_set.is_empty() {
1261 println!("\nā
Successfully set in current session:");
1262 for var in required_set {
1263 println!(" ā {var} (required)");
1264 }
1265 for var in optional_set {
1266 println!(" ā {var}");
1267 }
1268 }
1269
1270 if !required_missing.is_empty() {
1272 println!("\nā Missing required variables:");
1273 for var in &required_missing {
1274 println!(" ⢠{}", *var);
1275 }
1276
1277 println!("\nš” To apply these variables:");
1278 println!(" 1. Close and restart your terminal");
1279 println!(" 2. Run 'envx list' to verify they are set");
1280 println!(" 3. Or source the .env file: source .env");
1281 }
1282
1283 if !optional_missing.is_empty() {
1285 println!("\nā ļø Optional variables not set in current session:");
1286 for var in optional_missing {
1287 println!(" ⢠{var}");
1288 }
1289 println!(" These are optional and can be set later if needed.");
1290 }
1291
1292 if required_missing.is_empty() && !result.selected_vars.iter().any(|v| v.required) {
1294 println!("\nā
All variables have been configured!");
1295 } else if required_missing.is_empty() {
1296 println!("\nā
All required variables are set!");
1297 }
1298
1299 println!("\nā
Setup complete! Here's what to do next:");
1300 println!("\n 1. Run `envx list` to see your environment variables");
1301 println!(" 2. Run `envx tui` to launch the interactive interface");
1302 println!(" 3. Run `envx profile list` to see available profiles");
1303 println!(" 4. Run `envx profile set <name>` to switch profiles");
1304
1305 if result.team_config.is_some() {
1306 println!(" 5. Commit .envx/config.yaml to share with your team");
1307 }
1308 }
1309
1310 fn create_project_config(result: &SetupResult, path: &Path) -> Result<()> {
1311 if let Some(parent) = path.parent() {
1313 fs::create_dir_all(parent)?;
1314 }
1315
1316 let config = ProjectConfig {
1317 name: Some(result.project_type.name.to_lowercase().replace(' ', "-")),
1318 description: Some(format!("{} project", result.project_type.name)),
1319 required: result
1320 .selected_vars
1321 .iter()
1322 .filter(|v| v.required)
1323 .map(|v| RequiredVar {
1324 name: v.name.clone(),
1325 description: Some(v.description.clone()),
1326 pattern: None,
1327 example: Some(v.value.clone()),
1328 })
1329 .collect(),
1330 defaults: result
1331 .selected_vars
1332 .iter()
1333 .map(|v| (v.name.clone(), v.value.clone()))
1334 .collect(),
1335 auto_load: vec![".env".to_string(), ".env.local".to_string()],
1336 profile: result.profiles.first().cloned(),
1337 scripts: HashMap::new(),
1338 validation: ConfigValidationRules {
1339 warn_unused: result.validation_rules.warn_missing,
1340 strict_names: result.validation_rules.strict_mode,
1341 patterns: result.validation_rules.custom_patterns.clone(),
1342 },
1343 inherit: true,
1344 };
1345
1346 let yaml = serde_yaml::to_string(&config)?;
1347 fs::write(path, yaml)?;
1348
1349 Ok(())
1350 }
1351
1352 fn create_web_app_type() -> ProjectType {
1354 ProjectType {
1355 name: "Web Application".to_string(),
1356 category: ProjectCategory::WebApp,
1357 suggested_vars: vec![
1358 SuggestedVariable {
1359 name: "NODE_ENV".to_string(),
1360 description: "Application environment".to_string(),
1361 example: "development".to_string(),
1362 required: true,
1363 sensitive: false,
1364 },
1365 SuggestedVariable {
1366 name: "PORT".to_string(),
1367 description: "Server port".to_string(),
1368 example: "3000".to_string(),
1369 required: true,
1370 sensitive: false,
1371 },
1372 SuggestedVariable {
1373 name: "DATABASE_URL".to_string(),
1374 description: "Database connection string".to_string(),
1375 example: "postgresql://localhost:5432/myapp".to_string(),
1376 required: true,
1377 sensitive: true,
1378 },
1379 SuggestedVariable {
1380 name: "JWT_SECRET".to_string(),
1381 description: "JWT signing secret".to_string(),
1382 example: "your-secret-key".to_string(),
1383 required: false,
1384 sensitive: true,
1385 },
1386 SuggestedVariable {
1387 name: "API_KEY".to_string(),
1388 description: "External API key".to_string(),
1389 example: "your-api-key".to_string(),
1390 required: false,
1391 sensitive: true,
1392 },
1393 ],
1394 suggested_profiles: vec![
1395 "development".to_string(),
1396 "testing".to_string(),
1397 "production".to_string(),
1398 ],
1399 }
1400 }
1401
1402 fn create_python_type() -> ProjectType {
1403 ProjectType {
1404 name: "Python Application".to_string(),
1405 category: ProjectCategory::Python,
1406 suggested_vars: vec![
1407 SuggestedVariable {
1408 name: "PYTHONPATH".to_string(),
1409 description: "Python module search path".to_string(),
1410 example: "./src".to_string(),
1411 required: false,
1412 sensitive: false,
1413 },
1414 SuggestedVariable {
1415 name: "DATABASE_URL".to_string(),
1416 description: "Database connection string".to_string(),
1417 example: "postgresql://localhost:5432/myapp".to_string(),
1418 required: true,
1419 sensitive: true,
1420 },
1421 SuggestedVariable {
1422 name: "SECRET_KEY".to_string(),
1423 description: "Django/Flask secret key".to_string(),
1424 example: "your-secret-key".to_string(),
1425 required: true,
1426 sensitive: true,
1427 },
1428 SuggestedVariable {
1429 name: "DEBUG".to_string(),
1430 description: "Debug mode flag".to_string(),
1431 example: "True".to_string(),
1432 required: false,
1433 sensitive: false,
1434 },
1435 ],
1436 suggested_profiles: vec![
1437 "development".to_string(),
1438 "testing".to_string(),
1439 "production".to_string(),
1440 ],
1441 }
1442 }
1443
1444 fn create_rust_type() -> ProjectType {
1445 ProjectType {
1446 name: "Rust Application".to_string(),
1447 category: ProjectCategory::Rust,
1448 suggested_vars: vec![
1449 SuggestedVariable {
1450 name: "RUST_LOG".to_string(),
1451 description: "Rust logging level".to_string(),
1452 example: "info".to_string(),
1453 required: false,
1454 sensitive: false,
1455 },
1456 SuggestedVariable {
1457 name: "DATABASE_URL".to_string(),
1458 description: "Database connection string".to_string(),
1459 example: "postgresql://localhost:5432/myapp".to_string(),
1460 required: true,
1461 sensitive: true,
1462 },
1463 SuggestedVariable {
1464 name: "SERVER_PORT".to_string(),
1465 description: "Server port".to_string(),
1466 example: "8080".to_string(),
1467 required: true,
1468 sensitive: false,
1469 },
1470 ],
1471 suggested_profiles: vec!["development".to_string(), "release".to_string()],
1472 }
1473 }
1474
1475 fn create_docker_type() -> ProjectType {
1476 ProjectType {
1477 name: "Docker Application".to_string(),
1478 category: ProjectCategory::Docker,
1479 suggested_vars: vec![
1480 SuggestedVariable {
1481 name: "COMPOSE_PROJECT_NAME".to_string(),
1482 description: "Docker Compose project name".to_string(),
1483 example: "myapp".to_string(),
1484 required: true,
1485 sensitive: false,
1486 },
1487 SuggestedVariable {
1488 name: "DOCKER_REGISTRY".to_string(),
1489 description: "Docker registry URL".to_string(),
1490 example: "docker.io".to_string(),
1491 required: false,
1492 sensitive: false,
1493 },
1494 ],
1495 suggested_profiles: vec!["local".to_string(), "staging".to_string(), "production".to_string()],
1496 }
1497 }
1498
1499 fn create_microservices_type() -> ProjectType {
1500 ProjectType {
1501 name: "Microservices".to_string(),
1502 category: ProjectCategory::Microservices,
1503 suggested_vars: vec![
1504 SuggestedVariable {
1505 name: "SERVICE_DISCOVERY_URL".to_string(),
1506 description: "Service discovery endpoint".to_string(),
1507 example: "http://consul:8500".to_string(),
1508 required: true,
1509 sensitive: false,
1510 },
1511 SuggestedVariable {
1512 name: "KAFKA_BROKERS".to_string(),
1513 description: "Kafka broker addresses".to_string(),
1514 example: "kafka1:9092,kafka2:9092".to_string(),
1515 required: false,
1516 sensitive: false,
1517 },
1518 ],
1519 suggested_profiles: vec!["local".to_string(), "kubernetes".to_string(), "production".to_string()],
1520 }
1521 }
1522
1523 fn create_custom_type(&self) -> Result<ProjectType> {
1524 let value = Input::<String>::with_theme(&self.theme)
1525 .with_prompt("Enter project type name")
1526 .default("Custom Project".to_string())
1527 .interact()?;
1528 let name = value;
1529
1530 Ok(ProjectType {
1533 name,
1534 category: ProjectCategory::Custom,
1535 suggested_vars: Vec::new(), suggested_profiles: vec!["development".to_string(), "production".to_string()],
1537 })
1538 }
1539
1540 fn create_nextjs_type() -> ProjectType {
1541 ProjectType {
1542 name: "Next.js Full-Stack".to_string(),
1543 category: ProjectCategory::NextJs,
1544 suggested_vars: vec![
1545 SuggestedVariable {
1546 name: "NEXT_PUBLIC_API_URL".to_string(),
1547 description: "Public API URL".to_string(),
1548 example: "http://localhost:3000/api".to_string(),
1549 required: true,
1550 sensitive: false,
1551 },
1552 SuggestedVariable {
1553 name: "DATABASE_URL".to_string(),
1554 description: "Database connection string".to_string(),
1555 example: "postgresql://localhost:5432/myapp".to_string(),
1556 required: true,
1557 sensitive: true,
1558 },
1559 SuggestedVariable {
1560 name: "NEXTAUTH_SECRET".to_string(),
1561 description: "NextAuth.js secret".to_string(),
1562 example: "your-secret-key".to_string(),
1563 required: true,
1564 sensitive: true,
1565 },
1566 SuggestedVariable {
1567 name: "NEXTAUTH_URL".to_string(),
1568 description: "NextAuth callback URL".to_string(),
1569 example: "http://localhost:3000".to_string(),
1570 required: true,
1571 sensitive: false,
1572 },
1573 SuggestedVariable {
1574 name: "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY".to_string(),
1575 description: "Stripe publishable key".to_string(),
1576 example: "pk_test_...".to_string(),
1577 required: false,
1578 sensitive: false,
1579 },
1580 ],
1581 suggested_profiles: vec![
1582 "development".to_string(),
1583 "preview".to_string(),
1584 "production".to_string(),
1585 ],
1586 }
1587 }
1588 fn create_go_type() -> ProjectType {
1589 ProjectType {
1590 name: "Go Application".to_string(),
1591 category: ProjectCategory::Go,
1592 suggested_vars: vec![
1593 SuggestedVariable {
1594 name: "GO_ENV".to_string(),
1595 description: "Go environment".to_string(),
1596 example: "development".to_string(),
1597 required: true,
1598 sensitive: false,
1599 },
1600 SuggestedVariable {
1601 name: "DATABASE_URL".to_string(),
1602 description: "Database connection string".to_string(),
1603 example: "postgres://localhost:5432/myapp".to_string(),
1604 required: true,
1605 sensitive: true,
1606 },
1607 SuggestedVariable {
1608 name: "REDIS_URL".to_string(),
1609 description: "Redis connection URL".to_string(),
1610 example: "redis://localhost:6379".to_string(),
1611 required: false,
1612 sensitive: false,
1613 },
1614 SuggestedVariable {
1615 name: "JWT_SECRET".to_string(),
1616 description: "JWT signing secret".to_string(),
1617 example: "your-secret-key".to_string(),
1618 required: true,
1619 sensitive: true,
1620 },
1621 SuggestedVariable {
1622 name: "LOG_LEVEL".to_string(),
1623 description: "Logging level".to_string(),
1624 example: "info".to_string(),
1625 required: false,
1626 sensitive: false,
1627 },
1628 ],
1629 suggested_profiles: vec![
1630 "development".to_string(),
1631 "testing".to_string(),
1632 "production".to_string(),
1633 ],
1634 }
1635 }
1636}