1use crate::project_config::ProjectConfig;
2use crate::{EnvVarManager, ProfileManager, ValidationRules};
3use ahash::AHashMap as HashMap;
4use color_eyre::Result;
5use color_eyre::eyre::eyre;
6use regex::Regex;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10pub struct ProjectManager {
11 config_dir: PathBuf,
12 config: Option<ProjectConfig>,
13 current_dir: PathBuf,
14}
15
16impl ProjectManager {
17 pub fn new() -> Result<Self> {
23 Ok(Self {
24 config_dir: PathBuf::from(".envx"),
25 config: None,
26 current_dir: std::env::current_dir()?,
27 })
28 }
29
30 pub fn init(&self, name: Option<String>) -> Result<()> {
39 fs::create_dir_all(&self.config_dir)?;
41
42 let config = ProjectConfig::new(name);
44 let config_path = self.config_dir.join("config.yaml");
45 config.save(&config_path)?;
46
47 let gitignore_path = self.config_dir.join(".gitignore");
49 fs::write(gitignore_path, "local/\n*.local.yaml\n")?;
50
51 println!("✅ Initialized envx project configuration");
52 println!("📁 Created .envx/config.yaml");
53
54 Ok(())
55 }
56
57 pub fn init_with_file(&self, name: Option<String>, file_path: &Path) -> Result<()> {
65 if let Some(parent) = file_path.parent() {
67 fs::create_dir_all(parent)?;
68 }
69
70 let project_name = name
71 .or_else(|| {
72 std::env::current_dir()
73 .ok()
74 .and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
75 })
76 .unwrap_or_else(|| "my-project".to_string());
77
78 let config = ProjectConfig {
79 name: Some(project_name.clone()),
80 description: Some(format!("{project_name} environment configuration")),
81 required: vec![],
82 defaults: HashMap::new(),
83 auto_load: vec![".env".to_string()],
84 profile: None,
85 scripts: HashMap::new(),
86 validation: ValidationRules::default(),
87 inherit: true,
88 };
89
90 config.save(file_path)?;
91 println!("✅ Initialized project '{}' at {}", project_name, file_path.display());
92
93 Ok(())
94 }
95
96 pub fn find_and_load(&mut self) -> Result<Option<PathBuf>> {
102 let mut current = self.current_dir.clone();
103
104 loop {
105 let config_path = current.join(".envx").join("config.yaml");
106 if config_path.exists() {
107 self.config = Some(ProjectConfig::load(&config_path)?);
108 return Ok(Some(current));
109 }
110
111 if !current.pop() {
112 break;
113 }
114 }
115
116 Ok(None)
117 }
118
119 pub fn apply(&self, manager: &mut EnvVarManager, profile_manager: &mut ProfileManager) -> Result<()> {
129 let config = self
130 .config
131 .as_ref()
132 .ok_or_else(|| color_eyre::eyre::eyre!("No project configuration loaded"))?;
133
134 if let Some(profile_name) = &config.profile {
136 profile_manager.apply(profile_name, manager)?;
137 }
138
139 for file in &config.auto_load {
141 let file_path = self.current_dir.join(file);
142 if file_path.exists() {
143 Self::load_env_file(&file_path, manager)?;
144 }
145 }
146
147 for (name, value) in &config.defaults {
149 if manager.get(name).is_none() {
150 manager.set(name, value, true)?;
151 }
152 }
153
154 Ok(())
155 }
156
157 pub fn load_from_file(&mut self, file_path: &Path) -> Result<()> {
165 if !file_path.exists() {
166 return Err(eyre!("Configuration file not found: {}", file_path.display()));
167 }
168
169 self.config = Some(ProjectConfig::load(file_path)?);
170 self.config_dir = file_path.to_path_buf();
171
172 Ok(())
173 }
174
175 pub fn validate(&self, manager: &EnvVarManager) -> Result<ValidationReport> {
183 let config = self
184 .config
185 .as_ref()
186 .ok_or_else(|| color_eyre::eyre::eyre!("No project configuration loaded"))?;
187
188 let mut report = ValidationReport::default();
189
190 for required in &config.required {
192 match manager.get(&required.name) {
193 Some(var) => {
194 if let Some(pattern) = &required.pattern {
196 let re = Regex::new(pattern)?;
197 if !re.is_match(&var.value) {
198 report.errors.push(ValidationError {
199 var_name: required.name.clone(),
200 error_type: ErrorType::PatternMismatch,
201 message: format!("Value does not match pattern: {pattern}"),
202 });
203 }
204 }
205 report.found.push(required.name.clone());
206 }
207 None => {
208 report.missing.push(MissingVar {
209 name: required.name.clone(),
210 description: required.description.clone(),
211 example: required.example.clone(),
212 });
213 }
214 }
215 }
216
217 if config.validation.strict_names {
219 for var in manager.list() {
220 if !is_valid_var_name(&var.name) {
221 report.warnings.push(ValidationWarning {
222 var_name: var.name.clone(),
223 message: "Invalid variable name format".to_string(),
224 });
225 }
226 }
227 }
228
229 report.success = report.errors.is_empty() && report.missing.is_empty();
230 Ok(report)
231 }
232
233 pub fn run_script(&self, script_name: &str, manager: &mut EnvVarManager) -> Result<()> {
243 let config = self
244 .config
245 .as_ref()
246 .ok_or_else(|| color_eyre::eyre::eyre!("No project configuration loaded"))?;
247
248 let script = config
249 .scripts
250 .get(script_name)
251 .ok_or_else(|| color_eyre::eyre::eyre!("Script '{}' not found", script_name))?;
252
253 for (name, value) in &script.env {
255 manager.set(name, value, false)?;
256 }
257
258 #[cfg(unix)]
260 {
261 std::process::Command::new("sh").arg("-c").arg(&script.run).status()?;
262 }
263
264 #[cfg(windows)]
265 {
266 std::process::Command::new("cmd").arg("/C").arg(&script.run).status()?;
267 }
268
269 Ok(())
270 }
271
272 fn load_env_file(path: &Path, manager: &mut EnvVarManager) -> Result<()> {
273 let content = fs::read_to_string(path)?;
274
275 for line in content.lines() {
276 let line = line.trim();
277 if line.is_empty() || line.starts_with('#') {
278 continue;
279 }
280
281 if let Some((key, value)) = line.split_once('=') {
282 let key = key.trim();
283 let value = value.trim().trim_matches('"').trim_matches('\'');
284 manager.set(key, value, true)?;
285 }
286 }
287
288 Ok(())
289 }
290}
291
292#[derive(Debug, Default)]
293pub struct ValidationReport {
294 pub success: bool,
295 pub missing: Vec<MissingVar>,
296 pub found: Vec<String>,
297 pub errors: Vec<ValidationError>,
298 pub warnings: Vec<ValidationWarning>,
299}
300
301#[derive(Debug)]
302pub struct MissingVar {
303 pub name: String,
304 pub description: Option<String>,
305 pub example: Option<String>,
306}
307
308#[derive(Debug)]
309pub struct ValidationError {
310 pub var_name: String,
311 pub error_type: ErrorType,
312 pub message: String,
313}
314
315#[derive(Debug)]
316pub enum ErrorType {
317 PatternMismatch,
318 InvalidValue,
319}
320
321#[derive(Debug)]
322pub struct ValidationWarning {
323 pub var_name: String,
324 pub message: String,
325}
326
327fn is_valid_var_name(name: &str) -> bool {
328 let re = Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap();
330 re.is_match(name)
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::project_config::{RequiredVar, Script};
337 use ahash::AHashMap as HashMap;
338 use tempfile::TempDir;
339
340 fn create_test_project_manager() -> (ProjectManager, TempDir) {
341 let temp_dir = TempDir::new().unwrap();
342 let current_dir = temp_dir.path().to_path_buf();
343
344 let manager = ProjectManager {
345 config_dir: current_dir.join(".envx"),
346 config: None,
347 current_dir: current_dir.clone(),
348 };
349
350 (manager, temp_dir)
351 }
352
353 fn create_test_env_manager() -> EnvVarManager {
354 let mut manager = EnvVarManager::new();
355 manager.set("EXISTING_VAR", "existing_value", false).unwrap();
356 manager
357 }
358
359 fn create_test_profile_manager() -> ProfileManager {
360 ProfileManager::new().unwrap()
361 }
362
363 fn create_test_config() -> ProjectConfig {
364 let mut config = ProjectConfig::new(Some("test-project".to_string()));
365
366 config.required.push(RequiredVar {
368 name: "DATABASE_URL".to_string(),
369 description: Some("Database connection string".to_string()),
370 pattern: Some(r"^(postgresql|mysql)://.*".to_string()),
371 example: Some("postgresql://localhost/mydb".to_string()),
372 });
373
374 config.required.push(RequiredVar {
375 name: "API_KEY".to_string(),
376 description: Some("API authentication key".to_string()),
377 pattern: None,
378 example: None,
379 });
380
381 config
383 .defaults
384 .insert("NODE_ENV".to_string(), "development".to_string());
385 config.defaults.insert("PORT".to_string(), "3000".to_string());
386
387 let mut script_env = HashMap::new();
389 script_env.insert("NODE_ENV".to_string(), "test".to_string());
390
391 config.scripts.insert(
392 "test".to_string(),
393 Script {
394 description: Some("Run tests".to_string()),
395 run: "echo Running tests".to_string(),
396 env: script_env,
397 },
398 );
399
400 config
401 }
402
403 #[test]
404 fn test_project_manager_new() {
405 let result = ProjectManager::new();
406 assert!(result.is_ok());
407
408 let manager = result.unwrap();
409 assert_eq!(manager.config_dir, PathBuf::from(".envx"));
410 assert!(manager.config.is_none());
411 }
412
413 #[test]
414 fn test_init_creates_structure() {
415 let (manager, temp_dir) = create_test_project_manager();
416
417 let result = manager.init(Some("test-project".to_string()));
418 assert!(result.is_ok());
419
420 let envx_dir = temp_dir.path().join(".envx");
422 assert!(envx_dir.exists());
423 assert!(envx_dir.is_dir());
424
425 let config_path = envx_dir.join("config.yaml");
427 assert!(config_path.exists());
428
429 let gitignore_path = envx_dir.join(".gitignore");
431 assert!(gitignore_path.exists());
432
433 let gitignore_content = fs::read_to_string(gitignore_path).unwrap();
435 assert!(gitignore_content.contains("local/"));
436 assert!(gitignore_content.contains("*.local.yaml"));
437 }
438
439 #[test]
440 fn test_init_creates_valid_config() {
441 let (manager, temp_dir) = create_test_project_manager();
442
443 manager.init(Some("my-app".to_string())).unwrap();
444
445 let config_path = temp_dir.path().join(".envx").join("config.yaml");
446 let config = ProjectConfig::load(&config_path).unwrap();
447
448 assert_eq!(config.name, Some("my-app".to_string()));
449 assert!(config.required.is_empty());
450 assert!(config.defaults.is_empty());
451 assert_eq!(config.auto_load, vec![".env".to_string()]);
452 }
453
454 #[test]
455 fn test_find_and_load_in_current_dir() {
456 let (mut manager, temp_dir) = create_test_project_manager();
457
458 manager.init(Some("test".to_string())).unwrap();
460
461 let result = manager.find_and_load();
462 assert!(result.is_ok());
463
464 let found_path = result.unwrap();
465 assert!(found_path.is_some());
466 assert_eq!(found_path.unwrap(), temp_dir.path());
467 assert!(manager.config.is_some());
468 }
469
470 #[test]
471 fn test_find_and_load_in_parent_dir() {
472 let temp_dir = TempDir::new().unwrap();
473 let parent_dir = temp_dir.path();
474 let child_dir = parent_dir.join("subdir");
475 fs::create_dir(&child_dir).unwrap();
476
477 let parent_manager = ProjectManager {
479 config_dir: parent_dir.join(".envx"),
480 config: None,
481 current_dir: parent_dir.to_path_buf(),
482 };
483 parent_manager.init(Some("parent-project".to_string())).unwrap();
484
485 let mut child_manager = ProjectManager {
487 config_dir: PathBuf::from(".envx"),
488 config: None,
489 current_dir: child_dir,
490 };
491
492 let result = child_manager.find_and_load();
493 assert!(result.is_ok());
494
495 let found_path = result.unwrap();
496 assert!(found_path.is_some());
497 assert_eq!(found_path.unwrap(), parent_dir);
498 assert!(child_manager.config.is_some());
499 }
500
501 #[test]
502 fn test_find_and_load_not_found() {
503 let (mut manager, _temp) = create_test_project_manager();
504
505 let result = manager.find_and_load();
506 assert!(result.is_ok());
507 assert!(result.unwrap().is_none());
508 assert!(manager.config.is_none());
509 }
510
511 #[test]
512 fn test_apply_loads_env_files() {
513 let (mut manager, temp_dir) = create_test_project_manager();
514 let mut env_manager = create_test_env_manager();
515 let mut profile_manager = create_test_profile_manager();
516
517 let env_content = "TEST_VAR=test_value\nANOTHER_VAR=another_value";
519 fs::write(temp_dir.path().join(".env"), env_content).unwrap();
520
521 let mut config = create_test_config();
523 config.auto_load = vec![".env".to_string()];
524 manager.config = Some(config);
525
526 let result = manager.apply(&mut env_manager, &mut profile_manager);
527 assert!(result.is_ok());
528
529 assert_eq!(env_manager.get("TEST_VAR").unwrap().value, "test_value");
530 assert_eq!(env_manager.get("ANOTHER_VAR").unwrap().value, "another_value");
531 }
532
533 #[test]
534 fn test_apply_sets_defaults() {
535 let (mut manager, _temp) = create_test_project_manager();
536 let mut env_manager = create_test_env_manager();
537 let mut profile_manager = create_test_profile_manager();
538
539 manager.config = Some(create_test_config());
540
541 let result = manager.apply(&mut env_manager, &mut profile_manager);
542 assert!(result.is_ok());
543
544 assert_eq!(env_manager.get("NODE_ENV").unwrap().value, "development");
545 assert_eq!(env_manager.get("PORT").unwrap().value, "3000");
546 }
547
548 #[test]
549 fn test_apply_doesnt_override_existing() {
550 let (mut manager, _temp) = create_test_project_manager();
551 let mut env_manager = create_test_env_manager();
552 let mut profile_manager = create_test_profile_manager();
553
554 env_manager.set("NODE_ENV", "production", false).unwrap();
556
557 manager.config = Some(create_test_config());
558
559 manager.apply(&mut env_manager, &mut profile_manager).unwrap();
560
561 assert_eq!(env_manager.get("NODE_ENV").unwrap().value, "production");
563 }
564
565 #[test]
566 fn test_apply_no_config_error() {
567 let (manager, _temp) = create_test_project_manager();
568 let mut env_manager = create_test_env_manager();
569 let mut profile_manager = create_test_profile_manager();
570
571 let result = manager.apply(&mut env_manager, &mut profile_manager);
572 assert!(result.is_err());
573 assert!(
574 result
575 .unwrap_err()
576 .to_string()
577 .contains("No project configuration loaded")
578 );
579 }
580
581 #[test]
582 fn test_validate_all_present_and_valid() {
583 let (mut manager, _temp) = create_test_project_manager();
584 let mut env_manager = create_test_env_manager();
585
586 env_manager
588 .set("DATABASE_URL", "postgresql://localhost/mydb", false)
589 .unwrap();
590 env_manager.set("API_KEY", "secret-key", false).unwrap();
591
592 manager.config = Some(create_test_config());
593
594 let report = manager.validate(&env_manager).unwrap();
595 assert!(report.success);
596 assert!(report.missing.is_empty());
597 assert!(report.errors.is_empty());
598 assert_eq!(report.found.len(), 2);
599 }
600
601 #[test]
602 fn test_validate_missing_variables() {
603 let (mut manager, _temp) = create_test_project_manager();
604 let env_manager = create_test_env_manager();
605
606 manager.config = Some(create_test_config());
607
608 let report = manager.validate(&env_manager).unwrap();
609 assert!(!report.success);
610 assert_eq!(report.missing.len(), 2);
611
612 let missing_names: Vec<&str> = report.missing.iter().map(|m| m.name.as_str()).collect();
613 assert!(missing_names.contains(&"DATABASE_URL"));
614 assert!(missing_names.contains(&"API_KEY"));
615 }
616
617 #[test]
618 fn test_validate_pattern_mismatch() {
619 let (mut manager, _temp) = create_test_project_manager();
620 let mut env_manager = create_test_env_manager();
621
622 env_manager.set("DATABASE_URL", "invalid-url", false).unwrap();
624 env_manager.set("API_KEY", "valid-key", false).unwrap();
625
626 manager.config = Some(create_test_config());
627
628 let report = manager.validate(&env_manager).unwrap();
629 assert!(!report.success);
630 assert_eq!(report.errors.len(), 1);
631 assert_eq!(report.errors[0].var_name, "DATABASE_URL");
632 assert!(matches!(report.errors[0].error_type, ErrorType::PatternMismatch));
633 }
634
635 #[test]
636 fn test_validate_strict_names() {
637 let (mut manager, _temp) = create_test_project_manager();
638 let mut env_manager = create_test_env_manager();
639
640 env_manager.vars.insert(
642 "invalid-name".to_string(),
643 crate::EnvVar {
644 name: "invalid-name".to_string(),
645 value: "value".to_string(),
646 source: crate::EnvVarSource::User,
647 modified: chrono::Utc::now(),
648 original_value: None,
649 },
650 );
651
652 let mut config = create_test_config();
653 config.validation.strict_names = true;
654 manager.config = Some(config);
655
656 let report = manager.validate(&env_manager).unwrap();
657 assert!(!report.warnings.is_empty());
658 assert!(report.warnings.iter().any(|w| w.var_name == "invalid-name"));
659 }
660
661 #[test]
662 fn test_run_script_success() {
663 let (mut manager, _temp) = create_test_project_manager();
664 let mut env_manager = create_test_env_manager();
665
666 manager.config = Some(create_test_config());
667
668 let result = manager.run_script("test", &mut env_manager);
669 assert!(result.is_ok());
670
671 assert_eq!(env_manager.get("NODE_ENV").unwrap().value, "test");
673 }
674
675 #[test]
676 fn test_run_script_not_found() {
677 let (mut manager, _temp) = create_test_project_manager();
678 let mut env_manager = create_test_env_manager();
679
680 manager.config = Some(create_test_config());
681
682 let result = manager.run_script("nonexistent", &mut env_manager);
683 assert!(result.is_err());
684 assert!(
685 result
686 .unwrap_err()
687 .to_string()
688 .contains("Script 'nonexistent' not found")
689 );
690 }
691
692 #[test]
693 fn test_run_script_no_config() {
694 let (manager, _temp) = create_test_project_manager();
695 let mut env_manager = create_test_env_manager();
696
697 let result = manager.run_script("test", &mut env_manager);
698 assert!(result.is_err());
699 assert!(
700 result
701 .unwrap_err()
702 .to_string()
703 .contains("No project configuration loaded")
704 );
705 }
706
707 #[test]
708 fn test_load_env_file_basic() {
709 let temp_dir = TempDir::new().unwrap();
710 let env_path = temp_dir.path().join(".env");
711 let mut env_manager = create_test_env_manager();
712
713 let content = r#"
714# Comment line
715VAR1=value1
716VAR2="quoted value"
717VAR3='single quoted'
718EMPTY_LINE_ABOVE=yes
719
720# Another comment
721VAR_WITH_SPACES = spaced value
722"#;
723 fs::write(&env_path, content).unwrap();
724
725 ProjectManager::load_env_file(&env_path, &mut env_manager).unwrap();
726
727 assert_eq!(env_manager.get("VAR1").unwrap().value, "value1");
728 assert_eq!(env_manager.get("VAR2").unwrap().value, "quoted value");
729 assert_eq!(env_manager.get("VAR3").unwrap().value, "single quoted");
730 assert_eq!(env_manager.get("EMPTY_LINE_ABOVE").unwrap().value, "yes");
731 assert_eq!(env_manager.get("VAR_WITH_SPACES").unwrap().value, "spaced value");
732 }
733
734 #[test]
735 fn test_load_env_file_edge_cases() {
736 let temp_dir = TempDir::new().unwrap();
737 let env_path = temp_dir.path().join(".env");
738 let mut env_manager = create_test_env_manager();
739
740 let content = r"
741# Edge cases
742EMPTY_VALUE=
743EQUALS_IN_VALUE=key=value=more
744URL=https://example.com/path?query=value
745MULTILINE_ATTEMPT=line1\nline2
746SPECIAL_CHARS=!@#$%^&*()
747";
748 fs::write(&env_path, content).unwrap();
749
750 ProjectManager::load_env_file(&env_path, &mut env_manager).unwrap();
751
752 assert_eq!(env_manager.get("EMPTY_VALUE").unwrap().value, "");
753 assert_eq!(env_manager.get("EQUALS_IN_VALUE").unwrap().value, "key=value=more");
754 assert_eq!(
755 env_manager.get("URL").unwrap().value,
756 "https://example.com/path?query=value"
757 );
758 assert_eq!(env_manager.get("MULTILINE_ATTEMPT").unwrap().value, "line1\\nline2");
759 assert_eq!(env_manager.get("SPECIAL_CHARS").unwrap().value, "!@#$%^&*()");
760 }
761
762 #[test]
763 fn test_load_env_file_not_found() {
764 let mut env_manager = create_test_env_manager();
765 let result = ProjectManager::load_env_file(Path::new("/nonexistent/.env"), &mut env_manager);
766 assert!(result.is_err());
767 }
768
769 #[test]
770 fn test_is_valid_var_name() {
771 assert!(is_valid_var_name("VAR"));
773 assert!(is_valid_var_name("VAR_NAME"));
774 assert!(is_valid_var_name("_PRIVATE"));
775 assert!(is_valid_var_name("var123"));
776 assert!(is_valid_var_name("V"));
777 assert!(is_valid_var_name("VERY_LONG_VARIABLE_NAME_WITH_UNDERSCORES"));
778
779 assert!(!is_valid_var_name("123VAR")); assert!(!is_valid_var_name("VAR-NAME")); assert!(!is_valid_var_name("VAR NAME")); assert!(!is_valid_var_name("VAR.NAME")); assert!(!is_valid_var_name("")); assert!(!is_valid_var_name("VAR$")); assert!(!is_valid_var_name("@VAR")); }
788
789 #[test]
790 fn test_validation_report_success_calculation() {
791 let mut report = ValidationReport::default();
792 assert!(!report.success);
794
795 report.success = report.errors.is_empty() && report.missing.is_empty();
797 assert!(report.success); report.missing.push(MissingVar {
801 name: "VAR".to_string(),
802 description: None,
803 example: None,
804 });
805 report.success = report.errors.is_empty() && report.missing.is_empty();
806 assert!(!report.success);
807
808 report.missing.clear();
810 report.success = report.errors.is_empty() && report.missing.is_empty();
811 assert!(report.success);
812
813 report.errors.push(ValidationError {
815 var_name: "VAR".to_string(),
816 error_type: ErrorType::PatternMismatch,
817 message: "error".to_string(),
818 });
819 report.success = report.errors.is_empty() && report.missing.is_empty();
820 assert!(!report.success);
821
822 report.errors.clear();
824 report.missing.clear();
825 report.success = report.errors.is_empty() && report.missing.is_empty();
826 assert!(report.success);
827 }
828}