1use 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
23pub 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
31pub fn validate_configured_download_directory(configured_download_dir: &str) -> Result<PathBuf> {
33 resolve_download_directory_from_inputs(Some(configured_download_dir), None)
34}
35
36pub 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
101pub 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
118pub 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#[derive(Debug, Clone)]
148pub enum DownloadStatus {
149 Downloading,
150 Done,
151 SkippedAlreadyExists,
152 Cancelled,
153 FinalizeFailed(String),
154 Error(String),
155}
156
157#[derive(Debug, Clone)]
159pub struct DownloadJob {
160 pub id: usize,
161 pub rom_id: u64,
162 pub name: String,
163 pub platform: String,
164 pub progress: f64,
166 pub status: DownloadStatus,
167}
168
169static NEXT_JOB_ID: AtomicUsize = AtomicUsize::new(0);
170
171impl DownloadJob {
172 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 pub fn percent(&self) -> u16 {
186 (self.progress * 100.0).round().min(100.0) as u16
187 }
188}
189
190#[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 pub fn shared(&self) -> Arc<Mutex<Vec<DownloadJob>>> {
217 self.jobs.clone()
218 }
219
220 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}