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