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(separator_pos) = after_vol.find(['+', '-']) {
144 let before_separator = &after_vol[..separator_pos];
145 let after_separator = &after_vol[separator_pos + 1..];
146 return !before_separator.is_empty()
147 && before_separator.chars().all(|c| c.is_ascii_digit())
148 && !after_separator.is_empty()
149 && after_separator.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
263pub fn has_usable_output(dir: &Path) -> std::io::Result<bool> {
268 for entry in WalkDir::new(dir).into_iter().flatten() {
269 let path = entry.path();
270 if !path.is_file() {
271 continue;
272 }
273 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
274 continue;
275 };
276 if !is_cleanup_candidate(&name.to_ascii_lowercase()) {
277 return Ok(true);
278 }
279 }
280
281 std::fs::read_dir(dir)?;
283 Ok(false)
284}
285
286fn is_split_7z_first_volume(name_lower: &str) -> bool {
289 if let Some(stem) = name_lower.strip_suffix(".001") {
291 return stem.ends_with(".7z");
292 }
293 false
294}
295
296fn is_split_7z_volume(name_lower: &str) -> bool {
299 if let Some(dot_pos) = name_lower.rfind('.') {
301 let ext = &name_lower[dot_pos + 1..];
302 if !ext.is_empty() && ext.chars().all(|c| c.is_ascii_digit()) {
303 let stem = &name_lower[..dot_pos];
304 return stem.ends_with(".7z");
305 }
306 }
307 false
308}
309
310fn is_cleanup_candidate(name: &str) -> bool {
313 if name.ends_with(".par2") || name.ends_with(".zip") || name.ends_with(".7z") {
315 return true;
316 }
317
318 if name.ends_with(".rar") {
321 return true;
322 }
323
324 if name.len() > 4 {
327 let last4 = &name[name.len() - 4..];
328 if last4.starts_with('.')
329 && last4.as_bytes()[1].is_ascii_lowercase()
330 && last4.as_bytes()[2].is_ascii_digit()
331 && last4.as_bytes()[3].is_ascii_digit()
332 {
333 return true;
334 }
335 }
336
337 if is_split_7z_volume(name) {
342 return true;
343 }
344
345 if name.ends_with(".7z.enc") {
347 return true;
348 }
349
350 false
351}
352
353#[cfg(test)]
358mod tests {
359 use super::*;
360 use std::fs;
361
362 fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
364 let dir = tempfile::tempdir().unwrap();
365 for name in files {
366 let path = dir.path().join(name);
367 if let Some(parent) = path.parent() {
368 fs::create_dir_all(parent).unwrap();
369 }
370 fs::write(&path, b"").unwrap();
371 }
372 dir
373 }
374
375 #[test]
376 fn test_find_par2_index_first() {
377 let dir = make_test_dir(&["movie.vol00+01.par2", "movie.vol01+02.par2", "movie.par2"]);
378 let results = find_par2_files(dir.path());
379 assert_eq!(results.len(), 3);
380 assert!(
382 results[0].file_name().unwrap().to_str().unwrap() == "movie.par2",
383 "Index par2 file should be first, got {:?}",
384 results[0]
385 );
386 }
387
388 #[test]
389 fn test_find_par2_hyphenated_volumes_after_index() {
390 let dir = make_test_dir(&["movie.vol63-67.par2", "movie.par2", "movie.vol00-01.par2"]);
391 let files = find_par2_files(dir.path());
392 assert_eq!(files[0].file_name().unwrap(), "movie.par2");
393 assert_eq!(files.len(), 3);
394 }
395
396 #[test]
397 fn test_find_par2_empty_dir() {
398 let dir = make_test_dir(&["readme.txt", "movie.mkv"]);
399 let results = find_par2_files(dir.path());
400 assert!(results.is_empty());
401 }
402
403 #[test]
404 fn test_find_rar_new_style() {
405 let dir = make_test_dir(&[
406 "archive.part001.rar",
407 "archive.part002.rar",
408 "archive.part003.rar",
409 ]);
410 let results = find_rar_files(dir.path());
411 assert_eq!(results.len(), 1);
413 assert!(
414 results[0]
415 .file_name()
416 .unwrap()
417 .to_str()
418 .unwrap()
419 .contains("part001"),
420 );
421 }
422
423 #[test]
424 fn test_find_rar_old_style() {
425 let dir = make_test_dir(&["archive.rar", "archive.r00", "archive.r01", "archive.r02"]);
426 let results = find_rar_files(dir.path());
427 assert_eq!(results.len(), 1);
429 assert!(
430 results[0]
431 .file_name()
432 .unwrap()
433 .to_str()
434 .unwrap()
435 .ends_with(".rar")
436 );
437 }
438
439 #[test]
440 fn test_find_archives_mixed() {
441 let dir = make_test_dir(&[
442 "movie.part001.rar",
443 "movie.part002.rar",
444 "subs.zip",
445 "extras.7z",
446 ]);
447 let results = find_archives(dir.path());
448 let types: Vec<ArchiveType> = results.iter().map(|(t, _)| *t).collect();
449 assert!(types.contains(&ArchiveType::Rar));
450 assert!(types.contains(&ArchiveType::Zip));
451 assert!(types.contains(&ArchiveType::SevenZip));
452 assert_eq!(types.iter().filter(|&&t| t == ArchiveType::Rar).count(), 1);
454 }
455
456 #[test]
457 fn usable_output_requires_a_non_artifact_file() {
458 let raw_only = make_test_dir(&[
459 "release.part001.rar",
460 "release.part002.rar",
461 "release.par2",
462 "release.vol00+01.par2",
463 "release.7z",
464 ]);
465 assert!(!has_usable_output(raw_only.path()).unwrap());
466
467 let payload = make_test_dir(&["release.part001.rar", "Movie.2024.mkv"]);
468 assert!(has_usable_output(payload.path()).unwrap());
469 }
470
471 #[test]
472 fn test_find_cleanup_files() {
473 let dir = make_test_dir(&[
474 "movie.par2",
475 "movie.vol00+01.par2",
476 "movie.part001.rar",
477 "movie.part002.rar",
478 "movie.r00",
479 "movie.r01",
480 "movie.mkv", "readme.txt", ]);
483 let results = find_cleanup_files(dir.path());
484 assert_eq!(
486 results.len(),
487 6,
488 "Expected 6 cleanup files, got: {results:?}"
489 );
490 for path in &results {
492 let name = path.file_name().unwrap().to_str().unwrap();
493 assert!(!name.ends_with(".mkv"));
494 assert!(!name.ends_with(".txt"));
495 }
496 }
497
498 #[test]
499 fn test_is_par2_volume() {
500 assert!(is_par2_volume("file.vol00+01.par2"));
501 assert!(is_par2_volume("file.vol123+456.par2"));
502 assert!(is_par2_volume("file.vol00-01.par2"));
503 assert!(is_par2_volume("file.vol63-67.par2"));
504 assert!(!is_par2_volume("file.par2"));
505 assert!(!is_par2_volume("file.volume.par2"));
506 }
507
508 #[test]
509 fn test_cleanup_old_style_volumes() {
510 assert!(is_cleanup_candidate("archive.r00"));
511 assert!(is_cleanup_candidate("archive.r99"));
512 assert!(is_cleanup_candidate("archive.s00"));
513 assert!(!is_cleanup_candidate("readme.txt"));
514 assert!(!is_cleanup_candidate("movie.mkv"));
515 }
516
517 #[test]
522 fn test_split_7z_first_volume() {
523 assert!(is_split_7z_first_volume("archive.7z.001"));
524 assert!(is_split_7z_first_volume("my.movie.7z.001"));
525 assert!(!is_split_7z_first_volume("archive.7z.002"));
526 assert!(!is_split_7z_first_volume("archive.7z.010"));
527 assert!(!is_split_7z_first_volume("archive.7z"));
528 assert!(!is_split_7z_first_volume("archive.rar.001"));
529 }
530
531 #[test]
532 fn test_split_7z_volume() {
533 assert!(is_split_7z_volume("archive.7z.001"));
534 assert!(is_split_7z_volume("archive.7z.002"));
535 assert!(is_split_7z_volume("archive.7z.099"));
536 assert!(!is_split_7z_volume("archive.7z"));
537 assert!(!is_split_7z_volume("archive.rar.001"));
538 assert!(!is_split_7z_volume("archive.7z.abc"));
539 }
540
541 #[test]
542 fn test_find_archives_split_7z() {
543 let dir = make_test_dir(&["movie.7z.001", "movie.7z.002", "movie.7z.003"]);
544 let results = find_archives(dir.path());
545 assert_eq!(results.len(), 1);
547 assert_eq!(results[0].0, ArchiveType::SevenZip);
548 assert!(
549 results[0]
550 .1
551 .file_name()
552 .unwrap()
553 .to_str()
554 .unwrap()
555 .ends_with(".7z.001"),
556 );
557 }
558
559 #[test]
560 fn test_cleanup_split_7z_volumes() {
561 assert!(is_cleanup_candidate("archive.7z"));
562 assert!(is_cleanup_candidate("archive.7z.001"));
563 assert!(is_cleanup_candidate("archive.7z.002"));
564 assert!(is_cleanup_candidate("archive.7z.099"));
565 assert!(!is_cleanup_candidate("movie.mkv"));
566 }
567
568 #[test]
569 fn test_find_cleanup_includes_split_7z() {
570 let dir = make_test_dir(&[
571 "movie.7z.001",
572 "movie.7z.002",
573 "movie.7z.003",
574 "movie.mkv", ]);
576 let results = find_cleanup_files(dir.path());
577 assert_eq!(
578 results.len(),
579 3,
580 "Expected 3 split 7z cleanup files, got: {results:?}"
581 );
582 for path in &results {
583 let name = path.file_name().unwrap().to_str().unwrap();
584 assert!(!name.ends_with(".mkv"));
585 }
586 }
587
588 #[test]
593 fn test_parse_rar_volume_new_style() {
594 let v = parse_rar_volume("movie.part001.rar").unwrap();
595 assert_eq!(v.set_name, "movie");
596 assert_eq!(v.volume_number, 0);
597
598 let v = parse_rar_volume("movie.part002.rar").unwrap();
599 assert_eq!(v.set_name, "movie");
600 assert_eq!(v.volume_number, 1);
601
602 let v = parse_rar_volume("My.Movie.2024.part015.rar").unwrap();
603 assert_eq!(v.set_name, "My.Movie.2024");
604 assert_eq!(v.volume_number, 14);
605 }
606
607 #[test]
608 fn test_parse_rar_volume_old_style() {
609 let v = parse_rar_volume("archive.rar").unwrap();
610 assert_eq!(v.set_name, "archive");
611 assert_eq!(v.volume_number, 0);
612
613 let v = parse_rar_volume("archive.r00").unwrap();
614 assert_eq!(v.set_name, "archive");
615 assert_eq!(v.volume_number, 1);
616
617 let v = parse_rar_volume("archive.r01").unwrap();
618 assert_eq!(v.set_name, "archive");
619 assert_eq!(v.volume_number, 2);
620
621 let v = parse_rar_volume("archive.r99").unwrap();
622 assert_eq!(v.set_name, "archive");
623 assert_eq!(v.volume_number, 100);
624
625 let v = parse_rar_volume("archive.s00").unwrap();
626 assert_eq!(v.set_name, "archive");
627 assert_eq!(v.volume_number, 101);
628 }
629
630 #[test]
631 fn test_parse_rar_volume_non_rar() {
632 assert!(parse_rar_volume("movie.mkv").is_none());
633 assert!(parse_rar_volume("movie.par2").is_none());
634 assert!(parse_rar_volume("movie.7z").is_none());
635 assert!(parse_rar_volume("movie.zip").is_none());
636 assert!(parse_rar_volume("readme.txt").is_none());
637 }
638
639 #[test]
640 fn test_parse_rar_volume_case_insensitive() {
641 let v = parse_rar_volume("Movie.Part003.RAR").unwrap();
642 assert_eq!(v.set_name, "Movie");
643 assert_eq!(v.volume_number, 2);
644
645 let v = parse_rar_volume("ARCHIVE.R05").unwrap();
646 assert_eq!(v.set_name, "ARCHIVE");
647 assert_eq!(v.volume_number, 6);
648 }
649
650 #[test]
651 fn test_parse_rar_volume_preserves_original_set_name() {
652 let v = parse_rar_volume("My.Movie.2024.1080p.part001.rar").unwrap();
653 assert_eq!(v.set_name, "My.Movie.2024.1080p");
654 }
655}