use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use crate::client::RommClient;
use crate::core::extras::build_base_rom_file_targets;
use crate::core::extras::{DownloadAssetKind, DownloadTarget};
use crate::core::interrupt::is_cancelled_error;
use crate::core::utils;
use crate::types::Rom;
use anyhow::{anyhow, Context, Result};
use std::fs::File;
use zip::ZipArchive;
pub fn resolve_download_directory(configured_download_dir: Option<&str>) -> Result<PathBuf> {
let env_override = std::env::var("ROMM_ROMS_DIR")
.ok()
.or_else(|| std::env::var("ROMM_DOWNLOAD_DIR").ok());
resolve_download_directory_from_inputs(configured_download_dir, env_override.as_deref())
}
pub fn validate_configured_download_directory(configured_download_dir: &str) -> Result<PathBuf> {
resolve_download_directory_from_inputs(Some(configured_download_dir), None)
}
pub fn download_directory() -> PathBuf {
std::env::var("ROMM_ROMS_DIR")
.or_else(|_| std::env::var("ROMM_DOWNLOAD_DIR"))
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./downloads"))
}
fn resolve_download_directory_from_inputs(
configured_download_dir: Option<&str>,
env_override: Option<&str>,
) -> Result<PathBuf> {
let raw = env_override
.or(configured_download_dir)
.map(str::trim)
.ok_or_else(|| {
anyhow!("ROMs directory is not configured. Run setup to set a ROMs path.")
})?;
if raw.is_empty() {
return Err(anyhow!("ROMs directory cannot be empty"));
}
let input_path = PathBuf::from(raw);
let normalized = if input_path.is_relative() {
std::env::current_dir()
.context("Could not resolve current working directory")?
.join(input_path)
} else {
input_path
};
if normalized.exists() && !normalized.is_dir() {
return Err(anyhow!(
"Download path is not a directory: {}",
normalized.display()
));
}
std::fs::create_dir_all(&normalized).with_context(|| {
format!(
"Could not create download directory {}",
normalized.display()
)
})?;
let probe_name = format!(
".romm-write-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let probe_path = normalized.join(probe_name);
let probe = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe_path)
.with_context(|| format!("ROMs directory is not writable: {}", normalized.display()))?;
drop(probe);
let _ = std::fs::remove_file(&probe_path);
Ok(normalized)
}
pub fn unique_zip_path(dir: &Path, stem: &str) -> PathBuf {
let mut n = 1u32;
loop {
let name = if n == 1 {
format!("{}.zip", stem)
} else {
format!("{}__{}.zip", stem, n)
};
let p = dir.join(name);
if !p.exists() {
return p;
}
n = n.saturating_add(1);
}
}
pub fn extract_zip_archive(zip_path: &Path, destination_dir: &Path) -> Result<()> {
let zip_path = zip_path.to_path_buf();
let destination_dir = destination_dir.to_path_buf();
std::fs::create_dir_all(&destination_dir).with_context(|| {
format!(
"Could not create extraction directory {}",
destination_dir.display()
)
})?;
let file = File::open(&zip_path)
.with_context(|| format!("Could not open zip archive {}", zip_path.display()))?;
let mut archive = ZipArchive::new(file)
.with_context(|| format!("Invalid ZIP archive {}", zip_path.display()))?;
archive.extract(&destination_dir).with_context(|| {
format!(
"Could not extract archive into {}",
destination_dir.display()
)
})?;
Ok(())
}
#[derive(Debug, Clone)]
pub enum DownloadStatus {
Downloading,
Done,
SkippedAlreadyExists,
Cancelled,
FinalizeFailed(String),
Error(String),
}
#[derive(Debug, Clone)]
pub struct DownloadJob {
pub id: usize,
pub rom_id: u64,
pub name: String,
pub platform: String,
pub progress: f64,
pub status: DownloadStatus,
}
static NEXT_JOB_ID: AtomicUsize = AtomicUsize::new(0);
impl DownloadJob {
pub fn new(rom_id: u64, name: String, platform: String) -> Self {
Self {
id: NEXT_JOB_ID.fetch_add(1, Ordering::Relaxed),
rom_id,
name,
platform,
progress: 0.0,
status: DownloadStatus::Downloading,
}
}
pub fn percent(&self) -> u16 {
(self.progress * 100.0).round().min(100.0) as u16
}
}
#[derive(Debug, Clone)]
pub struct ExtrasItemResult {
pub title: String,
pub kind: DownloadAssetKind,
pub ok: bool,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExtrasJobStatus {
Running,
Done,
PartialFailure(usize),
AllFailed,
}
#[derive(Debug, Clone)]
pub struct ExtrasJob {
pub id: usize,
pub rom_id: u64,
pub name: String,
pub platform: String,
pub completed_items: usize,
pub total_items: usize,
pub status: ExtrasJobStatus,
pub item_results: Vec<ExtrasItemResult>,
}
static NEXT_EXTRAS_JOB_ID: AtomicUsize = AtomicUsize::new(0);
impl ExtrasJob {
pub fn new(rom_id: u64, name: String, platform: String, total_items: usize) -> Self {
Self {
id: NEXT_EXTRAS_JOB_ID.fetch_add(1, Ordering::Relaxed),
rom_id,
name,
platform,
completed_items: 0,
total_items,
status: ExtrasJobStatus::Running,
item_results: Vec::new(),
}
}
pub fn percent(&self) -> u16 {
if self.total_items == 0 {
return 100;
}
((self.completed_items.saturating_mul(100)) / self.total_items).min(100) as u16
}
}
fn finalize_extras_job_status(results: &[ExtrasItemResult]) -> ExtrasJobStatus {
let n = results.len();
if n == 0 {
return ExtrasJobStatus::Done;
}
let failures = results.iter().filter(|r| !r.ok).count();
if failures == 0 {
ExtrasJobStatus::Done
} else if failures == n {
ExtrasJobStatus::AllFailed
} else {
ExtrasJobStatus::PartialFailure(failures)
}
}
#[derive(Clone)]
pub struct DownloadManager {
jobs: Arc<Mutex<Vec<DownloadJob>>>,
extras_jobs: Arc<Mutex<Vec<ExtrasJob>>>,
}
impl Default for DownloadManager {
fn default() -> Self {
Self::new()
}
}
impl DownloadManager {
pub fn new() -> Self {
Self {
jobs: Arc::new(Mutex::new(Vec::new())),
extras_jobs: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn shared(&self) -> Arc<Mutex<Vec<DownloadJob>>> {
self.jobs.clone()
}
pub fn shared_extras(&self) -> Arc<Mutex<Vec<ExtrasJob>>> {
self.extras_jobs.clone()
}
pub fn start_download(
&self,
rom: &Rom,
client: RommClient,
configured_download_dir: Option<&str>,
) -> Result<()> {
let platform = rom
.platform_display_name
.as_deref()
.or(rom.platform_custom_name.as_deref())
.unwrap_or("—")
.to_string();
let job = DownloadJob::new(rom.id, rom.name.clone(), platform);
let job_id = job.id;
let rom_id = rom.id;
let fs_name = rom.fs_name.clone();
let final_console_slug = rom
.platform_fs_slug
.clone()
.or_else(|| rom.platform_slug.clone())
.unwrap_or_else(|| format!("platform-{}", rom.platform_id));
let final_name = sanitized_final_filename(&rom.fs_name, rom.id);
let rom_for_targets = rom.clone();
match self.jobs.lock() {
Ok(mut jobs) => jobs.push(job),
Err(err) => {
eprintln!("warning: download job list lock poisoned: {}", err);
return Err(anyhow!("download job list lock poisoned: {err}"));
}
}
let save_dir = resolve_download_directory(configured_download_dir)?;
let jobs = self.jobs.clone();
tokio::spawn(async move {
let temp_root = save_dir.join(".tmp");
if let Err(err) = tokio::fs::create_dir_all(&temp_root).await {
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.status = DownloadStatus::Error(format!(
"Could not create temp directory {}: {err}",
temp_root.display()
));
}
}
return;
}
let console_dir = save_dir.join(utils::sanitize_filename(&final_console_slug));
let final_path = console_dir.join(final_name.clone());
if let Err(err) = tokio::fs::create_dir_all(&console_dir).await {
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.status = DownloadStatus::Error(format!(
"Could not create console directory {}: {err}",
console_dir.display()
));
}
}
return;
}
let base_targets = build_base_rom_file_targets(&rom_for_targets, &save_dir);
if !base_targets.is_empty() {
let total_targets = base_targets.len() as f64;
for (idx, target) in base_targets.iter().enumerate() {
let client = client.clone();
let mut progress = {
let jobs = jobs.clone();
move |received: u64, total: u64| {
let file_ratio = if total > 0 {
received as f64 / total as f64
} else {
0.0
};
let total_ratio = ((idx as f64) + file_ratio) / total_targets;
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.progress = total_ratio.min(1.0);
}
}
}
};
match prepare_download_target_destination(target).await {
Ok(true) => {
progress(
target.expected_size_bytes.unwrap_or(0),
target.expected_size_bytes.unwrap_or(0),
);
continue;
}
Ok(false) => {}
Err(err) => {
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.status = DownloadStatus::Error(err.to_string());
}
}
return;
}
}
if let Err(final_err) =
download_target_with_fallback(&client, target, |_, _| false, &mut progress)
.await
{
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.status = DownloadStatus::Error(final_err.to_string());
}
}
return;
}
}
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.status = DownloadStatus::Done;
j.progress = 1.0;
}
}
return;
}
if final_path.exists() {
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.status = DownloadStatus::SkippedAlreadyExists;
j.progress = 1.0;
}
}
return;
}
let temp_name = format!(
"rom-{}-{}-{}.part",
rom_id,
utils::sanitize_filename(&fs_name),
job_id
);
let temp_path = temp_root.join(temp_name);
let on_progress = |received: u64, total: u64| {
let p = if total > 0 {
received as f64 / total as f64
} else {
0.0
};
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.progress = p;
}
}
};
let download_result = client.download_rom(rom_id, &temp_path, on_progress).await;
if download_result.is_err() {
let _ = tokio::fs::remove_file(&temp_path).await;
}
match download_result {
Ok(()) => match finalize_download(&temp_path, &final_path).await {
Ok(FinalizeResult::Done) => {
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.status = DownloadStatus::Done;
j.progress = 1.0;
}
}
}
Ok(FinalizeResult::SkippedAlreadyExists) => {
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.status = DownloadStatus::SkippedAlreadyExists;
j.progress = 1.0;
}
}
}
Err(err) => {
let _ = tokio::fs::remove_file(&temp_path).await;
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.status = DownloadStatus::FinalizeFailed(err.to_string());
}
}
}
},
Err(e) => {
if let Ok(mut list) = jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
if is_cancelled_error(&e) {
j.status = DownloadStatus::Cancelled;
} else {
j.status = DownloadStatus::Error(e.to_string());
}
}
}
}
}
});
Ok(())
}
pub fn start_extras_download(
&self,
rom: &Rom,
selected: Vec<DownloadTarget>,
client: RommClient,
configured_download_dir: Option<&str>,
) -> Result<()> {
if selected.is_empty() {
return Err(anyhow!("no extras targets selected"));
}
let _ = resolve_download_directory(configured_download_dir)?;
let platform = rom
.platform_display_name
.as_deref()
.or(rom.platform_custom_name.as_deref())
.unwrap_or("—")
.to_string();
let total_items = selected.len();
let job = ExtrasJob::new(rom.id, rom.name.clone(), platform, total_items);
let job_id = job.id;
match self.extras_jobs.lock() {
Ok(mut jobs) => jobs.push(job),
Err(err) => {
eprintln!("warning: extras job list lock poisoned: {}", err);
return Err(anyhow!("extras job list lock poisoned: {err}"));
}
}
let extras_jobs = self.extras_jobs.clone();
tokio::spawn(async move {
let semaphore = Arc::new(tokio::sync::Semaphore::new(4));
let mut handles = Vec::new();
for target in selected {
let permit = match semaphore.clone().acquire_owned().await {
Ok(p) => p,
Err(_) => break,
};
let client = client.clone();
let extras_jobs = extras_jobs.clone();
handles.push(tokio::spawn(async move {
let mut on_progress = |_r: u64, _t: u64| {};
let download_result = match prepare_download_target_destination(&target).await {
Ok(true) => Ok(()),
Ok(false) => {
download_target_with_fallback(
&client,
&target,
|_, _| false,
&mut on_progress,
)
.await
}
Err(err) => Err(err),
};
drop(permit);
let (ok, err) = match download_result {
Ok(()) => (true, None),
Err(e) => (false, Some(e.to_string())),
};
let item = ExtrasItemResult {
title: target.title.clone(),
kind: target.kind,
ok,
error: err,
};
if let Ok(mut list) = extras_jobs.lock() {
if let Some(j) = list.iter_mut().find(|j| j.id == job_id) {
j.completed_items = j.completed_items.saturating_add(1);
j.item_results.push(item);
if j.completed_items >= j.total_items {
j.status = finalize_extras_job_status(&j.item_results);
}
}
}
}));
}
for h in handles {
let _ = h.await;
}
});
Ok(())
}
}
pub async fn prepare_download_target_destination(target: &DownloadTarget) -> Result<bool> {
let Some(expected_size) = target.expected_size_bytes else {
return Ok(false);
};
if expected_size == 0 {
return Ok(false);
}
let Ok(metadata) = tokio::fs::metadata(&target.destination).await else {
return Ok(false);
};
let current_size = metadata.len();
if current_size == expected_size {
return Ok(true);
}
if current_size > expected_size {
tokio::fs::remove_file(&target.destination)
.await
.with_context(|| {
format!(
"remove oversized stale download {} ({} > {} bytes)",
target.destination.display(),
current_size,
expected_size
)
})?;
}
Ok(false)
}
async fn download_target_with_fallback<F, C>(
client: &RommClient,
target: &DownloadTarget,
mut is_cancelled: C,
on_progress: &mut F,
) -> Result<()>
where
F: FnMut(u64, u64) + Send,
C: FnMut(u64, u64) -> bool + Send,
{
let urls = candidate_download_urls(target);
let mut last_err: Option<anyhow::Error> = None;
for url in urls {
match client
.download_url_with_query_with_cancel(
&url,
&target.source_query,
&target.destination,
&mut is_cancelled,
on_progress,
)
.await
{
Ok(()) => return Ok(()),
Err(err) => {
if !err.to_string().contains("404 Not Found") {
return Err(err);
}
last_err = Some(err);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow!("download failed without error details")))
}
fn candidate_download_urls(target: &DownloadTarget) -> Vec<String> {
let mut out = vec![target.source_url.clone()];
if let Some((file_id, file_name)) = parse_current_rom_file_content_path(&target.source_url) {
out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
} else if let Some((file_id, file_name)) = parse_romsfiles_path(&target.source_url) {
out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
} else if let Some((file_id, file_name)) = parse_legacy_roms_files_path(&target.source_url) {
out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
}
dedupe_preserve_order(out)
}
fn parse_current_rom_file_content_path(url: &str) -> Option<(String, String)> {
let prefix = "/api/roms/";
let marker = "/files/content/";
let rest = url.strip_prefix(prefix)?;
let (id, name) = rest.split_once(marker)?;
Some((id.to_string(), name.to_string()))
}
fn parse_romsfiles_path(url: &str) -> Option<(String, String)> {
let prefix = "/api/romsfiles/";
let marker = "/content/";
let rest = url.strip_prefix(prefix)?;
let (id, name) = rest.split_once(marker)?;
Some((id.to_string(), name.to_string()))
}
fn parse_legacy_roms_files_path(url: &str) -> Option<(String, String)> {
let prefix = "/api/roms/files/";
let marker = "/content/";
let rest = url.strip_prefix(prefix)?;
let (id, name) = rest.split_once(marker)?;
Some((id.to_string(), name.to_string()))
}
fn dedupe_preserve_order(urls: Vec<String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for u in urls {
if seen.insert(u.clone()) {
out.push(u);
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FinalizeResult {
Done,
SkippedAlreadyExists,
}
async fn finalize_download(temp_path: &Path, final_path: &Path) -> Result<FinalizeResult> {
if final_path.exists() {
let _ = tokio::fs::remove_file(temp_path).await;
return Ok(FinalizeResult::SkippedAlreadyExists);
}
match tokio::fs::rename(temp_path, final_path).await {
Ok(()) => Ok(FinalizeResult::Done),
Err(rename_err) if is_cross_device_rename_error(&rename_err) => {
tokio::fs::copy(temp_path, final_path)
.await
.with_context(|| {
format!(
"Could not copy temp ROM {} to final destination {}",
temp_path.display(),
final_path.display()
)
})?;
let file = tokio::fs::File::open(final_path).await.with_context(|| {
format!(
"Could not open finalized ROM for sync: {}",
final_path.display()
)
})?;
file.sync_all().await.with_context(|| {
format!(
"Could not sync finalized ROM to disk: {}",
final_path.display()
)
})?;
tokio::fs::remove_file(temp_path).await.with_context(|| {
format!(
"Could not remove temp ROM after copy: {}",
temp_path.display()
)
})?;
Ok(FinalizeResult::Done)
}
Err(rename_err) => Err(anyhow!(
"Could not move temp ROM {} to final destination {}: {}",
temp_path.display(),
final_path.display(),
rename_err
)),
}
}
fn is_cross_device_rename_error(err: &std::io::Error) -> bool {
matches!(err.raw_os_error(), Some(18) | Some(17))
}
fn sanitized_final_filename(fs_name: &str, rom_id: u64) -> String {
let sanitized = utils::sanitize_filename(fs_name);
if sanitized.trim().is_empty() {
format!("rom-{rom_id}.zip")
} else {
sanitized
}
}
#[cfg(test)]
fn final_download_path_for_rom(roms_dir: &Path, rom: &Rom) -> PathBuf {
let platform_slug = rom
.platform_fs_slug
.clone()
.or_else(|| rom.platform_slug.clone())
.unwrap_or_else(|| format!("platform-{}", rom.platform_id));
let console_dir = roms_dir.join(utils::sanitize_filename(&platform_slug));
console_dir.join(sanitized_final_filename(&rom.fs_name, rom.id))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Rom;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
fn rom_fixture_with_platform(platform_fs_slug: Option<&str>, fs_name: &str) -> Rom {
Rom {
id: 42,
platform_id: 7,
platform_slug: Some("nintendo-switch".to_string()),
platform_fs_slug: platform_fs_slug.map(ToString::to_string),
platform_custom_name: None,
platform_display_name: None,
fs_name: fs_name.to_string(),
fs_name_no_tags: "game".to_string(),
fs_name_no_ext: "game".to_string(),
fs_extension: "zip".to_string(),
fs_path: "/game.zip".to_string(),
fs_size_bytes: 1,
name: "Game".to_string(),
slug: None,
summary: None,
path_cover_small: None,
path_cover_large: None,
url_cover: None,
has_manual: false,
path_manual: None,
url_manual: None,
is_unidentified: false,
is_identified: true,
files: Vec::new(),
}
}
#[test]
fn extras_job_percent_tracks_completed_items() {
let mut j = ExtrasJob::new(1, "Zelda".into(), "NES".into(), 4);
assert_eq!(j.percent(), 0);
j.completed_items = 2;
assert_eq!(j.percent(), 50);
j.completed_items = 4;
assert_eq!(j.percent(), 100);
}
#[test]
fn finalize_extras_job_status_reflects_failures() {
use crate::core::extras::DownloadAssetKind;
let ok = ExtrasItemResult {
title: "a".into(),
kind: DownloadAssetKind::Cover,
ok: true,
error: None,
};
let bad = ExtrasItemResult {
title: "b".into(),
kind: DownloadAssetKind::Manual,
ok: false,
error: Some("e".into()),
};
assert_eq!(
super::finalize_extras_job_status(&[ok.clone(), ok.clone()]),
ExtrasJobStatus::Done
);
assert_eq!(
super::finalize_extras_job_status(&[bad.clone(), bad.clone()]),
ExtrasJobStatus::AllFailed
);
assert_eq!(
super::finalize_extras_job_status(&[ok, bad]),
ExtrasJobStatus::PartialFailure(1)
);
}
#[test]
fn unique_zip_path_skips_existing_files() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("romm-dl-test-{ts}"));
std::fs::create_dir_all(&dir).unwrap();
let p1 = dir.join("game.zip");
std::fs::File::create(&p1).unwrap().write_all(b"x").unwrap();
let p2 = unique_zip_path(&dir, "game");
assert_eq!(p2.file_name().unwrap(), "game__2.zip");
let _ = std::fs::remove_file(&p1);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn resolve_download_directory_rejects_empty_configured_path() {
let err = resolve_download_directory_from_inputs(Some(" "), None)
.expect_err("empty configured path should be rejected");
assert!(
err.to_string().contains("cannot be empty"),
"unexpected error: {err:#}"
);
}
#[test]
fn resolve_download_directory_creates_missing_nested_directory() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!("romm-dl-resolve-{ts}"));
let nested = base.join("a").join("b").join("c");
let nested_str = nested.to_string_lossy().to_string();
let resolved = resolve_download_directory_from_inputs(Some(&nested_str), None)
.expect("expected missing directory to be created");
assert!(resolved.is_dir(), "resolved path must be a directory");
assert!(nested.is_dir(), "nested path should be created");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn resolve_download_directory_fails_when_target_is_a_file() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!("romm-dl-file-target-{ts}"));
std::fs::create_dir_all(&base).expect("create base dir");
let file_path = base.join("not-a-dir.txt");
std::fs::write(&file_path, b"x").expect("create file");
let input = file_path.to_string_lossy().to_string();
let err = resolve_download_directory_from_inputs(Some(&input), None)
.expect_err("file target must fail");
assert!(
err.to_string().contains("not a directory"),
"unexpected error: {err:#}"
);
let _ = std::fs::remove_file(&file_path);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn resolve_download_directory_env_override_takes_precedence() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let configured = std::env::temp_dir().join(format!("romm-dl-configured-{ts}"));
let env_dir = std::env::temp_dir().join(format!("romm-dl-env-{ts}"));
let configured_str = configured.to_string_lossy().to_string();
let env_str = env_dir.to_string_lossy().to_string();
let resolved =
resolve_download_directory_from_inputs(Some(&configured_str), Some(&env_str))
.expect("env override should be used");
assert_eq!(resolved, env_dir);
assert!(env_dir.is_dir(), "env directory should be created");
assert!(
!configured.is_dir(),
"configured path should be ignored when env override is set"
);
let _ = std::fs::remove_dir_all(&env_dir);
}
#[test]
fn final_download_path_uses_console_folder_and_original_file_name() {
let rom = rom_fixture_with_platform(Some("switch"), "Zelda (USA).xci");
let base = PathBuf::from("/roms");
let out = final_download_path_for_rom(&base, &rom);
assert_eq!(out, PathBuf::from("/roms/switch/Zelda _USA_.xci"));
}
#[test]
fn rom_file_download_candidates_use_official_romsfiles_endpoint() {
let target = DownloadTarget {
kind: DownloadAssetKind::RomFile,
title: "Update".into(),
source_url: "/api/roms/11/files/content/update%2Ensp".into(),
source_query: Vec::new(),
destination: PathBuf::from("/tmp/update.nsp"),
expected_size_bytes: Some(11),
};
assert_eq!(
candidate_download_urls(&target),
vec![
"/api/roms/11/files/content/update%2Ensp".to_string(),
"/api/romsfiles/11/content/update%2Ensp".to_string(),
"/api/roms/files/11/content/update%2Ensp".to_string()
]
);
}
#[test]
fn romsfiles_candidate_falls_forward_to_current_official_path() {
let target = DownloadTarget {
kind: DownloadAssetKind::RomFile,
title: "Update".into(),
source_url: "/api/romsfiles/11/content/update%2Ensp".into(),
source_query: Vec::new(),
destination: PathBuf::from("/tmp/update.nsp"),
expected_size_bytes: Some(11),
};
assert_eq!(
candidate_download_urls(&target),
vec![
"/api/romsfiles/11/content/update%2Ensp".to_string(),
"/api/roms/11/files/content/update%2Ensp".to_string(),
"/api/roms/files/11/content/update%2Ensp".to_string()
]
);
}
#[test]
fn legacy_roms_files_candidate_falls_forward_to_romsfiles() {
let target = DownloadTarget {
kind: DownloadAssetKind::RomFile,
title: "Update".into(),
source_url: "/api/roms/files/11/content/update%2Ensp".into(),
source_query: Vec::new(),
destination: PathBuf::from("/tmp/update.nsp"),
expected_size_bytes: Some(11),
};
assert_eq!(
candidate_download_urls(&target),
vec![
"/api/roms/files/11/content/update%2Ensp".to_string(),
"/api/roms/11/files/content/update%2Ensp".to_string(),
"/api/romsfiles/11/content/update%2Ensp".to_string()
]
);
}
#[tokio::test]
async fn prepare_target_removes_oversized_stale_rom_file() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("romm-oversized-target-{ts}.nsp"));
tokio::fs::write(&path, b"too-large").await.unwrap();
let target = DownloadTarget {
kind: DownloadAssetKind::RomFile,
title: "Base".into(),
source_url: "/api/roms/1/files/content/base.nsp".into(),
source_query: Vec::new(),
destination: path.clone(),
expected_size_bytes: Some(4),
};
let skip = prepare_download_target_destination(&target).await.unwrap();
assert!(!skip);
assert!(!path.exists());
}
#[tokio::test]
async fn prepare_target_skips_exact_size_rom_file() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("romm-exact-target-{ts}.nsp"));
tokio::fs::write(&path, b"done").await.unwrap();
let target = DownloadTarget {
kind: DownloadAssetKind::RomFile,
title: "Base".into(),
source_url: "/api/roms/1/files/content/base.nsp".into(),
source_query: Vec::new(),
destination: path.clone(),
expected_size_bytes: Some(4),
};
let skip = prepare_download_target_destination(&target).await.unwrap();
assert!(skip);
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"done");
let _ = tokio::fs::remove_file(path).await;
}
#[tokio::test]
async fn base_target_prepare_skips_exact_size_file() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!("romm-base-exact-{ts}"));
let mut rom = rom_fixture_with_platform(Some("switch"), "pack.zip");
rom.files = vec![crate::types::RomFile {
id: 1,
rom_id: rom.id,
file_name: "base.nsp".into(),
file_path: "/base.nsp".into(),
file_size_bytes: 4,
category: Some(crate::types::RomFileCategory::Game),
}];
let target = build_base_rom_file_targets(&rom, &base).remove(0);
tokio::fs::create_dir_all(target.destination.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&target.destination, b"done")
.await
.unwrap();
let skip = prepare_download_target_destination(&target).await.unwrap();
assert!(skip);
assert_eq!(tokio::fs::read(&target.destination).await.unwrap(), b"done");
let _ = tokio::fs::remove_dir_all(base).await;
}
#[tokio::test]
async fn base_target_prepare_removes_oversized_file() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!("romm-base-oversized-{ts}"));
let mut rom = rom_fixture_with_platform(Some("switch"), "pack.zip");
rom.files = vec![crate::types::RomFile {
id: 1,
rom_id: rom.id,
file_name: "base.nsp".into(),
file_path: "/base.nsp".into(),
file_size_bytes: 4,
category: Some(crate::types::RomFileCategory::Game),
}];
let target = build_base_rom_file_targets(&rom, &base).remove(0);
tokio::fs::create_dir_all(target.destination.parent().unwrap())
.await
.unwrap();
tokio::fs::write(&target.destination, b"too-large")
.await
.unwrap();
let skip = prepare_download_target_destination(&target).await.unwrap();
assert!(!skip);
assert!(!target.destination.exists());
let _ = tokio::fs::remove_dir_all(base).await;
}
#[tokio::test]
async fn finalize_download_skips_when_final_exists() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!("romm-finalize-skip-{ts}"));
std::fs::create_dir_all(&base).unwrap();
let temp = base.join("temp.part");
let final_path = base.join("final.zip");
std::fs::write(&temp, b"temp").unwrap();
std::fs::write(&final_path, b"existing").unwrap();
let result = finalize_download(&temp, &final_path).await.unwrap();
assert_eq!(result, super::FinalizeResult::SkippedAlreadyExists);
assert!(
!temp.exists(),
"temp file should be removed when final destination exists"
);
let _ = std::fs::remove_file(&final_path);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn extract_zip_archive_writes_files_to_destination() {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!("romm-extract-{ts}"));
let zip_path = base.join("sample.zip");
let out_dir = base.join("out");
std::fs::create_dir_all(&base).unwrap();
let zip_file = std::fs::File::create(&zip_path).unwrap();
let mut writer = ZipWriter::new(zip_file);
writer
.start_file("nested/game.rom", SimpleFileOptions::default())
.unwrap();
writer.write_all(b"rom-bytes").unwrap();
writer.finish().unwrap();
extract_zip_archive(&zip_path, &out_dir).unwrap();
let extracted = out_dir.join("nested").join("game.rom");
assert!(
extracted.exists(),
"expected extracted file at {:?}",
extracted
);
let data = std::fs::read(&extracted).unwrap();
assert_eq!(data, b"rom-bytes");
let _ = std::fs::remove_dir_all(&base);
}
}