use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::config::{default_theme_id, Config, RomsLayoutConfig, SaveSyncConfig};
use crate::core::download::extras_job::finalize_extras_job_status;
use crate::core::download::paths::resolve_download_directory_from_inputs;
use crate::core::download::transfer::{
candidate_download_urls, final_download_path_for_rom, finalize_download, FinalizeResult,
};
use crate::core::download::{
extract_zip_archive, prepare_download_target_destination, resolve_console_roms_dir,
resolve_console_save_dir, resolve_game_save_dir, unique_zip_path, ExtrasItemResult, ExtrasJob,
ExtrasJobStatus,
};
use crate::core::extras::{build_base_rom_file_targets, DownloadAssetKind, DownloadTarget};
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!(
finalize_extras_job_status(&[ok.clone(), ok.clone()]),
ExtrasJobStatus::Done
);
assert_eq!(
finalize_extras_job_status(&[bad.clone(), bad.clone()]),
ExtrasJobStatus::AllFailed
);
assert_eq!(
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 valid") || 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 resolve_console_roms_dir_uses_platform_slug_subfolder_by_default() {
let rom = rom_fixture_with_platform(Some("switch"), "game.zip");
let layout = RomsLayoutConfig::default();
let dir = resolve_console_roms_dir(&layout, Path::new("/roms"), &rom).unwrap();
assert_eq!(dir, PathBuf::from("/roms/switch"));
}
#[test]
fn resolve_console_roms_dir_uses_custom_mapped_path() {
let rom = rom_fixture_with_platform(Some("switch"), "game.zip");
let mut layout = RomsLayoutConfig::default();
let custom = std::env::temp_dir().join(format!(
"romm-cli-manual-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&custom).unwrap();
layout
.platform_dirs
.insert(rom.platform_id, custom.display().to_string());
let dir = resolve_console_roms_dir(&layout, Path::new("/roms"), &rom).unwrap();
assert_eq!(dir, custom);
let _ = std::fs::remove_dir_all(custom);
}
#[test]
fn resolve_console_roms_dir_falls_back_for_unmapped_platform() {
let rom = rom_fixture_with_platform(Some("switch"), "game.zip");
let layout = RomsLayoutConfig::default();
let dir = resolve_console_roms_dir(&layout, Path::new("/roms"), &rom).unwrap();
assert_eq!(dir, PathBuf::from("/roms/switch"));
}
#[test]
fn resolve_console_save_dir_uses_platform_slug_subfolder_by_default() {
let save_sync = SaveSyncConfig::default();
let dir = resolve_console_save_dir(
&save_sync,
Path::new("/saves"),
7,
Some("switch"),
Some("nintendo-switch"),
)
.unwrap();
assert_eq!(dir, PathBuf::from("/saves/switch"));
}
#[test]
fn resolve_console_save_dir_uses_custom_mapped_path() {
let mut save_sync = SaveSyncConfig::default();
let custom = std::env::temp_dir().join(format!(
"romm-cli-save-custom-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&custom).unwrap();
save_sync
.platform_dirs
.insert(7, custom.display().to_string());
let dir =
resolve_console_save_dir(&save_sync, Path::new("/saves"), 7, Some("switch"), None).unwrap();
assert_eq!(dir, custom);
let _ = std::fs::remove_dir_all(custom);
}
#[test]
fn resolve_console_save_dir_ignores_empty_override() {
let mut save_sync = SaveSyncConfig::default();
save_sync.platform_dirs.insert(7, " ".to_string());
let dir =
resolve_console_save_dir(&save_sync, Path::new("/saves"), 7, Some("switch"), None).unwrap();
assert_eq!(dir, PathBuf::from("/saves/switch"));
}
#[test]
fn resolve_game_save_dir_appends_game_folder() {
let rom = rom_fixture_with_platform(Some("switch"), "game.zip");
let cfg = Config {
base_url: "http://example.test".into(),
download_dir: "/roms".into(),
use_https: false,
auth: None,
extras_defaults: Default::default(),
save_sync: SaveSyncConfig {
save_dir: Some("/saves".into()),
device_id: None,
platform_dirs: HashMap::new(),
},
roms_layout: Default::default(),
theme: default_theme_id(),
tui_layout: Default::default(),
};
let dir = resolve_game_save_dir(&cfg, &rom).unwrap();
assert_eq!(dir, PathBuf::from("/saves/switch/Game"));
}
#[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, &RomsLayoutConfig::default(), base.as_path())
.unwrap()
.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, &RomsLayoutConfig::default(), base.as_path())
.unwrap()
.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, 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);
}
#[test]
fn extract_zip_archive_rejects_path_traversal_entries() {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!("romm-extract-traversal-{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("../escape.rom", SimpleFileOptions::default())
.unwrap();
writer.write_all(b"escape").unwrap();
writer.finish().unwrap();
assert!(extract_zip_archive(&zip_path, &out_dir).is_err());
assert!(
!base.join("escape.rom").exists(),
"archive entry escaped extraction directory"
);
}