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