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