envx_core/
analysis.rs

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