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::interrupt::is_cancelled_error;
17use crate::core::utils;
18use crate::types::Rom;
19use anyhow::{anyhow, Context, Result};
20use std::fs::File;
21use zip::ZipArchive;
22
23/// Directory for ROM storage (`ROMM_ROMS_DIR`, `ROMM_DOWNLOAD_DIR`, or configured path).
24pub fn resolve_download_directory(configured_download_dir: Option<&str>) -> Result<PathBuf> {
25    let env_override = std::env::var("ROMM_ROMS_DIR")
26        .ok()
27        .or_else(|| std::env::var("ROMM_DOWNLOAD_DIR").ok());
28    resolve_download_directory_from_inputs(configured_download_dir, env_override.as_deref())
29}
30
31/// Validate configured download path without env override fallback.
32pub fn validate_configured_download_directory(configured_download_dir: &str) -> Result<PathBuf> {
33    resolve_download_directory_from_inputs(Some(configured_download_dir), None)
34}
35
36/// Backward-compatible default used by legacy CLI download code.
37pub fn download_directory() -> PathBuf {
38    std::env::var("ROMM_ROMS_DIR")
39        .or_else(|_| std::env::var("ROMM_DOWNLOAD_DIR"))
40        .map(PathBuf::from)
41        .unwrap_or_else(|_| PathBuf::from("./downloads"))
42}
43
44fn resolve_download_directory_from_inputs(
45    configured_download_dir: Option<&str>,
46    env_override: Option<&str>,
47) -> Result<PathBuf> {
48    let raw = env_override
49        .or(configured_download_dir)
50        .map(str::trim)
51        .ok_or_else(|| {
52            anyhow!("ROMs directory is not configured. Run setup to set a ROMs path.")
53        })?;
54
55    if raw.is_empty() {
56        return Err(anyhow!("ROMs directory cannot be empty"));
57    }
58
59    let input_path = PathBuf::from(raw);
60    let normalized = if input_path.is_relative() {
61        std::env::current_dir()
62            .context("Could not resolve current working directory")?
63            .join(input_path)
64    } else {
65        input_path
66    };
67
68    if normalized.exists() && !normalized.is_dir() {
69        return Err(anyhow!(
70            "Download path is not a directory: {}",
71            normalized.display()
72        ));
73    }
74
75    std::fs::create_dir_all(&normalized).with_context(|| {
76        format!(
77            "Could not create download directory {}",
78            normalized.display()
79        )
80    })?;
81
82    let probe_name = format!(
83        ".romm-write-test-{}",
84        std::time::SystemTime::now()
85            .duration_since(std::time::UNIX_EPOCH)
86            .unwrap_or_default()
87            .as_nanos()
88    );
89    let probe_path = normalized.join(probe_name);
90    let probe = std::fs::OpenOptions::new()
91        .write(true)
92        .create_new(true)
93        .open(&probe_path)
94        .with_context(|| format!("ROMs directory is not writable: {}", normalized.display()))?;
95    drop(probe);
96    let _ = std::fs::remove_file(&probe_path);
97
98    Ok(normalized)
99}
100
101/// Pick `stem.zip`, then `stem__2.zip`, `stem__3.zip`, … until the path does not exist.
102pub fn unique_zip_path(dir: &Path, stem: &str) -> PathBuf {
103    let mut n = 1u32;
104    loop {
105        let name = if n == 1 {
106            format!("{}.zip", stem)
107        } else {
108            format!("{}__{}.zip", stem, n)
109        };
110        let p = dir.join(name);
111        if !p.exists() {
112            return p;
113        }
114        n = n.saturating_add(1);
115    }
116}
117
118/// Extract a ZIP archive into `destination_dir`.
119pub fn extract_zip_archive(zip_path: &Path, destination_dir: &Path) -> Result<()> {
120    let zip_path = zip_path.to_path_buf();
121    let destination_dir = destination_dir.to_path_buf();
122    std::fs::create_dir_all(&destination_dir).with_context(|| {
123        format!(
124            "Could not create extraction directory {}",
125            destination_dir.display()
126        )
127    })?;
128
129    let file = File::open(&zip_path)
130        .with_context(|| format!("Could not open zip archive {}", zip_path.display()))?;
131    let mut archive = ZipArchive::new(file)
132        .with_context(|| format!("Invalid ZIP archive {}", zip_path.display()))?;
133    archive.extract(&destination_dir).with_context(|| {
134        format!(
135            "Could not extract archive into {}",
136            destination_dir.display()
137        )
138    })?;
139    Ok(())
140}
141
142// ---------------------------------------------------------------------------
143// Job status / data
144// ---------------------------------------------------------------------------
145
146/// High-level status of a single download.
147#[derive(Debug, Clone)]
148pub enum DownloadStatus {
149    Downloading,
150    Done,
151    SkippedAlreadyExists,
152    Cancelled,
153    FinalizeFailed(String),
154    Error(String),
155}
156
157/// A single background download job (for one ROM).
158#[derive(Debug, Clone)]
159pub struct DownloadJob {
160    pub id: usize,
161    pub rom_id: u64,
162    pub name: String,
163    pub platform: String,
164    /// 0.0 ..= 1.0
165    pub progress: f64,
166    pub status: DownloadStatus,
167}
168
169static NEXT_JOB_ID: AtomicUsize = AtomicUsize::new(0);
170
171impl DownloadJob {
172    /// Construct a new job in the `Downloading` state.
173    pub fn new(rom_id: u64, name: String, platform: String) -> Self {
174        Self {
175            id: NEXT_JOB_ID.fetch_add(1, Ordering::Relaxed),
176            rom_id,
177            name,
178            platform,
179            progress: 0.0,
180            status: DownloadStatus::Downloading,
181        }
182    }
183
184    /// Progress as percentage 0..=100.
185    pub fn percent(&self) -> u16 {
186        (self.progress * 100.0).round().min(100.0) as u16
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Manager
192// ---------------------------------------------------------------------------
193
194/// Owns the shared download list and spawns background download tasks.
195///
196/// Frontends only need an `Arc<Mutex<Vec<DownloadJob>>>` to inspect jobs.
197#[derive(Clone)]
198pub struct DownloadManager {
199    jobs: Arc<Mutex<Vec<DownloadJob>>>,
200}
201
202impl Default for DownloadManager {
203    fn default() -> Self {
204        Self::new()
205    }
206}
207
208impl DownloadManager {
209    pub fn new() -> Self {
210        Self {
211            jobs: Arc::new(Mutex::new(Vec::new())),
212        }
213    }
214
215    /// Shared handle for observers (TUI, GUI, tests) to inspect jobs.
216    pub fn shared(&self) -> Arc<Mutex<Vec<DownloadJob>>> {
217        self.jobs.clone()
218    }
219
220    /// Start downloading `rom` in the background; returns immediately.
221    ///
222    /// Progress updates are pushed into the shared `jobs` list so that
223    /// any frontend can render them.
224    pub fn start_download(
225        &self,
226        rom: &Rom,
227        client: RommClient,
228        configured_download_dir: Option<&str>,
229    ) -> Result<()> {
230        let platform = rom
231            .platform_display_name
232            .as_deref()
233            .or(rom.platform_custom_name.as_deref())
234            .unwrap_or("—")
235            .to_string();
236
237        let job = DownloadJob::new(rom.id, rom.name.clone(), platform);
238        let job_id = job.id;
239        let rom_id = rom.id;
240        let fs_name = rom.fs_name.clone();
241        let final_console_slug = rom
242            .platform_fs_slug
243            .clone()
244            .or_else(|| rom.platform_slug.clone())
245            .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
246        let final_name = sanitized_final_filename(&rom.fs_name, rom.id);
247        match self.jobs.lock() {
248            Ok(mut jobs) => jobs.push(job),
249            Err(err) => {
250                eprintln!("warning: download job list lock poisoned: {}", err);
251                return Err(anyhow!("download job list lock poisoned: {err}"));
252            }
253        }
254
255        let save_dir = resolve_download_directory(configured_download_dir)?;
256        let jobs = self.jobs.clone();
257        tokio::spawn(async move {
258            let temp_root = save_dir.join(".tmp");
259            if let Err(err) = tokio::fs::create_dir_all(&temp_root).await {
260                if let Ok(mut list) = jobs.lock() {
261                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
262                        j.status = DownloadStatus::Error(format!(
263                            "Could not create temp directory {}: {err}",
264                            temp_root.display()
265                        ));
266                    }
267                }
268                return;
269            }
270
271            let console_dir = save_dir.join(utils::sanitize_filename(&final_console_slug));
272            let final_path = console_dir.join(final_name.clone());
273            if let Err(err) = tokio::fs::create_dir_all(&console_dir).await {
274                if let Ok(mut list) = jobs.lock() {
275                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
276                        j.status = DownloadStatus::Error(format!(
277                            "Could not create console directory {}: {err}",
278                            console_dir.display()
279                        ));
280                    }
281                }
282                return;
283            }
284
285            if final_path.exists() {
286                if let Ok(mut list) = jobs.lock() {
287                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
288                        j.status = DownloadStatus::SkippedAlreadyExists;
289                        j.progress = 1.0;
290                    }
291                }
292                return;
293            }
294
295            let temp_name = format!(
296                "rom-{}-{}-{}.part",
297                rom_id,
298                utils::sanitize_filename(&fs_name),
299                job_id
300            );
301            let temp_path = temp_root.join(temp_name);
302
303            let on_progress = |received: u64, total: u64| {
304                let p = if total > 0 {
305                    received as f64 / total as f64
306                } else {
307                    0.0
308                };
309
310                if let Ok(mut list) = jobs.lock() {
311                    if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
312                        j.progress = p;
313                    }
314                }
315            };
316
317            let download_result = client.download_rom(rom_id, &temp_path, on_progress).await;
318            if download_result.is_err() {
319                let _ = tokio::fs::remove_file(&temp_path).await;
320            }
321            match download_result {
322                Ok(()) => match finalize_download(&temp_path, &final_path).await {
323                    Ok(FinalizeResult::Done) => {
324                        if let Ok(mut list) = jobs.lock() {
325                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
326                                j.status = DownloadStatus::Done;
327                                j.progress = 1.0;
328                            }
329                        }
330                    }
331                    Ok(FinalizeResult::SkippedAlreadyExists) => {
332                        if let Ok(mut list) = jobs.lock() {
333                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
334                                j.status = DownloadStatus::SkippedAlreadyExists;
335                                j.progress = 1.0;
336                            }
337                        }
338                    }
339                    Err(err) => {
340                        let _ = tokio::fs::remove_file(&temp_path).await;
341                        if let Ok(mut list) = jobs.lock() {
342                            if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
343                                j.status = DownloadStatus::FinalizeFailed(err.to_string());
344                            }
345                        }
346                    }
347                },
348                Err(e) => {
349                    if let Ok(mut list) = jobs.lock() {
350                        if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
351                            if is_cancelled_error(&e) {
352                                j.status = DownloadStatus::Cancelled;
353                            } else {
354                                j.status = DownloadStatus::Error(e.to_string());
355                            }
356                        }
357                    }
358                }
359            }
360        });
361        Ok(())
362    }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366enum FinalizeResult {
367    Done,
368    SkippedAlreadyExists,
369}
370
371async fn finalize_download(temp_path: &Path, final_path: &Path) -> Result<FinalizeResult> {
372    if final_path.exists() {
373        let _ = tokio::fs::remove_file(temp_path).await;
374        return Ok(FinalizeResult::SkippedAlreadyExists);
375    }
376
377    match tokio::fs::rename(temp_path, final_path).await {
378        Ok(()) => Ok(FinalizeResult::Done),
379        Err(rename_err) if is_cross_device_rename_error(&rename_err) => {
380            tokio::fs::copy(temp_path, final_path)
381                .await
382                .with_context(|| {
383                    format!(
384                        "Could not copy temp ROM {} to final destination {}",
385                        temp_path.display(),
386                        final_path.display()
387                    )
388                })?;
389            let file = tokio::fs::File::open(final_path).await.with_context(|| {
390                format!(
391                    "Could not open finalized ROM for sync: {}",
392                    final_path.display()
393                )
394            })?;
395            file.sync_all().await.with_context(|| {
396                format!(
397                    "Could not sync finalized ROM to disk: {}",
398                    final_path.display()
399                )
400            })?;
401            tokio::fs::remove_file(temp_path).await.with_context(|| {
402                format!(
403                    "Could not remove temp ROM after copy: {}",
404                    temp_path.display()
405                )
406            })?;
407            Ok(FinalizeResult::Done)
408        }
409        Err(rename_err) => Err(anyhow!(
410            "Could not move temp ROM {} to final destination {}: {}",
411            temp_path.display(),
412            final_path.display(),
413            rename_err
414        )),
415    }
416}
417
418fn is_cross_device_rename_error(err: &std::io::Error) -> bool {
419    matches!(err.raw_os_error(), Some(18) | Some(17))
420}
421
422fn sanitized_final_filename(fs_name: &str, rom_id: u64) -> String {
423    let sanitized = utils::sanitize_filename(fs_name);
424    if sanitized.trim().is_empty() {
425        format!("rom-{rom_id}.zip")
426    } else {
427        sanitized
428    }
429}
430
431#[cfg(test)]
432fn final_download_path_for_rom(roms_dir: &Path, rom: &Rom) -> PathBuf {
433    let platform_slug = rom
434        .platform_fs_slug
435        .clone()
436        .or_else(|| rom.platform_slug.clone())
437        .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
438    let console_dir = roms_dir.join(utils::sanitize_filename(&platform_slug));
439    console_dir.join(sanitized_final_filename(&rom.fs_name, rom.id))
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::types::Rom;
446    use std::io::Write;
447    use std::time::{SystemTime, UNIX_EPOCH};
448    use zip::write::SimpleFileOptions;
449    use zip::ZipWriter;
450
451    fn rom_fixture_with_platform(platform_fs_slug: Option<&str>, fs_name: &str) -> Rom {
452        Rom {
453            id: 42,
454            platform_id: 7,
455            platform_slug: Some("nintendo-switch".to_string()),
456            platform_fs_slug: platform_fs_slug.map(ToString::to_string),
457            platform_custom_name: None,
458            platform_display_name: None,
459            fs_name: fs_name.to_string(),
460            fs_name_no_tags: "game".to_string(),
461            fs_name_no_ext: "game".to_string(),
462            fs_extension: "zip".to_string(),
463            fs_path: "/game.zip".to_string(),
464            fs_size_bytes: 1,
465            name: "Game".to_string(),
466            slug: None,
467            summary: None,
468            path_cover_small: None,
469            path_cover_large: None,
470            url_cover: None,
471            is_unidentified: false,
472            is_identified: true,
473        }
474    }
475
476    #[test]
477    fn unique_zip_path_skips_existing_files() {
478        let ts = SystemTime::now()
479            .duration_since(UNIX_EPOCH)
480            .unwrap()
481            .as_nanos();
482        let dir = std::env::temp_dir().join(format!("romm-dl-test-{ts}"));
483        std::fs::create_dir_all(&dir).unwrap();
484        let p1 = dir.join("game.zip");
485        std::fs::File::create(&p1).unwrap().write_all(b"x").unwrap();
486        let p2 = unique_zip_path(&dir, "game");
487        assert_eq!(p2.file_name().unwrap(), "game__2.zip");
488        let _ = std::fs::remove_file(&p1);
489        let _ = std::fs::remove_dir(&dir);
490    }
491
492    #[test]
493    fn resolve_download_directory_rejects_empty_configured_path() {
494        let err = resolve_download_directory_from_inputs(Some("   "), None)
495            .expect_err("empty configured path should be rejected");
496        assert!(
497            err.to_string().contains("cannot be empty"),
498            "unexpected error: {err:#}"
499        );
500    }
501
502    #[test]
503    fn resolve_download_directory_creates_missing_nested_directory() {
504        let ts = SystemTime::now()
505            .duration_since(UNIX_EPOCH)
506            .unwrap()
507            .as_nanos();
508        let base = std::env::temp_dir().join(format!("romm-dl-resolve-{ts}"));
509        let nested = base.join("a").join("b").join("c");
510        let nested_str = nested.to_string_lossy().to_string();
511
512        let resolved = resolve_download_directory_from_inputs(Some(&nested_str), None)
513            .expect("expected missing directory to be created");
514
515        assert!(resolved.is_dir(), "resolved path must be a directory");
516        assert!(nested.is_dir(), "nested path should be created");
517        let _ = std::fs::remove_dir_all(&base);
518    }
519
520    #[test]
521    fn resolve_download_directory_fails_when_target_is_a_file() {
522        let ts = SystemTime::now()
523            .duration_since(UNIX_EPOCH)
524            .unwrap()
525            .as_nanos();
526        let base = std::env::temp_dir().join(format!("romm-dl-file-target-{ts}"));
527        std::fs::create_dir_all(&base).expect("create base dir");
528        let file_path = base.join("not-a-dir.txt");
529        std::fs::write(&file_path, b"x").expect("create file");
530        let input = file_path.to_string_lossy().to_string();
531
532        let err = resolve_download_directory_from_inputs(Some(&input), None)
533            .expect_err("file target must fail");
534        assert!(
535            err.to_string().contains("not a directory"),
536            "unexpected error: {err:#}"
537        );
538
539        let _ = std::fs::remove_file(&file_path);
540        let _ = std::fs::remove_dir_all(&base);
541    }
542
543    #[test]
544    fn resolve_download_directory_env_override_takes_precedence() {
545        let ts = SystemTime::now()
546            .duration_since(UNIX_EPOCH)
547            .unwrap()
548            .as_nanos();
549        let configured = std::env::temp_dir().join(format!("romm-dl-configured-{ts}"));
550        let env_dir = std::env::temp_dir().join(format!("romm-dl-env-{ts}"));
551        let configured_str = configured.to_string_lossy().to_string();
552        let env_str = env_dir.to_string_lossy().to_string();
553
554        let resolved =
555            resolve_download_directory_from_inputs(Some(&configured_str), Some(&env_str))
556                .expect("env override should be used");
557
558        assert_eq!(resolved, env_dir);
559        assert!(env_dir.is_dir(), "env directory should be created");
560        assert!(
561            !configured.is_dir(),
562            "configured path should be ignored when env override is set"
563        );
564        let _ = std::fs::remove_dir_all(&env_dir);
565    }
566
567    #[test]
568    fn final_download_path_uses_console_folder_and_original_file_name() {
569        let rom = rom_fixture_with_platform(Some("switch"), "Zelda (USA).xci");
570        let base = PathBuf::from("/roms");
571        let out = final_download_path_for_rom(&base, &rom);
572        assert_eq!(out, PathBuf::from("/roms/switch/Zelda _USA_.xci"));
573    }
574
575    #[tokio::test]
576    async fn finalize_download_skips_when_final_exists() {
577        let ts = SystemTime::now()
578            .duration_since(UNIX_EPOCH)
579            .unwrap()
580            .as_nanos();
581        let base = std::env::temp_dir().join(format!("romm-finalize-skip-{ts}"));
582        std::fs::create_dir_all(&base).unwrap();
583        let temp = base.join("temp.part");
584        let final_path = base.join("final.zip");
585        std::fs::write(&temp, b"temp").unwrap();
586        std::fs::write(&final_path, b"existing").unwrap();
587
588        let result = finalize_download(&temp, &final_path).await.unwrap();
589        assert_eq!(result, super::FinalizeResult::SkippedAlreadyExists);
590        assert!(
591            !temp.exists(),
592            "temp file should be removed when final destination exists"
593        );
594
595        let _ = std::fs::remove_file(&final_path);
596        let _ = std::fs::remove_dir_all(&base);
597    }
598
599    #[test]
600    fn extract_zip_archive_writes_files_to_destination() {
601        let ts = SystemTime::now()
602            .duration_since(UNIX_EPOCH)
603            .unwrap()
604            .as_nanos();
605        let base = std::env::temp_dir().join(format!("romm-extract-{ts}"));
606        let zip_path = base.join("sample.zip");
607        let out_dir = base.join("out");
608        std::fs::create_dir_all(&base).unwrap();
609
610        let zip_file = std::fs::File::create(&zip_path).unwrap();
611        let mut writer = ZipWriter::new(zip_file);
612        writer
613            .start_file("nested/game.rom", SimpleFileOptions::default())
614            .unwrap();
615        writer.write_all(b"rom-bytes").unwrap();
616        writer.finish().unwrap();
617
618        extract_zip_archive(&zip_path, &out_dir).unwrap();
619
620        let extracted = out_dir.join("nested").join("game.rom");
621        assert!(
622            extracted.exists(),
623            "expected extracted file at {:?}",
624            extracted
625        );
626        let data = std::fs::read(&extracted).unwrap();
627        assert_eq!(data, b"rom-bytes");
628
629        let _ = std::fs::remove_dir_all(&base);
630    }
631}