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