Skip to main content

romm_cli/core/
download.rs

1//! Download state and management.
2//!
3//! `DownloadJob` holds per-download progress/status.
4//! `DownloadManager` owns the shared job list and spawns background tasks.
5
6//! ROM and save file download management.
7//!
8//! This module handles the logic for downloading files from the RomM server,
9//! including resume support and progress tracking.
10
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::sync::{Arc, Mutex};
14
15use crate::client::RommClient;
16use crate::config::RomsLayoutConfig;
17use crate::config::{resolved_save_dir, Config, SaveSyncConfig};
18use crate::core::extras::build_base_rom_file_targets;
19use crate::core::extras::{DownloadAssetKind, DownloadTarget};
20use crate::core::interrupt::is_cancelled_error;
21use crate::core::utils;
22use crate::types::Rom;
23use anyhow::{anyhow, Context, Result};
24use std::fs::File;
25use zip::ZipArchive;
26
27/// Directory for ROM storage (`ROMM_ROMS_DIR`, `ROMM_DOWNLOAD_DIR`, or configured path).
28pub fn resolve_download_directory(configured_download_dir: Option<&str>) -> Result<PathBuf> {
29    let env_override = std::env::var("ROMM_ROMS_DIR")
30        .ok()
31        .or_else(|| std::env::var("ROMM_DOWNLOAD_DIR").ok());
32    resolve_download_directory_from_inputs(configured_download_dir, env_override.as_deref())
33}
34
35/// Validate configured download path without env override fallback.
36pub fn validate_configured_download_directory(configured_download_dir: &str) -> Result<PathBuf> {
37    resolve_download_directory_from_inputs(Some(configured_download_dir), None)
38}
39
40/// Backward-compatible default used by legacy CLI download code.
41pub fn download_directory() -> PathBuf {
42    std::env::var("ROMM_ROMS_DIR")
43        .or_else(|_| std::env::var("ROMM_DOWNLOAD_DIR"))
44        .map(PathBuf::from)
45        .unwrap_or_else(|_| PathBuf::from("./downloads"))
46}
47
48fn resolve_download_directory_from_inputs(
49    configured_download_dir: Option<&str>,
50    env_override: Option<&str>,
51) -> Result<PathBuf> {
52    let raw = env_override
53        .or(configured_download_dir)
54        .map(str::trim)
55        .ok_or_else(|| {
56            anyhow!("ROMs directory is not configured. Run setup to set a ROMs path.")
57        })?;
58
59    if raw.is_empty() {
60        return Err(anyhow!("ROMs directory cannot be empty"));
61    }
62
63    let input_path = PathBuf::from(raw);
64    let normalized = if input_path.is_relative() {
65        std::env::current_dir()
66            .context("Could not resolve current working directory")?
67            .join(input_path)
68    } else {
69        input_path
70    };
71
72    if normalized.exists() && !normalized.is_dir() {
73        return Err(anyhow!(
74            "Download path is not a directory: {}",
75            normalized.display()
76        ));
77    }
78
79    std::fs::create_dir_all(&normalized).with_context(|| {
80        format!(
81            "Could not create download directory {}",
82            normalized.display()
83        )
84    })?;
85
86    let probe_name = format!(
87        ".romm-write-test-{}",
88        std::time::SystemTime::now()
89            .duration_since(std::time::UNIX_EPOCH)
90            .unwrap_or_default()
91            .as_nanos()
92    );
93    let probe_path = normalized.join(probe_name);
94    let probe = std::fs::OpenOptions::new()
95        .write(true)
96        .create_new(true)
97        .open(&probe_path)
98        .with_context(|| format!("ROMs directory is not writable: {}", normalized.display()))?;
99    drop(probe);
100    let _ = std::fs::remove_file(&probe_path);
101
102    Ok(normalized)
103}
104
105/// Filesystem slug used for auto-mode console subfolders.
106pub fn platform_download_slug(rom: &Rom) -> String {
107    rom.platform_fs_slug
108        .clone()
109        .or_else(|| rom.platform_slug.clone())
110        .unwrap_or_else(|| format!("platform-{}", rom.platform_id))
111}
112
113fn auto_console_roms_dir(base_download_dir: &Path, rom: &Rom) -> PathBuf {
114    base_download_dir.join(utils::sanitize_filename(&platform_download_slug(rom)))
115}
116
117/// Resolve the directory where ROM files for `rom` should be stored.
118pub fn resolve_console_roms_dir(
119    layout: &RomsLayoutConfig,
120    base_download_dir: &Path,
121    rom: &Rom,
122) -> Result<PathBuf> {
123    if let Some(raw) = layout
124        .platform_dirs
125        .get(&rom.platform_id)
126        .map(|s| s.trim())
127        .filter(|s| !s.is_empty())
128    {
129        validate_configured_download_directory(raw)
130    } else {
131        Ok(auto_console_roms_dir(base_download_dir, rom))
132    }
133}
134
135fn save_platform_slug(
136    platform_id: u64,
137    platform_fs_slug: Option<&str>,
138    platform_slug: Option<&str>,
139) -> String {
140    utils::sanitize_filename(
141        platform_fs_slug
142            .or(platform_slug)
143            .unwrap_or(&format!("platform-{platform_id}")),
144    )
145}
146
147fn auto_console_save_dir(
148    base_save_dir: &Path,
149    platform_id: u64,
150    platform_fs_slug: Option<&str>,
151    platform_slug: Option<&str>,
152) -> PathBuf {
153    base_save_dir.join(save_platform_slug(
154        platform_id,
155        platform_fs_slug,
156        platform_slug,
157    ))
158}
159
160/// Resolve the directory where save files for a console should be stored.
161pub fn resolve_console_save_dir(
162    save_sync: &SaveSyncConfig,
163    base_save_dir: &Path,
164    platform_id: u64,
165    platform_fs_slug: Option<&str>,
166    platform_slug: Option<&str>,
167) -> Result<PathBuf> {
168    if let Some(raw) = save_sync
169        .platform_dirs
170        .get(&platform_id)
171        .map(|s| s.trim())
172        .filter(|s| !s.is_empty())
173    {
174        validate_configured_download_directory(raw)
175    } else {
176        Ok(auto_console_save_dir(
177            base_save_dir,
178            platform_id,
179            platform_fs_slug,
180            platform_slug,
181        ))
182    }
183}
184
185fn safe_game_path_segment(input: &str) -> String {
186    let cleaned: String = input
187        .chars()
188        .map(|c| {
189            if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') {
190                c
191            } else {
192                '_'
193            }
194        })
195        .collect();
196    let trimmed = cleaned.trim().trim_matches('.').trim();
197    if trimmed.is_empty() {
198        "game".to_string()
199    } else {
200        trimmed.to_string()
201    }
202}
203
204/// Resolve the directory where a specific game's saves should be downloaded.
205pub fn resolve_game_save_dir(config: &Config, rom: &Rom) -> Result<PathBuf> {
206    let base = resolved_save_dir(config);
207    let console_dir = resolve_console_save_dir(
208        &config.save_sync,
209        &base,
210        rom.platform_id,
211        rom.platform_fs_slug.as_deref(),
212        rom.platform_slug.as_deref(),
213    )?;
214    Ok(console_dir.join(safe_game_path_segment(&rom.name)))
215}
216
217/// Pick `stem.zip`, then `stem__2.zip`, `stem__3.zip`, … until the path does not exist.
218pub fn unique_zip_path(dir: &Path, stem: &str) -> PathBuf {
219    let mut n = 1u32;
220    loop {
221        let name = if n == 1 {
222            format!("{}.zip", stem)
223        } else {
224            format!("{}__{}.zip", stem, n)
225        };
226        let p = dir.join(name);
227        if !p.exists() {
228            return p;
229        }
230        n = n.saturating_add(1);
231    }
232}
233
234/// Extract a ZIP archive into `destination_dir`.
235pub fn extract_zip_archive(zip_path: &Path, destination_dir: &Path) -> Result<()> {
236    let zip_path = zip_path.to_path_buf();
237    let destination_dir = destination_dir.to_path_buf();
238    std::fs::create_dir_all(&destination_dir).with_context(|| {
239        format!(
240            "Could not create extraction directory {}",
241            destination_dir.display()
242        )
243    })?;
244
245    let file = File::open(&zip_path)
246        .with_context(|| format!("Could not open zip archive {}", zip_path.display()))?;
247    let mut archive = ZipArchive::new(file)
248        .with_context(|| format!("Invalid ZIP archive {}", zip_path.display()))?;
249    archive.extract(&destination_dir).with_context(|| {
250        format!(
251            "Could not extract archive into {}",
252            destination_dir.display()
253        )
254    })?;
255    Ok(())
256}
257
258// ---------------------------------------------------------------------------
259// Job status / data
260// ---------------------------------------------------------------------------
261
262/// High-level status of a single download.
263#[derive(Debug, Clone)]
264pub enum DownloadStatus {
265    Downloading,
266    Done,
267    SkippedAlreadyExists,
268    Cancelled,
269    FinalizeFailed(String),
270    Error(String),
271}
272
273/// A single background download job (for one ROM).
274#[derive(Debug, Clone)]
275pub struct DownloadJob {
276    pub id: usize,
277    pub rom_id: u64,
278    pub name: String,
279    pub platform: String,
280    /// 0.0 ..= 1.0
281    pub progress: f64,
282    pub status: DownloadStatus,
283}
284
285static NEXT_JOB_ID: AtomicUsize = AtomicUsize::new(0);
286
287impl DownloadJob {
288    /// Construct a new job in the `Downloading` state.
289    pub fn new(rom_id: u64, name: String, platform: String) -> Self {
290        Self {
291            id: NEXT_JOB_ID.fetch_add(1, Ordering::Relaxed),
292            rom_id,
293            name,
294            platform,
295            progress: 0.0,
296            status: DownloadStatus::Downloading,
297        }
298    }
299
300    /// Progress as percentage 0..=100.
301    pub fn percent(&self) -> u16 {
302        (self.progress * 100.0).round().min(100.0) as u16
303    }
304}
305
306// ---------------------------------------------------------------------------
307// Extras (composite) jobs
308// ---------------------------------------------------------------------------
309
310/// Outcome of one item inside an [`ExtrasJob`].
311#[derive(Debug, Clone)]
312pub struct ExtrasItemResult {
313    pub title: String,
314    pub kind: DownloadAssetKind,
315    pub ok: bool,
316    pub error: Option<String>,
317}
318
319/// Terminal status for a composite extras download.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum ExtrasJobStatus {
322    Running,
323    Done,
324    /// Some items failed (`usize` = failure count).
325    PartialFailure(usize),
326    AllFailed,
327}
328
329/// One queued extras batch for a parent ROM (related files + cover + manual).
330#[derive(Debug, Clone)]
331pub struct ExtrasJob {
332    pub id: usize,
333    pub rom_id: u64,
334    pub name: String,
335    pub platform: String,
336    pub completed_items: usize,
337    pub total_items: usize,
338    pub status: ExtrasJobStatus,
339    pub item_results: Vec<ExtrasItemResult>,
340}
341
342static NEXT_EXTRAS_JOB_ID: AtomicUsize = AtomicUsize::new(0);
343
344impl ExtrasJob {
345    pub fn new(rom_id: u64, name: String, platform: String, total_items: usize) -> Self {
346        Self {
347            id: NEXT_EXTRAS_JOB_ID.fetch_add(1, Ordering::Relaxed),
348            rom_id,
349            name,
350            platform,
351            completed_items: 0,
352            total_items,
353            status: ExtrasJobStatus::Running,
354            item_results: Vec::new(),
355        }
356    }
357
358    /// Progress 0..=100 from completed item count only.
359    pub fn percent(&self) -> u16 {
360        if self.total_items == 0 {
361            return 100;
362        }
363        ((self.completed_items.saturating_mul(100)) / self.total_items).min(100) as u16
364    }
365}
366
367fn finalize_extras_job_status(results: &[ExtrasItemResult]) -> ExtrasJobStatus {
368    let n = results.len();
369    if n == 0 {
370        return ExtrasJobStatus::Done;
371    }
372    let failures = results.iter().filter(|r| !r.ok).count();
373    if failures == 0 {
374        ExtrasJobStatus::Done
375    } else if failures == n {
376        ExtrasJobStatus::AllFailed
377    } else {
378        ExtrasJobStatus::PartialFailure(failures)
379    }
380}
381
382// ---------------------------------------------------------------------------
383// Manager
384// ---------------------------------------------------------------------------
385
386/// Owns the shared download list and spawns background download tasks.
387///
388/// Frontends only need an `Arc<Mutex<Vec<DownloadJob>>>` to inspect jobs.
389#[derive(Clone)]
390pub struct DownloadManager {
391    jobs: Arc<Mutex<Vec<DownloadJob>>>,
392    extras_jobs: Arc<Mutex<Vec<ExtrasJob>>>,
393}
394
395impl Default for DownloadManager {
396    fn default() -> Self {
397        Self::new()
398    }
399}
400
401impl DownloadManager {
402    pub fn new() -> Self {
403        Self {
404            jobs: Arc::new(Mutex::new(Vec::new())),
405            extras_jobs: Arc::new(Mutex::new(Vec::new())),
406        }
407    }
408
409    /// Shared handle for observers (TUI, GUI, tests) to inspect jobs.
410    pub fn shared(&self) -> Arc<Mutex<Vec<DownloadJob>>> {
411        self.jobs.clone()
412    }
413
414    /// Shared extras jobs (composite batches).
415    pub fn shared_extras(&self) -> Arc<Mutex<Vec<ExtrasJob>>> {
416        self.extras_jobs.clone()
417    }
418
419    /// Start downloading `rom` in the background; returns immediately.
420    ///
421    /// Progress updates are pushed into the shared `jobs` list so that
422    /// any frontend can render them.
423    pub fn start_download(
424        &self,
425        rom: &Rom,
426        client: RommClient,
427        layout: &RomsLayoutConfig,
428        configured_download_dir: Option<&str>,
429    ) -> Result<()> {
430        let platform = rom
431            .platform_display_name
432            .as_deref()
433            .or(rom.platform_custom_name.as_deref())
434            .unwrap_or("—")
435            .to_string();
436
437        let job = DownloadJob::new(rom.id, rom.name.clone(), platform);
438        let job_id = job.id;
439        let rom_id = rom.id;
440        let fs_name = rom.fs_name.clone();
441        let final_name = sanitized_final_filename(&rom.fs_name, rom.id);
442        let rom_for_targets = rom.clone();
443        let layout = layout.clone();
444        match self.jobs.lock() {
445            Ok(mut jobs) => jobs.push(job),
446            Err(err) => {
447                eprintln!("warning: download job list lock poisoned: {}", err);
448                return Err(anyhow!("download job list lock poisoned: {err}"));
449            }
450        }
451
452        let save_dir = resolve_download_directory(configured_download_dir)?;
453        let console_dir = resolve_console_roms_dir(&layout, &save_dir, rom)?;
454        let base_targets = build_base_rom_file_targets(&rom_for_targets, &layout, &save_dir)?;
455        let jobs = self.jobs.clone();
456        tokio::spawn(async move {
457            let temp_root = save_dir.join(".tmp");
458            if let Err(err) = tokio::fs::create_dir_all(&temp_root).await {
459                if let Ok(mut list) = jobs.lock() {
460                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
461                        j.status = DownloadStatus::Error(format!(
462                            "Could not create temp directory {}: {err}",
463                            temp_root.display()
464                        ));
465                    }
466                }
467                return;
468            }
469
470            let final_path = console_dir.join(final_name.clone());
471            if let Err(err) = tokio::fs::create_dir_all(&console_dir).await {
472                if let Ok(mut list) = jobs.lock() {
473                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
474                        j.status = DownloadStatus::Error(format!(
475                            "Could not create console directory {}: {err}",
476                            console_dir.display()
477                        ));
478                    }
479                }
480                return;
481            }
482
483            let base_targets = base_targets;
484            if !base_targets.is_empty() {
485                let total_targets = base_targets.len() as f64;
486                for (idx, target) in base_targets.iter().enumerate() {
487                    let client = client.clone();
488                    let mut progress = {
489                        let jobs = jobs.clone();
490                        move |received: u64, total: u64| {
491                            let file_ratio = if total > 0 {
492                                received as f64 / total as f64
493                            } else {
494                                0.0
495                            };
496                            let total_ratio = ((idx as f64) + file_ratio) / total_targets;
497                            if let Ok(mut list) = jobs.lock() {
498                                if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
499                                    j.progress = total_ratio.min(1.0);
500                                }
501                            }
502                        }
503                    };
504                    match prepare_download_target_destination(target).await {
505                        Ok(true) => {
506                            progress(
507                                target.expected_size_bytes.unwrap_or(0),
508                                target.expected_size_bytes.unwrap_or(0),
509                            );
510                            continue;
511                        }
512                        Ok(false) => {}
513                        Err(err) => {
514                            if let Ok(mut list) = jobs.lock() {
515                                if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
516                                    j.status = DownloadStatus::Error(err.to_string());
517                                }
518                            }
519                            return;
520                        }
521                    }
522                    if let Err(final_err) =
523                        download_target_with_fallback(&client, target, |_, _| false, &mut progress)
524                            .await
525                    {
526                        if let Ok(mut list) = jobs.lock() {
527                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
528                                j.status = DownloadStatus::Error(final_err.to_string());
529                            }
530                        }
531                        return;
532                    }
533                }
534                if let Ok(mut list) = jobs.lock() {
535                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
536                        j.status = DownloadStatus::Done;
537                        j.progress = 1.0;
538                    }
539                }
540                return;
541            }
542
543            if final_path.exists() {
544                if let Ok(mut list) = jobs.lock() {
545                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
546                        j.status = DownloadStatus::SkippedAlreadyExists;
547                        j.progress = 1.0;
548                    }
549                }
550                return;
551            }
552
553            let temp_name = format!(
554                "rom-{}-{}-{}.part",
555                rom_id,
556                utils::sanitize_filename(&fs_name),
557                job_id
558            );
559            let temp_path = temp_root.join(temp_name);
560
561            let on_progress = |received: u64, total: u64| {
562                let p = if total > 0 {
563                    received as f64 / total as f64
564                } else {
565                    0.0
566                };
567
568                if let Ok(mut list) = jobs.lock() {
569                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
570                        j.progress = p;
571                    }
572                }
573            };
574
575            let download_result = client.download_rom(rom_id, &temp_path, on_progress).await;
576            if download_result.is_err() {
577                let _ = tokio::fs::remove_file(&temp_path).await;
578            }
579            match download_result {
580                Ok(()) => match finalize_download(&temp_path, &final_path).await {
581                    Ok(FinalizeResult::Done) => {
582                        if let Ok(mut list) = jobs.lock() {
583                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
584                                j.status = DownloadStatus::Done;
585                                j.progress = 1.0;
586                            }
587                        }
588                    }
589                    Ok(FinalizeResult::SkippedAlreadyExists) => {
590                        if let Ok(mut list) = jobs.lock() {
591                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
592                                j.status = DownloadStatus::SkippedAlreadyExists;
593                                j.progress = 1.0;
594                            }
595                        }
596                    }
597                    Err(err) => {
598                        let _ = tokio::fs::remove_file(&temp_path).await;
599                        if let Ok(mut list) = jobs.lock() {
600                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
601                                j.status = DownloadStatus::FinalizeFailed(err.to_string());
602                            }
603                        }
604                    }
605                },
606                Err(e) => {
607                    if let Ok(mut list) = jobs.lock() {
608                        if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
609                            if is_cancelled_error(&e) {
610                                j.status = DownloadStatus::Cancelled;
611                            } else {
612                                j.status = DownloadStatus::Error(e.to_string());
613                            }
614                        }
615                    }
616                }
617            }
618        });
619        Ok(())
620    }
621
622    /// Download selected extras targets in the background as one composite job.
623    ///
624    /// Uses up to 4 concurrent URL downloads. Progress is item-count only (`ExtrasJob::percent`).
625    pub fn start_extras_download(
626        &self,
627        rom: &Rom,
628        selected: Vec<DownloadTarget>,
629        client: RommClient,
630        configured_download_dir: Option<&str>,
631    ) -> Result<()> {
632        if selected.is_empty() {
633            return Err(anyhow!("no extras targets selected"));
634        }
635
636        let _ = resolve_download_directory(configured_download_dir)?;
637
638        let platform = rom
639            .platform_display_name
640            .as_deref()
641            .or(rom.platform_custom_name.as_deref())
642            .unwrap_or("—")
643            .to_string();
644
645        let total_items = selected.len();
646        let job = ExtrasJob::new(rom.id, rom.name.clone(), platform, total_items);
647        let job_id = job.id;
648
649        match self.extras_jobs.lock() {
650            Ok(mut jobs) => jobs.push(job),
651            Err(err) => {
652                eprintln!("warning: extras job list lock poisoned: {}", err);
653                return Err(anyhow!("extras job list lock poisoned: {err}"));
654            }
655        }
656
657        let extras_jobs = self.extras_jobs.clone();
658        tokio::spawn(async move {
659            let semaphore = Arc::new(tokio::sync::Semaphore::new(4));
660            let mut handles = Vec::new();
661
662            for target in selected {
663                let permit = match semaphore.clone().acquire_owned().await {
664                    Ok(p) => p,
665                    Err(_) => break,
666                };
667                let client = client.clone();
668                let extras_jobs = extras_jobs.clone();
669                handles.push(tokio::spawn(async move {
670                    let mut on_progress = |_r: u64, _t: u64| {};
671                    let download_result = match prepare_download_target_destination(&target).await {
672                        Ok(true) => Ok(()),
673                        Ok(false) => {
674                            download_target_with_fallback(
675                                &client,
676                                &target,
677                                |_, _| false,
678                                &mut on_progress,
679                            )
680                            .await
681                        }
682                        Err(err) => Err(err),
683                    };
684
685                    drop(permit);
686
687                    let (ok, err) = match download_result {
688                        Ok(()) => (true, None),
689                        Err(e) => (false, Some(e.to_string())),
690                    };
691
692                    let item = ExtrasItemResult {
693                        title: target.title.clone(),
694                        kind: target.kind,
695                        ok,
696                        error: err,
697                    };
698
699                    if let Ok(mut list) = extras_jobs.lock() {
700                        if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
701                            j.completed_items = j.completed_items.saturating_add(1);
702                            j.item_results.push(item);
703                            if j.completed_items >= j.total_items {
704                                j.status = finalize_extras_job_status(&j.item_results);
705                            }
706                        }
707                    }
708                }));
709            }
710
711            for h in handles {
712                let _ = h.await;
713            }
714        });
715
716        Ok(())
717    }
718}
719
720pub async fn prepare_download_target_destination(target: &DownloadTarget) -> Result<bool> {
721    let Some(expected_size) = target.expected_size_bytes else {
722        return Ok(false);
723    };
724    if expected_size == 0 {
725        return Ok(false);
726    }
727
728    let Ok(metadata) = tokio::fs::metadata(&target.destination).await else {
729        return Ok(false);
730    };
731    let current_size = metadata.len();
732    if current_size == expected_size {
733        return Ok(true);
734    }
735    if current_size > expected_size {
736        tokio::fs::remove_file(&target.destination)
737            .await
738            .with_context(|| {
739                format!(
740                    "remove oversized stale download {} ({} > {} bytes)",
741                    target.destination.display(),
742                    current_size,
743                    expected_size
744                )
745            })?;
746    }
747    Ok(false)
748}
749
750async fn download_target_with_fallback<F, C>(
751    client: &RommClient,
752    target: &DownloadTarget,
753    mut is_cancelled: C,
754    on_progress: &mut F,
755) -> Result<()>
756where
757    F: FnMut(u64, u64) + Send,
758    C: FnMut(u64, u64) -> bool + Send,
759{
760    let urls = candidate_download_urls(target);
761    let mut last_err: Option<anyhow::Error> = None;
762    for url in urls {
763        match client
764            .download_url_with_query_with_cancel(
765                &url,
766                &target.source_query,
767                &target.destination,
768                &mut is_cancelled,
769                on_progress,
770            )
771            .await
772        {
773            Ok(()) => return Ok(()),
774            Err(err) => {
775                if !err.to_string().contains("404 Not Found") {
776                    return Err(err);
777                }
778                last_err = Some(err);
779            }
780        }
781    }
782    Err(last_err.unwrap_or_else(|| anyhow!("download failed without error details")))
783}
784
785fn candidate_download_urls(target: &DownloadTarget) -> Vec<String> {
786    let mut out = vec![target.source_url.clone()];
787    if let Some((file_id, file_name)) = parse_current_rom_file_content_path(&target.source_url) {
788        out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
789        out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
790    } else if let Some((file_id, file_name)) = parse_romsfiles_path(&target.source_url) {
791        out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
792        out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
793    } else if let Some((file_id, file_name)) = parse_legacy_roms_files_path(&target.source_url) {
794        out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
795        out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
796    }
797    dedupe_preserve_order(out)
798}
799
800fn parse_current_rom_file_content_path(url: &str) -> Option<(String, String)> {
801    let prefix = "/api/roms/";
802    let marker = "/files/content/";
803    let rest = url.strip_prefix(prefix)?;
804    let (id, name) = rest.split_once(marker)?;
805    Some((id.to_string(), name.to_string()))
806}
807
808fn parse_romsfiles_path(url: &str) -> Option<(String, String)> {
809    let prefix = "/api/romsfiles/";
810    let marker = "/content/";
811    let rest = url.strip_prefix(prefix)?;
812    let (id, name) = rest.split_once(marker)?;
813    Some((id.to_string(), name.to_string()))
814}
815
816fn parse_legacy_roms_files_path(url: &str) -> Option<(String, String)> {
817    let prefix = "/api/roms/files/";
818    let marker = "/content/";
819    let rest = url.strip_prefix(prefix)?;
820    let (id, name) = rest.split_once(marker)?;
821    Some((id.to_string(), name.to_string()))
822}
823
824fn dedupe_preserve_order(urls: Vec<String>) -> Vec<String> {
825    let mut seen = std::collections::HashSet::new();
826    let mut out = Vec::new();
827    for u in urls {
828        if seen.insert(u.clone()) {
829            out.push(u);
830        }
831    }
832    out
833}
834
835#[derive(Debug, Clone, Copy, PartialEq, Eq)]
836enum FinalizeResult {
837    Done,
838    SkippedAlreadyExists,
839}
840
841async fn finalize_download(temp_path: &Path, final_path: &Path) -> Result<FinalizeResult> {
842    if final_path.exists() {
843        let _ = tokio::fs::remove_file(temp_path).await;
844        return Ok(FinalizeResult::SkippedAlreadyExists);
845    }
846
847    match tokio::fs::rename(temp_path, final_path).await {
848        Ok(()) => Ok(FinalizeResult::Done),
849        Err(rename_err) if is_cross_device_rename_error(&rename_err) => {
850            tokio::fs::copy(temp_path, final_path)
851                .await
852                .with_context(|| {
853                    format!(
854                        "Could not copy temp ROM {} to final destination {}",
855                        temp_path.display(),
856                        final_path.display()
857                    )
858                })?;
859            let file = tokio::fs::File::open(final_path).await.with_context(|| {
860                format!(
861                    "Could not open finalized ROM for sync: {}",
862                    final_path.display()
863                )
864            })?;
865            file.sync_all().await.with_context(|| {
866                format!(
867                    "Could not sync finalized ROM to disk: {}",
868                    final_path.display()
869                )
870            })?;
871            tokio::fs::remove_file(temp_path).await.with_context(|| {
872                format!(
873                    "Could not remove temp ROM after copy: {}",
874                    temp_path.display()
875                )
876            })?;
877            Ok(FinalizeResult::Done)
878        }
879        Err(rename_err) => Err(anyhow!(
880            "Could not move temp ROM {} to final destination {}: {}",
881            temp_path.display(),
882            final_path.display(),
883            rename_err
884        )),
885    }
886}
887
888fn is_cross_device_rename_error(err: &std::io::Error) -> bool {
889    matches!(err.raw_os_error(), Some(18) | Some(17))
890}
891
892fn sanitized_final_filename(fs_name: &str, rom_id: u64) -> String {
893    let sanitized = utils::sanitize_filename(fs_name);
894    if sanitized.trim().is_empty() {
895        format!("rom-{rom_id}.zip")
896    } else {
897        sanitized
898    }
899}
900
901#[cfg(test)]
902fn final_download_path_for_rom(roms_dir: &Path, rom: &Rom) -> PathBuf {
903    let platform_slug = rom
904        .platform_fs_slug
905        .clone()
906        .or_else(|| rom.platform_slug.clone())
907        .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
908    let console_dir = roms_dir.join(utils::sanitize_filename(&platform_slug));
909    console_dir.join(sanitized_final_filename(&rom.fs_name, rom.id))
910}
911
912#[cfg(test)]
913mod tests {
914    use std::collections::HashMap;
915
916    use super::*;
917    use crate::config::RomsLayoutConfig;
918    use crate::types::Rom;
919    use std::io::Write;
920    use std::time::{SystemTime, UNIX_EPOCH};
921    use zip::write::SimpleFileOptions;
922    use zip::ZipWriter;
923
924    fn rom_fixture_with_platform(platform_fs_slug: Option<&str>, fs_name: &str) -> Rom {
925        Rom {
926            id: 42,
927            platform_id: 7,
928            platform_slug: Some("nintendo-switch".to_string()),
929            platform_fs_slug: platform_fs_slug.map(ToString::to_string),
930            platform_custom_name: None,
931            platform_display_name: None,
932            fs_name: fs_name.to_string(),
933            fs_name_no_tags: "game".to_string(),
934            fs_name_no_ext: "game".to_string(),
935            fs_extension: "zip".to_string(),
936            fs_path: "/game.zip".to_string(),
937            fs_size_bytes: 1,
938            name: "Game".to_string(),
939            slug: None,
940            summary: None,
941            path_cover_small: None,
942            path_cover_large: None,
943            url_cover: None,
944            has_manual: false,
945            path_manual: None,
946            url_manual: None,
947            is_unidentified: false,
948            is_identified: true,
949            files: Vec::new(),
950        }
951    }
952
953    #[test]
954    fn extras_job_percent_tracks_completed_items() {
955        let mut j = ExtrasJob::new(1, "Zelda".into(), "NES".into(), 4);
956        assert_eq!(j.percent(), 0);
957        j.completed_items = 2;
958        assert_eq!(j.percent(), 50);
959        j.completed_items = 4;
960        assert_eq!(j.percent(), 100);
961    }
962
963    #[test]
964    fn finalize_extras_job_status_reflects_failures() {
965        use crate::core::extras::DownloadAssetKind;
966
967        let ok = ExtrasItemResult {
968            title: "a".into(),
969            kind: DownloadAssetKind::Cover,
970            ok: true,
971            error: None,
972        };
973        let bad = ExtrasItemResult {
974            title: "b".into(),
975            kind: DownloadAssetKind::Manual,
976            ok: false,
977            error: Some("e".into()),
978        };
979        assert_eq!(
980            super::finalize_extras_job_status(&[ok.clone(), ok.clone()]),
981            ExtrasJobStatus::Done
982        );
983        assert_eq!(
984            super::finalize_extras_job_status(&[bad.clone(), bad.clone()]),
985            ExtrasJobStatus::AllFailed
986        );
987        assert_eq!(
988            super::finalize_extras_job_status(&[ok, bad]),
989            ExtrasJobStatus::PartialFailure(1)
990        );
991    }
992
993    #[test]
994    fn unique_zip_path_skips_existing_files() {
995        let ts = SystemTime::now()
996            .duration_since(UNIX_EPOCH)
997            .unwrap()
998            .as_nanos();
999        let dir = std::env::temp_dir().join(format!("romm-dl-test-{ts}"));
1000        std::fs::create_dir_all(&dir).unwrap();
1001        let p1 = dir.join("game.zip");
1002        std::fs::File::create(&p1).unwrap().write_all(b"x").unwrap();
1003        let p2 = unique_zip_path(&dir, "game");
1004        assert_eq!(p2.file_name().unwrap(), "game__2.zip");
1005        let _ = std::fs::remove_file(&p1);
1006        let _ = std::fs::remove_dir(&dir);
1007    }
1008
1009    #[test]
1010    fn resolve_download_directory_rejects_empty_configured_path() {
1011        let err = resolve_download_directory_from_inputs(Some("   "), None)
1012            .expect_err("empty configured path should be rejected");
1013        assert!(
1014            err.to_string().contains("cannot be empty"),
1015            "unexpected error: {err:#}"
1016        );
1017    }
1018
1019    #[test]
1020    fn resolve_download_directory_creates_missing_nested_directory() {
1021        let ts = SystemTime::now()
1022            .duration_since(UNIX_EPOCH)
1023            .unwrap()
1024            .as_nanos();
1025        let base = std::env::temp_dir().join(format!("romm-dl-resolve-{ts}"));
1026        let nested = base.join("a").join("b").join("c");
1027        let nested_str = nested.to_string_lossy().to_string();
1028
1029        let resolved = resolve_download_directory_from_inputs(Some(&nested_str), None)
1030            .expect("expected missing directory to be created");
1031
1032        assert!(resolved.is_dir(), "resolved path must be a directory");
1033        assert!(nested.is_dir(), "nested path should be created");
1034        let _ = std::fs::remove_dir_all(&base);
1035    }
1036
1037    #[test]
1038    fn resolve_download_directory_fails_when_target_is_a_file() {
1039        let ts = SystemTime::now()
1040            .duration_since(UNIX_EPOCH)
1041            .unwrap()
1042            .as_nanos();
1043        let base = std::env::temp_dir().join(format!("romm-dl-file-target-{ts}"));
1044        std::fs::create_dir_all(&base).expect("create base dir");
1045        let file_path = base.join("not-a-dir.txt");
1046        std::fs::write(&file_path, b"x").expect("create file");
1047        let input = file_path.to_string_lossy().to_string();
1048
1049        let err = resolve_download_directory_from_inputs(Some(&input), None)
1050            .expect_err("file target must fail");
1051        assert!(
1052            err.to_string().contains("not a directory"),
1053            "unexpected error: {err:#}"
1054        );
1055
1056        let _ = std::fs::remove_file(&file_path);
1057        let _ = std::fs::remove_dir_all(&base);
1058    }
1059
1060    #[test]
1061    fn resolve_download_directory_env_override_takes_precedence() {
1062        let ts = SystemTime::now()
1063            .duration_since(UNIX_EPOCH)
1064            .unwrap()
1065            .as_nanos();
1066        let configured = std::env::temp_dir().join(format!("romm-dl-configured-{ts}"));
1067        let env_dir = std::env::temp_dir().join(format!("romm-dl-env-{ts}"));
1068        let configured_str = configured.to_string_lossy().to_string();
1069        let env_str = env_dir.to_string_lossy().to_string();
1070
1071        let resolved =
1072            resolve_download_directory_from_inputs(Some(&configured_str), Some(&env_str))
1073                .expect("env override should be used");
1074
1075        assert_eq!(resolved, env_dir);
1076        assert!(env_dir.is_dir(), "env directory should be created");
1077        assert!(
1078            !configured.is_dir(),
1079            "configured path should be ignored when env override is set"
1080        );
1081        let _ = std::fs::remove_dir_all(&env_dir);
1082    }
1083
1084    #[test]
1085    fn resolve_console_roms_dir_uses_platform_slug_subfolder_by_default() {
1086        let rom = rom_fixture_with_platform(Some("switch"), "game.zip");
1087        let layout = RomsLayoutConfig::default();
1088        let dir = resolve_console_roms_dir(&layout, Path::new("/roms"), &rom).unwrap();
1089        assert_eq!(dir, PathBuf::from("/roms/switch"));
1090    }
1091
1092    #[test]
1093    fn resolve_console_roms_dir_uses_custom_mapped_path() {
1094        let rom = rom_fixture_with_platform(Some("switch"), "game.zip");
1095        let mut layout = RomsLayoutConfig::default();
1096        let custom = std::env::temp_dir().join(format!(
1097            "romm-cli-manual-{}",
1098            SystemTime::now()
1099                .duration_since(UNIX_EPOCH)
1100                .unwrap()
1101                .as_nanos()
1102        ));
1103        std::fs::create_dir_all(&custom).unwrap();
1104        layout
1105            .platform_dirs
1106            .insert(rom.platform_id, custom.display().to_string());
1107
1108        let dir = resolve_console_roms_dir(&layout, Path::new("/roms"), &rom).unwrap();
1109        assert_eq!(dir, custom);
1110        let _ = std::fs::remove_dir_all(custom);
1111    }
1112
1113    #[test]
1114    fn resolve_console_roms_dir_falls_back_for_unmapped_platform() {
1115        let rom = rom_fixture_with_platform(Some("switch"), "game.zip");
1116        let layout = RomsLayoutConfig::default();
1117        let dir = resolve_console_roms_dir(&layout, Path::new("/roms"), &rom).unwrap();
1118        assert_eq!(dir, PathBuf::from("/roms/switch"));
1119    }
1120
1121    #[test]
1122    fn resolve_console_save_dir_uses_platform_slug_subfolder_by_default() {
1123        let save_sync = SaveSyncConfig::default();
1124        let dir = resolve_console_save_dir(
1125            &save_sync,
1126            Path::new("/saves"),
1127            7,
1128            Some("switch"),
1129            Some("nintendo-switch"),
1130        )
1131        .unwrap();
1132        assert_eq!(dir, PathBuf::from("/saves/switch"));
1133    }
1134
1135    #[test]
1136    fn resolve_console_save_dir_uses_custom_mapped_path() {
1137        let mut save_sync = SaveSyncConfig::default();
1138        let custom = std::env::temp_dir().join(format!(
1139            "romm-cli-save-custom-{}",
1140            SystemTime::now()
1141                .duration_since(UNIX_EPOCH)
1142                .unwrap()
1143                .as_nanos()
1144        ));
1145        std::fs::create_dir_all(&custom).unwrap();
1146        save_sync
1147            .platform_dirs
1148            .insert(7, custom.display().to_string());
1149
1150        let dir =
1151            resolve_console_save_dir(&save_sync, Path::new("/saves"), 7, Some("switch"), None)
1152                .unwrap();
1153        assert_eq!(dir, custom);
1154        let _ = std::fs::remove_dir_all(custom);
1155    }
1156
1157    #[test]
1158    fn resolve_console_save_dir_ignores_empty_override() {
1159        let mut save_sync = SaveSyncConfig::default();
1160        save_sync.platform_dirs.insert(7, "   ".to_string());
1161        let dir =
1162            resolve_console_save_dir(&save_sync, Path::new("/saves"), 7, Some("switch"), None)
1163                .unwrap();
1164        assert_eq!(dir, PathBuf::from("/saves/switch"));
1165    }
1166
1167    #[test]
1168    fn resolve_game_save_dir_appends_game_folder() {
1169        let rom = rom_fixture_with_platform(Some("switch"), "game.zip");
1170        let cfg = Config {
1171            base_url: "http://example.test".into(),
1172            download_dir: "/roms".into(),
1173            use_https: false,
1174            auth: None,
1175            extras_defaults: Default::default(),
1176            save_sync: SaveSyncConfig {
1177                save_dir: Some("/saves".into()),
1178                device_id: None,
1179                platform_dirs: HashMap::new(),
1180            },
1181            roms_layout: Default::default(),
1182        };
1183        let dir = resolve_game_save_dir(&cfg, &rom).unwrap();
1184        assert_eq!(dir, PathBuf::from("/saves/switch/Game"));
1185    }
1186
1187    #[test]
1188    fn final_download_path_uses_console_folder_and_original_file_name() {
1189        let rom = rom_fixture_with_platform(Some("switch"), "Zelda (USA).xci");
1190        let base = PathBuf::from("/roms");
1191        let out = final_download_path_for_rom(&base, &rom);
1192        assert_eq!(out, PathBuf::from("/roms/switch/Zelda _USA_.xci"));
1193    }
1194
1195    #[test]
1196    fn rom_file_download_candidates_use_official_romsfiles_endpoint() {
1197        let target = DownloadTarget {
1198            kind: DownloadAssetKind::RomFile,
1199            title: "Update".into(),
1200            source_url: "/api/roms/11/files/content/update%2Ensp".into(),
1201            source_query: Vec::new(),
1202            destination: PathBuf::from("/tmp/update.nsp"),
1203            expected_size_bytes: Some(11),
1204        };
1205
1206        assert_eq!(
1207            candidate_download_urls(&target),
1208            vec![
1209                "/api/roms/11/files/content/update%2Ensp".to_string(),
1210                "/api/romsfiles/11/content/update%2Ensp".to_string(),
1211                "/api/roms/files/11/content/update%2Ensp".to_string()
1212            ]
1213        );
1214    }
1215
1216    #[test]
1217    fn romsfiles_candidate_falls_forward_to_current_official_path() {
1218        let target = DownloadTarget {
1219            kind: DownloadAssetKind::RomFile,
1220            title: "Update".into(),
1221            source_url: "/api/romsfiles/11/content/update%2Ensp".into(),
1222            source_query: Vec::new(),
1223            destination: PathBuf::from("/tmp/update.nsp"),
1224            expected_size_bytes: Some(11),
1225        };
1226
1227        assert_eq!(
1228            candidate_download_urls(&target),
1229            vec![
1230                "/api/romsfiles/11/content/update%2Ensp".to_string(),
1231                "/api/roms/11/files/content/update%2Ensp".to_string(),
1232                "/api/roms/files/11/content/update%2Ensp".to_string()
1233            ]
1234        );
1235    }
1236
1237    #[test]
1238    fn legacy_roms_files_candidate_falls_forward_to_romsfiles() {
1239        let target = DownloadTarget {
1240            kind: DownloadAssetKind::RomFile,
1241            title: "Update".into(),
1242            source_url: "/api/roms/files/11/content/update%2Ensp".into(),
1243            source_query: Vec::new(),
1244            destination: PathBuf::from("/tmp/update.nsp"),
1245            expected_size_bytes: Some(11),
1246        };
1247
1248        assert_eq!(
1249            candidate_download_urls(&target),
1250            vec![
1251                "/api/roms/files/11/content/update%2Ensp".to_string(),
1252                "/api/roms/11/files/content/update%2Ensp".to_string(),
1253                "/api/romsfiles/11/content/update%2Ensp".to_string()
1254            ]
1255        );
1256    }
1257
1258    #[tokio::test]
1259    async fn prepare_target_removes_oversized_stale_rom_file() {
1260        let ts = SystemTime::now()
1261            .duration_since(UNIX_EPOCH)
1262            .unwrap()
1263            .as_nanos();
1264        let path = std::env::temp_dir().join(format!("romm-oversized-target-{ts}.nsp"));
1265        tokio::fs::write(&path, b"too-large").await.unwrap();
1266
1267        let target = DownloadTarget {
1268            kind: DownloadAssetKind::RomFile,
1269            title: "Base".into(),
1270            source_url: "/api/roms/1/files/content/base.nsp".into(),
1271            source_query: Vec::new(),
1272            destination: path.clone(),
1273            expected_size_bytes: Some(4),
1274        };
1275
1276        let skip = prepare_download_target_destination(&target).await.unwrap();
1277        assert!(!skip);
1278        assert!(!path.exists());
1279    }
1280
1281    #[tokio::test]
1282    async fn prepare_target_skips_exact_size_rom_file() {
1283        let ts = SystemTime::now()
1284            .duration_since(UNIX_EPOCH)
1285            .unwrap()
1286            .as_nanos();
1287        let path = std::env::temp_dir().join(format!("romm-exact-target-{ts}.nsp"));
1288        tokio::fs::write(&path, b"done").await.unwrap();
1289
1290        let target = DownloadTarget {
1291            kind: DownloadAssetKind::RomFile,
1292            title: "Base".into(),
1293            source_url: "/api/roms/1/files/content/base.nsp".into(),
1294            source_query: Vec::new(),
1295            destination: path.clone(),
1296            expected_size_bytes: Some(4),
1297        };
1298
1299        let skip = prepare_download_target_destination(&target).await.unwrap();
1300        assert!(skip);
1301        assert_eq!(tokio::fs::read(&path).await.unwrap(), b"done");
1302        let _ = tokio::fs::remove_file(path).await;
1303    }
1304
1305    #[tokio::test]
1306    async fn base_target_prepare_skips_exact_size_file() {
1307        let ts = SystemTime::now()
1308            .duration_since(UNIX_EPOCH)
1309            .unwrap()
1310            .as_nanos();
1311        let base = std::env::temp_dir().join(format!("romm-base-exact-{ts}"));
1312        let mut rom = rom_fixture_with_platform(Some("switch"), "pack.zip");
1313        rom.files = vec![crate::types::RomFile {
1314            id: 1,
1315            rom_id: rom.id,
1316            file_name: "base.nsp".into(),
1317            file_path: "/base.nsp".into(),
1318            file_size_bytes: 4,
1319            category: Some(crate::types::RomFileCategory::Game),
1320        }];
1321        let target =
1322            build_base_rom_file_targets(&rom, &RomsLayoutConfig::default(), base.as_path())
1323                .unwrap()
1324                .remove(0);
1325        tokio::fs::create_dir_all(target.destination.parent().unwrap())
1326            .await
1327            .unwrap();
1328        tokio::fs::write(&target.destination, b"done")
1329            .await
1330            .unwrap();
1331
1332        let skip = prepare_download_target_destination(&target).await.unwrap();
1333        assert!(skip);
1334        assert_eq!(tokio::fs::read(&target.destination).await.unwrap(), b"done");
1335        let _ = tokio::fs::remove_dir_all(base).await;
1336    }
1337
1338    #[tokio::test]
1339    async fn base_target_prepare_removes_oversized_file() {
1340        let ts = SystemTime::now()
1341            .duration_since(UNIX_EPOCH)
1342            .unwrap()
1343            .as_nanos();
1344        let base = std::env::temp_dir().join(format!("romm-base-oversized-{ts}"));
1345        let mut rom = rom_fixture_with_platform(Some("switch"), "pack.zip");
1346        rom.files = vec![crate::types::RomFile {
1347            id: 1,
1348            rom_id: rom.id,
1349            file_name: "base.nsp".into(),
1350            file_path: "/base.nsp".into(),
1351            file_size_bytes: 4,
1352            category: Some(crate::types::RomFileCategory::Game),
1353        }];
1354        let target =
1355            build_base_rom_file_targets(&rom, &RomsLayoutConfig::default(), base.as_path())
1356                .unwrap()
1357                .remove(0);
1358        tokio::fs::create_dir_all(target.destination.parent().unwrap())
1359            .await
1360            .unwrap();
1361        tokio::fs::write(&target.destination, b"too-large")
1362            .await
1363            .unwrap();
1364
1365        let skip = prepare_download_target_destination(&target).await.unwrap();
1366        assert!(!skip);
1367        assert!(!target.destination.exists());
1368        let _ = tokio::fs::remove_dir_all(base).await;
1369    }
1370
1371    #[tokio::test]
1372    async fn finalize_download_skips_when_final_exists() {
1373        let ts = SystemTime::now()
1374            .duration_since(UNIX_EPOCH)
1375            .unwrap()
1376            .as_nanos();
1377        let base = std::env::temp_dir().join(format!("romm-finalize-skip-{ts}"));
1378        std::fs::create_dir_all(&base).unwrap();
1379        let temp = base.join("temp.part");
1380        let final_path = base.join("final.zip");
1381        std::fs::write(&temp, b"temp").unwrap();
1382        std::fs::write(&final_path, b"existing").unwrap();
1383
1384        let result = finalize_download(&temp, &final_path).await.unwrap();
1385        assert_eq!(result, super::FinalizeResult::SkippedAlreadyExists);
1386        assert!(
1387            !temp.exists(),
1388            "temp file should be removed when final destination exists"
1389        );
1390
1391        let _ = std::fs::remove_file(&final_path);
1392        let _ = std::fs::remove_dir_all(&base);
1393    }
1394
1395    #[test]
1396    fn extract_zip_archive_writes_files_to_destination() {
1397        let ts = SystemTime::now()
1398            .duration_since(UNIX_EPOCH)
1399            .unwrap()
1400            .as_nanos();
1401        let base = std::env::temp_dir().join(format!("romm-extract-{ts}"));
1402        let zip_path = base.join("sample.zip");
1403        let out_dir = base.join("out");
1404        std::fs::create_dir_all(&base).unwrap();
1405
1406        let zip_file = std::fs::File::create(&zip_path).unwrap();
1407        let mut writer = ZipWriter::new(zip_file);
1408        writer
1409            .start_file("nested/game.rom", SimpleFileOptions::default())
1410            .unwrap();
1411        writer.write_all(b"rom-bytes").unwrap();
1412        writer.finish().unwrap();
1413
1414        extract_zip_archive(&zip_path, &out_dir).unwrap();
1415
1416        let extracted = out_dir.join("nested").join("game.rom");
1417        assert!(
1418            extracted.exists(),
1419            "expected extracted file at {:?}",
1420            extracted
1421        );
1422        let data = std::fs::read(&extracted).unwrap();
1423        assert_eq!(data, b"rom-bytes");
1424
1425        let _ = std::fs::remove_dir_all(&base);
1426    }
1427}