1use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::sync::{Arc, Mutex};
14
15use crate::client::RommClient;
16use crate::core::extras::build_base_rom_file_targets;
17use crate::core::extras::{DownloadAssetKind, DownloadTarget};
18use crate::core::interrupt::is_cancelled_error;
19use crate::core::utils;
20use crate::types::Rom;
21use anyhow::{anyhow, Context, Result};
22use std::fs::File;
23use zip::ZipArchive;
24
25pub fn resolve_download_directory(configured_download_dir: Option<&str>) -> Result<PathBuf> {
27 let env_override = std::env::var("ROMM_ROMS_DIR")
28 .ok()
29 .or_else(|| std::env::var("ROMM_DOWNLOAD_DIR").ok());
30 resolve_download_directory_from_inputs(configured_download_dir, env_override.as_deref())
31}
32
33pub fn validate_configured_download_directory(configured_download_dir: &str) -> Result<PathBuf> {
35 resolve_download_directory_from_inputs(Some(configured_download_dir), None)
36}
37
38pub fn download_directory() -> PathBuf {
40 std::env::var("ROMM_ROMS_DIR")
41 .or_else(|_| std::env::var("ROMM_DOWNLOAD_DIR"))
42 .map(PathBuf::from)
43 .unwrap_or_else(|_| PathBuf::from("./downloads"))
44}
45
46fn resolve_download_directory_from_inputs(
47 configured_download_dir: Option<&str>,
48 env_override: Option<&str>,
49) -> Result<PathBuf> {
50 let raw = env_override
51 .or(configured_download_dir)
52 .map(str::trim)
53 .ok_or_else(|| {
54 anyhow!("ROMs directory is not configured. Run setup to set a ROMs path.")
55 })?;
56
57 if raw.is_empty() {
58 return Err(anyhow!("ROMs directory cannot be empty"));
59 }
60
61 let input_path = PathBuf::from(raw);
62 let normalized = if input_path.is_relative() {
63 std::env::current_dir()
64 .context("Could not resolve current working directory")?
65 .join(input_path)
66 } else {
67 input_path
68 };
69
70 if normalized.exists() && !normalized.is_dir() {
71 return Err(anyhow!(
72 "Download path is not a directory: {}",
73 normalized.display()
74 ));
75 }
76
77 std::fs::create_dir_all(&normalized).with_context(|| {
78 format!(
79 "Could not create download directory {}",
80 normalized.display()
81 )
82 })?;
83
84 let probe_name = format!(
85 ".romm-write-test-{}",
86 std::time::SystemTime::now()
87 .duration_since(std::time::UNIX_EPOCH)
88 .unwrap_or_default()
89 .as_nanos()
90 );
91 let probe_path = normalized.join(probe_name);
92 let probe = std::fs::OpenOptions::new()
93 .write(true)
94 .create_new(true)
95 .open(&probe_path)
96 .with_context(|| format!("ROMs directory is not writable: {}", normalized.display()))?;
97 drop(probe);
98 let _ = std::fs::remove_file(&probe_path);
99
100 Ok(normalized)
101}
102
103pub fn unique_zip_path(dir: &Path, stem: &str) -> PathBuf {
105 let mut n = 1u32;
106 loop {
107 let name = if n == 1 {
108 format!("{}.zip", stem)
109 } else {
110 format!("{}__{}.zip", stem, n)
111 };
112 let p = dir.join(name);
113 if !p.exists() {
114 return p;
115 }
116 n = n.saturating_add(1);
117 }
118}
119
120pub fn extract_zip_archive(zip_path: &Path, destination_dir: &Path) -> Result<()> {
122 let zip_path = zip_path.to_path_buf();
123 let destination_dir = destination_dir.to_path_buf();
124 std::fs::create_dir_all(&destination_dir).with_context(|| {
125 format!(
126 "Could not create extraction directory {}",
127 destination_dir.display()
128 )
129 })?;
130
131 let file = File::open(&zip_path)
132 .with_context(|| format!("Could not open zip archive {}", zip_path.display()))?;
133 let mut archive = ZipArchive::new(file)
134 .with_context(|| format!("Invalid ZIP archive {}", zip_path.display()))?;
135 archive.extract(&destination_dir).with_context(|| {
136 format!(
137 "Could not extract archive into {}",
138 destination_dir.display()
139 )
140 })?;
141 Ok(())
142}
143
144#[derive(Debug, Clone)]
150pub enum DownloadStatus {
151 Downloading,
152 Done,
153 SkippedAlreadyExists,
154 Cancelled,
155 FinalizeFailed(String),
156 Error(String),
157}
158
159#[derive(Debug, Clone)]
161pub struct DownloadJob {
162 pub id: usize,
163 pub rom_id: u64,
164 pub name: String,
165 pub platform: String,
166 pub progress: f64,
168 pub status: DownloadStatus,
169}
170
171static NEXT_JOB_ID: AtomicUsize = AtomicUsize::new(0);
172
173impl DownloadJob {
174 pub fn new(rom_id: u64, name: String, platform: String) -> Self {
176 Self {
177 id: NEXT_JOB_ID.fetch_add(1, Ordering::Relaxed),
178 rom_id,
179 name,
180 platform,
181 progress: 0.0,
182 status: DownloadStatus::Downloading,
183 }
184 }
185
186 pub fn percent(&self) -> u16 {
188 (self.progress * 100.0).round().min(100.0) as u16
189 }
190}
191
192#[derive(Debug, Clone)]
198pub struct ExtrasItemResult {
199 pub title: String,
200 pub kind: DownloadAssetKind,
201 pub ok: bool,
202 pub error: Option<String>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
207pub enum ExtrasJobStatus {
208 Running,
209 Done,
210 PartialFailure(usize),
212 AllFailed,
213}
214
215#[derive(Debug, Clone)]
217pub struct ExtrasJob {
218 pub id: usize,
219 pub rom_id: u64,
220 pub name: String,
221 pub platform: String,
222 pub completed_items: usize,
223 pub total_items: usize,
224 pub status: ExtrasJobStatus,
225 pub item_results: Vec<ExtrasItemResult>,
226}
227
228static NEXT_EXTRAS_JOB_ID: AtomicUsize = AtomicUsize::new(0);
229
230impl ExtrasJob {
231 pub fn new(rom_id: u64, name: String, platform: String, total_items: usize) -> Self {
232 Self {
233 id: NEXT_EXTRAS_JOB_ID.fetch_add(1, Ordering::Relaxed),
234 rom_id,
235 name,
236 platform,
237 completed_items: 0,
238 total_items,
239 status: ExtrasJobStatus::Running,
240 item_results: Vec::new(),
241 }
242 }
243
244 pub fn percent(&self) -> u16 {
246 if self.total_items == 0 {
247 return 100;
248 }
249 ((self.completed_items.saturating_mul(100)) / self.total_items).min(100) as u16
250 }
251}
252
253fn finalize_extras_job_status(results: &[ExtrasItemResult]) -> ExtrasJobStatus {
254 let n = results.len();
255 if n == 0 {
256 return ExtrasJobStatus::Done;
257 }
258 let failures = results.iter().filter(|r| !r.ok).count();
259 if failures == 0 {
260 ExtrasJobStatus::Done
261 } else if failures == n {
262 ExtrasJobStatus::AllFailed
263 } else {
264 ExtrasJobStatus::PartialFailure(failures)
265 }
266}
267
268#[derive(Clone)]
276pub struct DownloadManager {
277 jobs: Arc<Mutex<Vec<DownloadJob>>>,
278 extras_jobs: Arc<Mutex<Vec<ExtrasJob>>>,
279}
280
281impl Default for DownloadManager {
282 fn default() -> Self {
283 Self::new()
284 }
285}
286
287impl DownloadManager {
288 pub fn new() -> Self {
289 Self {
290 jobs: Arc::new(Mutex::new(Vec::new())),
291 extras_jobs: Arc::new(Mutex::new(Vec::new())),
292 }
293 }
294
295 pub fn shared(&self) -> Arc<Mutex<Vec<DownloadJob>>> {
297 self.jobs.clone()
298 }
299
300 pub fn shared_extras(&self) -> Arc<Mutex<Vec<ExtrasJob>>> {
302 self.extras_jobs.clone()
303 }
304
305 pub fn start_download(
310 &self,
311 rom: &Rom,
312 client: RommClient,
313 configured_download_dir: Option<&str>,
314 ) -> Result<()> {
315 let platform = rom
316 .platform_display_name
317 .as_deref()
318 .or(rom.platform_custom_name.as_deref())
319 .unwrap_or("—")
320 .to_string();
321
322 let job = DownloadJob::new(rom.id, rom.name.clone(), platform);
323 let job_id = job.id;
324 let rom_id = rom.id;
325 let fs_name = rom.fs_name.clone();
326 let final_console_slug = rom
327 .platform_fs_slug
328 .clone()
329 .or_else(|| rom.platform_slug.clone())
330 .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
331 let final_name = sanitized_final_filename(&rom.fs_name, rom.id);
332 let rom_for_targets = rom.clone();
333 match self.jobs.lock() {
334 Ok(mut jobs) => jobs.push(job),
335 Err(err) => {
336 eprintln!("warning: download job list lock poisoned: {}", err);
337 return Err(anyhow!("download job list lock poisoned: {err}"));
338 }
339 }
340
341 let save_dir = resolve_download_directory(configured_download_dir)?;
342 let jobs = self.jobs.clone();
343 tokio::spawn(async move {
344 let temp_root = save_dir.join(".tmp");
345 if let Err(err) = tokio::fs::create_dir_all(&temp_root).await {
346 if let Ok(mut list) = jobs.lock() {
347 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
348 j.status = DownloadStatus::Error(format!(
349 "Could not create temp directory {}: {err}",
350 temp_root.display()
351 ));
352 }
353 }
354 return;
355 }
356
357 let console_dir = save_dir.join(utils::sanitize_filename(&final_console_slug));
358 let final_path = console_dir.join(final_name.clone());
359 if let Err(err) = tokio::fs::create_dir_all(&console_dir).await {
360 if let Ok(mut list) = jobs.lock() {
361 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
362 j.status = DownloadStatus::Error(format!(
363 "Could not create console directory {}: {err}",
364 console_dir.display()
365 ));
366 }
367 }
368 return;
369 }
370
371 let base_targets = build_base_rom_file_targets(&rom_for_targets, &save_dir);
372 if !base_targets.is_empty() {
373 let total_targets = base_targets.len() as f64;
374 for (idx, target) in base_targets.iter().enumerate() {
375 let client = client.clone();
376 let mut progress = {
377 let jobs = jobs.clone();
378 move |received: u64, total: u64| {
379 let file_ratio = if total > 0 {
380 received as f64 / total as f64
381 } else {
382 0.0
383 };
384 let total_ratio = ((idx as f64) + file_ratio) / total_targets;
385 if let Ok(mut list) = jobs.lock() {
386 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
387 j.progress = total_ratio.min(1.0);
388 }
389 }
390 }
391 };
392 match prepare_download_target_destination(target).await {
393 Ok(true) => {
394 progress(
395 target.expected_size_bytes.unwrap_or(0),
396 target.expected_size_bytes.unwrap_or(0),
397 );
398 continue;
399 }
400 Ok(false) => {}
401 Err(err) => {
402 if let Ok(mut list) = jobs.lock() {
403 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
404 j.status = DownloadStatus::Error(err.to_string());
405 }
406 }
407 return;
408 }
409 }
410 if let Err(final_err) =
411 download_target_with_fallback(&client, target, |_, _| false, &mut progress)
412 .await
413 {
414 if let Ok(mut list) = jobs.lock() {
415 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
416 j.status = DownloadStatus::Error(final_err.to_string());
417 }
418 }
419 return;
420 }
421 }
422 if let Ok(mut list) = jobs.lock() {
423 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
424 j.status = DownloadStatus::Done;
425 j.progress = 1.0;
426 }
427 }
428 return;
429 }
430
431 if final_path.exists() {
432 if let Ok(mut list) = jobs.lock() {
433 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
434 j.status = DownloadStatus::SkippedAlreadyExists;
435 j.progress = 1.0;
436 }
437 }
438 return;
439 }
440
441 let temp_name = format!(
442 "rom-{}-{}-{}.part",
443 rom_id,
444 utils::sanitize_filename(&fs_name),
445 job_id
446 );
447 let temp_path = temp_root.join(temp_name);
448
449 let on_progress = |received: u64, total: u64| {
450 let p = if total > 0 {
451 received as f64 / total as f64
452 } else {
453 0.0
454 };
455
456 if let Ok(mut list) = jobs.lock() {
457 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
458 j.progress = p;
459 }
460 }
461 };
462
463 let download_result = client.download_rom(rom_id, &temp_path, on_progress).await;
464 if download_result.is_err() {
465 let _ = tokio::fs::remove_file(&temp_path).await;
466 }
467 match download_result {
468 Ok(()) => match finalize_download(&temp_path, &final_path).await {
469 Ok(FinalizeResult::Done) => {
470 if let Ok(mut list) = jobs.lock() {
471 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
472 j.status = DownloadStatus::Done;
473 j.progress = 1.0;
474 }
475 }
476 }
477 Ok(FinalizeResult::SkippedAlreadyExists) => {
478 if let Ok(mut list) = jobs.lock() {
479 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
480 j.status = DownloadStatus::SkippedAlreadyExists;
481 j.progress = 1.0;
482 }
483 }
484 }
485 Err(err) => {
486 let _ = tokio::fs::remove_file(&temp_path).await;
487 if let Ok(mut list) = jobs.lock() {
488 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
489 j.status = DownloadStatus::FinalizeFailed(err.to_string());
490 }
491 }
492 }
493 },
494 Err(e) => {
495 if let Ok(mut list) = jobs.lock() {
496 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
497 if is_cancelled_error(&e) {
498 j.status = DownloadStatus::Cancelled;
499 } else {
500 j.status = DownloadStatus::Error(e.to_string());
501 }
502 }
503 }
504 }
505 }
506 });
507 Ok(())
508 }
509
510 pub fn start_extras_download(
514 &self,
515 rom: &Rom,
516 selected: Vec<DownloadTarget>,
517 client: RommClient,
518 configured_download_dir: Option<&str>,
519 ) -> Result<()> {
520 if selected.is_empty() {
521 return Err(anyhow!("no extras targets selected"));
522 }
523
524 let _ = resolve_download_directory(configured_download_dir)?;
525
526 let platform = rom
527 .platform_display_name
528 .as_deref()
529 .or(rom.platform_custom_name.as_deref())
530 .unwrap_or("—")
531 .to_string();
532
533 let total_items = selected.len();
534 let job = ExtrasJob::new(rom.id, rom.name.clone(), platform, total_items);
535 let job_id = job.id;
536
537 match self.extras_jobs.lock() {
538 Ok(mut jobs) => jobs.push(job),
539 Err(err) => {
540 eprintln!("warning: extras job list lock poisoned: {}", err);
541 return Err(anyhow!("extras job list lock poisoned: {err}"));
542 }
543 }
544
545 let extras_jobs = self.extras_jobs.clone();
546 tokio::spawn(async move {
547 let semaphore = Arc::new(tokio::sync::Semaphore::new(4));
548 let mut handles = Vec::new();
549
550 for target in selected {
551 let permit = match semaphore.clone().acquire_owned().await {
552 Ok(p) => p,
553 Err(_) => break,
554 };
555 let client = client.clone();
556 let extras_jobs = extras_jobs.clone();
557 handles.push(tokio::spawn(async move {
558 let mut on_progress = |_r: u64, _t: u64| {};
559 let download_result = match prepare_download_target_destination(&target).await {
560 Ok(true) => Ok(()),
561 Ok(false) => {
562 download_target_with_fallback(
563 &client,
564 &target,
565 |_, _| false,
566 &mut on_progress,
567 )
568 .await
569 }
570 Err(err) => Err(err),
571 };
572
573 drop(permit);
574
575 let (ok, err) = match download_result {
576 Ok(()) => (true, None),
577 Err(e) => (false, Some(e.to_string())),
578 };
579
580 let item = ExtrasItemResult {
581 title: target.title.clone(),
582 kind: target.kind,
583 ok,
584 error: err,
585 };
586
587 if let Ok(mut list) = extras_jobs.lock() {
588 if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
589 j.completed_items = j.completed_items.saturating_add(1);
590 j.item_results.push(item);
591 if j.completed_items >= j.total_items {
592 j.status = finalize_extras_job_status(&j.item_results);
593 }
594 }
595 }
596 }));
597 }
598
599 for h in handles {
600 let _ = h.await;
601 }
602 });
603
604 Ok(())
605 }
606}
607
608pub async fn prepare_download_target_destination(target: &DownloadTarget) -> Result<bool> {
609 let Some(expected_size) = target.expected_size_bytes else {
610 return Ok(false);
611 };
612 if expected_size == 0 {
613 return Ok(false);
614 }
615
616 let Ok(metadata) = tokio::fs::metadata(&target.destination).await else {
617 return Ok(false);
618 };
619 let current_size = metadata.len();
620 if current_size == expected_size {
621 return Ok(true);
622 }
623 if current_size > expected_size {
624 tokio::fs::remove_file(&target.destination)
625 .await
626 .with_context(|| {
627 format!(
628 "remove oversized stale download {} ({} > {} bytes)",
629 target.destination.display(),
630 current_size,
631 expected_size
632 )
633 })?;
634 }
635 Ok(false)
636}
637
638async fn download_target_with_fallback<F, C>(
639 client: &RommClient,
640 target: &DownloadTarget,
641 mut is_cancelled: C,
642 on_progress: &mut F,
643) -> Result<()>
644where
645 F: FnMut(u64, u64) + Send,
646 C: FnMut(u64, u64) -> bool + Send,
647{
648 let urls = candidate_download_urls(target);
649 let mut last_err: Option<anyhow::Error> = None;
650 for url in urls {
651 match client
652 .download_url_with_query_with_cancel(
653 &url,
654 &target.source_query,
655 &target.destination,
656 &mut is_cancelled,
657 on_progress,
658 )
659 .await
660 {
661 Ok(()) => return Ok(()),
662 Err(err) => {
663 if !err.to_string().contains("404 Not Found") {
664 return Err(err);
665 }
666 last_err = Some(err);
667 }
668 }
669 }
670 Err(last_err.unwrap_or_else(|| anyhow!("download failed without error details")))
671}
672
673fn candidate_download_urls(target: &DownloadTarget) -> Vec<String> {
674 let mut out = vec![target.source_url.clone()];
675 if let Some((file_id, file_name)) = parse_current_rom_file_content_path(&target.source_url) {
676 out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
677 out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
678 } else if let Some((file_id, file_name)) = parse_romsfiles_path(&target.source_url) {
679 out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
680 out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
681 } else if let Some((file_id, file_name)) = parse_legacy_roms_files_path(&target.source_url) {
682 out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
683 out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
684 }
685 dedupe_preserve_order(out)
686}
687
688fn parse_current_rom_file_content_path(url: &str) -> Option<(String, String)> {
689 let prefix = "/api/roms/";
690 let marker = "/files/content/";
691 let rest = url.strip_prefix(prefix)?;
692 let (id, name) = rest.split_once(marker)?;
693 Some((id.to_string(), name.to_string()))
694}
695
696fn parse_romsfiles_path(url: &str) -> Option<(String, String)> {
697 let prefix = "/api/romsfiles/";
698 let marker = "/content/";
699 let rest = url.strip_prefix(prefix)?;
700 let (id, name) = rest.split_once(marker)?;
701 Some((id.to_string(), name.to_string()))
702}
703
704fn parse_legacy_roms_files_path(url: &str) -> Option<(String, String)> {
705 let prefix = "/api/roms/files/";
706 let marker = "/content/";
707 let rest = url.strip_prefix(prefix)?;
708 let (id, name) = rest.split_once(marker)?;
709 Some((id.to_string(), name.to_string()))
710}
711
712fn dedupe_preserve_order(urls: Vec<String>) -> Vec<String> {
713 let mut seen = std::collections::HashSet::new();
714 let mut out = Vec::new();
715 for u in urls {
716 if seen.insert(u.clone()) {
717 out.push(u);
718 }
719 }
720 out
721}
722
723#[derive(Debug, Clone, Copy, PartialEq, Eq)]
724enum FinalizeResult {
725 Done,
726 SkippedAlreadyExists,
727}
728
729async fn finalize_download(temp_path: &Path, final_path: &Path) -> Result<FinalizeResult> {
730 if final_path.exists() {
731 let _ = tokio::fs::remove_file(temp_path).await;
732 return Ok(FinalizeResult::SkippedAlreadyExists);
733 }
734
735 match tokio::fs::rename(temp_path, final_path).await {
736 Ok(()) => Ok(FinalizeResult::Done),
737 Err(rename_err) if is_cross_device_rename_error(&rename_err) => {
738 tokio::fs::copy(temp_path, final_path)
739 .await
740 .with_context(|| {
741 format!(
742 "Could not copy temp ROM {} to final destination {}",
743 temp_path.display(),
744 final_path.display()
745 )
746 })?;
747 let file = tokio::fs::File::open(final_path).await.with_context(|| {
748 format!(
749 "Could not open finalized ROM for sync: {}",
750 final_path.display()
751 )
752 })?;
753 file.sync_all().await.with_context(|| {
754 format!(
755 "Could not sync finalized ROM to disk: {}",
756 final_path.display()
757 )
758 })?;
759 tokio::fs::remove_file(temp_path).await.with_context(|| {
760 format!(
761 "Could not remove temp ROM after copy: {}",
762 temp_path.display()
763 )
764 })?;
765 Ok(FinalizeResult::Done)
766 }
767 Err(rename_err) => Err(anyhow!(
768 "Could not move temp ROM {} to final destination {}: {}",
769 temp_path.display(),
770 final_path.display(),
771 rename_err
772 )),
773 }
774}
775
776fn is_cross_device_rename_error(err: &std::io::Error) -> bool {
777 matches!(err.raw_os_error(), Some(18) | Some(17))
778}
779
780fn sanitized_final_filename(fs_name: &str, rom_id: u64) -> String {
781 let sanitized = utils::sanitize_filename(fs_name);
782 if sanitized.trim().is_empty() {
783 format!("rom-{rom_id}.zip")
784 } else {
785 sanitized
786 }
787}
788
789#[cfg(test)]
790fn final_download_path_for_rom(roms_dir: &Path, rom: &Rom) -> PathBuf {
791 let platform_slug = rom
792 .platform_fs_slug
793 .clone()
794 .or_else(|| rom.platform_slug.clone())
795 .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
796 let console_dir = roms_dir.join(utils::sanitize_filename(&platform_slug));
797 console_dir.join(sanitized_final_filename(&rom.fs_name, rom.id))
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use crate::types::Rom;
804 use std::io::Write;
805 use std::time::{SystemTime, UNIX_EPOCH};
806 use zip::write::SimpleFileOptions;
807 use zip::ZipWriter;
808
809 fn rom_fixture_with_platform(platform_fs_slug: Option<&str>, fs_name: &str) -> Rom {
810 Rom {
811 id: 42,
812 platform_id: 7,
813 platform_slug: Some("nintendo-switch".to_string()),
814 platform_fs_slug: platform_fs_slug.map(ToString::to_string),
815 platform_custom_name: None,
816 platform_display_name: None,
817 fs_name: fs_name.to_string(),
818 fs_name_no_tags: "game".to_string(),
819 fs_name_no_ext: "game".to_string(),
820 fs_extension: "zip".to_string(),
821 fs_path: "/game.zip".to_string(),
822 fs_size_bytes: 1,
823 name: "Game".to_string(),
824 slug: None,
825 summary: None,
826 path_cover_small: None,
827 path_cover_large: None,
828 url_cover: None,
829 has_manual: false,
830 path_manual: None,
831 url_manual: None,
832 is_unidentified: false,
833 is_identified: true,
834 files: Vec::new(),
835 }
836 }
837
838 #[test]
839 fn extras_job_percent_tracks_completed_items() {
840 let mut j = ExtrasJob::new(1, "Zelda".into(), "NES".into(), 4);
841 assert_eq!(j.percent(), 0);
842 j.completed_items = 2;
843 assert_eq!(j.percent(), 50);
844 j.completed_items = 4;
845 assert_eq!(j.percent(), 100);
846 }
847
848 #[test]
849 fn finalize_extras_job_status_reflects_failures() {
850 use crate::core::extras::DownloadAssetKind;
851
852 let ok = ExtrasItemResult {
853 title: "a".into(),
854 kind: DownloadAssetKind::Cover,
855 ok: true,
856 error: None,
857 };
858 let bad = ExtrasItemResult {
859 title: "b".into(),
860 kind: DownloadAssetKind::Manual,
861 ok: false,
862 error: Some("e".into()),
863 };
864 assert_eq!(
865 super::finalize_extras_job_status(&[ok.clone(), ok.clone()]),
866 ExtrasJobStatus::Done
867 );
868 assert_eq!(
869 super::finalize_extras_job_status(&[bad.clone(), bad.clone()]),
870 ExtrasJobStatus::AllFailed
871 );
872 assert_eq!(
873 super::finalize_extras_job_status(&[ok, bad]),
874 ExtrasJobStatus::PartialFailure(1)
875 );
876 }
877
878 #[test]
879 fn unique_zip_path_skips_existing_files() {
880 let ts = SystemTime::now()
881 .duration_since(UNIX_EPOCH)
882 .unwrap()
883 .as_nanos();
884 let dir = std::env::temp_dir().join(format!("romm-dl-test-{ts}"));
885 std::fs::create_dir_all(&dir).unwrap();
886 let p1 = dir.join("game.zip");
887 std::fs::File::create(&p1).unwrap().write_all(b"x").unwrap();
888 let p2 = unique_zip_path(&dir, "game");
889 assert_eq!(p2.file_name().unwrap(), "game__2.zip");
890 let _ = std::fs::remove_file(&p1);
891 let _ = std::fs::remove_dir(&dir);
892 }
893
894 #[test]
895 fn resolve_download_directory_rejects_empty_configured_path() {
896 let err = resolve_download_directory_from_inputs(Some(" "), None)
897 .expect_err("empty configured path should be rejected");
898 assert!(
899 err.to_string().contains("cannot be empty"),
900 "unexpected error: {err:#}"
901 );
902 }
903
904 #[test]
905 fn resolve_download_directory_creates_missing_nested_directory() {
906 let ts = SystemTime::now()
907 .duration_since(UNIX_EPOCH)
908 .unwrap()
909 .as_nanos();
910 let base = std::env::temp_dir().join(format!("romm-dl-resolve-{ts}"));
911 let nested = base.join("a").join("b").join("c");
912 let nested_str = nested.to_string_lossy().to_string();
913
914 let resolved = resolve_download_directory_from_inputs(Some(&nested_str), None)
915 .expect("expected missing directory to be created");
916
917 assert!(resolved.is_dir(), "resolved path must be a directory");
918 assert!(nested.is_dir(), "nested path should be created");
919 let _ = std::fs::remove_dir_all(&base);
920 }
921
922 #[test]
923 fn resolve_download_directory_fails_when_target_is_a_file() {
924 let ts = SystemTime::now()
925 .duration_since(UNIX_EPOCH)
926 .unwrap()
927 .as_nanos();
928 let base = std::env::temp_dir().join(format!("romm-dl-file-target-{ts}"));
929 std::fs::create_dir_all(&base).expect("create base dir");
930 let file_path = base.join("not-a-dir.txt");
931 std::fs::write(&file_path, b"x").expect("create file");
932 let input = file_path.to_string_lossy().to_string();
933
934 let err = resolve_download_directory_from_inputs(Some(&input), None)
935 .expect_err("file target must fail");
936 assert!(
937 err.to_string().contains("not a directory"),
938 "unexpected error: {err:#}"
939 );
940
941 let _ = std::fs::remove_file(&file_path);
942 let _ = std::fs::remove_dir_all(&base);
943 }
944
945 #[test]
946 fn resolve_download_directory_env_override_takes_precedence() {
947 let ts = SystemTime::now()
948 .duration_since(UNIX_EPOCH)
949 .unwrap()
950 .as_nanos();
951 let configured = std::env::temp_dir().join(format!("romm-dl-configured-{ts}"));
952 let env_dir = std::env::temp_dir().join(format!("romm-dl-env-{ts}"));
953 let configured_str = configured.to_string_lossy().to_string();
954 let env_str = env_dir.to_string_lossy().to_string();
955
956 let resolved =
957 resolve_download_directory_from_inputs(Some(&configured_str), Some(&env_str))
958 .expect("env override should be used");
959
960 assert_eq!(resolved, env_dir);
961 assert!(env_dir.is_dir(), "env directory should be created");
962 assert!(
963 !configured.is_dir(),
964 "configured path should be ignored when env override is set"
965 );
966 let _ = std::fs::remove_dir_all(&env_dir);
967 }
968
969 #[test]
970 fn final_download_path_uses_console_folder_and_original_file_name() {
971 let rom = rom_fixture_with_platform(Some("switch"), "Zelda (USA).xci");
972 let base = PathBuf::from("/roms");
973 let out = final_download_path_for_rom(&base, &rom);
974 assert_eq!(out, PathBuf::from("/roms/switch/Zelda _USA_.xci"));
975 }
976
977 #[test]
978 fn rom_file_download_candidates_use_official_romsfiles_endpoint() {
979 let target = DownloadTarget {
980 kind: DownloadAssetKind::RomFile,
981 title: "Update".into(),
982 source_url: "/api/roms/11/files/content/update%2Ensp".into(),
983 source_query: Vec::new(),
984 destination: PathBuf::from("/tmp/update.nsp"),
985 expected_size_bytes: Some(11),
986 };
987
988 assert_eq!(
989 candidate_download_urls(&target),
990 vec![
991 "/api/roms/11/files/content/update%2Ensp".to_string(),
992 "/api/romsfiles/11/content/update%2Ensp".to_string(),
993 "/api/roms/files/11/content/update%2Ensp".to_string()
994 ]
995 );
996 }
997
998 #[test]
999 fn romsfiles_candidate_falls_forward_to_current_official_path() {
1000 let target = DownloadTarget {
1001 kind: DownloadAssetKind::RomFile,
1002 title: "Update".into(),
1003 source_url: "/api/romsfiles/11/content/update%2Ensp".into(),
1004 source_query: Vec::new(),
1005 destination: PathBuf::from("/tmp/update.nsp"),
1006 expected_size_bytes: Some(11),
1007 };
1008
1009 assert_eq!(
1010 candidate_download_urls(&target),
1011 vec![
1012 "/api/romsfiles/11/content/update%2Ensp".to_string(),
1013 "/api/roms/11/files/content/update%2Ensp".to_string(),
1014 "/api/roms/files/11/content/update%2Ensp".to_string()
1015 ]
1016 );
1017 }
1018
1019 #[test]
1020 fn legacy_roms_files_candidate_falls_forward_to_romsfiles() {
1021 let target = DownloadTarget {
1022 kind: DownloadAssetKind::RomFile,
1023 title: "Update".into(),
1024 source_url: "/api/roms/files/11/content/update%2Ensp".into(),
1025 source_query: Vec::new(),
1026 destination: PathBuf::from("/tmp/update.nsp"),
1027 expected_size_bytes: Some(11),
1028 };
1029
1030 assert_eq!(
1031 candidate_download_urls(&target),
1032 vec![
1033 "/api/roms/files/11/content/update%2Ensp".to_string(),
1034 "/api/roms/11/files/content/update%2Ensp".to_string(),
1035 "/api/romsfiles/11/content/update%2Ensp".to_string()
1036 ]
1037 );
1038 }
1039
1040 #[tokio::test]
1041 async fn prepare_target_removes_oversized_stale_rom_file() {
1042 let ts = SystemTime::now()
1043 .duration_since(UNIX_EPOCH)
1044 .unwrap()
1045 .as_nanos();
1046 let path = std::env::temp_dir().join(format!("romm-oversized-target-{ts}.nsp"));
1047 tokio::fs::write(&path, b"too-large").await.unwrap();
1048
1049 let target = DownloadTarget {
1050 kind: DownloadAssetKind::RomFile,
1051 title: "Base".into(),
1052 source_url: "/api/roms/1/files/content/base.nsp".into(),
1053 source_query: Vec::new(),
1054 destination: path.clone(),
1055 expected_size_bytes: Some(4),
1056 };
1057
1058 let skip = prepare_download_target_destination(&target).await.unwrap();
1059 assert!(!skip);
1060 assert!(!path.exists());
1061 }
1062
1063 #[tokio::test]
1064 async fn prepare_target_skips_exact_size_rom_file() {
1065 let ts = SystemTime::now()
1066 .duration_since(UNIX_EPOCH)
1067 .unwrap()
1068 .as_nanos();
1069 let path = std::env::temp_dir().join(format!("romm-exact-target-{ts}.nsp"));
1070 tokio::fs::write(&path, b"done").await.unwrap();
1071
1072 let target = DownloadTarget {
1073 kind: DownloadAssetKind::RomFile,
1074 title: "Base".into(),
1075 source_url: "/api/roms/1/files/content/base.nsp".into(),
1076 source_query: Vec::new(),
1077 destination: path.clone(),
1078 expected_size_bytes: Some(4),
1079 };
1080
1081 let skip = prepare_download_target_destination(&target).await.unwrap();
1082 assert!(skip);
1083 assert_eq!(tokio::fs::read(&path).await.unwrap(), b"done");
1084 let _ = tokio::fs::remove_file(path).await;
1085 }
1086
1087 #[tokio::test]
1088 async fn base_target_prepare_skips_exact_size_file() {
1089 let ts = SystemTime::now()
1090 .duration_since(UNIX_EPOCH)
1091 .unwrap()
1092 .as_nanos();
1093 let base = std::env::temp_dir().join(format!("romm-base-exact-{ts}"));
1094 let mut rom = rom_fixture_with_platform(Some("switch"), "pack.zip");
1095 rom.files = vec![crate::types::RomFile {
1096 id: 1,
1097 rom_id: rom.id,
1098 file_name: "base.nsp".into(),
1099 file_path: "/base.nsp".into(),
1100 file_size_bytes: 4,
1101 category: Some(crate::types::RomFileCategory::Game),
1102 }];
1103 let target = build_base_rom_file_targets(&rom, &base).remove(0);
1104 tokio::fs::create_dir_all(target.destination.parent().unwrap())
1105 .await
1106 .unwrap();
1107 tokio::fs::write(&target.destination, b"done")
1108 .await
1109 .unwrap();
1110
1111 let skip = prepare_download_target_destination(&target).await.unwrap();
1112 assert!(skip);
1113 assert_eq!(tokio::fs::read(&target.destination).await.unwrap(), b"done");
1114 let _ = tokio::fs::remove_dir_all(base).await;
1115 }
1116
1117 #[tokio::test]
1118 async fn base_target_prepare_removes_oversized_file() {
1119 let ts = SystemTime::now()
1120 .duration_since(UNIX_EPOCH)
1121 .unwrap()
1122 .as_nanos();
1123 let base = std::env::temp_dir().join(format!("romm-base-oversized-{ts}"));
1124 let mut rom = rom_fixture_with_platform(Some("switch"), "pack.zip");
1125 rom.files = vec![crate::types::RomFile {
1126 id: 1,
1127 rom_id: rom.id,
1128 file_name: "base.nsp".into(),
1129 file_path: "/base.nsp".into(),
1130 file_size_bytes: 4,
1131 category: Some(crate::types::RomFileCategory::Game),
1132 }];
1133 let target = build_base_rom_file_targets(&rom, &base).remove(0);
1134 tokio::fs::create_dir_all(target.destination.parent().unwrap())
1135 .await
1136 .unwrap();
1137 tokio::fs::write(&target.destination, b"too-large")
1138 .await
1139 .unwrap();
1140
1141 let skip = prepare_download_target_destination(&target).await.unwrap();
1142 assert!(!skip);
1143 assert!(!target.destination.exists());
1144 let _ = tokio::fs::remove_dir_all(base).await;
1145 }
1146
1147 #[tokio::test]
1148 async fn finalize_download_skips_when_final_exists() {
1149 let ts = SystemTime::now()
1150 .duration_since(UNIX_EPOCH)
1151 .unwrap()
1152 .as_nanos();
1153 let base = std::env::temp_dir().join(format!("romm-finalize-skip-{ts}"));
1154 std::fs::create_dir_all(&base).unwrap();
1155 let temp = base.join("temp.part");
1156 let final_path = base.join("final.zip");
1157 std::fs::write(&temp, b"temp").unwrap();
1158 std::fs::write(&final_path, b"existing").unwrap();
1159
1160 let result = finalize_download(&temp, &final_path).await.unwrap();
1161 assert_eq!(result, super::FinalizeResult::SkippedAlreadyExists);
1162 assert!(
1163 !temp.exists(),
1164 "temp file should be removed when final destination exists"
1165 );
1166
1167 let _ = std::fs::remove_file(&final_path);
1168 let _ = std::fs::remove_dir_all(&base);
1169 }
1170
1171 #[test]
1172 fn extract_zip_archive_writes_files_to_destination() {
1173 let ts = SystemTime::now()
1174 .duration_since(UNIX_EPOCH)
1175 .unwrap()
1176 .as_nanos();
1177 let base = std::env::temp_dir().join(format!("romm-extract-{ts}"));
1178 let zip_path = base.join("sample.zip");
1179 let out_dir = base.join("out");
1180 std::fs::create_dir_all(&base).unwrap();
1181
1182 let zip_file = std::fs::File::create(&zip_path).unwrap();
1183 let mut writer = ZipWriter::new(zip_file);
1184 writer
1185 .start_file("nested/game.rom", SimpleFileOptions::default())
1186 .unwrap();
1187 writer.write_all(b"rom-bytes").unwrap();
1188 writer.finish().unwrap();
1189
1190 extract_zip_archive(&zip_path, &out_dir).unwrap();
1191
1192 let extracted = out_dir.join("nested").join("game.rom");
1193 assert!(
1194 extracted.exists(),
1195 "expected extracted file at {:?}",
1196 extracted
1197 );
1198 let data = std::fs::read(&extracted).unwrap();
1199 assert_eq!(data, b"rom-bytes");
1200
1201 let _ = std::fs::remove_dir_all(&base);
1202 }
1203}