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