1use std::collections::BTreeMap;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9
10use walkdir::WalkDir;
11
12const RAR4_SIGNATURE: &[u8] = b"Rar!\x1a\x07\x00";
14const RAR5_SIGNATURE: &[u8] = b"Rar!\x1a\x07\x01\x00";
16
17pub fn has_rar_signature(path: &Path) -> bool {
23 let Ok(mut file) = std::fs::File::open(path) else {
24 return false;
25 };
26 let mut header = [0u8; RAR5_SIGNATURE.len()];
28 let mut filled = 0;
29 while filled < header.len() {
30 match file.read(&mut header[filled..]) {
31 Ok(0) => break,
32 Ok(n) => filled += n,
33 Err(_) => return false,
34 }
35 }
36 let head = &header[..filled];
37 head.starts_with(RAR4_SIGNATURE) || head.starts_with(RAR5_SIGNATURE)
38}
39
40fn split_numeric_volume(filename: &str) -> Option<(&str, u32)> {
46 let dot = filename.rfind('.')?;
47 let ext = &filename[dot + 1..];
48 if ext.is_empty() || !ext.chars().all(|c| c.is_ascii_digit()) {
49 return None;
50 }
51 let stem = &filename[..dot];
52 if stem.to_ascii_lowercase().ends_with(".7z") {
53 return None;
54 }
55 ext.parse::<u32>().ok().map(|num| (stem, num))
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct RarVolumeInfo {
61 pub set_name: String,
63 pub volume_number: u32,
66}
67
68pub fn parse_rar_volume(filename: &str) -> Option<RarVolumeInfo> {
76 let name_lower = filename.to_lowercase();
77
78 if let Some(stem) = name_lower.strip_suffix(".rar") {
80 if let Some(dot_pos) = stem.rfind(".part") {
81 let part_num_str = &stem[dot_pos + 5..];
82 if !part_num_str.is_empty()
83 && part_num_str.chars().all(|c| c.is_ascii_digit())
84 && let Ok(part_num) = part_num_str.parse::<u32>()
85 {
86 let set_name = &filename[..dot_pos];
88 return Some(RarVolumeInfo {
89 set_name: set_name.to_string(),
90 volume_number: part_num.saturating_sub(1),
91 });
92 }
93 }
94 let set_name = &filename[..filename.len() - 4];
96 return Some(RarVolumeInfo {
97 set_name: set_name.to_string(),
98 volume_number: 0,
99 });
100 }
101
102 if name_lower.len() > 4 {
104 let last4 = &name_lower[name_lower.len() - 4..];
105 if last4.starts_with('.')
106 && last4.as_bytes()[1].is_ascii_lowercase()
107 && last4.as_bytes()[2].is_ascii_digit()
108 && last4.as_bytes()[3].is_ascii_digit()
109 {
110 let letter = last4.as_bytes()[1];
111 let tens = (last4.as_bytes()[2] - b'0') as u32;
112 let ones = (last4.as_bytes()[3] - b'0') as u32;
113 let letter_offset = (letter - b'r') as u32 * 100;
116 let vol = letter_offset + tens * 10 + ones + 1;
117 let set_name = &filename[..filename.len() - 4];
118 return Some(RarVolumeInfo {
119 set_name: set_name.to_string(),
120 volume_number: vol,
121 });
122 }
123 }
124
125 None
126}
127
128pub fn parse_rar_volume_at(path: &Path) -> Option<RarVolumeInfo> {
140 let name = path.file_name().and_then(|n| n.to_str())?;
141 if let Some(info) = parse_rar_volume(name) {
142 return Some(info);
143 }
144 let (set_name, volume_number) = split_numeric_volume(name)?;
145 if !has_rar_signature(path) {
146 return None;
147 }
148 Some(RarVolumeInfo {
149 set_name: set_name.to_string(),
150 volume_number,
151 })
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum ArchiveType {
157 Rar,
158 SevenZip,
159 Tar,
160 Zip,
161}
162
163impl std::fmt::Display for ArchiveType {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 match self {
166 Self::Rar => write!(f, "RAR"),
167 Self::SevenZip => write!(f, "7z"),
168 Self::Tar => write!(f, "TAR"),
169 Self::Zip => write!(f, "ZIP"),
170 }
171 }
172}
173
174pub fn find_par2_files(dir: &Path) -> Vec<PathBuf> {
178 let mut index_files: Vec<PathBuf> = Vec::new();
179 let mut volume_files: Vec<PathBuf> = Vec::new();
180
181 for entry in WalkDir::new(dir).into_iter().flatten() {
182 let path = entry.path();
183 if !path.is_file() {
184 continue;
185 }
186 let name = match path.file_name().and_then(|n| n.to_str()) {
187 Some(n) => n.to_lowercase(),
188 None => continue,
189 };
190 if !name.ends_with(".par2") {
191 continue;
192 }
193 if is_par2_volume(&name) {
195 volume_files.push(path.to_path_buf());
196 } else {
197 index_files.push(path.to_path_buf());
198 }
199 }
200
201 index_files.sort();
202 volume_files.sort();
203
204 index_files.extend(volume_files);
206 index_files
207}
208
209fn is_par2_volume(name_lower: &str) -> bool {
212 let without_ext = name_lower.trim_end_matches(".par2");
215 if let Some(vol_pos) = without_ext.rfind(".vol") {
217 let after_vol = &without_ext[vol_pos + 4..];
218 if let Some(separator_pos) = after_vol.find(['+', '-']) {
220 let before_separator = &after_vol[..separator_pos];
221 let after_separator = &after_vol[separator_pos + 1..];
222 return !before_separator.is_empty()
223 && before_separator.chars().all(|c| c.is_ascii_digit())
224 && !after_separator.is_empty()
225 && after_separator.chars().all(|c| c.is_ascii_digit());
226 }
227 }
228 false
229}
230
231pub fn find_rar_files(dir: &Path) -> Vec<PathBuf> {
237 let mut first_volumes: Vec<PathBuf> = Vec::new();
238 let mut numeric_sets: BTreeMap<String, (u32, PathBuf)> = BTreeMap::new();
240
241 for entry in WalkDir::new(dir).into_iter().flatten() {
242 let path = entry.path();
243 if !path.is_file() {
244 continue;
245 }
246 let name = match path.file_name().and_then(|n| n.to_str()) {
247 Some(n) => n,
248 None => continue,
249 };
250 let name_lower = name.to_lowercase();
251
252 if name_lower.ends_with(".rar")
254 && let Some(stem) = name_lower.strip_suffix(".rar")
255 {
256 if let Some(dot_pos) = stem.rfind(".part") {
258 let part_num_str = &stem[dot_pos + 5..];
259 if !part_num_str.is_empty()
260 && part_num_str.chars().all(|c| c.is_ascii_digit())
261 && let Ok(part_num) = part_num_str.parse::<u32>()
262 {
263 if part_num == 1 {
264 first_volumes.push(path.to_path_buf());
265 }
266 continue;
268 }
269 }
270 first_volumes.push(path.to_path_buf());
272 continue;
273 }
274 if parse_rar_volume(&name_lower).is_some() {
277 continue;
278 }
279
280 if let Some((set_name, volume_number)) = split_numeric_volume(&name_lower)
285 && has_rar_signature(path)
286 {
287 numeric_sets
288 .entry(set_name.to_string())
289 .and_modify(|(lowest, lowest_path)| {
290 if volume_number < *lowest {
291 *lowest = volume_number;
292 *lowest_path = path.to_path_buf();
293 }
294 })
295 .or_insert_with(|| (volume_number, path.to_path_buf()));
296 }
297 }
298
299 first_volumes.extend(numeric_sets.into_values().map(|(_, path)| path));
300 first_volumes.sort();
301 first_volumes
302}
303
304pub fn find_archives(dir: &Path) -> Vec<(ArchiveType, PathBuf)> {
307 let mut archives: Vec<(ArchiveType, PathBuf)> = Vec::new();
308
309 for path in find_rar_files(dir) {
311 archives.push((ArchiveType::Rar, path));
312 }
313
314 for entry in WalkDir::new(dir).into_iter().flatten() {
316 let path = entry.path();
317 if !path.is_file() {
318 continue;
319 }
320 let name = match path.file_name().and_then(|n| n.to_str()) {
321 Some(n) => n.to_lowercase(),
322 None => continue,
323 };
324
325 if name.ends_with(".7z") || name.ends_with(".7z.enc") {
326 archives.push((ArchiveType::SevenZip, path.to_path_buf()));
327 } else if is_split_7z_first_volume(&name) {
328 archives.push((ArchiveType::SevenZip, path.to_path_buf()));
330 } else if name.ends_with(".tar") {
331 archives.push((ArchiveType::Tar, path.to_path_buf()));
332 } else if name.ends_with(".zip") {
333 archives.push((ArchiveType::Zip, path.to_path_buf()));
334 }
335 }
336
337 archives.sort_by(|a, b| a.1.cmp(&b.1));
338 archives
339}
340
341pub fn find_cleanup_files(dir: &Path) -> Vec<PathBuf> {
345 let mut cleanup: Vec<PathBuf> = Vec::new();
346
347 for entry in WalkDir::new(dir).into_iter().flatten() {
348 let path = entry.path();
349 if !path.is_file() {
350 continue;
351 }
352 let name = match path.file_name().and_then(|n| n.to_str()) {
353 Some(n) => n.to_lowercase(),
354 None => continue,
355 };
356
357 if is_cleanup_candidate_at(path, &name) {
358 cleanup.push(path.to_path_buf());
359 }
360 }
361
362 cleanup.sort();
363 cleanup
364}
365
366pub fn has_usable_output(dir: &Path) -> std::io::Result<bool> {
371 for entry in WalkDir::new(dir).into_iter().flatten() {
372 let path = entry.path();
373 if !path.is_file() {
374 continue;
375 }
376 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
377 continue;
378 };
379 if !is_cleanup_candidate_at(path, &name.to_ascii_lowercase()) {
380 return Ok(true);
381 }
382 }
383
384 std::fs::read_dir(dir)?;
386 Ok(false)
387}
388
389fn is_split_7z_first_volume(name_lower: &str) -> bool {
392 if let Some(stem) = name_lower.strip_suffix(".001") {
394 return stem.ends_with(".7z");
395 }
396 false
397}
398
399fn is_split_7z_volume(name_lower: &str) -> bool {
402 if let Some(dot_pos) = name_lower.rfind('.') {
404 let ext = &name_lower[dot_pos + 1..];
405 if !ext.is_empty() && ext.chars().all(|c| c.is_ascii_digit()) {
406 let stem = &name_lower[..dot_pos];
407 return stem.ends_with(".7z");
408 }
409 }
410 false
411}
412
413fn is_cleanup_candidate_at(path: &Path, name_lower: &str) -> bool {
426 if is_cleanup_candidate(name_lower) {
427 return true;
428 }
429 split_numeric_volume(name_lower).is_some() && has_rar_signature(path)
430}
431
432fn is_cleanup_candidate(name: &str) -> bool {
438 if name.ends_with(".par2")
440 || name.ends_with(".zip")
441 || name.ends_with(".7z")
442 || name.ends_with(".tar")
443 {
444 return true;
445 }
446
447 if name.ends_with(".rar") {
450 return true;
451 }
452
453 if name.len() > 4 {
456 let last4 = &name[name.len() - 4..];
457 if last4.starts_with('.')
458 && last4.as_bytes()[1].is_ascii_lowercase()
459 && last4.as_bytes()[2].is_ascii_digit()
460 && last4.as_bytes()[3].is_ascii_digit()
461 {
462 return true;
463 }
464 }
465
466 if is_split_7z_volume(name) {
471 return true;
472 }
473
474 if name.ends_with(".7z.enc") {
476 return true;
477 }
478
479 false
480}
481
482#[cfg(test)]
487mod tests {
488 use super::*;
489 use std::fs;
490
491 fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
493 let dir = tempfile::tempdir().unwrap();
494 for name in files {
495 let path = dir.path().join(name);
496 if let Some(parent) = path.parent() {
497 fs::create_dir_all(parent).unwrap();
498 }
499 fs::write(&path, b"").unwrap();
500 }
501 dir
502 }
503
504 #[test]
505 fn test_find_par2_index_first() {
506 let dir = make_test_dir(&["movie.vol00+01.par2", "movie.vol01+02.par2", "movie.par2"]);
507 let results = find_par2_files(dir.path());
508 assert_eq!(results.len(), 3);
509 assert!(
511 results[0].file_name().unwrap().to_str().unwrap() == "movie.par2",
512 "Index par2 file should be first, got {:?}",
513 results[0]
514 );
515 }
516
517 #[test]
518 fn test_find_par2_hyphenated_volumes_after_index() {
519 let dir = make_test_dir(&["movie.vol63-67.par2", "movie.par2", "movie.vol00-01.par2"]);
520 let files = find_par2_files(dir.path());
521 assert_eq!(files[0].file_name().unwrap(), "movie.par2");
522 assert_eq!(files.len(), 3);
523 }
524
525 #[test]
526 fn test_find_par2_empty_dir() {
527 let dir = make_test_dir(&["readme.txt", "movie.mkv"]);
528 let results = find_par2_files(dir.path());
529 assert!(results.is_empty());
530 }
531
532 #[test]
533 fn test_find_rar_new_style() {
534 let dir = make_test_dir(&[
535 "archive.part001.rar",
536 "archive.part002.rar",
537 "archive.part003.rar",
538 ]);
539 let results = find_rar_files(dir.path());
540 assert_eq!(results.len(), 1);
542 assert!(
543 results[0]
544 .file_name()
545 .unwrap()
546 .to_str()
547 .unwrap()
548 .contains("part001"),
549 );
550 }
551
552 #[test]
553 fn test_find_rar_old_style() {
554 let dir = make_test_dir(&["archive.rar", "archive.r00", "archive.r01", "archive.r02"]);
555 let results = find_rar_files(dir.path());
556 assert_eq!(results.len(), 1);
558 assert!(
559 results[0]
560 .file_name()
561 .unwrap()
562 .to_str()
563 .unwrap()
564 .ends_with(".rar")
565 );
566 }
567
568 #[test]
569 fn test_find_archives_mixed() {
570 let dir = make_test_dir(&[
571 "movie.part001.rar",
572 "movie.part002.rar",
573 "subs.zip",
574 "extras.7z",
575 ]);
576 let results = find_archives(dir.path());
577 let types: Vec<ArchiveType> = results.iter().map(|(t, _)| *t).collect();
578 assert!(types.contains(&ArchiveType::Rar));
579 assert!(types.contains(&ArchiveType::Zip));
580 assert!(types.contains(&ArchiveType::SevenZip));
581 assert_eq!(types.iter().filter(|&&t| t == ArchiveType::Rar).count(), 1);
583 }
584
585 #[test]
586 fn usable_output_requires_a_non_artifact_file() {
587 let raw_only = make_test_dir(&[
588 "release.part001.rar",
589 "release.part002.rar",
590 "release.par2",
591 "release.vol00+01.par2",
592 "release.7z",
593 ]);
594 assert!(!has_usable_output(raw_only.path()).unwrap());
595
596 let payload = make_test_dir(&["release.part001.rar", "Movie.2024.mkv"]);
597 assert!(has_usable_output(payload.path()).unwrap());
598 }
599
600 #[test]
601 fn test_find_cleanup_files() {
602 let dir = make_test_dir(&[
603 "movie.par2",
604 "movie.vol00+01.par2",
605 "movie.part001.rar",
606 "movie.part002.rar",
607 "movie.r00",
608 "movie.r01",
609 "movie.mkv", "readme.txt", ]);
612 let results = find_cleanup_files(dir.path());
613 assert_eq!(
615 results.len(),
616 6,
617 "Expected 6 cleanup files, got: {results:?}"
618 );
619 for path in &results {
621 let name = path.file_name().unwrap().to_str().unwrap();
622 assert!(!name.ends_with(".mkv"));
623 assert!(!name.ends_with(".txt"));
624 }
625 }
626
627 #[test]
628 fn test_is_par2_volume() {
629 assert!(is_par2_volume("file.vol00+01.par2"));
630 assert!(is_par2_volume("file.vol123+456.par2"));
631 assert!(is_par2_volume("file.vol00-01.par2"));
632 assert!(is_par2_volume("file.vol63-67.par2"));
633 assert!(!is_par2_volume("file.par2"));
634 assert!(!is_par2_volume("file.volume.par2"));
635 }
636
637 #[test]
638 fn test_cleanup_old_style_volumes() {
639 assert!(is_cleanup_candidate("archive.r00"));
640 assert!(is_cleanup_candidate("archive.r99"));
641 assert!(is_cleanup_candidate("archive.s00"));
642 assert!(!is_cleanup_candidate("readme.txt"));
643 assert!(!is_cleanup_candidate("movie.mkv"));
644 }
645
646 #[test]
651 fn test_split_7z_first_volume() {
652 assert!(is_split_7z_first_volume("archive.7z.001"));
653 assert!(is_split_7z_first_volume("my.movie.7z.001"));
654 assert!(!is_split_7z_first_volume("archive.7z.002"));
655 assert!(!is_split_7z_first_volume("archive.7z.010"));
656 assert!(!is_split_7z_first_volume("archive.7z"));
657 assert!(!is_split_7z_first_volume("archive.rar.001"));
658 }
659
660 #[test]
661 fn test_split_7z_volume() {
662 assert!(is_split_7z_volume("archive.7z.001"));
663 assert!(is_split_7z_volume("archive.7z.002"));
664 assert!(is_split_7z_volume("archive.7z.099"));
665 assert!(!is_split_7z_volume("archive.7z"));
666 assert!(!is_split_7z_volume("archive.rar.001"));
667 assert!(!is_split_7z_volume("archive.7z.abc"));
668 }
669
670 #[test]
671 fn test_find_archives_split_7z() {
672 let dir = make_test_dir(&["movie.7z.001", "movie.7z.002", "movie.7z.003"]);
673 let results = find_archives(dir.path());
674 assert_eq!(results.len(), 1);
676 assert_eq!(results[0].0, ArchiveType::SevenZip);
677 assert!(
678 results[0]
679 .1
680 .file_name()
681 .unwrap()
682 .to_str()
683 .unwrap()
684 .ends_with(".7z.001"),
685 );
686 }
687
688 #[test]
689 fn test_cleanup_split_7z_volumes() {
690 assert!(is_cleanup_candidate("archive.7z"));
691 assert!(is_cleanup_candidate("archive.7z.001"));
692 assert!(is_cleanup_candidate("archive.7z.002"));
693 assert!(is_cleanup_candidate("archive.7z.099"));
694 assert!(!is_cleanup_candidate("movie.mkv"));
695 }
696
697 #[test]
698 fn test_find_cleanup_includes_split_7z() {
699 let dir = make_test_dir(&[
700 "movie.7z.001",
701 "movie.7z.002",
702 "movie.7z.003",
703 "movie.mkv", ]);
705 let results = find_cleanup_files(dir.path());
706 assert_eq!(
707 results.len(),
708 3,
709 "Expected 3 split 7z cleanup files, got: {results:?}"
710 );
711 for path in &results {
712 let name = path.file_name().unwrap().to_str().unwrap();
713 assert!(!name.ends_with(".mkv"));
714 }
715 }
716
717 #[test]
722 fn test_parse_rar_volume_new_style() {
723 let v = parse_rar_volume("movie.part001.rar").unwrap();
724 assert_eq!(v.set_name, "movie");
725 assert_eq!(v.volume_number, 0);
726
727 let v = parse_rar_volume("movie.part002.rar").unwrap();
728 assert_eq!(v.set_name, "movie");
729 assert_eq!(v.volume_number, 1);
730
731 let v = parse_rar_volume("My.Movie.2024.part015.rar").unwrap();
732 assert_eq!(v.set_name, "My.Movie.2024");
733 assert_eq!(v.volume_number, 14);
734 }
735
736 #[test]
737 fn test_parse_rar_volume_old_style() {
738 let v = parse_rar_volume("archive.rar").unwrap();
739 assert_eq!(v.set_name, "archive");
740 assert_eq!(v.volume_number, 0);
741
742 let v = parse_rar_volume("archive.r00").unwrap();
743 assert_eq!(v.set_name, "archive");
744 assert_eq!(v.volume_number, 1);
745
746 let v = parse_rar_volume("archive.r01").unwrap();
747 assert_eq!(v.set_name, "archive");
748 assert_eq!(v.volume_number, 2);
749
750 let v = parse_rar_volume("archive.r99").unwrap();
751 assert_eq!(v.set_name, "archive");
752 assert_eq!(v.volume_number, 100);
753
754 let v = parse_rar_volume("archive.s00").unwrap();
755 assert_eq!(v.set_name, "archive");
756 assert_eq!(v.volume_number, 101);
757 }
758
759 #[test]
760 fn test_parse_rar_volume_non_rar() {
761 assert!(parse_rar_volume("movie.mkv").is_none());
762 assert!(parse_rar_volume("movie.par2").is_none());
763 assert!(parse_rar_volume("movie.7z").is_none());
764 assert!(parse_rar_volume("movie.zip").is_none());
765 assert!(parse_rar_volume("readme.txt").is_none());
766 }
767
768 #[test]
769 fn test_parse_rar_volume_case_insensitive() {
770 let v = parse_rar_volume("Movie.Part003.RAR").unwrap();
771 assert_eq!(v.set_name, "Movie");
772 assert_eq!(v.volume_number, 2);
773
774 let v = parse_rar_volume("ARCHIVE.R05").unwrap();
775 assert_eq!(v.set_name, "ARCHIVE");
776 assert_eq!(v.volume_number, 6);
777 }
778
779 #[test]
780 fn test_parse_rar_volume_preserves_original_set_name() {
781 let v = parse_rar_volume("My.Movie.2024.1080p.part001.rar").unwrap();
782 assert_eq!(v.set_name, "My.Movie.2024.1080p");
783 }
784}