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