envx_core/
analysis.rs

1use crate::EnvVar;
2use std::collections::{HashMap, HashSet};
3use std::path::Path;
4
5#[derive(Debug)]
6pub struct ValidationResult {
7    pub valid: bool,
8    pub errors: Vec<String>,
9    pub warnings: Vec<String>,
10}
11
12pub struct Analyzer {
13    vars: Vec<EnvVar>,
14}
15
16impl Analyzer {
17    #[must_use]
18    pub const fn new(vars: Vec<EnvVar>) -> Self {
19        Self { vars }
20    }
21
22    pub fn find_duplicates(&self) -> HashMap<String, Vec<&EnvVar>> {
23        let mut duplicates = HashMap::new();
24        let mut seen = HashMap::new();
25
26        for var in &self.vars {
27            seen.entry(var.name.to_uppercase()).or_insert_with(Vec::new).push(var);
28        }
29
30        for (name, vars) in seen {
31            if vars.len() > 1 {
32                duplicates.insert(name, vars);
33            }
34        }
35
36        duplicates
37    }
38
39    #[must_use]
40    pub fn validate_all(&self) -> HashMap<String, ValidationResult> {
41        let mut results = HashMap::new();
42
43        for var in &self.vars {
44            let mut errors = Vec::new();
45            let mut warnings = Vec::new();
46
47            // Check for common issues
48            if var.name.is_empty() {
49                errors.push("Variable name is empty".to_string());
50            }
51
52            if var.name.contains(' ') {
53                errors.push("Variable name contains spaces".to_string());
54            }
55
56            if var.name.starts_with(|c: char| c.is_numeric()) {
57                errors.push("Variable name starts with a number".to_string());
58            }
59
60            // Check for PATH-like variables
61            if var.name.to_uppercase().ends_with("PATH") {
62                let path_analyzer = PathAnalyzer::new(&var.value);
63                let path_result = path_analyzer.analyze();
64                errors.extend(path_result.errors);
65                warnings.extend(path_result.warnings);
66            }
67
68            let valid = errors.is_empty();
69            results.insert(
70                var.name.clone(),
71                ValidationResult {
72                    valid,
73                    errors,
74                    warnings,
75                },
76            );
77        }
78
79        results
80    }
81
82    #[must_use]
83    pub fn find_unused(&self) -> Vec<&EnvVar> {
84        // This is a simplified version - real implementation would check running processes
85        self.vars
86            .iter()
87            .filter(|v| {
88                // Check for common unused patterns
89                v.name.starts_with("OLD_")
90                    || v.name.starts_with("BACKUP_")
91                    || v.name.ends_with("_OLD")
92                    || v.name.ends_with("_BACKUP")
93            })
94            .collect()
95    }
96
97    #[must_use]
98    pub fn analyze_dependencies(&self) -> HashMap<String, Vec<String>> {
99        let mut deps = HashMap::new();
100
101        for var in &self.vars {
102            let mut var_deps = Vec::new();
103
104            // Check if this variable references other variables
105            for other in &self.vars {
106                if var.name != other.name && !other.name.is_empty() {
107                    // Windows style: %VAR_NAME%
108                    let pattern_windows = format!("%{}%", other.name);
109                    // Unix style with braces: ${VAR_NAME}
110                    let pattern_unix_braces = format!("${{{}}}", other.name);
111
112                    if var.value.contains(&pattern_windows) || var.value.contains(&pattern_unix_braces) {
113                        var_deps.push(other.name.clone());
114                    } else {
115                        // For $VAR_NAME pattern, we need to be more careful
116                        // to avoid matching just $ at the end of string
117                        let unix_pattern = format!("${}", other.name);
118                        // Check if followed by a non-alphanumeric character or end of string
119                        if let Some(pos) = var.value.find(&unix_pattern) {
120                            let next_pos = pos + unix_pattern.len();
121                            if next_pos == var.value.len()
122                                || !var
123                                    .value
124                                    .chars()
125                                    .nth(next_pos)
126                                    .is_some_and(|c| c.is_alphanumeric() || c == '_')
127                            {
128                                var_deps.push(other.name.clone());
129                            }
130                        }
131                    }
132                }
133            }
134
135            // Remove duplicates
136            var_deps.sort();
137            var_deps.dedup();
138
139            if !var_deps.is_empty() {
140                deps.insert(var.name.clone(), var_deps);
141            }
142        }
143
144        deps
145    }
146}
147
148pub struct PathAnalyzer {
149    paths: Vec<String>,
150}
151
152impl PathAnalyzer {
153    #[must_use]
154    pub fn new(path_value: &str) -> Self {
155        let separator = if cfg!(windows) { ';' } else { ':' };
156        let paths = path_value
157            .split(separator)
158            .map(std::string::ToString::to_string)
159            .collect();
160
161        Self { paths }
162    }
163
164    #[must_use]
165    pub fn analyze(&self) -> ValidationResult {
166        let mut errors = Vec::new();
167        let mut warnings = Vec::new();
168        let mut seen = HashSet::new();
169
170        for path_str in &self.paths {
171            if path_str.is_empty() {
172                warnings.push("Empty path entry found".to_string());
173                continue;
174            }
175
176            // Check for duplicates
177            if !seen.insert(path_str.to_lowercase()) {
178                warnings.push(format!("Duplicate path entry: {path_str}"));
179            }
180
181            // Check if path exists
182            let path = Path::new(path_str);
183            if !path.exists() {
184                errors.push(format!("Path does not exist: {path_str}"));
185            } else if !path.is_dir() {
186                errors.push(format!("Path is not a directory: {path_str}"));
187            }
188
189            // Check for common issues
190            if path_str.contains("..") {
191                warnings.push(format!("Path contains relative parent reference: {path_str}"));
192            }
193
194            #[cfg(windows)]
195            {
196                if path_str.contains('/') {
197                    warnings.push(format!("Path uses Unix-style separators on Windows: {path_str}"));
198                }
199            }
200
201            #[cfg(unix)]
202            {
203                if path_str.contains('\\') {
204                    warnings.push(format!("Path uses Windows-style separators on Unix: {path_str}"));
205                }
206            }
207        }
208
209        ValidationResult {
210            valid: errors.is_empty(),
211            errors,
212            warnings,
213        }
214    }
215
216    #[must_use]
217    pub fn get_duplicates(&self) -> Vec<String> {
218        let mut seen = HashSet::new();
219        let mut duplicates = Vec::new();
220
221        for path in &self.paths {
222            let normalized = path.to_lowercase();
223            if !seen.insert(normalized.clone()) {
224                duplicates.push(path.clone());
225            }
226        }
227
228        duplicates
229    }
230
231    #[must_use]
232    pub fn get_invalid(&self) -> Vec<String> {
233        self.paths.iter().filter(|p| !Path::new(p).exists()).cloned().collect()
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::{EnvVar, EnvVarSource};
241    use chrono::Utc;
242    use std::fs;
243    use tempfile::TempDir;
244
245    // Helper function to create test environment variables
246    fn create_test_var(name: &str, value: &str) -> EnvVar {
247        EnvVar {
248            name: name.to_string(),
249            value: value.to_string(),
250            source: EnvVarSource::User,
251            modified: Utc::now(),
252            original_value: None,
253        }
254    }
255
256    // Helper to create test variables with different sources
257    fn create_test_vars() -> Vec<EnvVar> {
258        vec![
259            create_test_var("PATH", "/usr/bin:/usr/local/bin"),
260            create_test_var("HOME", "/home/user"),
261            create_test_var("JAVA_HOME", "/usr/lib/jvm/java-11"),
262            create_test_var("PYTHON_PATH", "/usr/bin/python"),
263            create_test_var("OLD_PATH", "/old/path"),
264            create_test_var("BACKUP_HOME", "/backup/home"),
265            create_test_var("API_KEY", "secret123"),
266            create_test_var("DATABASE_URL", "postgres://localhost:5432/db"),
267            create_test_var("APP_CONFIG", "${HOME}/config:${JAVA_HOME}/conf"),
268            create_test_var("FULL_PATH", "%PATH%;%JAVA_HOME%\\bin"),
269        ]
270    }
271
272    #[test]
273    fn test_analyzer_new() {
274        let vars = create_test_vars();
275        let analyzer = Analyzer::new(vars.clone());
276        assert_eq!(analyzer.vars.len(), vars.len());
277    }
278
279    #[test]
280    fn test_find_duplicates_no_duplicates() {
281        let vars = create_test_vars();
282        let analyzer = Analyzer::new(vars);
283
284        let duplicates = analyzer.find_duplicates();
285        assert!(duplicates.is_empty());
286    }
287
288    #[test]
289    fn test_find_duplicates_with_case_variations() {
290        let vars = vec![
291            create_test_var("PATH", "/usr/bin"),
292            create_test_var("Path", "/usr/local/bin"),
293            create_test_var("path", "/bin"),
294            create_test_var("HOME", "/home/user"),
295            create_test_var("home", "/home/user2"),
296        ];
297
298        let analyzer = Analyzer::new(vars);
299        let duplicates = analyzer.find_duplicates();
300
301        assert_eq!(duplicates.len(), 2); // PATH and HOME groups
302        assert_eq!(duplicates.get("PATH").unwrap().len(), 3);
303        assert_eq!(duplicates.get("HOME").unwrap().len(), 2);
304    }
305
306    #[test]
307    fn test_validate_all_valid_variables() {
308        let vars = vec![
309            create_test_var("VALID_VAR", "value"),
310            create_test_var("ANOTHER_VAR", "another value"),
311            create_test_var("_UNDERSCORE_START", "value"),
312        ];
313
314        let analyzer = Analyzer::new(vars);
315        let results = analyzer.validate_all();
316
317        for (_, result) in results {
318            assert!(result.valid);
319            assert!(result.errors.is_empty());
320        }
321    }
322
323    #[test]
324    fn test_validate_all_invalid_names() {
325        let vars = vec![
326            create_test_var("", "empty name"),
327            create_test_var("SPACE IN NAME", "value"),
328            create_test_var("123_STARTS_WITH_NUMBER", "value"),
329            create_test_var("VALID_NAME", "value"),
330        ];
331
332        let analyzer = Analyzer::new(vars);
333        let results = analyzer.validate_all();
334
335        // Empty name
336        let empty_result = results.get("").unwrap();
337        assert!(!empty_result.valid);
338        assert!(empty_result.errors.iter().any(|e| e.contains("empty")));
339
340        // Space in name
341        let space_result = results.get("SPACE IN NAME").unwrap();
342        assert!(!space_result.valid);
343        assert!(space_result.errors.iter().any(|e| e.contains("spaces")));
344
345        // Starts with number
346        let number_result = results.get("123_STARTS_WITH_NUMBER").unwrap();
347        assert!(!number_result.valid);
348        assert!(number_result.errors.iter().any(|e| e.contains("number")));
349
350        // Valid name
351        let valid_result = results.get("VALID_NAME").unwrap();
352        assert!(valid_result.valid);
353    }
354
355    #[test]
356    fn test_validate_path_variables() {
357        // Create a temporary directory for testing
358        let temp_dir = TempDir::new().unwrap();
359        let valid_path = temp_dir.path().to_str().unwrap();
360        let invalid_path = "/nonexistent/path/that/does/not/exist";
361
362        let separator = if cfg!(windows) { ";" } else { ":" };
363        let path_value = format!("{valid_path}{separator}{invalid_path}");
364
365        let vars = vec![
366            create_test_var("CUSTOM_PATH", &path_value),
367            create_test_var("EMPTY_PATH", &format!("{valid_path}{separator}")),
368        ];
369
370        let analyzer = Analyzer::new(vars);
371        let results = analyzer.validate_all();
372
373        // CUSTOM_PATH should have errors about non-existent path
374        let custom_result = results.get("CUSTOM_PATH").unwrap();
375        assert!(!custom_result.valid);
376        assert!(custom_result.errors.iter().any(|e| e.contains("does not exist")));
377
378        // EMPTY_PATH should have warning about empty entry
379        let empty_result = results.get("EMPTY_PATH").unwrap();
380        assert!(empty_result.warnings.iter().any(|w| w.contains("Empty path entry")));
381    }
382
383    #[test]
384    fn test_find_unused() {
385        let vars = vec![
386            create_test_var("ACTIVE_VAR", "value"),
387            create_test_var("OLD_CONFIG", "old value"),
388            create_test_var("BACKUP_PATH", "backup"),
389            create_test_var("DATA_OLD", "old data"),
390            create_test_var("CONFIG_BACKUP", "backup config"),
391            create_test_var("CURRENT_VAR", "current"),
392        ];
393
394        let analyzer = Analyzer::new(vars);
395        let unused = analyzer.find_unused();
396
397        assert_eq!(unused.len(), 4);
398        let unused_names: Vec<&str> = unused.iter().map(|v| v.name.as_str()).collect();
399        assert!(unused_names.contains(&"OLD_CONFIG"));
400        assert!(unused_names.contains(&"BACKUP_PATH"));
401        assert!(unused_names.contains(&"DATA_OLD"));
402        assert!(unused_names.contains(&"CONFIG_BACKUP"));
403        assert!(!unused_names.contains(&"ACTIVE_VAR"));
404        assert!(!unused_names.contains(&"CURRENT_VAR"));
405    }
406
407    #[test]
408    fn test_analyze_dependencies_no_deps() {
409        let vars = vec![
410            create_test_var("VAR1", "value1"),
411            create_test_var("VAR2", "value2"),
412            create_test_var("VAR3", "value3"),
413        ];
414
415        let analyzer = Analyzer::new(vars);
416        let deps = analyzer.analyze_dependencies();
417        assert!(deps.is_empty());
418    }
419
420    #[test]
421    fn test_analyze_dependencies_with_references() {
422        let vars = vec![
423            create_test_var("HOME", "/home/user"),
424            create_test_var("JAVA_HOME", "/usr/lib/jvm/java"),
425            create_test_var("CONFIG_PATH", "${HOME}/config"),
426            create_test_var("JAVA_BIN", "${JAVA_HOME}/bin"),
427            create_test_var("FULL_PATH", "${HOME}/bin:${JAVA_HOME}/bin"),
428            create_test_var("WINDOWS_PATH", "%HOME%;%JAVA_HOME%"),
429        ];
430
431        let analyzer = Analyzer::new(vars);
432        let deps = analyzer.analyze_dependencies();
433
434        // CONFIG_PATH depends on HOME
435        assert!(deps.contains_key("CONFIG_PATH"));
436        assert_eq!(deps.get("CONFIG_PATH").unwrap(), &vec!["HOME".to_string()]);
437
438        // JAVA_BIN depends on JAVA_HOME
439        assert!(deps.contains_key("JAVA_BIN"));
440        assert_eq!(deps.get("JAVA_BIN").unwrap(), &vec!["JAVA_HOME".to_string()]);
441
442        // FULL_PATH depends on both HOME and JAVA_HOME
443        assert!(deps.contains_key("FULL_PATH"));
444        let full_path_deps = deps.get("FULL_PATH").unwrap();
445        assert_eq!(full_path_deps.len(), 2);
446        assert!(full_path_deps.contains(&"HOME".to_string()));
447        assert!(full_path_deps.contains(&"JAVA_HOME".to_string()));
448
449        // WINDOWS_PATH also has dependencies
450        assert!(deps.contains_key("WINDOWS_PATH"));
451        let windows_deps = deps.get("WINDOWS_PATH").unwrap();
452        assert_eq!(windows_deps.len(), 2);
453    }
454
455    #[test]
456    fn test_path_analyzer_new() {
457        let path_value = if cfg!(windows) {
458            "C:\\Windows;C:\\Program Files;C:\\Users"
459        } else {
460            "/usr/bin:/usr/local/bin:/home/user/bin"
461        };
462
463        let analyzer = PathAnalyzer::new(path_value);
464        assert_eq!(analyzer.paths.len(), 3);
465    }
466
467    #[test]
468    fn test_path_analyzer_empty_entries() {
469        let separator = if cfg!(windows) { ";" } else { ":" };
470        let path_value = format!("/path1{separator}{separator}/path2");
471
472        let analyzer = PathAnalyzer::new(&path_value);
473        let result = analyzer.analyze();
474
475        assert!(result.warnings.iter().any(|w| w.contains("Empty path entry")));
476    }
477
478    #[test]
479    fn test_path_analyzer_duplicate_detection() {
480        let separator = if cfg!(windows) { ";" } else { ":" };
481        let path_value = format!("/path1{separator}/path2{separator}/path1{separator}/PATH1");
482
483        let analyzer = PathAnalyzer::new(&path_value);
484        let result = analyzer.analyze();
485
486        assert!(result.warnings.iter().any(|w| w.contains("Duplicate")));
487
488        let duplicates = analyzer.get_duplicates();
489        assert!(!duplicates.is_empty());
490    }
491
492    #[test]
493    fn test_path_analyzer_invalid_paths() {
494        let path_value = if cfg!(windows) {
495            "C:\\NonExistent;C:\\AlsoNonExistent"
496        } else {
497            "/nonexistent:/also/nonexistent"
498        };
499
500        let analyzer = PathAnalyzer::new(path_value);
501        let result = analyzer.analyze();
502
503        assert!(!result.valid);
504        assert!(result.errors.iter().any(|e| e.contains("does not exist")));
505
506        let invalid = analyzer.get_invalid();
507        assert_eq!(invalid.len(), 2);
508    }
509
510    #[test]
511    fn test_path_analyzer_relative_paths() {
512        let separator = if cfg!(windows) { ";" } else { ":" };
513        let path_value = format!("/absolute/path{separator}../relative/path");
514
515        let analyzer = PathAnalyzer::new(&path_value);
516        let result = analyzer.analyze();
517
518        assert!(result.warnings.iter().any(|w| w.contains("relative parent reference")));
519    }
520
521    #[test]
522    #[cfg(windows)]
523    fn test_path_analyzer_wrong_separators_windows() {
524        let path_value = "C:\\Windows;/unix/style/path";
525
526        let analyzer = PathAnalyzer::new(path_value);
527        let result = analyzer.analyze();
528
529        assert!(result.warnings.iter().any(|w| w.contains("Unix-style separators")));
530    }
531
532    #[test]
533    #[cfg(unix)]
534    fn test_path_analyzer_wrong_separators_unix() {
535        let path_value = "/usr/bin:C:\\Windows\\Style\\Path";
536
537        let analyzer = PathAnalyzer::new(path_value);
538        let result = analyzer.analyze();
539
540        assert!(result.warnings.iter().any(|w| w.contains("Windows-style separators")));
541    }
542
543    #[test]
544    fn test_path_analyzer_file_not_directory() {
545        // Create a temporary file (not directory)
546        let temp_dir = TempDir::new().unwrap();
547        let temp_file = temp_dir.path().join("test.txt");
548        fs::write(&temp_file, "test").unwrap();
549
550        let path_value = temp_file.to_str().unwrap();
551        let analyzer = PathAnalyzer::new(path_value);
552        let result = analyzer.analyze();
553
554        assert!(!result.valid);
555        assert!(result.errors.iter().any(|e| e.contains("not a directory")));
556    }
557
558    #[test]
559    fn test_complex_validation_scenario() {
560        let temp_dir = TempDir::new().unwrap();
561        let valid_path = temp_dir.path().to_str().unwrap();
562        let separator = if cfg!(windows) { ";" } else { ":" };
563
564        let vars = vec![
565            create_test_var("", "empty name"),
566            create_test_var("SPACE NAME", "value"),
567            create_test_var("123START", "value"),
568            create_test_var("VALID_PATH", valid_path),
569            create_test_var("INVALID_PATH", "/nonexistent"),
570            create_test_var("MIXED_PATH", &format!("{valid_path}{separator}/nonexistent")),
571            create_test_var("OLD_VAR", "old value"),
572            create_test_var("REF_VAR", "${VALID_PATH}/subdir"),
573        ];
574
575        let analyzer = Analyzer::new(vars);
576
577        // Test validation
578        let validation_results = analyzer.validate_all();
579        assert!(!validation_results.get("").unwrap().valid);
580        assert!(!validation_results.get("SPACE NAME").unwrap().valid);
581        assert!(!validation_results.get("123START").unwrap().valid);
582        assert!(validation_results.get("VALID_PATH").unwrap().valid);
583        assert!(!validation_results.get("INVALID_PATH").unwrap().valid);
584        assert!(!validation_results.get("MIXED_PATH").unwrap().valid);
585
586        // Test unused detection
587        let unused = analyzer.find_unused();
588        assert!(unused.iter().any(|v| v.name == "OLD_VAR"));
589
590        // Test dependency analysis
591        let deps = analyzer.analyze_dependencies();
592        assert!(deps.contains_key("REF_VAR"));
593        assert_eq!(deps.get("REF_VAR").unwrap(), &vec!["VALID_PATH".to_string()]);
594    }
595
596    #[test]
597    fn test_validation_result_structure() {
598        let result = ValidationResult {
599            valid: false,
600            errors: vec!["Error 1".to_string(), "Error 2".to_string()],
601            warnings: vec!["Warning 1".to_string()],
602        };
603
604        assert!(!result.valid);
605        assert_eq!(result.errors.len(), 2);
606        assert_eq!(result.warnings.len(), 1);
607    }
608
609    #[test]
610    fn test_case_insensitive_duplicate_detection() {
611        let vars = vec![
612            create_test_var("path", "/lower"),
613            create_test_var("PATH", "/upper"),
614            create_test_var("Path", "/mixed"),
615            create_test_var("pAtH", "/weird"),
616        ];
617
618        let analyzer = Analyzer::new(vars);
619        let duplicates = analyzer.find_duplicates();
620
621        assert_eq!(duplicates.len(), 1);
622        assert!(duplicates.contains_key("PATH"));
623        assert_eq!(duplicates.get("PATH").unwrap().len(), 4);
624    }
625
626    #[test]
627    fn test_empty_analyzer() {
628        let analyzer = Analyzer::new(vec![]);
629
630        assert!(analyzer.find_duplicates().is_empty());
631        assert!(analyzer.validate_all().is_empty());
632        assert!(analyzer.find_unused().is_empty());
633        assert!(analyzer.analyze_dependencies().is_empty());
634    }
635
636    #[test]
637    fn test_circular_dependencies() {
638        let vars = vec![
639            create_test_var("VAR_A", "${VAR_B}/a"),
640            create_test_var("VAR_B", "${VAR_C}/b"),
641            create_test_var("VAR_C", "${VAR_A}/c"),
642        ];
643
644        let analyzer = Analyzer::new(vars);
645        let deps = analyzer.analyze_dependencies();
646
647        assert!(deps.contains_key("VAR_A"));
648        assert!(deps.contains_key("VAR_B"));
649        assert!(deps.contains_key("VAR_C"));
650        assert_eq!(deps.get("VAR_A").unwrap(), &vec!["VAR_B".to_string()]);
651        assert_eq!(deps.get("VAR_B").unwrap(), &vec!["VAR_C".to_string()]);
652        assert_eq!(deps.get("VAR_C").unwrap(), &vec!["VAR_A".to_string()]);
653    }
654
655    #[test]
656    fn test_multiple_dependency_formats() {
657        let vars = vec![
658            create_test_var("BASE", "/base"),
659            create_test_var("DEP1", "$BASE/path"),            // Unix style
660            create_test_var("DEP2", "${BASE}/path"),          // Unix style with braces
661            create_test_var("DEP3", "%BASE%\\path"),          // Windows style
662            create_test_var("MULTI", "${BASE}:$BASE:%BASE%"), // Multiple references
663        ];
664
665        let analyzer = Analyzer::new(vars);
666        let deps = analyzer.analyze_dependencies();
667
668        assert!(deps.contains_key("DEP1"));
669        assert!(deps.contains_key("DEP2"));
670        assert!(deps.contains_key("DEP3"));
671        assert!(deps.contains_key("MULTI"));
672
673        // All should depend on BASE
674        assert_eq!(deps.get("DEP1").unwrap(), &vec!["BASE".to_string()]);
675        assert_eq!(deps.get("DEP2").unwrap(), &vec!["BASE".to_string()]);
676        assert_eq!(deps.get("DEP3").unwrap(), &vec!["BASE".to_string()]);
677        assert_eq!(deps.get("MULTI").unwrap(), &vec!["BASE".to_string()]);
678    }
679}