Skip to main content

nzb_postproc/
detect.rs

1//! File detection helpers for post-processing.
2//!
3//! Scans a completed download directory to find par2 files, RAR archives,
4//! 7z archives, TAR archives, ZIP archives, and cleanup candidates.
5
6use std::collections::BTreeMap;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9
10use walkdir::WalkDir;
11
12/// RAR 4.x volume signature.
13const RAR4_SIGNATURE: &[u8] = b"Rar!\x1a\x07\x00";
14/// RAR 5.x volume signature.
15const RAR5_SIGNATURE: &[u8] = b"Rar!\x1a\x07\x01\x00";
16
17/// Returns true if the file begins with a RAR volume signature.
18///
19/// Obfuscated posts strip every naming cue, so content is the only reliable
20/// evidence that a `<hash>.NN` file is an archive volume. Extension matching
21/// alone would misclassify unrelated numeric-suffixed files.
22pub fn has_rar_signature(path: &Path) -> bool {
23    let Ok(mut file) = std::fs::File::open(path) else {
24        return false;
25    };
26    // Read up to the longest signature; short files simply cannot match.
27    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
40/// Split a bare numeric extension: `"cfd4be79….45"` → `("cfd4be79…", 45)`.
41///
42/// Returns `None` for anything with a non-numeric extension, and for split 7z
43/// volumes (`archive.7z.001`) — those are numeric too, but they belong to the
44/// 7z path and must not be reclassified as RAR.
45fn 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/// Parsed RAR volume information: set name and volume number.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct RarVolumeInfo {
61    /// The base name of the RAR set (e.g. "movie" from "movie.part003.rar").
62    pub set_name: String,
63    /// Zero-based volume number. For new-style `.partNNN.rar`, this is NNN-1.
64    /// For old-style, `.rar` = 0, `.r00` = 1, `.r01` = 2, etc.
65    pub volume_number: u32,
66}
67
68/// Parse a filename to extract RAR set name and volume number.
69///
70/// Returns `None` if the file isn't a recognizable RAR volume.
71///
72/// Handles:
73///   - New-style: `"movie.part001.rar"` → `("movie", 0)`, `"movie.part002.rar"` → `("movie", 1)`
74///   - Old-style: `"movie.rar"` → `("movie", 0)`, `"movie.r00"` → `("movie", 1)`, `"movie.r01"` → `("movie", 2)`
75pub fn parse_rar_volume(filename: &str) -> Option<RarVolumeInfo> {
76    let name_lower = filename.to_ascii_lowercase();
77
78    // New-style: .partNNN.rar
79    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                // Use the original filename's casing for set_name
87                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        // Plain .rar — first volume in old-style set
95        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    // Old-style continuation: .r00, .r01, ..., .s00, etc. The letter runs
103    // r..=z only; .n64, .a52, .c01 and friends are unrelated formats.
104    if name_lower.len() > 4
105        && let Some(last4) = name_lower.get(name_lower.len() - 4..)
106        && last4.starts_with('.')
107        && (b'r'..=b'z').contains(&last4.as_bytes()[1])
108        && last4.as_bytes()[2].is_ascii_digit()
109        && last4.as_bytes()[3].is_ascii_digit()
110    {
111        let letter = last4.as_bytes()[1];
112        let tens = (last4.as_bytes()[2] - b'0') as u32;
113        let ones = (last4.as_bytes()[3] - b'0') as u32;
114        // .r00 = volume 1, .r01 = volume 2, ..., .r99 = volume 100
115        // .s00 = volume 101, .s01 = volume 102, etc.
116        let letter_offset = (letter - b'r') as u32 * 100;
117        let vol = letter_offset + tens * 10 + ones + 1;
118        let set_name = &filename[..filename.len() - 4];
119        return Some(RarVolumeInfo {
120            set_name: set_name.to_string(),
121            volume_number: vol,
122        });
123    }
124
125    None
126}
127
128/// Parse a RAR volume from a file on disk, falling back to content inspection.
129///
130/// [`parse_rar_volume`] can only judge names, so it cannot recognise an
131/// obfuscated volume like `cfd4be79….45`. This variant additionally accepts a
132/// bare numeric extension when the file actually begins with a RAR signature —
133/// evidence a filename cannot provide. Prefer it wherever a path is available.
134///
135/// The volume number for an obfuscated set is the numeric extension itself, so
136/// volumes order correctly relative to one another within the set. It is not
137/// comparable with the numbering [`parse_rar_volume`] assigns to conventional
138/// sets, which is anchored to `.rar` = 0.
139pub 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/// The type of archive detected.
155#[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
174/// Find all `.par2` files in a directory. The index par2 file (without
175/// `.volNNN+NNN.par2` or `.volNNN-NNN.par2` suffix) is returned first so callers can use it
176/// as the primary verification target.
177pub 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        // Index par2 files do NOT contain ".vol" before ".par2"
194        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 first, then volumes
205    index_files.extend(volume_files);
206    index_files
207}
208
209/// Returns true if a filename looks like a par2 volume file (e.g.
210/// `foo.vol00+01.par2` or `foo.vol00-01.par2`) rather than the index file.
211pub(crate) fn is_par2_volume(name_lower: &str) -> bool {
212    // Typical patterns: .vol000+000.par2 and .vol000-000.par2.
213    // We check for ".vol" anywhere before the final ".par2"
214    let without_ext = name_lower.trim_end_matches(".par2");
215    // Look for ".vol" followed by digits, a '+', and more digits
216    if let Some(vol_pos) = without_ext.rfind(".vol") {
217        let after_vol = &without_ext[vol_pos + 4..];
218        // Check pattern: digits + '+' + digits
219        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
231/// Find the first RAR volume(s) in a directory. Handles both old-style naming
232/// (.rar, .r00, .r01, ...) and new-style (.part001.rar, .part002.rar, ...).
233///
234/// Returns only the *first* volume of each archive set (the one you pass to
235/// `unrar x`).
236pub fn find_rar_files(dir: &Path) -> Vec<PathBuf> {
237    let mut first_volumes: Vec<PathBuf> = Vec::new();
238    // Obfuscated sets keyed by set name → (lowest volume seen, its path).
239    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        // New-style: .part001.rar is the first volume
253        if name_lower.ends_with(".rar")
254            && let Some(stem) = name_lower.strip_suffix(".rar")
255        {
256            // Check for .partNNN pattern
257            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                    // part > 1 is not a first volume
267                    continue;
268                }
269            }
270            // Plain .rar with no .partNNN — this is the first volume in old-style
271            first_volumes.push(path.to_path_buf());
272            continue;
273        }
274        // Old-style: .r00, .r01, etc. — we do NOT add these; the .rar file
275        // is the first volume in old-style sets.
276        if parse_rar_volume(&name_lower).is_some() {
277            continue;
278        }
279
280        // Obfuscated set: `<name>.NN` with no recognisable extension. Only
281        // files that actually begin with a RAR signature qualify, so unrelated
282        // numeric-suffixed files (`Concert.Recording.1987`) are left alone.
283        // The lowest-numbered volume is treated as the one to hand to unrar.
284        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
304/// Detect all archives in a directory. Returns (ArchiveType, path) pairs.
305/// For multi-volume RAR sets, only the first volume is returned.
306pub fn find_archives(dir: &Path) -> Vec<(ArchiveType, PathBuf)> {
307    let mut archives: Vec<(ArchiveType, PathBuf)> = Vec::new();
308
309    // RAR first volumes
310    for path in find_rar_files(dir) {
311        archives.push((ArchiveType::Rar, path));
312    }
313
314    // 7z (including split volumes), TAR, and ZIP
315    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            // Split 7z: .7z.001 is the first volume — 7z handles the rest
329            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
341/// Find files that are safe to delete after successful extraction.
342/// This includes par2 files, RAR volumes (old-style and new-style), and
343/// other recovery/split files.
344pub 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
366/// Returns whether a completed output directory contains at least one file
367/// that is not an archive or PAR2 recovery artifact. This is deliberately a
368/// conservative final-status check: a job made solely of raw recovery and
369/// archive files is not a usable completed download.
370pub 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    // Distinguish an empty, readable output directory from a missing one.
385    std::fs::read_dir(dir)?;
386    Ok(false)
387}
388
389/// Returns true if a lowercased filename is the first volume of a split 7z
390/// archive (e.g., `archive.7z.001`).
391fn is_split_7z_first_volume(name_lower: &str) -> bool {
392    // Pattern: anything.7z.001
393    if let Some(stem) = name_lower.strip_suffix(".001") {
394        return stem.ends_with(".7z");
395    }
396    false
397}
398
399/// Returns true if a lowercased filename is any volume of a split 7z archive
400/// (e.g., `.7z.001`, `.7z.002`, ...).
401fn is_split_7z_volume(name_lower: &str) -> bool {
402    // Pattern: anything.7z.NNN where NNN is digits
403    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
413/// Determine whether a file on disk is safe to clean up after successful
414/// extraction, using its name and — where the name is uninformative — its
415/// content.
416///
417/// `name_lower` must be the lowercased file name of `path`.
418///
419/// An obfuscated `<hash>.NN` volume is indistinguishable by name from an
420/// ordinary file that happens to end in digits, so the RAR signature is what
421/// separates junk to delete from payload to keep. Getting this wrong in either
422/// direction is costly: treating payload as junk deletes it, and treating an
423/// archive volume as payload lets a job report Completed with nothing usable
424/// in it (issue #87).
425fn is_cleanup_candidate_at(path: &Path, name_lower: &str) -> bool {
426    if is_cleanup_candidate(name_lower) {
427        return true;
428    }
429    // Old-style continuation volume (.r00 ... .z99). `.r00` is also a
430    // plausible name for ordinary payload, so only treat it as a volume when
431    // it belongs to a RAR set present alongside it or is itself a RAR file.
432    if !name_lower.ends_with(".rar")
433        && let Some(info) = parse_rar_volume(name_lower)
434    {
435        return has_rar_first_volume(path, &info.set_name) || has_rar_signature(path);
436    }
437    split_numeric_volume(name_lower).is_some() && has_rar_signature(path)
438}
439
440/// Returns true if the directory containing `path` holds the first volume
441/// (`<set>.rar` or `<set>.partNN.rar`) of the RAR set named `set_lower`.
442/// Matching is case-insensitive; `set_lower` must already be lowercased.
443fn has_rar_first_volume(path: &Path, set_lower: &str) -> bool {
444    let Some(parent) = path.parent() else {
445        return false;
446    };
447    let Ok(entries) = std::fs::read_dir(parent) else {
448        return false;
449    };
450    entries.flatten().any(|entry| {
451        let Some(name) = entry.file_name().to_str().map(str::to_lowercase) else {
452            return false;
453        };
454        name.ends_with(".rar")
455            && parse_rar_volume(&name).is_some_and(|info| info.set_name == set_lower)
456            && entry.file_type().is_ok_and(|t| t.is_file())
457    })
458}
459
460/// Determine whether a file (by its lowercased name alone) is safe to clean up
461/// after successful extraction.
462///
463/// Name-only: prefer [`is_cleanup_candidate_at`] wherever a path is available,
464/// so obfuscated volumes are caught too.
465fn is_cleanup_candidate(name: &str) -> bool {
466    // Par2 files: .par2
467    if name.ends_with(".par2")
468        || name.ends_with(".zip")
469        || name.ends_with(".7z")
470        || name.ends_with(".tar")
471    {
472        return true;
473    }
474
475    // RAR volumes (new-style): .part001.rar, .part002.rar, ...
476    // and plain .rar files
477    if name.ends_with(".rar") {
478        return true;
479    }
480
481    // Old-style RAR split volumes (.r00, .s00, ...) are deliberately not
482    // matched here: the same name shape is used by unrelated payload, so they
483    // need on-disk evidence — see `is_cleanup_candidate_at`.
484
485    // Split 7z volumes: .7z.001, .7z.002, etc.
486    if is_split_7z_volume(name) {
487        return true;
488    }
489
490    // Encrypted 7z archives: .7z.enc
491    if name.ends_with(".7z.enc") {
492        return true;
493    }
494
495    false
496}
497
498// ---------------------------------------------------------------------------
499// Tests
500// ---------------------------------------------------------------------------
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use std::fs;
506
507    /// Create a temporary directory with the given filenames (empty files).
508    fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
509        let dir = tempfile::tempdir().unwrap();
510        for name in files {
511            let path = dir.path().join(name);
512            if let Some(parent) = path.parent() {
513                fs::create_dir_all(parent).unwrap();
514            }
515            fs::write(&path, b"").unwrap();
516        }
517        dir
518    }
519
520    #[test]
521    fn test_find_par2_index_first() {
522        let dir = make_test_dir(&["movie.vol00+01.par2", "movie.vol01+02.par2", "movie.par2"]);
523        let results = find_par2_files(dir.path());
524        assert_eq!(results.len(), 3);
525        // Index file should come first
526        assert!(
527            results[0].file_name().unwrap().to_str().unwrap() == "movie.par2",
528            "Index par2 file should be first, got {:?}",
529            results[0]
530        );
531    }
532
533    #[test]
534    fn test_find_par2_hyphenated_volumes_after_index() {
535        let dir = make_test_dir(&["movie.vol63-67.par2", "movie.par2", "movie.vol00-01.par2"]);
536        let files = find_par2_files(dir.path());
537        assert_eq!(files[0].file_name().unwrap(), "movie.par2");
538        assert_eq!(files.len(), 3);
539    }
540
541    #[test]
542    fn test_find_par2_empty_dir() {
543        let dir = make_test_dir(&["readme.txt", "movie.mkv"]);
544        let results = find_par2_files(dir.path());
545        assert!(results.is_empty());
546    }
547
548    #[test]
549    fn test_find_rar_new_style() {
550        let dir = make_test_dir(&[
551            "archive.part001.rar",
552            "archive.part002.rar",
553            "archive.part003.rar",
554        ]);
555        let results = find_rar_files(dir.path());
556        // Only the first volume should be returned
557        assert_eq!(results.len(), 1);
558        assert!(
559            results[0]
560                .file_name()
561                .unwrap()
562                .to_str()
563                .unwrap()
564                .contains("part001"),
565        );
566    }
567
568    #[test]
569    fn test_find_rar_old_style() {
570        let dir = make_test_dir(&["archive.rar", "archive.r00", "archive.r01", "archive.r02"]);
571        let results = find_rar_files(dir.path());
572        // Only .rar (the first volume) should be returned
573        assert_eq!(results.len(), 1);
574        assert!(
575            results[0]
576                .file_name()
577                .unwrap()
578                .to_str()
579                .unwrap()
580                .ends_with(".rar")
581        );
582    }
583
584    #[test]
585    fn test_find_archives_mixed() {
586        let dir = make_test_dir(&[
587            "movie.part001.rar",
588            "movie.part002.rar",
589            "subs.zip",
590            "extras.7z",
591        ]);
592        let results = find_archives(dir.path());
593        let types: Vec<ArchiveType> = results.iter().map(|(t, _)| *t).collect();
594        assert!(types.contains(&ArchiveType::Rar));
595        assert!(types.contains(&ArchiveType::Zip));
596        assert!(types.contains(&ArchiveType::SevenZip));
597        // RAR should only have 1 entry (first volume)
598        assert_eq!(types.iter().filter(|&&t| t == ArchiveType::Rar).count(), 1);
599    }
600
601    #[test]
602    fn usable_output_requires_a_non_artifact_file() {
603        let raw_only = make_test_dir(&[
604            "release.part001.rar",
605            "release.part002.rar",
606            "release.par2",
607            "release.vol00+01.par2",
608            "release.7z",
609        ]);
610        assert!(!has_usable_output(raw_only.path()).unwrap());
611
612        let payload = make_test_dir(&["release.part001.rar", "Movie.2024.mkv"]);
613        assert!(has_usable_output(payload.path()).unwrap());
614    }
615
616    #[test]
617    fn test_find_cleanup_files() {
618        let dir = make_test_dir(&[
619            "movie.par2",
620            "movie.vol00+01.par2",
621            "movie.part001.rar",
622            "movie.part002.rar",
623            "movie.r00",
624            "movie.r01",
625            "movie.mkv",  // should NOT be cleaned up
626            "readme.txt", // should NOT be cleaned up
627        ]);
628        let results = find_cleanup_files(dir.path());
629        // par2 (2) + rar (2) + r00 + r01 = 6
630        assert_eq!(
631            results.len(),
632            6,
633            "Expected 6 cleanup files, got: {results:?}"
634        );
635        // .mkv and .txt should NOT be present
636        for path in &results {
637            let name = path.file_name().unwrap().to_str().unwrap();
638            assert!(!name.ends_with(".mkv"));
639            assert!(!name.ends_with(".txt"));
640        }
641    }
642
643    #[test]
644    fn test_is_par2_volume() {
645        assert!(is_par2_volume("file.vol00+01.par2"));
646        assert!(is_par2_volume("file.vol123+456.par2"));
647        assert!(is_par2_volume("file.vol00-01.par2"));
648        assert!(is_par2_volume("file.vol63-67.par2"));
649        assert!(!is_par2_volume("file.par2"));
650        assert!(!is_par2_volume("file.volume.par2"));
651    }
652
653    #[test]
654    fn test_cleanup_old_style_volumes() {
655        let dir = make_test_dir(&[
656            "archive.rar",
657            "archive.r00",
658            "archive.r99",
659            "archive.s00",
660            "readme.txt",
661            "movie.mkv",
662        ]);
663        let candidate = |name: &str| {
664            is_cleanup_candidate_at(&dir.path().join(name), &name.to_ascii_lowercase())
665        };
666        assert!(candidate("archive.r00"));
667        assert!(candidate("archive.r99"));
668        assert!(candidate("archive.s00"));
669        assert!(!candidate("readme.txt"));
670        assert!(!candidate("movie.mkv"));
671    }
672
673    #[test]
674    fn test_cleanup_keeps_letter_digit_payload_without_rar_set() {
675        // No archive anywhere: these are payload (N64 ROM, C64 disk image,
676        // and an orphan .r00 that is not a RAR volume) and must survive.
677        let dir = make_test_dir(&["Game.n64", "disk.d64", "notes.r00", "readme.txt"]);
678        let results = find_cleanup_files(dir.path());
679        assert!(
680            results.is_empty(),
681            "payload marked for cleanup: {results:?}"
682        );
683        assert!(has_usable_output(dir.path()).unwrap());
684    }
685
686    #[test]
687    fn test_cleanup_old_style_volume_matches_set_case_insensitively() {
688        let dir = make_test_dir(&["Movie.RAR", "Movie.R00", "movie.r01", "Game.n64"]);
689        let names: Vec<String> = find_cleanup_files(dir.path())
690            .iter()
691            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
692            .collect();
693        assert_eq!(names, ["Movie.R00", "Movie.RAR", "movie.r01"]);
694    }
695
696    #[test]
697    fn test_cleanup_orphan_old_style_volume_with_rar_signature() {
698        // A continuation volume whose first volume is gone is still a RAR
699        // volume when its content says so.
700        let dir = make_test_dir(&[]);
701        fs::write(dir.path().join("movie.r00"), b"Rar!\x1a\x07\x00rest").unwrap();
702        let results = find_cleanup_files(dir.path());
703        assert_eq!(results.len(), 1, "{results:?}");
704    }
705
706    // -----------------------------------------------------------------------
707    // Split 7z archive detection
708    // -----------------------------------------------------------------------
709
710    #[test]
711    fn test_split_7z_first_volume() {
712        assert!(is_split_7z_first_volume("archive.7z.001"));
713        assert!(is_split_7z_first_volume("my.movie.7z.001"));
714        assert!(!is_split_7z_first_volume("archive.7z.002"));
715        assert!(!is_split_7z_first_volume("archive.7z.010"));
716        assert!(!is_split_7z_first_volume("archive.7z"));
717        assert!(!is_split_7z_first_volume("archive.rar.001"));
718    }
719
720    #[test]
721    fn test_split_7z_volume() {
722        assert!(is_split_7z_volume("archive.7z.001"));
723        assert!(is_split_7z_volume("archive.7z.002"));
724        assert!(is_split_7z_volume("archive.7z.099"));
725        assert!(!is_split_7z_volume("archive.7z"));
726        assert!(!is_split_7z_volume("archive.rar.001"));
727        assert!(!is_split_7z_volume("archive.7z.abc"));
728    }
729
730    #[test]
731    fn test_find_archives_split_7z() {
732        let dir = make_test_dir(&["movie.7z.001", "movie.7z.002", "movie.7z.003"]);
733        let results = find_archives(dir.path());
734        // Only the first volume (.001) should be returned
735        assert_eq!(results.len(), 1);
736        assert_eq!(results[0].0, ArchiveType::SevenZip);
737        assert!(
738            results[0]
739                .1
740                .file_name()
741                .unwrap()
742                .to_str()
743                .unwrap()
744                .ends_with(".7z.001"),
745        );
746    }
747
748    #[test]
749    fn test_cleanup_split_7z_volumes() {
750        assert!(is_cleanup_candidate("archive.7z"));
751        assert!(is_cleanup_candidate("archive.7z.001"));
752        assert!(is_cleanup_candidate("archive.7z.002"));
753        assert!(is_cleanup_candidate("archive.7z.099"));
754        assert!(!is_cleanup_candidate("movie.mkv"));
755    }
756
757    #[test]
758    fn test_find_cleanup_includes_split_7z() {
759        let dir = make_test_dir(&[
760            "movie.7z.001",
761            "movie.7z.002",
762            "movie.7z.003",
763            "movie.mkv", // should NOT be cleaned up
764        ]);
765        let results = find_cleanup_files(dir.path());
766        assert_eq!(
767            results.len(),
768            3,
769            "Expected 3 split 7z cleanup files, got: {results:?}"
770        );
771        for path in &results {
772            let name = path.file_name().unwrap().to_str().unwrap();
773            assert!(!name.ends_with(".mkv"));
774        }
775    }
776
777    // -----------------------------------------------------------------------
778    // RAR volume filename parser
779    // -----------------------------------------------------------------------
780
781    #[test]
782    fn test_parse_rar_volume_new_style() {
783        let v = parse_rar_volume("movie.part001.rar").unwrap();
784        assert_eq!(v.set_name, "movie");
785        assert_eq!(v.volume_number, 0);
786
787        let v = parse_rar_volume("movie.part002.rar").unwrap();
788        assert_eq!(v.set_name, "movie");
789        assert_eq!(v.volume_number, 1);
790
791        let v = parse_rar_volume("My.Movie.2024.part015.rar").unwrap();
792        assert_eq!(v.set_name, "My.Movie.2024");
793        assert_eq!(v.volume_number, 14);
794    }
795
796    #[test]
797    fn test_parse_rar_volume_old_style() {
798        let v = parse_rar_volume("archive.rar").unwrap();
799        assert_eq!(v.set_name, "archive");
800        assert_eq!(v.volume_number, 0);
801
802        let v = parse_rar_volume("archive.r00").unwrap();
803        assert_eq!(v.set_name, "archive");
804        assert_eq!(v.volume_number, 1);
805
806        let v = parse_rar_volume("archive.r01").unwrap();
807        assert_eq!(v.set_name, "archive");
808        assert_eq!(v.volume_number, 2);
809
810        let v = parse_rar_volume("archive.r99").unwrap();
811        assert_eq!(v.set_name, "archive");
812        assert_eq!(v.volume_number, 100);
813
814        let v = parse_rar_volume("archive.s00").unwrap();
815        assert_eq!(v.set_name, "archive");
816        assert_eq!(v.volume_number, 101);
817    }
818
819    #[test]
820    fn test_parse_rar_volume_non_rar() {
821        assert!(parse_rar_volume("movie.mkv").is_none());
822        assert!(parse_rar_volume("movie.par2").is_none());
823        assert!(parse_rar_volume("movie.7z").is_none());
824        assert!(parse_rar_volume("movie.zip").is_none());
825        assert!(parse_rar_volume("readme.txt").is_none());
826    }
827
828    #[test]
829    fn parse_rar_volume_does_not_panic_on_multibyte_names() {
830        assert!(parse_rar_volume("テスト").is_none());
831        assert!(parse_rar_volume("file.日本").is_none());
832        assert!(parse_rar_volume(".r0").is_none());
833        assert!(parse_rar_volume(".r00").is_none());
834        assert!(parse_rar_volume("r0").is_none());
835        assert!(parse_rar_volume("").is_none());
836        let v = parse_rar_volume("İSTANBUL.part01.rar").unwrap();
837        assert_eq!(v.set_name, "İSTANBUL");
838        assert_eq!(v.volume_number, 0);
839    }
840
841    #[test]
842    fn test_parse_rar_volume_rejects_letters_before_r() {
843        // Old-style continuation volumes run .r00-.r99, .s00-.s99, ... .z99.
844        // Anything a-q is an unrelated extension (N64 ROM, AC-3 audio, ...)
845        // and must neither be parsed as a volume nor panic on underflow.
846        for name in ["Game.n64", "clip.a52", "x.c01", "disk.d64", "tape.q99"] {
847            assert!(
848                parse_rar_volume(name).is_none(),
849                "{name} is not a RAR volume"
850            );
851        }
852
853        let v = parse_rar_volume("archive.t00").unwrap();
854        assert_eq!(v.volume_number, 201);
855        let v = parse_rar_volume("archive.z99").unwrap();
856        assert_eq!(v.volume_number, 900);
857    }
858
859    #[test]
860    fn test_find_archives_ignores_non_rar_letter_extensions() {
861        let dir = make_test_dir(&["Game.n64", "clip.a52", "x.c01", "movie.mkv"]);
862        assert!(find_archives(dir.path()).is_empty());
863    }
864
865    #[test]
866    fn test_parse_rar_volume_case_insensitive() {
867        let v = parse_rar_volume("Movie.Part003.RAR").unwrap();
868        assert_eq!(v.set_name, "Movie");
869        assert_eq!(v.volume_number, 2);
870
871        let v = parse_rar_volume("ARCHIVE.R05").unwrap();
872        assert_eq!(v.set_name, "ARCHIVE");
873        assert_eq!(v.volume_number, 6);
874    }
875
876    #[test]
877    fn test_parse_rar_volume_preserves_original_set_name() {
878        let v = parse_rar_volume("My.Movie.2024.1080p.part001.rar").unwrap();
879        assert_eq!(v.set_name, "My.Movie.2024.1080p");
880    }
881}