1use color_eyre::Result;
2use std::collections::HashSet;
3use std::path::Path;
4
5pub 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 let normalized_entries: Vec<String> = self.entries.iter().map(|e| Self::normalize_path(e)).collect();
74
75 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 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(()); }
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 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 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 fn normalize_path(path: &str) -> String {
176 let mut normalized = path.to_string();
177
178 while normalized.ends_with('/') || normalized.ends_with('\\') {
180 normalized.pop();
181 }
182
183 #[cfg(windows)]
185 {
186 normalized = normalized.to_lowercase();
187 }
188
189 #[cfg(windows)]
191 {
192 normalized = normalized.replace('/', "\\");
193 }
194
195 #[cfg(unix)]
197 {
198 normalized = normalized.replace('\\', "/");
199 }
200
201 normalized
202 }
203}
204
205#[cfg(test)]
208mod tests {
209 use super::*;
210
211 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")); assert!(mgr.contains("C:/Windows")); assert!(!mgr.contains("C:\\NonExistent"));
278 } else {
279 assert!(mgr.contains("/usr/bin"));
280 assert!(mgr.contains("/usr/bin/")); 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 assert!(mgr.contains("C:\\Windows")); 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 assert!(mgr.contains("/usr/bin")); }
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); 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); 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 assert!(mgr.move_entry(0, 1).is_ok());
400 assert_eq!(mgr.entries()[0], second);
401 assert_eq!(mgr.entries()[1], first);
402
403 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); }
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); assert_eq!(mgr.len(), 4);
470
471 let duplicates = mgr.get_duplicates();
473 assert!(duplicates.is_empty());
474
475 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); assert_eq!(mgr.len(), 4);
490
491 let duplicates = mgr.get_duplicates();
493 assert!(duplicates.is_empty());
494
495 if cfg!(windows) {
497 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 assert!(result.contains(';'));
514 assert!(result.contains("C:\\Windows"));
516 assert!(result.contains("C:\\Program Files"));
517
518 let separator_count = result.matches(';').count();
520 assert_eq!(separator_count, mgr.len() - 1); } else {
522 assert!(result.contains(':'));
524 assert!(!result.contains(';'));
525 assert!(result.contains("/usr/bin"));
526 assert!(result.contains("/usr/local/bin"));
527
528 let separator_count = result.matches(':').count();
530 assert_eq!(separator_count, mgr.len() - 1); }
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 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 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 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 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 #[test]
607 fn test_complex_scenario() {
608 let mut mgr = PathManager::new("");
609
610 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()); mgr.add_last("c:\\program files".to_string()); assert_eq!(mgr.len(), 5);
619
620 let removed = mgr.deduplicate(true);
622 assert_eq!(removed, 2);
623 assert_eq!(mgr.len(), 3);
624
625 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()); mgr.add_last("/usr/local/bin/".to_string()); assert_eq!(mgr.len(), 5);
637
638 let removed = mgr.deduplicate(true);
640 assert_eq!(removed, 2);
641 assert_eq!(mgr.len(), 3);
642
643 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}