1use std::path::{Path, PathBuf};
7
8use walkdir::WalkDir;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RarVolumeInfo {
13 pub set_name: String,
15 pub volume_number: u32,
18}
19
20pub fn parse_rar_volume(filename: &str) -> Option<RarVolumeInfo> {
28 let name_lower = filename.to_lowercase();
29
30 if let Some(stem) = name_lower.strip_suffix(".rar") {
32 if let Some(dot_pos) = stem.rfind(".part") {
33 let part_num_str = &stem[dot_pos + 5..];
34 if !part_num_str.is_empty()
35 && part_num_str.chars().all(|c| c.is_ascii_digit())
36 && let Ok(part_num) = part_num_str.parse::<u32>()
37 {
38 let set_name = &filename[..dot_pos];
40 return Some(RarVolumeInfo {
41 set_name: set_name.to_string(),
42 volume_number: part_num.saturating_sub(1),
43 });
44 }
45 }
46 let set_name = &filename[..filename.len() - 4];
48 return Some(RarVolumeInfo {
49 set_name: set_name.to_string(),
50 volume_number: 0,
51 });
52 }
53
54 if name_lower.len() > 4 {
56 let last4 = &name_lower[name_lower.len() - 4..];
57 if last4.starts_with('.')
58 && last4.as_bytes()[1].is_ascii_lowercase()
59 && last4.as_bytes()[2].is_ascii_digit()
60 && last4.as_bytes()[3].is_ascii_digit()
61 {
62 let letter = last4.as_bytes()[1];
63 let tens = (last4.as_bytes()[2] - b'0') as u32;
64 let ones = (last4.as_bytes()[3] - b'0') as u32;
65 let letter_offset = (letter - b'r') as u32 * 100;
68 let vol = letter_offset + tens * 10 + ones + 1;
69 let set_name = &filename[..filename.len() - 4];
70 return Some(RarVolumeInfo {
71 set_name: set_name.to_string(),
72 volume_number: vol,
73 });
74 }
75 }
76
77 None
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ArchiveType {
83 Rar,
84 SevenZip,
85 Zip,
86}
87
88impl std::fmt::Display for ArchiveType {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::Rar => write!(f, "RAR"),
92 Self::SevenZip => write!(f, "7z"),
93 Self::Zip => write!(f, "ZIP"),
94 }
95 }
96}
97
98pub fn find_par2_files(dir: &Path) -> Vec<PathBuf> {
102 let mut index_files: Vec<PathBuf> = Vec::new();
103 let mut volume_files: Vec<PathBuf> = Vec::new();
104
105 for entry in WalkDir::new(dir).into_iter().flatten() {
106 let path = entry.path();
107 if !path.is_file() {
108 continue;
109 }
110 let name = match path.file_name().and_then(|n| n.to_str()) {
111 Some(n) => n.to_lowercase(),
112 None => continue,
113 };
114 if !name.ends_with(".par2") {
115 continue;
116 }
117 if is_par2_volume(&name) {
119 volume_files.push(path.to_path_buf());
120 } else {
121 index_files.push(path.to_path_buf());
122 }
123 }
124
125 index_files.sort();
126 volume_files.sort();
127
128 index_files.extend(volume_files);
130 index_files
131}
132
133fn is_par2_volume(name_lower: &str) -> bool {
136 let without_ext = name_lower.trim_end_matches(".par2");
139 if let Some(vol_pos) = without_ext.rfind(".vol") {
141 let after_vol = &without_ext[vol_pos + 4..];
142 if let Some(plus_pos) = after_vol.find('+') {
144 let before_plus = &after_vol[..plus_pos];
145 let after_plus = &after_vol[plus_pos + 1..];
146 return !before_plus.is_empty()
147 && before_plus.chars().all(|c| c.is_ascii_digit())
148 && !after_plus.is_empty()
149 && after_plus.chars().all(|c| c.is_ascii_digit());
150 }
151 }
152 false
153}
154
155pub fn find_rar_files(dir: &Path) -> Vec<PathBuf> {
161 let mut first_volumes: Vec<PathBuf> = Vec::new();
162
163 for entry in WalkDir::new(dir).into_iter().flatten() {
164 let path = entry.path();
165 if !path.is_file() {
166 continue;
167 }
168 let name = match path.file_name().and_then(|n| n.to_str()) {
169 Some(n) => n,
170 None => continue,
171 };
172 let name_lower = name.to_lowercase();
173
174 if name_lower.ends_with(".rar")
176 && let Some(stem) = name_lower.strip_suffix(".rar")
177 {
178 if let Some(dot_pos) = stem.rfind(".part") {
180 let part_num_str = &stem[dot_pos + 5..];
181 if !part_num_str.is_empty()
182 && part_num_str.chars().all(|c| c.is_ascii_digit())
183 && let Ok(part_num) = part_num_str.parse::<u32>()
184 {
185 if part_num == 1 {
186 first_volumes.push(path.to_path_buf());
187 }
188 continue;
190 }
191 }
192 first_volumes.push(path.to_path_buf());
194 }
195 }
198
199 first_volumes.sort();
200 first_volumes
201}
202
203pub fn find_archives(dir: &Path) -> Vec<(ArchiveType, PathBuf)> {
206 let mut archives: Vec<(ArchiveType, PathBuf)> = Vec::new();
207
208 for path in find_rar_files(dir) {
210 archives.push((ArchiveType::Rar, path));
211 }
212
213 for entry in WalkDir::new(dir).into_iter().flatten() {
215 let path = entry.path();
216 if !path.is_file() {
217 continue;
218 }
219 let name = match path.file_name().and_then(|n| n.to_str()) {
220 Some(n) => n.to_lowercase(),
221 None => continue,
222 };
223
224 if name.ends_with(".7z") || name.ends_with(".7z.enc") {
225 archives.push((ArchiveType::SevenZip, path.to_path_buf()));
226 } else if is_split_7z_first_volume(&name) {
227 archives.push((ArchiveType::SevenZip, path.to_path_buf()));
229 } else if name.ends_with(".zip") {
230 archives.push((ArchiveType::Zip, path.to_path_buf()));
231 }
232 }
233
234 archives.sort_by(|a, b| a.1.cmp(&b.1));
235 archives
236}
237
238pub fn find_cleanup_files(dir: &Path) -> Vec<PathBuf> {
242 let mut cleanup: Vec<PathBuf> = Vec::new();
243
244 for entry in WalkDir::new(dir).into_iter().flatten() {
245 let path = entry.path();
246 if !path.is_file() {
247 continue;
248 }
249 let name = match path.file_name().and_then(|n| n.to_str()) {
250 Some(n) => n.to_lowercase(),
251 None => continue,
252 };
253
254 if is_cleanup_candidate(&name) {
255 cleanup.push(path.to_path_buf());
256 }
257 }
258
259 cleanup.sort();
260 cleanup
261}
262
263fn is_split_7z_first_volume(name_lower: &str) -> bool {
266 if let Some(stem) = name_lower.strip_suffix(".001") {
268 return stem.ends_with(".7z");
269 }
270 false
271}
272
273fn is_split_7z_volume(name_lower: &str) -> bool {
276 if let Some(dot_pos) = name_lower.rfind('.') {
278 let ext = &name_lower[dot_pos + 1..];
279 if !ext.is_empty() && ext.chars().all(|c| c.is_ascii_digit()) {
280 let stem = &name_lower[..dot_pos];
281 return stem.ends_with(".7z");
282 }
283 }
284 false
285}
286
287fn is_cleanup_candidate(name: &str) -> bool {
290 if name.ends_with(".par2") {
292 return true;
293 }
294
295 if name.ends_with(".rar") {
298 return true;
299 }
300
301 if name.len() > 4 {
304 let last4 = &name[name.len() - 4..];
305 if last4.starts_with('.')
306 && last4.as_bytes()[1].is_ascii_lowercase()
307 && last4.as_bytes()[2].is_ascii_digit()
308 && last4.as_bytes()[3].is_ascii_digit()
309 {
310 return true;
311 }
312 }
313
314 if is_split_7z_volume(name) {
319 return true;
320 }
321
322 if name.ends_with(".7z.enc") {
324 return true;
325 }
326
327 false
328}
329
330#[cfg(test)]
335mod tests {
336 use super::*;
337 use std::fs;
338
339 fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
341 let dir = tempfile::tempdir().unwrap();
342 for name in files {
343 let path = dir.path().join(name);
344 if let Some(parent) = path.parent() {
345 fs::create_dir_all(parent).unwrap();
346 }
347 fs::write(&path, b"").unwrap();
348 }
349 dir
350 }
351
352 #[test]
353 fn test_find_par2_index_first() {
354 let dir = make_test_dir(&["movie.vol00+01.par2", "movie.vol01+02.par2", "movie.par2"]);
355 let results = find_par2_files(dir.path());
356 assert_eq!(results.len(), 3);
357 assert!(
359 results[0].file_name().unwrap().to_str().unwrap() == "movie.par2",
360 "Index par2 file should be first, got {:?}",
361 results[0]
362 );
363 }
364
365 #[test]
366 fn test_find_par2_empty_dir() {
367 let dir = make_test_dir(&["readme.txt", "movie.mkv"]);
368 let results = find_par2_files(dir.path());
369 assert!(results.is_empty());
370 }
371
372 #[test]
373 fn test_find_rar_new_style() {
374 let dir = make_test_dir(&[
375 "archive.part001.rar",
376 "archive.part002.rar",
377 "archive.part003.rar",
378 ]);
379 let results = find_rar_files(dir.path());
380 assert_eq!(results.len(), 1);
382 assert!(
383 results[0]
384 .file_name()
385 .unwrap()
386 .to_str()
387 .unwrap()
388 .contains("part001"),
389 );
390 }
391
392 #[test]
393 fn test_find_rar_old_style() {
394 let dir = make_test_dir(&["archive.rar", "archive.r00", "archive.r01", "archive.r02"]);
395 let results = find_rar_files(dir.path());
396 assert_eq!(results.len(), 1);
398 assert!(
399 results[0]
400 .file_name()
401 .unwrap()
402 .to_str()
403 .unwrap()
404 .ends_with(".rar")
405 );
406 }
407
408 #[test]
409 fn test_find_archives_mixed() {
410 let dir = make_test_dir(&[
411 "movie.part001.rar",
412 "movie.part002.rar",
413 "subs.zip",
414 "extras.7z",
415 ]);
416 let results = find_archives(dir.path());
417 let types: Vec<ArchiveType> = results.iter().map(|(t, _)| *t).collect();
418 assert!(types.contains(&ArchiveType::Rar));
419 assert!(types.contains(&ArchiveType::Zip));
420 assert!(types.contains(&ArchiveType::SevenZip));
421 assert_eq!(types.iter().filter(|&&t| t == ArchiveType::Rar).count(), 1);
423 }
424
425 #[test]
426 fn test_find_cleanup_files() {
427 let dir = make_test_dir(&[
428 "movie.par2",
429 "movie.vol00+01.par2",
430 "movie.part001.rar",
431 "movie.part002.rar",
432 "movie.r00",
433 "movie.r01",
434 "movie.mkv", "readme.txt", ]);
437 let results = find_cleanup_files(dir.path());
438 assert_eq!(
440 results.len(),
441 6,
442 "Expected 6 cleanup files, got: {:?}",
443 results
444 );
445 for path in &results {
447 let name = path.file_name().unwrap().to_str().unwrap();
448 assert!(!name.ends_with(".mkv"));
449 assert!(!name.ends_with(".txt"));
450 }
451 }
452
453 #[test]
454 fn test_is_par2_volume() {
455 assert!(is_par2_volume("file.vol00+01.par2"));
456 assert!(is_par2_volume("file.vol123+456.par2"));
457 assert!(!is_par2_volume("file.par2"));
458 assert!(!is_par2_volume("file.volume.par2"));
459 }
460
461 #[test]
462 fn test_cleanup_old_style_volumes() {
463 assert!(is_cleanup_candidate("archive.r00"));
464 assert!(is_cleanup_candidate("archive.r99"));
465 assert!(is_cleanup_candidate("archive.s00"));
466 assert!(!is_cleanup_candidate("readme.txt"));
467 assert!(!is_cleanup_candidate("movie.mkv"));
468 }
469
470 #[test]
475 fn test_split_7z_first_volume() {
476 assert!(is_split_7z_first_volume("archive.7z.001"));
477 assert!(is_split_7z_first_volume("my.movie.7z.001"));
478 assert!(!is_split_7z_first_volume("archive.7z.002"));
479 assert!(!is_split_7z_first_volume("archive.7z.010"));
480 assert!(!is_split_7z_first_volume("archive.7z"));
481 assert!(!is_split_7z_first_volume("archive.rar.001"));
482 }
483
484 #[test]
485 fn test_split_7z_volume() {
486 assert!(is_split_7z_volume("archive.7z.001"));
487 assert!(is_split_7z_volume("archive.7z.002"));
488 assert!(is_split_7z_volume("archive.7z.099"));
489 assert!(!is_split_7z_volume("archive.7z"));
490 assert!(!is_split_7z_volume("archive.rar.001"));
491 assert!(!is_split_7z_volume("archive.7z.abc"));
492 }
493
494 #[test]
495 fn test_find_archives_split_7z() {
496 let dir = make_test_dir(&["movie.7z.001", "movie.7z.002", "movie.7z.003"]);
497 let results = find_archives(dir.path());
498 assert_eq!(results.len(), 1);
500 assert_eq!(results[0].0, ArchiveType::SevenZip);
501 assert!(
502 results[0]
503 .1
504 .file_name()
505 .unwrap()
506 .to_str()
507 .unwrap()
508 .ends_with(".7z.001"),
509 );
510 }
511
512 #[test]
513 fn test_cleanup_split_7z_volumes() {
514 assert!(is_cleanup_candidate("archive.7z.001"));
515 assert!(is_cleanup_candidate("archive.7z.002"));
516 assert!(is_cleanup_candidate("archive.7z.099"));
517 assert!(!is_cleanup_candidate("movie.mkv"));
518 }
519
520 #[test]
521 fn test_find_cleanup_includes_split_7z() {
522 let dir = make_test_dir(&[
523 "movie.7z.001",
524 "movie.7z.002",
525 "movie.7z.003",
526 "movie.mkv", ]);
528 let results = find_cleanup_files(dir.path());
529 assert_eq!(
530 results.len(),
531 3,
532 "Expected 3 split 7z cleanup files, got: {:?}",
533 results
534 );
535 for path in &results {
536 let name = path.file_name().unwrap().to_str().unwrap();
537 assert!(!name.ends_with(".mkv"));
538 }
539 }
540
541 #[test]
546 fn test_parse_rar_volume_new_style() {
547 let v = parse_rar_volume("movie.part001.rar").unwrap();
548 assert_eq!(v.set_name, "movie");
549 assert_eq!(v.volume_number, 0);
550
551 let v = parse_rar_volume("movie.part002.rar").unwrap();
552 assert_eq!(v.set_name, "movie");
553 assert_eq!(v.volume_number, 1);
554
555 let v = parse_rar_volume("My.Movie.2024.part015.rar").unwrap();
556 assert_eq!(v.set_name, "My.Movie.2024");
557 assert_eq!(v.volume_number, 14);
558 }
559
560 #[test]
561 fn test_parse_rar_volume_old_style() {
562 let v = parse_rar_volume("archive.rar").unwrap();
563 assert_eq!(v.set_name, "archive");
564 assert_eq!(v.volume_number, 0);
565
566 let v = parse_rar_volume("archive.r00").unwrap();
567 assert_eq!(v.set_name, "archive");
568 assert_eq!(v.volume_number, 1);
569
570 let v = parse_rar_volume("archive.r01").unwrap();
571 assert_eq!(v.set_name, "archive");
572 assert_eq!(v.volume_number, 2);
573
574 let v = parse_rar_volume("archive.r99").unwrap();
575 assert_eq!(v.set_name, "archive");
576 assert_eq!(v.volume_number, 100);
577
578 let v = parse_rar_volume("archive.s00").unwrap();
579 assert_eq!(v.set_name, "archive");
580 assert_eq!(v.volume_number, 101);
581 }
582
583 #[test]
584 fn test_parse_rar_volume_non_rar() {
585 assert!(parse_rar_volume("movie.mkv").is_none());
586 assert!(parse_rar_volume("movie.par2").is_none());
587 assert!(parse_rar_volume("movie.7z").is_none());
588 assert!(parse_rar_volume("movie.zip").is_none());
589 assert!(parse_rar_volume("readme.txt").is_none());
590 }
591
592 #[test]
593 fn test_parse_rar_volume_case_insensitive() {
594 let v = parse_rar_volume("Movie.Part003.RAR").unwrap();
595 assert_eq!(v.set_name, "Movie");
596 assert_eq!(v.volume_number, 2);
597
598 let v = parse_rar_volume("ARCHIVE.R05").unwrap();
599 assert_eq!(v.set_name, "ARCHIVE");
600 assert_eq!(v.volume_number, 6);
601 }
602
603 #[test]
604 fn test_parse_rar_volume_preserves_original_set_name() {
605 let v = parse_rar_volume("My.Movie.2024.1080p.part001.rar").unwrap();
606 assert_eq!(v.set_name, "My.Movie.2024.1080p");
607 }
608}