envx_core/
project_manager.rs

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    /// Create a new `ProjectManager` instance
16    ///
17    /// # Errors
18    ///
19    /// This function will return an error if getting the current directory fails.
20    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    /// Initialize a new project configuration
29    ///
30    /// # Errors
31    ///
32    /// This function will return an error if:
33    /// - Creating the .envx directory fails
34    /// - Saving the configuration file fails
35    /// - Writing the .gitignore file fails
36    pub fn init(&self, name: Option<String>) -> Result<()> {
37        // Create .envx directory
38        fs::create_dir_all(&self.config_dir)?;
39
40        // Create config.yaml
41        let config = ProjectConfig::new(name);
42        let config_path = self.config_dir.join("config.yaml");
43        config.save(&config_path)?;
44
45        // Create .gitignore for .envx directory
46        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    /// Find and load project configuration
56    ///
57    /// # Errors
58    ///
59    /// This function will return an error if loading the project configuration file fails.
60    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    /// Apply project configuration
79    ///
80    /// # Errors
81    ///
82    /// This function will return an error if:
83    /// - No project configuration is loaded
84    /// - Profile application fails
85    /// - Loading environment files fails
86    /// - Setting environment variables fails
87    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        // Apply profile if specified
94        if let Some(profile_name) = &config.profile {
95            profile_manager.apply(profile_name, manager)?;
96        }
97
98        // Load auto-load files
99        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        // Apply defaults (only if variable not already set)
107        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    /// Validate required variables
117    ///
118    /// # Errors
119    ///
120    /// This function will return an error if:
121    /// - No project configuration is loaded
122    /// - Regex compilation fails for pattern validation
123    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        // Check required variables
132        for required in &config.required {
133            match manager.get(&required.name) {
134                Some(var) => {
135                    // Validate pattern if specified
136                    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        // Check validation rules
159        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    /// Run a project script
175    ///
176    /// # Errors
177    ///
178    /// This function will return an error if:
179    /// - No project configuration is loaded
180    /// - The specified script is not found in the configuration
181    /// - Setting environment variables fails
182    /// - The script execution fails
183    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        // Apply script-specific environment variables
195        for (name, value) in &script.env {
196            manager.set(name, value, false)?;
197        }
198
199        // Execute the script
200        #[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    // Unix/Windows compatible variable name
270    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        // Add required variables
308        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        // Add defaults
323        config
324            .defaults
325            .insert("NODE_ENV".to_string(), "development".to_string());
326        config.defaults.insert("PORT".to_string(), "3000".to_string());
327
328        // Add scripts
329        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        // Verify .envx directory exists
362        let envx_dir = temp_dir.path().join(".envx");
363        assert!(envx_dir.exists());
364        assert!(envx_dir.is_dir());
365
366        // Verify config.yaml exists
367        let config_path = envx_dir.join("config.yaml");
368        assert!(config_path.exists());
369
370        // Verify .gitignore exists
371        let gitignore_path = envx_dir.join(".gitignore");
372        assert!(gitignore_path.exists());
373
374        // Verify gitignore content
375        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        // Create config in current dir
400        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        // Create config in parent
419        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        // Try to find from child
427        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        // Create .env file
459        let env_content = "TEST_VAR=test_value\nANOTHER_VAR=another_value";
460        fs::write(temp_dir.path().join(".env"), env_content).unwrap();
461
462        // Create config with auto_load
463        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        // Set a variable that's also in defaults
496        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        // Should not override existing value
503        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        // Set required variables
528        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        // Set with invalid pattern
564        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        // Set variable with invalid name
582        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        // Verify script environment was applied
613        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        // Valid names
713        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        // Invalid names
721        assert!(!is_valid_var_name("123VAR")); // Starts with number
722        assert!(!is_valid_var_name("VAR-NAME")); // Contains dash
723        assert!(!is_valid_var_name("VAR NAME")); // Contains space
724        assert!(!is_valid_var_name("VAR.NAME")); // Contains dot
725        assert!(!is_valid_var_name("")); // Empty
726        assert!(!is_valid_var_name("VAR$")); // Contains special char
727        assert!(!is_valid_var_name("@VAR")); // Starts with special char
728    }
729
730    #[test]
731    fn test_validation_report_success_calculation() {
732        let mut report = ValidationReport::default();
733        // Default should be false because Default trait initializes bool as false
734        assert!(!report.success);
735
736        // Manually set success based on empty errors and missing
737        report.success = report.errors.is_empty() && report.missing.is_empty();
738        assert!(report.success); // Now it should be true since both are empty
739
740        // Add missing variable
741        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        // Clear missing
750        report.missing.clear();
751        report.success = report.errors.is_empty() && report.missing.is_empty();
752        assert!(report.success);
753
754        // Add error
755        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        // Clear everything
764        report.errors.clear();
765        report.missing.clear();
766        report.success = report.errors.is_empty() && report.missing.is_empty();
767        assert!(report.success);
768    }
769}