Skip to main content

nzb_postproc/
pipeline.rs

1//! Post-processing pipeline orchestrator.
2//!
3//! Stages:
4//! - **Verify** — native PAR2 verification (skipped when articles_failed == 0)
5//! - **Repair** — native PAR2 repair when files are damaged
6//! - **Extract** — unpack RAR, 7z, ZIP archives
7//! - **Cleanup** — remove archive/par2 files
8
9use std::path::{Path, PathBuf};
10use std::time::Instant;
11
12use nzb_core::models::{StageResult, StageStatus};
13use tracing::{debug, error, info, warn};
14
15use crate::detect::{ArchiveType, find_archives, find_cleanup_files, find_par2_files};
16use crate::par2::par2_repair;
17use crate::unpack::{extract_7z, extract_rar, extract_zip};
18
19/// Outcome of the combined verify+repair spawn_blocking task.
20/// Keeps VerifyResult (which is !Send) on the blocking thread, then returns
21/// only Send-safe data back to the async context.
22enum VerifyRepairOutcome {
23    AllCorrect {
24        intact_count: usize,
25    },
26    Damaged {
27        intact: usize,
28        damaged: usize,
29        missing: usize,
30        blocks_needed: u32,
31        blocks_available: u32,
32        repair_result: Result<rust_par2::RepairResult, rust_par2::RepairError>,
33    },
34}
35
36/// Final result of the complete post-processing pipeline.
37#[derive(Debug)]
38pub struct PostProcResult {
39    /// Whether all stages completed successfully.
40    pub success: bool,
41    /// Results from each stage that was attempted.
42    pub stages: Vec<StageResult>,
43    /// Error message if the pipeline failed.
44    pub error: Option<String>,
45}
46
47/// Configuration for the post-processing pipeline.
48#[derive(Debug, Clone)]
49pub struct PostProcConfig {
50    /// Remove par2 and archive files after successful extraction.
51    pub cleanup_after_extract: bool,
52    /// Directory where extracted files should be placed.
53    /// If None, extracts into the job directory itself.
54    pub output_dir: Option<PathBuf>,
55    /// Number of articles that failed during download.
56    /// When 0, par2 verification is skipped (files are known-good).
57    /// When > 0, `par2 repair` is run directly (which verifies + repairs
58    /// in a single pass), avoiding the redundant verify-then-repair double-scan.
59    pub articles_failed: usize,
60    /// When true, the extract stage is skipped because direct unpack already
61    /// handled RAR extraction during the download phase.
62    pub skip_extract: bool,
63}
64
65impl Default for PostProcConfig {
66    fn default() -> Self {
67        Self {
68            cleanup_after_extract: true,
69            output_dir: None,
70            articles_failed: 0,
71            skip_extract: false,
72        }
73    }
74}
75
76/// Run the full post-processing pipeline on a completed job directory.
77///
78/// Stages executed in order:
79/// 1. **Verify** — par2 verification
80/// 2. **Repair** — par2 repair (only if verify found issues)
81/// 3. **Extract** — unpack RAR, 7z, ZIP archives
82/// 4. **Cleanup** — remove archive/par2 files (if configured)
83pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcResult {
84    let mut stages: Vec<StageResult> = Vec::new();
85    let mut pipeline_ok = true;
86
87    info!(dir = %job_dir.display(), "Starting post-processing pipeline");
88
89    // ------------------------------------------------------------------
90    // Stage 1: Native PAR2 verification
91    // ------------------------------------------------------------------
92    // Parse the PAR2 index file and verify all files via MD5 hashing.
93    // This is pure Rust — no process spawn, no stdout parsing.
94    //
95    // If all files pass → done (no par2cmdline needed).
96    // If files are damaged → attempt native repair.
97    //
98    // When articles_failed == 0 the files are known-good from CRC checks
99    // during yEnc decode, so we skip the expensive MD5 verification pass.
100    let par2_files = find_par2_files(job_dir);
101
102    if par2_files.is_empty() {
103        stages.push(StageResult {
104            name: "Verify".to_string(),
105            status: StageStatus::Skipped,
106            message: Some("No par2 files found".to_string()),
107            duration_secs: 0.0,
108        });
109    } else if config.articles_failed == 0 {
110        info!("Skipping PAR2 verification — zero article failures (CRC-verified)");
111        stages.push(StageResult {
112            name: "Verify".to_string(),
113            status: StageStatus::Skipped,
114            message: Some("Skipped — zero article failures".to_string()),
115            duration_secs: 0.0,
116        });
117    } else {
118        let verify_start = Instant::now();
119        let index_par2 = par2_files[0].clone();
120
121        match rust_par2::parse(&index_par2) {
122            Ok(file_set) => {
123                // PAR2-guided deobfuscation: if files on disk don't match
124                // PAR2 expected names (common with obfuscated posts where
125                // NZB subjects have readable names but PAR2 references
126                // the original obfuscated filenames), rename them using
127                // MD5-16k hash matching before verification runs.
128                rename_to_par2_names(&file_set, job_dir);
129
130                // Run verify (and repair if needed) in a single spawn_blocking call.
131                // This avoids two problems:
132                //   1. CPU-intensive verify/repair doesn't block the async runtime
133                //   2. VerifyResult (not Send) stays on one thread, so repair_from_verify
134                //      can reuse it — no redundant second verification pass
135                let dir = job_dir.to_path_buf();
136                let verify_repair_result = tokio::task::spawn_blocking(move || {
137                    let verify_result = rust_par2::verify(&file_set, &dir);
138
139                    if verify_result.all_correct() {
140                        VerifyRepairOutcome::AllCorrect {
141                            intact_count: verify_result.intact.len(),
142                        }
143                    } else {
144                        let intact = verify_result.intact.len();
145                        let damaged = verify_result.damaged.len();
146                        let missing = verify_result.missing.len();
147                        let blocks_needed = verify_result.blocks_needed();
148                        let blocks_available = verify_result.recovery_blocks_available;
149
150                        info!(
151                            intact,
152                            damaged,
153                            missing,
154                            blocks_needed,
155                            "Native PAR2 verify: damage detected, attempting native repair"
156                        );
157
158                        // Repair using the pre-computed verify result — no second verify pass
159                        info!("Running native PAR2 repair (with pre-computed verify)");
160                        let repair_result =
161                            rust_par2::repair_from_verify(&file_set, &dir, &verify_result);
162
163                        VerifyRepairOutcome::Damaged {
164                            intact,
165                            damaged,
166                            missing,
167                            blocks_needed,
168                            blocks_available,
169                            repair_result,
170                        }
171                    }
172                })
173                .await;
174
175                let verify_duration = verify_start.elapsed().as_secs_f64();
176
177                match verify_repair_result {
178                    Ok(VerifyRepairOutcome::AllCorrect { intact_count }) => {
179                        info!(
180                            files = intact_count,
181                            duration_secs = verify_duration,
182                            "Native PAR2 verify: all files correct"
183                        );
184                        stages.push(StageResult {
185                            name: "Verify".to_string(),
186                            status: StageStatus::Success,
187                            message: Some(format!(
188                                "All {intact_count} files correct (native verify, {verify_duration:.3}s)",
189                            )),
190                            duration_secs: verify_duration,
191                        });
192                    }
193                    Ok(VerifyRepairOutcome::Damaged {
194                        intact,
195                        damaged,
196                        missing,
197                        blocks_needed,
198                        blocks_available,
199                        repair_result,
200                    }) => {
201                        // Push the verify stage result
202                        stages.push(StageResult {
203                            name: "Verify".to_string(),
204                            status: StageStatus::Success,
205                            message: Some(format!(
206                                "{intact} intact, {damaged} damaged, {missing} missing — {blocks_needed} blocks needed (native verify)",
207                            )),
208                            duration_secs: verify_duration,
209                        });
210
211                        // Push the repair stage result
212                        match repair_result {
213                            Ok(result) => {
214                                info!(
215                                    blocks_repaired = result.blocks_repaired,
216                                    files_repaired = result.files_repaired,
217                                    "Native PAR2 repair complete"
218                                );
219                                if !result.success {
220                                    pipeline_ok = false;
221                                }
222                                stages.push(StageResult {
223                                    name: "Repair".to_string(),
224                                    status: if result.success {
225                                        StageStatus::Success
226                                    } else {
227                                        StageStatus::Failed
228                                    },
229                                    message: Some(result.message),
230                                    duration_secs: verify_duration,
231                                });
232                            }
233                            Err(e) => {
234                                error!(
235                                    error = %e,
236                                    blocks_needed,
237                                    blocks_available,
238                                    damaged,
239                                    missing,
240                                    "Native PAR2 repair failed"
241                                );
242                                pipeline_ok = false;
243                                stages.push(StageResult {
244                                    name: "Repair".to_string(),
245                                    status: StageStatus::Failed,
246                                    message: Some(format!("Repair failed: {e}")),
247                                    duration_secs: verify_duration,
248                                });
249                            }
250                        }
251                    }
252                    Err(e) => {
253                        error!(error = %e, "Verify/repair task panicked");
254                        pipeline_ok = false;
255                        stages.push(StageResult {
256                            name: "Verify".to_string(),
257                            status: StageStatus::Failed,
258                            message: Some(format!("Verify task panicked: {e}")),
259                            duration_secs: verify_duration,
260                        });
261                    }
262                }
263            }
264            Err(e) => {
265                // Native parse failed — try full repair path as fallback.
266                debug!(error = %e, "Native PAR2 parse failed");
267                let verify_duration = verify_start.elapsed().as_secs_f64();
268
269                if config.articles_failed == 0 {
270                    // No article failures, can't parse par2 — skip.
271                    stages.push(StageResult {
272                        name: "Verify".to_string(),
273                        status: StageStatus::Skipped,
274                        message: Some(format!(
275                            "PAR2 parse failed ({e}), but zero article failures"
276                        )),
277                        duration_secs: verify_duration,
278                    });
279                } else {
280                    // Articles failed and can't parse par2 — try repair with fresh parse.
281                    stages.push(StageResult {
282                        name: "Verify".to_string(),
283                        status: StageStatus::Skipped,
284                        message: Some(format!("PAR2 parse failed ({e}), attempting repair")),
285                        duration_secs: verify_duration,
286                    });
287
288                    let repair_result = run_repair_stage(job_dir).await;
289                    if repair_result.status == StageStatus::Failed {
290                        pipeline_ok = false;
291                    }
292                    stages.push(repair_result);
293                }
294            }
295        }
296    }
297
298    // ------------------------------------------------------------------
299    // Stage 3: Extract
300    // ------------------------------------------------------------------
301    // Attempt extraction even if verify/repair failed when only a few articles
302    // were missing — the failed articles may have been PAR2 files rather than
303    // data files, so the RAR archive could still be intact.
304    let should_extract = pipeline_ok || config.articles_failed <= 5;
305    if config.skip_extract {
306        info!("Skipping extraction — completed by direct unpack");
307        stages.push(StageResult {
308            name: "Extract".to_string(),
309            status: StageStatus::Skipped,
310            message: Some("Skipped — completed by direct unpack".to_string()),
311            duration_secs: 0.0,
312        });
313    } else if should_extract {
314        let output_dir = config.output_dir.as_deref().unwrap_or(job_dir);
315        let result = run_extract_stage(job_dir, output_dir).await;
316        if result.status == StageStatus::Failed {
317            pipeline_ok = false;
318        } else if result.status == StageStatus::Success {
319            // Extraction succeeded despite verify/repair failure — recover
320            pipeline_ok = true;
321        }
322        stages.push(result);
323    }
324
325    // ------------------------------------------------------------------
326    // Stage 4: Cleanup
327    // ------------------------------------------------------------------
328    if pipeline_ok && config.cleanup_after_extract {
329        let result = run_cleanup_stage(job_dir);
330        stages.push(result);
331    }
332
333    let error = if pipeline_ok {
334        None
335    } else {
336        // Collect failure messages from stages
337        let msgs: Vec<String> = stages
338            .iter()
339            .filter(|s| s.status == StageStatus::Failed)
340            .filter_map(|s| s.message.clone())
341            .collect();
342        Some(msgs.join("; "))
343    };
344
345    info!(
346        success = pipeline_ok,
347        stages = stages.len(),
348        "Post-processing pipeline finished"
349    );
350
351    PostProcResult {
352        success: pipeline_ok,
353        stages,
354        error,
355    }
356}
357
358// ---------------------------------------------------------------------------
359// PAR2-guided deobfuscation
360// ---------------------------------------------------------------------------
361
362/// Rename files on disk to match PAR2 expected filenames.
363///
364/// Obfuscated Usenet posts often have readable names in NZB subjects but the
365/// actual PAR2 metadata references the original obfuscated filenames. This
366/// causes PAR2 verify to report all files as "missing" even though they exist.
367///
368/// This function matches files by MD5 hash of their first 16 KiB (which PAR2
369/// stores for each file) and renames them to what PAR2 expects. This is the
370/// same approach used by SABnzbd's `decode_par2()`.
371fn rename_to_par2_names(file_set: &rust_par2::Par2FileSet, dir: &Path) {
372    // Build a map of expected hash_16k → par2 filename
373    let mut expected: std::collections::HashMap<[u8; 16], &str> = std::collections::HashMap::new();
374    for par2_file in file_set.files.values() {
375        expected.insert(par2_file.hash_16k, &par2_file.filename);
376    }
377
378    // Check if any PAR2-expected files are already present — if so, no renaming needed
379    let any_match = file_set
380        .files
381        .values()
382        .any(|f| dir.join(&f.filename).exists());
383    if any_match {
384        return;
385    }
386
387    // Scan all files on disk and try to match by hash_16k
388    let entries: Vec<_> = match std::fs::read_dir(dir) {
389        Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
390        Err(_) => return,
391    };
392
393    let mut renamed = 0u32;
394    for entry in &entries {
395        let path = entry.path();
396        if !path.is_file() {
397            continue;
398        }
399        let current_name = match path.file_name().and_then(|n| n.to_str()) {
400            Some(n) => n.to_string(),
401            None => continue,
402        };
403
404        // Skip PAR2 files themselves — they don't need renaming
405        if current_name.to_lowercase().ends_with(".par2") {
406            continue;
407        }
408
409        let hash = match rust_par2::compute_hash_16k(&path) {
410            Ok(h) => h,
411            Err(_) => continue,
412        };
413
414        if let Some(&par2_name) = expected.get(&hash)
415            && current_name != par2_name
416        {
417            let new_path = dir.join(par2_name);
418            if !new_path.exists() {
419                if let Err(e) = std::fs::rename(&path, &new_path) {
420                    warn!(
421                        from = %current_name,
422                        to = %par2_name,
423                        "Failed to rename file to PAR2 expected name: {e}"
424                    );
425                } else {
426                    renamed += 1;
427                    debug!(
428                        from = %current_name,
429                        to = %par2_name,
430                        "Renamed file to match PAR2 metadata"
431                    );
432                }
433            }
434        }
435    }
436
437    if renamed > 0 {
438        info!(
439            renamed,
440            "PAR2-guided deobfuscation: renamed files to match PAR2 expected names"
441        );
442    }
443}
444
445// ---------------------------------------------------------------------------
446// Internal stage runners
447// ---------------------------------------------------------------------------
448
449/// Repair stage when we don't have a pre-computed verify result.
450/// Uses par2_repair which does its own parse + verify + repair.
451async fn run_repair_stage(job_dir: &Path) -> StageResult {
452    let start = Instant::now();
453    let par2_files = find_par2_files(job_dir);
454
455    if par2_files.is_empty() {
456        return StageResult {
457            name: "Repair".to_string(),
458            status: StageStatus::Skipped,
459            message: Some("No par2 files found".to_string()),
460            duration_secs: start.elapsed().as_secs_f64(),
461        };
462    }
463
464    let index_par2 = &par2_files[0];
465    info!(file = %index_par2.display(), "Running native par2 repair");
466
467    match par2_repair(index_par2).await {
468        Ok(result) => {
469            let status = if result.repaired || result.success {
470                StageStatus::Success
471            } else {
472                StageStatus::Failed
473            };
474
475            StageResult {
476                name: "Repair".to_string(),
477                status,
478                message: Some(result.message),
479                duration_secs: start.elapsed().as_secs_f64(),
480            }
481        }
482        Err(e) => {
483            error!(error = %e, "par2 repair failed with error");
484            StageResult {
485                name: "Repair".to_string(),
486                status: StageStatus::Failed,
487                message: Some(format!("par2 repair error: {e}")),
488                duration_secs: start.elapsed().as_secs_f64(),
489            }
490        }
491    }
492}
493
494async fn run_extract_stage(job_dir: &Path, output_dir: &Path) -> StageResult {
495    let start = Instant::now();
496    let archives = find_archives(job_dir);
497
498    if archives.is_empty() {
499        info!("No archives found — skipping extraction");
500        return StageResult {
501            name: "Extract".to_string(),
502            status: StageStatus::Skipped,
503            message: Some("No archives found".to_string()),
504            duration_secs: start.elapsed().as_secs_f64(),
505        };
506    }
507
508    let mut all_ok = true;
509    let mut messages: Vec<String> = Vec::new();
510
511    for (archive_type, path) in &archives {
512        info!(kind = %archive_type, file = %path.display(), "Extracting archive");
513
514        let result = match archive_type {
515            ArchiveType::Rar => extract_rar(path, output_dir).await,
516            ArchiveType::SevenZip => extract_7z(path, output_dir).await,
517            ArchiveType::Zip => extract_zip(path, output_dir).await,
518        };
519
520        match result {
521            Ok(unpack_result) => {
522                if unpack_result.success {
523                    messages.push(format!("{archive_type}: OK"));
524                } else {
525                    all_ok = false;
526                    warn!(kind = %archive_type, file = %path.display(), "Extraction reported failure");
527                    messages.push(format!("{archive_type}: failed"));
528                }
529            }
530            Err(e) => {
531                all_ok = false;
532                error!(kind = %archive_type, file = %path.display(), error = %e, "Extraction error");
533                messages.push(format!("{archive_type}: {e}"));
534            }
535        }
536    }
537
538    StageResult {
539        name: "Extract".to_string(),
540        status: if all_ok {
541            StageStatus::Success
542        } else {
543            StageStatus::Failed
544        },
545        message: Some(messages.join("; ")),
546        duration_secs: start.elapsed().as_secs_f64(),
547    }
548}
549
550fn run_cleanup_stage(job_dir: &Path) -> StageResult {
551    let start = Instant::now();
552    let files = find_cleanup_files(job_dir);
553
554    if files.is_empty() {
555        return StageResult {
556            name: "Cleanup".to_string(),
557            status: StageStatus::Skipped,
558            message: Some("No files to clean up".to_string()),
559            duration_secs: start.elapsed().as_secs_f64(),
560        };
561    }
562
563    let mut removed = 0u32;
564    let mut errors = 0u32;
565
566    for path in &files {
567        match std::fs::remove_file(path) {
568            Ok(()) => {
569                removed += 1;
570            }
571            Err(e) => {
572                warn!(file = %path.display(), error = %e, "Failed to remove cleanup file");
573                errors += 1;
574            }
575        }
576    }
577
578    let status = if errors == 0 {
579        StageStatus::Success
580    } else {
581        StageStatus::Failed
582    };
583
584    StageResult {
585        name: "Cleanup".to_string(),
586        status,
587        message: Some(format!("Removed {removed} files, {errors} errors")),
588        duration_secs: start.elapsed().as_secs_f64(),
589    }
590}
591
592// ---------------------------------------------------------------------------
593// Tests
594// ---------------------------------------------------------------------------
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use std::fs;
600
601    fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
602        let dir = tempfile::tempdir().unwrap();
603        for name in files {
604            let path = dir.path().join(name);
605            if let Some(parent) = path.parent() {
606                fs::create_dir_all(parent).unwrap();
607            }
608            fs::write(&path, b"").unwrap();
609        }
610        dir
611    }
612
613    #[test]
614    fn test_post_proc_result_default() {
615        let result = PostProcResult {
616            success: true,
617            stages: vec![],
618            error: None,
619        };
620        assert!(result.success);
621        assert!(result.stages.is_empty());
622        assert!(result.error.is_none());
623    }
624
625    #[test]
626    fn test_config_default() {
627        let config = PostProcConfig::default();
628        assert!(config.cleanup_after_extract);
629        assert!(config.output_dir.is_none());
630        assert_eq!(config.articles_failed, 0);
631    }
632
633    #[tokio::test]
634    async fn test_pipeline_no_files() {
635        // An empty directory should skip all stages
636        let dir = make_test_dir(&[]);
637        let config = PostProcConfig::default();
638        let result = run_pipeline(dir.path(), &config).await;
639
640        assert!(result.success, "Pipeline should succeed for empty dir");
641
642        // Verify should be skipped (no par2), Extract should be skipped (no archives)
643        let verify_stage = result.stages.iter().find(|s| s.name == "Verify");
644        assert!(verify_stage.is_some(), "Verify stage should be present");
645        assert_eq!(verify_stage.unwrap().status, StageStatus::Skipped);
646
647        let extract_stage = result.stages.iter().find(|s| s.name == "Extract");
648        assert!(extract_stage.is_some(), "Extract stage should be present");
649        assert_eq!(extract_stage.unwrap().status, StageStatus::Skipped);
650    }
651
652    #[tokio::test]
653    async fn test_pipeline_only_text_files() {
654        let dir = make_test_dir(&["readme.txt", "info.nfo"]);
655        let config = PostProcConfig::default();
656        let result = run_pipeline(dir.path(), &config).await;
657
658        assert!(result.success);
659        // All stages should be skipped
660        for stage in &result.stages {
661            assert_eq!(
662                stage.status,
663                StageStatus::Skipped,
664                "Stage '{}' should be skipped",
665                stage.name
666            );
667        }
668    }
669
670    #[test]
671    fn test_cleanup_removes_files() {
672        let dir = make_test_dir(&[
673            "movie.par2",
674            "movie.vol00+01.par2",
675            "movie.rar",
676            "movie.r00",
677            "movie.mkv", // should NOT be removed
678        ]);
679
680        let result = run_cleanup_stage(dir.path());
681        assert_eq!(result.status, StageStatus::Success);
682
683        // movie.mkv should still exist
684        assert!(dir.path().join("movie.mkv").exists());
685        // par2 and rar files should be gone
686        assert!(!dir.path().join("movie.par2").exists());
687        assert!(!dir.path().join("movie.vol00+01.par2").exists());
688        assert!(!dir.path().join("movie.rar").exists());
689        assert!(!dir.path().join("movie.r00").exists());
690    }
691
692    #[tokio::test]
693    async fn test_pipeline_stage_order() {
694        // With an empty dir, we can at least verify the stages that run
695        // are in the correct order.
696        let dir = make_test_dir(&[]);
697        let config = PostProcConfig {
698            cleanup_after_extract: false,
699            output_dir: None,
700            articles_failed: 0,
701            skip_extract: false,
702        };
703        let result = run_pipeline(dir.path(), &config).await;
704
705        // Should have Verify and Extract (both skipped). Cleanup is disabled.
706        let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
707        assert!(stage_names.contains(&"Verify"), "Should have Verify stage");
708        assert!(
709            stage_names.contains(&"Extract"),
710            "Should have Extract stage"
711        );
712
713        // Verify should come before Extract
714        let verify_idx = stage_names.iter().position(|&n| n == "Verify").unwrap();
715        let extract_idx = stage_names.iter().position(|&n| n == "Extract").unwrap();
716        assert!(
717            verify_idx < extract_idx,
718            "Verify ({verify_idx}) should come before Extract ({extract_idx})"
719        );
720    }
721
722    #[tokio::test]
723    async fn test_pipeline_skips_verify_with_zero_failures() {
724        // With par2 files present and articles_failed == 0, verify is skipped
725        // because files are known-good from CRC checks during yEnc decode.
726        let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
727        let config = PostProcConfig {
728            cleanup_after_extract: false,
729            output_dir: None,
730            articles_failed: 0,
731            skip_extract: false,
732        };
733        let result = run_pipeline(dir.path(), &config).await;
734        assert!(result.success);
735
736        let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
737        assert_eq!(
738            verify_stage.status,
739            StageStatus::Skipped,
740            "Verify should be skipped when articles_failed == 0"
741        );
742        assert!(
743            verify_stage
744                .message
745                .as_deref()
746                .unwrap_or("")
747                .contains("zero article failures"),
748            "Skip message should indicate zero failures"
749        );
750    }
751
752    #[tokio::test]
753    async fn test_pipeline_no_par2_files_skips_regardless() {
754        // No par2 files — should skip even if articles_failed > 0
755        let dir = make_test_dir(&["movie.mkv"]);
756        let config = PostProcConfig {
757            cleanup_after_extract: false,
758            output_dir: None,
759            articles_failed: 5,
760            skip_extract: false,
761        };
762        let result = run_pipeline(dir.path(), &config).await;
763        assert!(result.success);
764
765        let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
766        assert_eq!(
767            verify_stage.status,
768            StageStatus::Skipped,
769            "Verify should be skipped when no par2 files exist"
770        );
771        assert!(
772            verify_stage
773                .message
774                .as_deref()
775                .unwrap_or("")
776                .contains("No par2 files"),
777            "Skip message should indicate no par2 files"
778        );
779    }
780
781    #[tokio::test]
782    async fn test_pipeline_runs_verify_then_repair_when_failures() {
783        // With par2 files and articles_failed > 0, native verify should run first.
784        // Since these are dummy empty par2 files, native parse will fail and
785        // the pipeline should fall back to par2cmdline for repair.
786        let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
787        let config = PostProcConfig {
788            cleanup_after_extract: false,
789            output_dir: None,
790            articles_failed: 3,
791            skip_extract: false,
792        };
793        let result = run_pipeline(dir.path(), &config).await;
794
795        // Should always have a Verify stage now (native par2 verify runs first)
796        let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
797        assert!(
798            stage_names.contains(&"Verify"),
799            "Should have Verify stage (native par2), got: {stage_names:?}"
800        );
801        // Repair stage should also be present since dummy par2 files
802        // will either fail to parse or report damage
803        assert!(
804            stage_names.contains(&"Repair"),
805            "Should have Repair stage when articles_failed > 0, got: {stage_names:?}"
806        );
807    }
808
809    // -----------------------------------------------------------------------
810    // PAR2-guided deobfuscation tests
811    // -----------------------------------------------------------------------
812
813    /// Helper to build a Par2FileSet with given filename→content mappings.
814    /// Computes hash_16k by writing content to temp files and using rust_par2.
815    fn make_par2_file_set(tmp: &Path, files: &[(&str, &[u8])]) -> rust_par2::Par2FileSet {
816        use rust_par2::{Par2File, Par2FileSet};
817        let mut map = std::collections::HashMap::new();
818        for (i, (name, content)) in files.iter().enumerate() {
819            // Write to temp file so we can use compute_hash_16k
820            let tmp_path = tmp.join(format!("_par2_tmp_{i}"));
821            fs::write(&tmp_path, content).unwrap();
822            let hash_16k = rust_par2::compute_hash_16k(&tmp_path).unwrap();
823            let _ = fs::remove_file(&tmp_path);
824
825            let file_id = [i as u8; 16];
826            map.insert(
827                file_id,
828                Par2File {
829                    file_id,
830                    hash: [0u8; 16],
831                    hash_16k,
832                    size: content.len() as u64,
833                    filename: name.to_string(),
834                    slices: vec![],
835                },
836            );
837        }
838        Par2FileSet {
839            recovery_set_id: [0u8; 16],
840            slice_size: 16384,
841            files: map,
842            recovery_block_count: 0,
843            creator: None,
844        }
845    }
846
847    #[test]
848    fn test_rename_to_par2_names_renames_mismatched() {
849        let dir = tempfile::tempdir().unwrap();
850
851        // Write files with "readable" names
852        let content_a = b"AAAA test data for part01";
853        let content_b = b"BBBB test data for part02";
854        fs::write(dir.path().join("Movie.Name.part01.rar"), content_a).unwrap();
855        fs::write(dir.path().join("Movie.Name.part02.rar"), content_b).unwrap();
856
857        // PAR2 expects obfuscated names with the same content
858        let file_set = make_par2_file_set(
859            dir.path(),
860            &[
861                ("xY7kQ3.part01.rar", content_a),
862                ("xY7kQ3.part02.rar", content_b),
863            ],
864        );
865
866        rename_to_par2_names(&file_set, dir.path());
867
868        // Files should be renamed to PAR2 expected names
869        assert!(
870            dir.path().join("xY7kQ3.part01.rar").exists(),
871            "part01 should be renamed to obfuscated name"
872        );
873        assert!(
874            dir.path().join("xY7kQ3.part02.rar").exists(),
875            "part02 should be renamed to obfuscated name"
876        );
877        assert!(
878            !dir.path().join("Movie.Name.part01.rar").exists(),
879            "old readable name should no longer exist"
880        );
881        assert!(
882            !dir.path().join("Movie.Name.part02.rar").exists(),
883            "old readable name should no longer exist"
884        );
885    }
886
887    #[test]
888    fn test_rename_to_par2_names_skips_when_already_correct() {
889        let dir = tempfile::tempdir().unwrap();
890
891        // Files already have the PAR2-expected names
892        let content = b"test data already correct";
893        fs::write(dir.path().join("xY7kQ3.part01.rar"), content).unwrap();
894
895        let file_set = make_par2_file_set(dir.path(), &[("xY7kQ3.part01.rar", content)]);
896
897        rename_to_par2_names(&file_set, dir.path());
898
899        // File should still exist with same name (no rename needed)
900        assert!(dir.path().join("xY7kQ3.part01.rar").exists());
901    }
902
903    #[test]
904    fn test_rename_to_par2_names_skips_par2_files() {
905        let dir = tempfile::tempdir().unwrap();
906
907        // PAR2 files should not be renamed even if hash matches
908        let content = b"par2 file content";
909        fs::write(dir.path().join("Movie.Name.par2"), content).unwrap();
910        fs::write(dir.path().join("Movie.Name.part01.rar"), b"rar data").unwrap();
911
912        let file_set = make_par2_file_set(dir.path(), &[("obfuscated.par2", content)]);
913
914        rename_to_par2_names(&file_set, dir.path());
915
916        // PAR2 file should NOT be renamed
917        assert!(
918            dir.path().join("Movie.Name.par2").exists(),
919            "PAR2 files should be skipped"
920        );
921    }
922
923    #[test]
924    fn test_rename_to_par2_names_no_match() {
925        let dir = tempfile::tempdir().unwrap();
926
927        // File content doesn't match any PAR2 entry
928        fs::write(dir.path().join("Movie.Name.part01.rar"), b"unrelated data").unwrap();
929
930        let file_set = make_par2_file_set(
931            dir.path(),
932            &[("xY7kQ3.part01.rar", b"different data" as &[u8])],
933        );
934
935        rename_to_par2_names(&file_set, dir.path());
936
937        // File should remain with original name (no hash match)
938        assert!(dir.path().join("Movie.Name.part01.rar").exists());
939        assert!(!dir.path().join("xY7kQ3.part01.rar").exists());
940    }
941}