envx_core/
path.rs

1use color_eyre::Result;
2use std::collections::HashSet;
3use std::path::Path;
4
5/// Manages PATH-like environment variables
6pub struct PathManager {
7    entries: Vec<String>,
8    separator: char,
9}
10
11impl PathManager {
12    #[must_use]
13    pub fn new(path_value: &str) -> Self {
14        let separator = if cfg!(windows) { ';' } else { ':' };
15        let entries = path_value
16            .split(separator)
17            .filter(|s| !s.is_empty())
18            .map(std::string::ToString::to_string)
19            .collect();
20
21        Self { entries, separator }
22    }
23
24    #[must_use]
25    pub fn entries(&self) -> &[String] {
26        &self.entries
27    }
28
29    #[must_use]
30    pub fn len(&self) -> usize {
31        self.entries.len()
32    }
33
34    #[must_use]
35    pub fn is_empty(&self) -> bool {
36        self.entries.is_empty()
37    }
38
39    #[must_use]
40    pub fn contains(&self, path: &str) -> bool {
41        let normalized = Self::normalize_path(path);
42        self.entries.iter().any(|e| Self::normalize_path(e) == normalized)
43    }
44
45    #[must_use]
46    pub fn find_index(&self, path: &str) -> Option<usize> {
47        let normalized = Self::normalize_path(path);
48        self.entries.iter().position(|e| Self::normalize_path(e) == normalized)
49    }
50
51    pub fn add_first(&mut self, path: String) {
52        self.entries.insert(0, path);
53    }
54
55    pub fn add_last(&mut self, path: String) {
56        self.entries.push(path);
57    }
58
59    pub fn remove_first(&mut self, pattern: &str) -> usize {
60        if let Some(idx) = self.find_index(pattern) {
61            self.entries.remove(idx);
62            1
63        } else {
64            0
65        }
66    }
67
68    pub fn remove_all(&mut self, pattern: &str) -> usize {
69        let normalized = Self::normalize_path(pattern);
70        let original_len = self.entries.len();
71
72        // Pre-normalize all entries to avoid borrowing self in the closure
73        let normalized_entries: Vec<String> = self.entries.iter().map(|e| Self::normalize_path(e)).collect();
74
75        // Keep only entries that don't match the normalized pattern
76        let mut new_entries = Vec::new();
77        for (i, entry) in self.entries.iter().enumerate() {
78            if normalized_entries[i] != normalized {
79                new_entries.push(entry.clone());
80            }
81        }
82        self.entries = new_entries;
83
84        original_len - self.entries.len()
85    }
86
87    /// Moves an entry from one position to another in the PATH entries.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if either `from` or `to` index is out of bounds.
92    pub fn move_entry(&mut self, from: usize, to: usize) -> Result<()> {
93        if from >= self.entries.len() || to >= self.entries.len() {
94            return Err(color_eyre::eyre::eyre!("Index out of bounds"));
95        }
96
97        if from == to {
98            return Ok(()); // No-op if moving to same position
99        }
100
101        let entry = self.entries.remove(from);
102
103        self.entries.insert(to, entry);
104
105        Ok(())
106    }
107
108    #[must_use]
109    pub fn get_invalid(&self) -> Vec<String> {
110        self.entries
111            .iter()
112            .filter(|e| !Path::new(e).exists())
113            .cloned()
114            .collect()
115    }
116
117    pub fn remove_invalid(&mut self) -> usize {
118        let original_len = self.entries.len();
119        self.entries.retain(|e| Path::new(e).exists());
120        original_len - self.entries.len()
121    }
122
123    #[must_use]
124    pub fn get_duplicates(&self) -> Vec<String> {
125        let mut seen = HashSet::new();
126        let mut duplicates = Vec::new();
127
128        for entry in &self.entries {
129            let normalized = Self::normalize_path(entry);
130            if !seen.insert(normalized.clone()) {
131                duplicates.push(entry.clone());
132            }
133        }
134
135        duplicates
136    }
137
138    pub fn deduplicate(&mut self, keep_first: bool) -> usize {
139        let mut seen = HashSet::new();
140        let original_len = self.entries.len();
141
142        if keep_first {
143            // Keep first occurrence
144            let mut deduped = Vec::new();
145            for entry in &self.entries {
146                let normalized = Self::normalize_path(entry);
147                if seen.insert(normalized) {
148                    deduped.push(entry.clone());
149                }
150            }
151            self.entries = deduped;
152        } else {
153            // Keep last occurrence
154            let mut deduped = Vec::new();
155            for entry in self.entries.iter().rev() {
156                let normalized = Self::normalize_path(entry);
157                if seen.insert(normalized) {
158                    deduped.push(entry.clone());
159                }
160            }
161            deduped.reverse();
162            self.entries = deduped;
163        }
164
165        original_len - self.entries.len()
166    }
167
168    #[must_use]
169    #[allow(clippy::inherent_to_string)]
170    pub fn to_string(&self) -> String {
171        self.entries.join(&self.separator.to_string())
172    }
173
174    /// Normalize path for comparison (handle case sensitivity and trailing slashes)
175    fn normalize_path(path: &str) -> String {
176        let mut normalized = path.to_string();
177
178        // Remove trailing slashes
179        while normalized.ends_with('/') || normalized.ends_with('\\') {
180            normalized.pop();
181        }
182
183        // On Windows, normalize to lowercase for case-insensitive comparison
184        #[cfg(windows)]
185        {
186            normalized = normalized.to_lowercase();
187        }
188
189        // Convert forward slashes to backslashes on Windows
190        #[cfg(windows)]
191        {
192            normalized = normalized.replace('/', "\\");
193        }
194
195        // Convert backslashes to forward slashes on Unix
196        #[cfg(unix)]
197        {
198            normalized = normalized.replace('\\', "/");
199        }
200
201        normalized
202    }
203}
204
205// ...existing code...
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    // Helper function to create a PathManager with test data
212    fn create_test_manager() -> PathManager {
213        let path = if cfg!(windows) {
214            "C:\\Windows;C:\\Program Files;C:\\Users\\Test;C:\\Windows;D:\\Tools"
215        } else {
216            "/usr/bin:/usr/local/bin:/home/user/bin:/usr/bin:/opt/tools"
217        };
218        PathManager::new(path)
219    }
220
221    #[test]
222    fn test_new_empty() {
223        let mgr = PathManager::new("");
224        assert!(mgr.is_empty());
225        assert_eq!(mgr.len(), 0);
226    }
227
228    #[test]
229    fn test_new_with_paths() {
230        let mgr = create_test_manager();
231        assert!(!mgr.is_empty());
232        assert_eq!(mgr.len(), 5);
233    }
234
235    #[test]
236    fn test_new_filters_empty_entries() {
237        let path = if cfg!(windows) {
238            "C:\\Windows;;C:\\Program Files;;;D:\\Tools;"
239        } else {
240            "/usr/bin::/usr/local/bin:::/opt/tools:"
241        };
242        let mgr = PathManager::new(path);
243        assert_eq!(mgr.len(), 3);
244    }
245
246    #[test]
247    fn test_separator_detection() {
248        let mgr = PathManager::new("");
249        if cfg!(windows) {
250            assert_eq!(mgr.separator, ';');
251        } else {
252            assert_eq!(mgr.separator, ':');
253        }
254    }
255
256    #[test]
257    fn test_entries() {
258        let mgr = create_test_manager();
259        let entries = mgr.entries();
260        assert_eq!(entries.len(), 5);
261        if cfg!(windows) {
262            assert!(entries.contains(&"C:\\Windows".to_string()));
263            assert!(entries.contains(&"C:\\Program Files".to_string()));
264        } else {
265            assert!(entries.contains(&"/usr/bin".to_string()));
266            assert!(entries.contains(&"/usr/local/bin".to_string()));
267        }
268    }
269
270    #[test]
271    fn test_contains() {
272        let mgr = create_test_manager();
273        if cfg!(windows) {
274            assert!(mgr.contains("C:\\Windows"));
275            assert!(mgr.contains("c:\\windows")); // Case insensitive on Windows
276            assert!(mgr.contains("C:/Windows")); // Forward slash normalization
277            assert!(!mgr.contains("C:\\NonExistent"));
278        } else {
279            assert!(mgr.contains("/usr/bin"));
280            assert!(mgr.contains("/usr/bin/")); // Trailing slash normalization
281            assert!(!mgr.contains("/nonexistent"));
282        }
283    }
284
285    #[test]
286    fn test_contains_with_trailing_slashes() {
287        let mgr = create_test_manager();
288        if cfg!(windows) {
289            assert!(mgr.contains("C:\\Windows\\"));
290            assert!(mgr.contains("C:\\Windows/"));
291        } else {
292            assert!(mgr.contains("/usr/bin/"));
293        }
294    }
295
296    #[test]
297    fn test_find_index() {
298        let mgr = create_test_manager();
299        if cfg!(windows) {
300            assert_eq!(mgr.find_index("C:\\Windows"), Some(0));
301            assert_eq!(mgr.find_index("C:\\Program Files"), Some(1));
302            assert_eq!(mgr.find_index("D:\\Tools"), Some(4));
303            assert_eq!(mgr.find_index("C:\\NonExistent"), None);
304        } else {
305            assert_eq!(mgr.find_index("/usr/bin"), Some(0));
306            assert_eq!(mgr.find_index("/opt/tools"), Some(4));
307            assert_eq!(mgr.find_index("/nonexistent"), None);
308        }
309    }
310
311    #[test]
312    fn test_add_first() {
313        let mut mgr = create_test_manager();
314        let original_len = mgr.len();
315
316        if cfg!(windows) {
317            mgr.add_first("C:\\NewPath".to_string());
318            assert_eq!(mgr.entries()[0], "C:\\NewPath");
319        } else {
320            mgr.add_first("/new/path".to_string());
321            assert_eq!(mgr.entries()[0], "/new/path");
322        }
323        assert_eq!(mgr.len(), original_len + 1);
324    }
325
326    #[test]
327    fn test_add_last() {
328        let mut mgr = create_test_manager();
329        let original_len = mgr.len();
330
331        if cfg!(windows) {
332            mgr.add_last("C:\\NewPath".to_string());
333            assert_eq!(mgr.entries()[mgr.len() - 1], "C:\\NewPath");
334        } else {
335            mgr.add_last("/new/path".to_string());
336            assert_eq!(mgr.entries()[mgr.len() - 1], "/new/path");
337        }
338        assert_eq!(mgr.len(), original_len + 1);
339    }
340
341    #[test]
342    fn test_remove_first() {
343        let mut mgr = create_test_manager();
344        let original_len = mgr.len();
345
346        if cfg!(windows) {
347            let removed = mgr.remove_first("C:\\Windows");
348            assert_eq!(removed, 1);
349            assert_eq!(mgr.len(), original_len - 1);
350            // Should only remove first occurrence
351            assert!(mgr.contains("C:\\Windows")); // Second occurrence still there
352
353            let removed = mgr.remove_first("C:\\NonExistent");
354            assert_eq!(removed, 0);
355            assert_eq!(mgr.len(), original_len - 1);
356        } else {
357            let removed = mgr.remove_first("/usr/bin");
358            assert_eq!(removed, 1);
359            assert_eq!(mgr.len(), original_len - 1);
360            // Should only remove first occurrence
361            assert!(mgr.contains("/usr/bin")); // Second occurrence still there
362        }
363    }
364
365    #[test]
366    fn test_remove_all() {
367        let mut mgr = create_test_manager();
368
369        if cfg!(windows) {
370            let removed = mgr.remove_all("C:\\Windows");
371            assert_eq!(removed, 2); // There are two C:\Windows entries
372            assert!(!mgr.contains("C:\\Windows"));
373            assert_eq!(mgr.len(), 3);
374        } else {
375            let removed = mgr.remove_all("/usr/bin");
376            assert_eq!(removed, 2); // There are two /usr/bin entries
377            assert!(!mgr.contains("/usr/bin"));
378            assert_eq!(mgr.len(), 3);
379        }
380    }
381
382    #[test]
383    fn test_remove_all_nonexistent() {
384        let mut mgr = create_test_manager();
385        let original_len = mgr.len();
386
387        let removed = mgr.remove_all("NonExistent");
388        assert_eq!(removed, 0);
389        assert_eq!(mgr.len(), original_len);
390    }
391
392    #[test]
393    fn test_move_entry() {
394        let mut mgr = create_test_manager();
395        let first = mgr.entries()[0].clone();
396        let second = mgr.entries()[1].clone();
397
398        // Move first to second position
399        assert!(mgr.move_entry(0, 1).is_ok());
400        assert_eq!(mgr.entries()[0], second);
401        assert_eq!(mgr.entries()[1], first);
402
403        // Move back
404        assert!(mgr.move_entry(1, 0).is_ok());
405        assert_eq!(mgr.entries()[0], first);
406        assert_eq!(mgr.entries()[1], second);
407    }
408
409    #[test]
410    fn test_move_entry_to_end() {
411        let mut mgr = create_test_manager();
412        let first = mgr.entries()[0].clone();
413        let last_idx = mgr.len() - 1;
414
415        assert!(mgr.move_entry(0, last_idx).is_ok());
416        assert_eq!(mgr.entries()[last_idx], first);
417    }
418
419    #[test]
420    fn test_move_entry_out_of_bounds() {
421        let mut mgr = create_test_manager();
422
423        assert!(mgr.move_entry(10, 0).is_err());
424        assert!(mgr.move_entry(0, 10).is_err());
425        assert!(mgr.move_entry(10, 10).is_err());
426    }
427
428    #[test]
429    fn test_get_duplicates() {
430        let mgr = create_test_manager();
431        let duplicates = mgr.get_duplicates();
432
433        if cfg!(windows) {
434            assert_eq!(duplicates.len(), 1);
435            assert_eq!(duplicates[0], "C:\\Windows");
436        } else {
437            assert_eq!(duplicates.len(), 1);
438            assert_eq!(duplicates[0], "/usr/bin");
439        }
440    }
441
442    #[test]
443    fn test_get_duplicates_no_dupes() {
444        let path = if cfg!(windows) {
445            "C:\\Path1;C:\\Path2;C:\\Path3"
446        } else {
447            "/path1:/path2:/path3"
448        };
449        let mgr = PathManager::new(path);
450        let duplicates = mgr.get_duplicates();
451        assert!(duplicates.is_empty());
452    }
453
454    #[test]
455    fn test_get_duplicates_case_insensitive_windows() {
456        if cfg!(windows) {
457            let mgr = PathManager::new("C:\\Windows;c:\\windows;C:\\WINDOWS");
458            let duplicates = mgr.get_duplicates();
459            assert_eq!(duplicates.len(), 2); // First one is not a duplicate
460        }
461    }
462
463    #[test]
464    fn test_deduplicate_keep_first() {
465        let mut mgr = create_test_manager();
466        let removed = mgr.deduplicate(true);
467
468        assert_eq!(removed, 1); // One duplicate removed
469        assert_eq!(mgr.len(), 4);
470
471        // Check no duplicates remain
472        let duplicates = mgr.get_duplicates();
473        assert!(duplicates.is_empty());
474
475        // Verify first occurrence was kept
476        if cfg!(windows) {
477            assert_eq!(mgr.entries()[0], "C:\\Windows");
478        } else {
479            assert_eq!(mgr.entries()[0], "/usr/bin");
480        }
481    }
482
483    #[test]
484    fn test_deduplicate_keep_last() {
485        let mut mgr = create_test_manager();
486        let removed = mgr.deduplicate(false);
487
488        assert_eq!(removed, 1); // One duplicate removed
489        assert_eq!(mgr.len(), 4);
490
491        // Check no duplicates remain
492        let duplicates = mgr.get_duplicates();
493        assert!(duplicates.is_empty());
494
495        // Verify last occurrence was kept
496        if cfg!(windows) {
497            // C:\Windows was at index 0 and 3, so after dedup keeping last, it should be at index 2
498            assert!(mgr.contains("C:\\Windows"));
499            assert_eq!(mgr.find_index("C:\\Windows"), Some(2));
500        } else {
501            assert!(mgr.contains("/usr/bin"));
502            assert_eq!(mgr.find_index("/usr/bin"), Some(2));
503        }
504    }
505
506    #[test]
507    fn test_to_string() {
508        let mgr = create_test_manager();
509        let result = mgr.to_string();
510
511        if cfg!(windows) {
512            // On Windows, paths are separated by semicolons
513            assert!(result.contains(';'));
514            // Windows paths can contain colons (e.g., C:), so don't check for absence of colons
515            assert!(result.contains("C:\\Windows"));
516            assert!(result.contains("C:\\Program Files"));
517
518            // Verify the separator is used correctly by counting occurrences
519            let separator_count = result.matches(';').count();
520            assert_eq!(separator_count, mgr.len() - 1); // n-1 separators for n entries
521        } else {
522            // On Unix, paths are separated by colons
523            assert!(result.contains(':'));
524            assert!(!result.contains(';'));
525            assert!(result.contains("/usr/bin"));
526            assert!(result.contains("/usr/local/bin"));
527
528            // Verify the separator is used correctly by counting occurrences
529            let separator_count = result.matches(':').count();
530            assert_eq!(separator_count, mgr.len() - 1); // n-1 separators for n entries
531        }
532    }
533
534    #[test]
535    fn test_to_string_empty() {
536        let mgr = PathManager::new("");
537        assert_eq!(mgr.to_string(), "");
538    }
539
540    #[test]
541    fn test_to_string_single_entry() {
542        let mut mgr = PathManager::new("");
543        if cfg!(windows) {
544            mgr.add_first("C:\\Single".to_string());
545            assert_eq!(mgr.to_string(), "C:\\Single");
546        } else {
547            mgr.add_first("/single".to_string());
548            assert_eq!(mgr.to_string(), "/single");
549        }
550    }
551
552    #[test]
553    fn test_normalize_path_trailing_slashes() {
554        if cfg!(windows) {
555            assert_eq!(PathManager::normalize_path("C:\\Path\\"), "c:\\path");
556            assert_eq!(PathManager::normalize_path("C:\\Path/"), "c:\\path");
557            assert_eq!(PathManager::normalize_path("C:\\Path\\\\"), "c:\\path");
558        } else {
559            assert_eq!(PathManager::normalize_path("/path/"), "/path");
560            assert_eq!(PathManager::normalize_path("/path//"), "/path");
561        }
562    }
563
564    #[test]
565    fn test_normalize_path_case_sensitivity() {
566        if cfg!(windows) {
567            // Windows: case-insensitive
568            assert_eq!(
569                PathManager::normalize_path("C:\\Path"),
570                PathManager::normalize_path("c:\\path")
571            );
572            assert_eq!(
573                PathManager::normalize_path("C:\\PATH"),
574                PathManager::normalize_path("c:\\path")
575            );
576        } else {
577            // Unix: case-sensitive
578            assert_ne!(
579                PathManager::normalize_path("/Path"),
580                PathManager::normalize_path("/path")
581            );
582            assert_ne!(
583                PathManager::normalize_path("/PATH"),
584                PathManager::normalize_path("/path")
585            );
586        }
587    }
588
589    #[test]
590    fn test_normalize_path_slash_conversion() {
591        if cfg!(windows) {
592            // Windows: convert forward slashes to backslashes
593            assert_eq!(PathManager::normalize_path("C:/Path/To/Dir"), "c:\\path\\to\\dir");
594            assert_eq!(PathManager::normalize_path("C:\\Path/To\\Dir"), "c:\\path\\to\\dir");
595        } else {
596            // Unix: convert backslashes to forward slashes
597            assert_eq!(PathManager::normalize_path("/path\\to\\dir"), "/path/to/dir");
598            assert_eq!(PathManager::normalize_path("/path\\to/dir"), "/path/to/dir");
599        }
600    }
601
602    // Note: get_invalid() and remove_invalid() tests would require actual filesystem
603    // operations or mocking, which is beyond the scope of unit tests.
604    // These would be better as integration tests.
605
606    #[test]
607    fn test_complex_scenario() {
608        let mut mgr = PathManager::new("");
609
610        // Build a complex PATH
611        if cfg!(windows) {
612            mgr.add_last("C:\\Windows".to_string());
613            mgr.add_last("C:\\Program Files".to_string());
614            mgr.add_first("C:\\Priority".to_string());
615            mgr.add_last("C:\\Windows".to_string()); // Duplicate
616            mgr.add_last("c:\\program files".to_string()); // Case variant duplicate
617
618            assert_eq!(mgr.len(), 5);
619
620            // Remove duplicates
621            let removed = mgr.deduplicate(true);
622            assert_eq!(removed, 2);
623            assert_eq!(mgr.len(), 3);
624
625            // Verify order
626            assert_eq!(mgr.entries()[0], "C:\\Priority");
627            assert_eq!(mgr.entries()[1], "C:\\Windows");
628            assert_eq!(mgr.entries()[2], "C:\\Program Files");
629        } else {
630            mgr.add_last("/usr/bin".to_string());
631            mgr.add_last("/usr/local/bin".to_string());
632            mgr.add_first("/priority".to_string());
633            mgr.add_last("/usr/bin".to_string()); // Duplicate
634            mgr.add_last("/usr/local/bin/".to_string()); // Trailing slash duplicate
635
636            assert_eq!(mgr.len(), 5);
637
638            // Remove duplicates
639            let removed = mgr.deduplicate(true);
640            assert_eq!(removed, 2);
641            assert_eq!(mgr.len(), 3);
642
643            // Verify order
644            assert_eq!(mgr.entries()[0], "/priority");
645            assert_eq!(mgr.entries()[1], "/usr/bin");
646            assert_eq!(mgr.entries()[2], "/usr/local/bin");
647        }
648    }
649}