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_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.
103    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            // .r00 = volume 1, .r01 = volume 2, ..., .r99 = volume 100
114            // .s00 = volume 101, .s01 = volume 102, etc.
115            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
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.
211fn 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    split_numeric_volume(name_lower).is_some() && has_rar_signature(path)
430}
431
432/// Determine whether a file (by its lowercased name alone) is safe to clean up
433/// after successful extraction.
434///
435/// Name-only: prefer [`is_cleanup_candidate_at`] wherever a path is available,
436/// so obfuscated volumes are caught too.
437fn is_cleanup_candidate(name: &str) -> bool {
438    // Par2 files: .par2
439    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    // RAR volumes (new-style): .part001.rar, .part002.rar, ...
448    // and plain .rar files
449    if name.ends_with(".rar") {
450        return true;
451    }
452
453    // Old-style RAR split volumes: .r00, .r01, ..., .r99, .s00, ...
454    // Pattern: ends with .rNN or .sNN (or any letter + two digits)
455    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    // Extended old-style volumes beyond .r99: .s00, .t00, etc. are caught above.
467    // Also handle three-digit extensions: .part001.rar already covered.
468
469    // Split 7z volumes: .7z.001, .7z.002, etc.
470    if is_split_7z_volume(name) {
471        return true;
472    }
473
474    // Encrypted 7z archives: .7z.enc
475    if name.ends_with(".7z.enc") {
476        return true;
477    }
478
479    false
480}
481
482// ---------------------------------------------------------------------------
483// Tests
484// ---------------------------------------------------------------------------
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use std::fs;
490
491    /// Create a temporary directory with the given filenames (empty files).
492    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        // Index file should come first
510        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        // Only the first volume should be returned
541        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        // Only .rar (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                .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        // RAR should only have 1 entry (first volume)
582        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",  // should NOT be cleaned up
610            "readme.txt", // should NOT be cleaned up
611        ]);
612        let results = find_cleanup_files(dir.path());
613        // par2 (2) + rar (2) + r00 + r01 = 6
614        assert_eq!(
615            results.len(),
616            6,
617            "Expected 6 cleanup files, got: {results:?}"
618        );
619        // .mkv and .txt should NOT be present
620        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    // -----------------------------------------------------------------------
647    // Split 7z archive detection
648    // -----------------------------------------------------------------------
649
650    #[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        // Only the first volume (.001) should be returned
675        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", // should NOT be cleaned up
704        ]);
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    // -----------------------------------------------------------------------
718    // RAR volume filename parser
719    // -----------------------------------------------------------------------
720
721    #[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}