1use anyhow::{anyhow, Result};
2use clap::{Args, Subcommand, ValueEnum};
3use dialoguer::Confirm;
4use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
5use std::io::{self, IsTerminal};
6use std::path::PathBuf;
7use std::sync::Arc;
8use tokio::sync::Semaphore;
9
10use crate::client::RommClient;
11use crate::core::download::{
12 download_directory, extract_zip_archive, prepare_download_target_destination, unique_zip_path,
13};
14use crate::core::extras::{
15 build_base_rom_file_targets, build_extras_targets, build_update_dlc_targets_for_rom,
16 DownloadTarget,
17};
18use crate::core::interrupt::{cancelled_error, is_cancelled_error, InterruptContext};
19use crate::core::utils;
20use crate::endpoints::roms::GetRoms;
21use crate::services::{PlatformService, RomService};
22use crate::types::Platform;
23
24const DEFAULT_CONCURRENCY: usize = 4;
26
27fn parse_nonzero_usize(value: &str) -> std::result::Result<usize, String> {
28 let parsed = value
29 .parse::<usize>()
30 .map_err(|err| format!("invalid number: {err}"))?;
31 if parsed == 0 {
32 Err("must be at least 1".to_string())
33 } else {
34 Ok(parsed)
35 }
36}
37
38#[derive(Args, Debug)]
40pub struct DownloadCommand {
41 pub rom_id: Option<u64>,
43
44 #[command(subcommand)]
45 pub action: Option<DownloadAction>,
46
47 #[arg(short, long, global = true)]
49 pub output: Option<PathBuf>,
50
51 #[arg(long, global = true)]
53 pub platform: Option<String>,
54
55 #[arg(long, global = true)]
57 pub search_term: Option<String>,
58
59 #[arg(long, default_value_t = DEFAULT_CONCURRENCY, value_parser = parse_nonzero_usize, global = true)]
61 pub jobs: usize,
62
63 #[arg(long, global = true)]
65 pub extract: bool,
66
67 #[arg(long, value_enum, default_value_t = ExtractLayout::Platform, global = true)]
69 pub extract_layout: ExtractLayout,
70
71 #[arg(long, global = true)]
73 pub delete_zip_after_extract: bool,
74
75 #[arg(long, global = true)]
77 pub with_extras: bool,
78
79 #[arg(long, global = true)]
81 pub no_extras: bool,
82
83 #[arg(short = 'y', long, global = true)]
85 pub yes: bool,
86}
87
88#[derive(Subcommand, Debug, Clone)]
89pub enum DownloadAction {
90 #[command(visible_alias = "all")]
92 Batch,
93 Extras(DownloadExtrasCommand),
95}
96
97#[derive(Args, Debug, Clone)]
98pub struct DownloadExtrasCommand {
99 pub rom_id: u64,
101}
102
103#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
104pub enum ExtractLayout {
105 Platform,
107 Flat,
109 Rom,
111}
112
113fn make_progress_style() -> ProgressStyle {
114 ProgressStyle::with_template(
115 "[{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} ({eta}) {msg}",
116 )
117 .expect("hardcoded download progress template")
118 .progress_chars("#>-")
119}
120
121async fn download_one(
122 client: &RommClient,
123 rom_id: u64,
124 name: &str,
125 save_path: &std::path::Path,
126 pb: ProgressBar,
127) -> Result<()> {
128 pb.set_message(name.to_string());
129
130 client
131 .download_rom(rom_id, save_path, {
132 let pb = pb.clone();
133 move |received, total| {
134 if pb.length() != Some(total) {
135 pb.set_length(total);
136 }
137 pb.set_position(received);
138 }
139 })
140 .await?;
141
142 pb.finish_with_message(format!("✓ {name}"));
143 Ok(())
144}
145
146async fn download_target(
147 client: &RommClient,
148 target: &DownloadTarget,
149 interrupt: &InterruptContext,
150 pb: ProgressBar,
151) -> Result<()> {
152 pb.set_message(format!("{}: {}", target.kind.label(), target.title));
153
154 let mut progress = {
155 let pb = pb.clone();
156 move |received, total| {
157 if pb.length() != Some(total) {
158 pb.set_length(total);
159 }
160 pb.set_position(received);
161 }
162 };
163
164 let urls = candidate_download_urls(target);
165 let mut last_err: Option<anyhow::Error> = None;
166 if prepare_download_target_destination(target).await? {
167 if let Some(expected_size) = target.expected_size_bytes {
168 progress(expected_size, expected_size);
169 }
170 pb.finish_with_message(format!("✓ {}: {}", target.kind.label(), target.title));
171 return Ok(());
172 }
173 for url in urls {
174 match client
175 .download_url_with_query_with_cancel(
176 &url,
177 &target.source_query,
178 &target.destination,
179 |_, _| interrupt.is_cancelled(),
180 &mut progress,
181 )
182 .await
183 {
184 Ok(()) => {
185 last_err = None;
186 break;
187 }
188 Err(err) => {
189 if !err.to_string().contains("404 Not Found") {
190 return Err(err);
191 }
192 last_err = Some(err);
193 }
194 }
195 }
196 if let Some(err) = last_err {
197 return Err(err);
198 }
199
200 pb.finish_with_message(format!("✓ {}: {}", target.kind.label(), target.title));
201 Ok(())
202}
203
204fn candidate_download_urls(target: &DownloadTarget) -> Vec<String> {
205 if target.kind != crate::core::extras::DownloadAssetKind::RomFile {
206 return vec![target.source_url.clone()];
207 }
208 let mut out = vec![target.source_url.clone()];
209 if let Some((file_id, file_name)) = parse_current_rom_file_content_path(&target.source_url) {
210 out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
211 out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
212 } else if let Some((file_id, file_name)) = parse_romsfiles_path(&target.source_url) {
213 out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
214 out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
215 } else if let Some((file_id, file_name)) = parse_legacy_roms_files_path(&target.source_url) {
216 out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
217 out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
218 }
219 dedupe_preserve_order(out)
220}
221
222fn parse_current_rom_file_content_path(url: &str) -> Option<(String, String)> {
223 let prefix = "/api/roms/";
224 let marker = "/files/content/";
225 let rest = url.strip_prefix(prefix)?;
226 let (id, name) = rest.split_once(marker)?;
227 Some((id.to_string(), name.to_string()))
228}
229
230fn parse_romsfiles_path(url: &str) -> Option<(String, String)> {
231 let prefix = "/api/romsfiles/";
232 let marker = "/content/";
233 let rest = url.strip_prefix(prefix)?;
234 let (id, name) = rest.split_once(marker)?;
235 Some((id.to_string(), name.to_string()))
236}
237
238fn parse_legacy_roms_files_path(url: &str) -> Option<(String, String)> {
239 let prefix = "/api/roms/files/";
240 let marker = "/content/";
241 let rest = url.strip_prefix(prefix)?;
242 let (id, name) = rest.split_once(marker)?;
243 Some((id.to_string(), name.to_string()))
244}
245
246fn dedupe_preserve_order(urls: Vec<String>) -> Vec<String> {
247 let mut seen = std::collections::HashSet::new();
248 let mut out = Vec::new();
249 for u in urls {
250 if seen.insert(u.clone()) {
251 out.push(u);
252 }
253 }
254 out
255}
256
257pub async fn handle(
258 cmd: DownloadCommand,
259 client: &RommClient,
260 interrupt: Option<InterruptContext>,
261) -> Result<()> {
262 let interrupt = interrupt.unwrap_or_default();
263 let output_dir = cmd.output.clone().unwrap_or_else(download_directory);
264 let action = cmd.action.clone();
265
266 if cmd.with_extras && cmd.no_extras {
267 return Err(anyhow!(
268 "--with-extras and --no-extras are mutually exclusive"
269 ));
270 }
271
272 tokio::fs::create_dir_all(&output_dir)
274 .await
275 .map_err(|e| anyhow!("create download dir {:?}: {e}", output_dir))?;
276
277 if let Some(DownloadAction::Extras(extras)) = action.clone() {
278 return handle_extras(extras, client, interrupt, output_dir, cmd.jobs).await;
279 }
280
281 let is_batch = matches!(action, Some(DownloadAction::Batch));
283
284 if is_batch {
285 if cmd.platform.is_none() && cmd.search_term.is_none() {
287 return Err(anyhow!(
288 "Batch download requires at least --platform or --search-term to scope the download"
289 ));
290 }
291 let resolved_platform_id = resolve_platform_id(client, cmd.platform.as_deref()).await?;
292
293 let ep = GetRoms {
294 search_term: cmd.search_term.clone(),
295 platform_id: resolved_platform_id,
296 collection_id: None,
297 smart_collection_id: None,
298 virtual_collection_id: None,
299 limit: Some(9999),
300 offset: None,
301 ..Default::default()
302 };
303
304 let service = RomService::new(client);
305 let results = service.search_roms(&ep).await?;
306
307 if results.items.is_empty() {
308 println!("No ROMs found matching the given filters.");
309 return Ok(());
310 }
311
312 println!(
313 "Found {} ROM(s). Starting download with {} concurrent connections...",
314 results.items.len(),
315 cmd.jobs
316 );
317
318 let mp = MultiProgress::new();
319 let semaphore = Arc::new(Semaphore::new(cmd.jobs));
320 let mut handles = Vec::new();
321
322 'enqueue: for rom in results.items {
323 if interrupt.is_cancelled() {
324 break 'enqueue;
325 }
326 let permit = semaphore
327 .clone()
328 .acquire_owned()
329 .await
330 .map_err(|_| anyhow!("download worker semaphore closed unexpectedly"))?;
331 let client = client.clone();
332 let dir = output_dir.clone();
333 let interrupt = interrupt.clone();
334 let pb = mp.add(ProgressBar::new(0));
335 pb.set_style(make_progress_style());
336
337 let name = rom.name.clone();
338 let rom_id = rom.id;
339 let platform_slug = rom
340 .platform_fs_slug
341 .clone()
342 .or_else(|| rom.platform_slug.clone())
343 .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
344 let base = utils::sanitize_filename(&rom.fs_name);
345 let stem = base
346 .rsplit_once('.')
347 .map(|(s, _)| s.to_string())
348 .unwrap_or(base.clone());
349 let save_path = unique_zip_path(&dir, &stem);
350 let extract = cmd.extract;
351 let extract_layout = cmd.extract_layout;
352 let delete_zip_after_extract = cmd.delete_zip_after_extract;
353
354 handles.push(tokio::spawn(async move {
355 let mut progress = {
356 let pb = pb.clone();
357 move |received, total| {
358 if pb.length() != Some(total) {
359 pb.set_length(total);
360 }
361 pb.set_position(received);
362 }
363 };
364 let mut result = client
365 .download_rom_with_cancel(
366 rom_id,
367 &save_path,
368 |_, _| interrupt.is_cancelled(),
369 &mut progress,
370 )
371 .await
372 .map(|_| {
373 pb.finish_with_message(format!("✓ {name}"));
374 });
375
376 if result.is_ok() && extract {
377 let extract_dir =
378 extraction_target_dir(&dir, &platform_slug, &stem, extract_layout);
379 if let Err(err) = tokio::fs::create_dir_all(&extract_dir).await {
380 result = Err(anyhow!(
381 "failed to create extraction directory {:?}: {}",
382 extract_dir,
383 err
384 ));
385 } else if let Err(err) = extract_zip_archive(&save_path, &extract_dir) {
386 result = Err(anyhow!(
387 "failed to extract {:?} to {:?}: {}",
388 save_path,
389 extract_dir,
390 err
391 ));
392 } else if delete_zip_after_extract {
393 tokio::fs::remove_file(&save_path).await.map_err(|err| {
394 anyhow!(
395 "failed to delete zip {:?} after extraction: {}",
396 save_path,
397 err
398 )
399 })?;
400 }
401 }
402
403 drop(permit);
404 if let Err(e) = &result {
405 if !is_cancelled_error(e) {
406 eprintln!("error downloading {name} (id={rom_id}): {e}");
407 }
408 }
409 result
410 }));
411 }
412
413 let mut successes = 0u32;
414 let mut failures = 0u32;
415 let mut cancelled = 0u32;
416 for handle in handles {
417 let task_result = tokio::select! {
418 res = handle => res,
419 _ = interrupt.cancelled() => {
420 cancelled += 1;
421 continue;
422 }
423 };
424 match task_result {
425 Ok(Ok(())) => successes += 1,
426 Ok(Err(e)) if is_cancelled_error(&e) => cancelled += 1,
427 _ => failures += 1,
428 }
429 }
430
431 if interrupt.is_cancelled() {
432 println!("\nInterrupted by user.");
433 }
434 println!(
435 "\nBatch complete: {successes} succeeded, {failures} failed, {cancelled} cancelled."
436 );
437 } else {
438 let rom_id = cmd.rom_id.ok_or_else(|| {
440 anyhow!(
441 "ROM ID is required (e.g. 'download 123' or 'download batch --search-term ...')"
442 )
443 })?;
444 let service = RomService::new(client);
445 let rom = service.get_rom(rom_id).await?;
446 let base_targets = build_base_rom_file_targets(&rom, &output_dir);
447
448 if !base_targets.is_empty() {
449 let summary = run_targets(base_targets, client, interrupt.clone(), 1).await?;
450 if summary.failures > 0 || summary.cancelled > 0 || summary.successes == 0 {
451 return Err(anyhow!(
452 "base game download failed; not prompting for updates/DLC"
453 ));
454 }
455 println!("Base game files downloaded.");
456 } else {
457 let save_path = output_dir.join(format!("rom_{rom_id}.zip"));
458 let mp = MultiProgress::new();
459 let pb = mp.add(ProgressBar::new(0));
460 pb.set_style(make_progress_style());
461 if interrupt.is_cancelled() {
462 return Err(cancelled_error());
463 }
464 download_one(client, rom_id, &format!("ROM {rom_id}"), &save_path, pb).await?;
465 println!("Saved to {:?}", save_path);
466 }
467
468 let extras_targets = build_update_dlc_targets_for_rom(client, &rom, &output_dir).await?;
469 if !extras_targets.is_empty() {
470 let include_extras = resolve_include_extras_choice(&cmd)?;
471 if include_extras {
472 run_targets(extras_targets, client, interrupt, cmd.jobs).await?;
473 }
474 }
475 }
476
477 Ok(())
478}
479
480async fn handle_extras(
481 cmd: DownloadExtrasCommand,
482 client: &RommClient,
483 interrupt: InterruptContext,
484 output_dir: PathBuf,
485 jobs: usize,
486) -> Result<()> {
487 let targets = build_extras_targets(client, cmd.rom_id, &output_dir).await?;
488 run_targets(targets, client, interrupt, jobs).await?;
489 Ok(())
490}
491
492#[derive(Debug, Clone, Copy)]
493struct DownloadRunSummary {
494 successes: u32,
495 failures: u32,
496 cancelled: u32,
497}
498
499async fn run_targets(
500 targets: Vec<DownloadTarget>,
501 client: &RommClient,
502 interrupt: InterruptContext,
503 jobs: usize,
504) -> Result<DownloadRunSummary> {
505 if targets.is_empty() {
506 println!("No downloadable extras were found.");
507 return Ok(DownloadRunSummary {
508 successes: 0,
509 failures: 0,
510 cancelled: 0,
511 });
512 }
513
514 println!(
515 "Found {} download(s). Starting download with {} concurrent connections...",
516 targets.len(),
517 jobs
518 );
519
520 let mp = MultiProgress::new();
521 let semaphore = Arc::new(Semaphore::new(jobs));
522 let mut handles = Vec::new();
523
524 'enqueue: for target in targets {
525 if interrupt.is_cancelled() {
526 break 'enqueue;
527 }
528 let permit = semaphore
529 .clone()
530 .acquire_owned()
531 .await
532 .map_err(|_| anyhow!("download worker semaphore closed unexpectedly"))?;
533 let client = client.clone();
534 let interrupt = interrupt.clone();
535 let pb = mp.add(ProgressBar::new(0));
536 pb.set_style(make_progress_style());
537
538 handles.push(tokio::spawn(async move {
539 let result = download_target(&client, &target, &interrupt, pb).await;
540 drop(permit);
541 if let Err(err) = &result {
542 if !is_cancelled_error(err) {
543 eprintln!(
544 "error downloading {} ({:?}): {}",
545 target.title, target.kind, err
546 );
547 }
548 }
549 result
550 }));
551 }
552
553 let mut successes = 0u32;
554 let mut failures = 0u32;
555 let mut cancelled = 0u32;
556 for handle in handles {
557 let task_result = tokio::select! {
558 res = handle => res,
559 _ = interrupt.cancelled() => {
560 cancelled += 1;
561 continue;
562 }
563 };
564 match task_result {
565 Ok(Ok(())) => successes += 1,
566 Ok(Err(e)) if is_cancelled_error(&e) => cancelled += 1,
567 _ => failures += 1,
568 }
569 }
570
571 if interrupt.is_cancelled() {
572 println!("\nInterrupted by user.");
573 }
574 println!(
575 "\nDownload complete: {successes} succeeded, {failures} failed, {cancelled} cancelled."
576 );
577
578 Ok(DownloadRunSummary {
579 successes,
580 failures,
581 cancelled,
582 })
583}
584
585fn resolve_include_extras_choice(cmd: &DownloadCommand) -> Result<bool> {
586 if cmd.with_extras || cmd.yes {
587 return Ok(true);
588 }
589 if cmd.no_extras {
590 return Ok(false);
591 }
592 if !is_interactive_terminal() {
593 return Ok(false);
594 }
595 Confirm::new()
596 .with_prompt("Updates/DLC are available. Download them now as extras?")
597 .default(false)
598 .interact()
599 .map_err(|e| anyhow!("extras prompt failed: {e}"))
600}
601
602fn is_interactive_terminal() -> bool {
603 io::stdin().is_terminal() && io::stdout().is_terminal()
604}
605
606async fn resolve_platform_id(
607 client: &RommClient,
608 platform_query: Option<&str>,
609) -> Result<Option<u64>> {
610 let Some(query) = platform_query.map(str::trim).filter(|q| !q.is_empty()) else {
611 return Ok(None);
612 };
613 let service = PlatformService::new(client);
614 let platforms = service.list_platforms().await?;
615 resolve_platform_query(query, &platforms).map(Some)
616}
617
618fn resolve_platform_query(query: &str, platforms: &[Platform]) -> Result<u64> {
619 let normalized = query.trim().to_ascii_lowercase();
620
621 if let Some(platform) = platforms.iter().find(|p| {
622 p.slug.eq_ignore_ascii_case(&normalized) || p.fs_slug.eq_ignore_ascii_case(&normalized)
623 }) {
624 return Ok(platform.id);
625 }
626
627 let exact_name_matches: Vec<&Platform> = platforms
628 .iter()
629 .filter(|p| {
630 p.name.eq_ignore_ascii_case(&normalized)
631 || p.display_name
632 .as_deref()
633 .is_some_and(|name| name.eq_ignore_ascii_case(&normalized))
634 || p.custom_name
635 .as_deref()
636 .is_some_and(|name| name.eq_ignore_ascii_case(&normalized))
637 })
638 .collect();
639
640 match exact_name_matches.len() {
641 1 => Ok(exact_name_matches[0].id),
642 0 => Err(anyhow!(
643 "No platform found for '{}'. Use 'romm-cli platforms list' to inspect available values.",
644 query
645 )),
646 _ => {
647 let names = exact_name_matches
648 .iter()
649 .map(|p| format!("{} ({})", p.name, p.id))
650 .collect::<Vec<_>>()
651 .join(", ");
652 Err(anyhow!(
653 "Platform '{}' is ambiguous. Matches: {}. Please use a more specific --platform value.",
654 query,
655 names
656 ))
657 }
658 }
659}
660
661fn extraction_target_dir(
662 output_dir: &std::path::Path,
663 platform_slug: &str,
664 rom_stem: &str,
665 layout: ExtractLayout,
666) -> PathBuf {
667 let platform = utils::sanitize_filename(platform_slug);
668 let rom = utils::sanitize_filename(rom_stem);
669 match layout {
670 ExtractLayout::Platform => output_dir.join(platform),
671 ExtractLayout::Flat => output_dir.to_path_buf(),
672 ExtractLayout::Rom => output_dir.join(platform).join(rom),
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use clap::Parser;
680
681 use crate::commands::{Cli, Commands};
682 use crate::types::Firmware;
683
684 #[test]
685 fn parse_download_batch_with_extract_flags() {
686 let cli = Cli::parse_from([
687 "romm-cli",
688 "download",
689 "batch",
690 "--search-term",
691 "Super Mario",
692 "--extract",
693 "--extract-layout",
694 "platform",
695 "--delete-zip-after-extract",
696 "--jobs",
697 "8",
698 ]);
699
700 let Commands::Download(cmd) = cli.command else {
701 panic!("expected download command");
702 };
703
704 assert!(matches!(cmd.action, Some(DownloadAction::Batch)));
705 assert_eq!(cmd.search_term.as_deref(), Some("Super Mario"));
706 assert!(cmd.extract);
707 assert_eq!(cmd.extract_layout, ExtractLayout::Platform);
708 assert!(cmd.delete_zip_after_extract);
709 assert_eq!(cmd.jobs, 8);
710 }
711
712 #[test]
713 fn parse_download_batch_extract_defaults() {
714 let cli = Cli::parse_from(["romm-cli", "download", "batch", "--search-term", "Metroid"]);
715
716 let Commands::Download(cmd) = cli.command else {
717 panic!("expected download command");
718 };
719
720 assert!(matches!(cmd.action, Some(DownloadAction::Batch)));
721 assert!(!cmd.extract);
722 assert_eq!(cmd.extract_layout, ExtractLayout::Platform);
723 assert!(!cmd.delete_zip_after_extract);
724 }
725
726 #[test]
727 fn parse_download_batch_with_platform_alias() {
728 let cli = Cli::parse_from([
729 "romm-cli",
730 "download",
731 "batch",
732 "--platform",
733 "3ds",
734 "--search-term",
735 "Mario",
736 ]);
737
738 let Commands::Download(cmd) = cli.command else {
739 panic!("expected download command");
740 };
741
742 assert_eq!(cmd.platform.as_deref(), Some("3ds"));
743 }
744
745 #[test]
746 fn parse_download_extras_command() {
747 let cli = Cli::parse_from(["romm-cli", "download", "extras", "42"]);
748
749 let Commands::Download(cmd) = cli.command else {
750 panic!("expected download command");
751 };
752
753 let Some(DownloadAction::Extras(extras)) = cmd.action else {
754 panic!("expected download extras");
755 };
756 assert_eq!(extras.rom_id, 42);
757 }
758
759 #[test]
760 fn parse_download_batch_rejects_platform_id_flag() {
761 let parsed = Cli::try_parse_from([
762 "romm-cli",
763 "download",
764 "batch",
765 "--platform",
766 "3ds",
767 "--platform-id",
768 "3",
769 ]);
770 assert!(parsed.is_err(), "expected clap parse failure");
771 }
772
773 #[test]
774 fn parse_download_rejects_zero_jobs() {
775 let parsed = Cli::try_parse_from(["romm-cli", "download", "42", "--jobs", "0"]);
776 assert!(parsed.is_err(), "expected --jobs 0 to fail");
777 }
778
779 #[test]
780 fn parse_download_single_with_extras_flags() {
781 let cli = Cli::parse_from(["romm-cli", "download", "42", "--with-extras", "--yes"]);
782 let Commands::Download(cmd) = cli.command else {
783 panic!("expected download command");
784 };
785 assert_eq!(cmd.rom_id, Some(42));
786 assert!(cmd.with_extras);
787 assert!(cmd.yes);
788 assert!(!cmd.no_extras);
789 }
790
791 #[test]
792 fn rom_file_download_candidates_use_official_romsfiles_endpoint() {
793 let target = DownloadTarget {
794 kind: crate::core::extras::DownloadAssetKind::RomFile,
795 title: "DLC".into(),
796 source_url: "/api/roms/12/files/content/dlc%2Ensp".into(),
797 source_query: Vec::new(),
798 destination: PathBuf::from("/tmp/dlc.nsp"),
799 expected_size_bytes: Some(12),
800 };
801
802 assert_eq!(
803 candidate_download_urls(&target),
804 vec![
805 "/api/roms/12/files/content/dlc%2Ensp".to_string(),
806 "/api/romsfiles/12/content/dlc%2Ensp".to_string(),
807 "/api/roms/files/12/content/dlc%2Ensp".to_string()
808 ]
809 );
810 }
811
812 #[test]
813 fn extraction_target_dir_platform_layout() {
814 let dir = PathBuf::from("/tmp/out");
815 let target = extraction_target_dir(
816 &dir,
817 "Nintendo Switch",
818 "Mario (USA)",
819 ExtractLayout::Platform,
820 );
821 assert_eq!(target, PathBuf::from("/tmp/out/Nintendo Switch"));
822 }
823
824 #[test]
825 fn extraction_target_dir_rom_layout() {
826 let dir = PathBuf::from("/tmp/out");
827 let target = extraction_target_dir(&dir, "SNES", "Super Mario World", ExtractLayout::Rom);
828 assert_eq!(target, PathBuf::from("/tmp/out/SNES/Super Mario World"));
829 }
830
831 #[test]
832 fn resolve_platform_query_matches_slug_first() {
833 let platforms = vec![platform_fixture(
834 3,
835 "3ds",
836 "3ds",
837 "Nintendo 3DS",
838 None,
839 None,
840 )];
841 let id = resolve_platform_query("3ds", &platforms).expect("slug should resolve");
842 assert_eq!(id, 3);
843 }
844
845 #[test]
846 fn resolve_platform_query_matches_name_case_insensitive() {
847 let platforms = vec![platform_fixture(
848 4,
849 "nintendo-3ds",
850 "3ds",
851 "Nintendo 3DS",
852 None,
853 None,
854 )];
855 let id = resolve_platform_query("nintendo 3ds", &platforms).expect("name should resolve");
856 assert_eq!(id, 4);
857 }
858
859 #[test]
860 fn resolve_platform_query_errors_when_ambiguous() {
861 let platforms = vec![
862 platform_fixture(7, "foo-a", "foo-a", "Arcade", None, None),
863 platform_fixture(8, "foo-b", "foo-b", "Arcade", None, None),
864 ];
865 let err = resolve_platform_query("Arcade", &platforms).expect_err("should be ambiguous");
866 assert!(
867 err.to_string().contains("ambiguous"),
868 "unexpected error: {err:#}"
869 );
870 }
871
872 #[test]
873 fn resolve_platform_query_errors_when_missing() {
874 let platforms = vec![platform_fixture(
875 2,
876 "gba",
877 "gba",
878 "Game Boy Advance",
879 None,
880 None,
881 )];
882 let err = resolve_platform_query("3ds", &platforms).expect_err("should not match");
883 assert!(
884 err.to_string().contains("No platform found"),
885 "unexpected error: {err:#}"
886 );
887 }
888
889 fn platform_fixture(
890 id: u64,
891 slug: &str,
892 fs_slug: &str,
893 name: &str,
894 display_name: Option<&str>,
895 custom_name: Option<&str>,
896 ) -> Platform {
897 Platform {
898 id,
899 slug: slug.to_string(),
900 fs_slug: fs_slug.to_string(),
901 rom_count: 0,
902 name: name.to_string(),
903 igdb_slug: None,
904 moby_slug: None,
905 hltb_slug: None,
906 custom_name: custom_name.map(ToString::to_string),
907 igdb_id: None,
908 sgdb_id: None,
909 moby_id: None,
910 launchbox_id: None,
911 ss_id: None,
912 ra_id: None,
913 hasheous_id: None,
914 tgdb_id: None,
915 flashpoint_id: None,
916 category: None,
917 generation: None,
918 family_name: None,
919 family_slug: None,
920 url: None,
921 url_logo: None,
922 firmware: Vec::<Firmware>::new(),
923 aspect_ratio: None,
924 created_at: "2026-01-01T00:00:00Z".to_string(),
925 updated_at: "2026-01-01T00:00:00Z".to_string(),
926 fs_size_bytes: 0,
927 is_unidentified: false,
928 is_identified: true,
929 missing_from_fs: false,
930 display_name: display_name.map(ToString::to_string),
931 }
932 }
933}