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