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::config::{load_config, RomsLayoutConfig};
12use crate::core::download::{
13 extract_zip_archive, prepare_download_target_destination, resolve_console_roms_dir,
14 resolve_download_directory, unique_zip_path,
15};
16use crate::core::extras::{
17 build_base_rom_file_targets, build_extras_targets, build_update_dlc_targets_for_rom,
18 DownloadTarget,
19};
20use crate::core::interrupt::{cancelled_error, is_cancelled_error, InterruptContext};
21use crate::core::resolve::resolve_platform_id;
22use crate::core::utils;
23use crate::endpoints::roms::{GetRom, GetRoms};
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 config = load_config()?;
264 let layout = config.roms_layout.clone();
265 let output_dir = match cmd.output.clone() {
266 Some(path) => path,
267 None => resolve_download_directory(Some(config.download_dir.as_str()))?,
268 };
269 let action = cmd.action.clone();
270
271 if cmd.with_extras && cmd.no_extras {
272 return Err(anyhow!(
273 "--with-extras and --no-extras are mutually exclusive"
274 ));
275 }
276
277 tokio::fs::create_dir_all(&output_dir)
279 .await
280 .map_err(|e| anyhow!("create download dir {:?}: {e}", output_dir))?;
281
282 if let Some(DownloadAction::Extras(extras)) = action.clone() {
283 return handle_extras(extras, client, interrupt, &layout, output_dir, cmd.jobs).await;
284 }
285
286 let is_batch = matches!(action, Some(DownloadAction::Batch));
288
289 if is_batch {
290 if cmd.platform.is_none() && cmd.search_term.is_none() {
292 return Err(anyhow!(
293 "Batch download requires at least --platform or --search-term to scope the download"
294 ));
295 }
296 let resolved_platform_id = resolve_platform_id(client, cmd.platform.as_deref()).await?;
297
298 let ep = GetRoms {
299 search_term: cmd.search_term.clone(),
300 platform_id: resolved_platform_id,
301 collection_id: None,
302 smart_collection_id: None,
303 virtual_collection_id: None,
304 limit: Some(9999),
305 offset: None,
306 ..Default::default()
307 };
308
309 let results = client.call(&ep).await?;
310
311 if results.items.is_empty() {
312 println!("No ROMs found matching the given filters.");
313 return Ok(());
314 }
315
316 println!(
317 "Found {} ROM(s). Starting download with {} concurrent connections...",
318 results.items.len(),
319 cmd.jobs
320 );
321
322 let mp = MultiProgress::new();
323 let semaphore = Arc::new(Semaphore::new(cmd.jobs));
324 let mut handles = Vec::new();
325
326 'enqueue: for rom in results.items {
327 if interrupt.is_cancelled() {
328 break 'enqueue;
329 }
330 let permit = semaphore
331 .clone()
332 .acquire_owned()
333 .await
334 .map_err(|_| anyhow!("download worker semaphore closed unexpectedly"))?;
335 let client = client.clone();
336 let base_dir = output_dir.clone();
337 let layout = layout.clone();
338 let interrupt = interrupt.clone();
339 let pb = mp.add(ProgressBar::new(0));
340 pb.set_style(make_progress_style());
341
342 let name = rom.name.clone();
343 let rom_id = rom.id;
344 let console_dir = resolve_console_roms_dir(&layout, &base_dir, &rom)?;
345 tokio::fs::create_dir_all(&console_dir)
346 .await
347 .map_err(|e| anyhow!("create console download dir {:?}: {e}", console_dir))?;
348 let platform_slug = rom
349 .platform_fs_slug
350 .clone()
351 .or_else(|| rom.platform_slug.clone())
352 .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
353 let base = utils::sanitize_filename(&rom.fs_name);
354 let stem = base
355 .rsplit_once('.')
356 .map(|(s, _)| s.to_string())
357 .unwrap_or(base.clone());
358 let save_path = unique_zip_path(&console_dir, &stem);
359 let extract = cmd.extract;
360 let extract_layout = cmd.extract_layout;
361 let delete_zip_after_extract = cmd.delete_zip_after_extract;
362
363 handles.push(tokio::spawn(async move {
364 let mut progress = {
365 let pb = pb.clone();
366 move |received, total| {
367 if pb.length() != Some(total) {
368 pb.set_length(total);
369 }
370 pb.set_position(received);
371 }
372 };
373 let mut result = client
374 .download_rom_with_cancel(
375 rom_id,
376 &save_path,
377 |_, _| interrupt.is_cancelled(),
378 &mut progress,
379 )
380 .await
381 .map(|_| {
382 pb.finish_with_message(format!("✓ {name}"));
383 });
384
385 if result.is_ok() && extract {
386 let extract_dir =
387 extraction_target_dir(&console_dir, &platform_slug, &stem, extract_layout);
388 if let Err(err) = tokio::fs::create_dir_all(&extract_dir).await {
389 result = Err(anyhow!(
390 "failed to create extraction directory {:?}: {}",
391 extract_dir,
392 err
393 ));
394 } else if let Err(err) = extract_zip_archive(&save_path, &extract_dir) {
395 result = Err(anyhow!(
396 "failed to extract {:?} to {:?}: {}",
397 save_path,
398 extract_dir,
399 err
400 ));
401 } else if delete_zip_after_extract {
402 tokio::fs::remove_file(&save_path).await.map_err(|err| {
403 anyhow!(
404 "failed to delete zip {:?} after extraction: {}",
405 save_path,
406 err
407 )
408 })?;
409 }
410 }
411
412 drop(permit);
413 if let Err(e) = &result {
414 if !is_cancelled_error(e) {
415 eprintln!("error downloading {name} (id={rom_id}): {e}");
416 }
417 }
418 result
419 }));
420 }
421
422 let mut successes = 0u32;
423 let mut failures = 0u32;
424 let mut cancelled = 0u32;
425 for handle in handles {
426 let task_result = tokio::select! {
427 res = handle => res,
428 _ = interrupt.cancelled() => {
429 cancelled += 1;
430 continue;
431 }
432 };
433 match task_result {
434 Ok(Ok(())) => successes += 1,
435 Ok(Err(e)) if is_cancelled_error(&e) => cancelled += 1,
436 _ => failures += 1,
437 }
438 }
439
440 if interrupt.is_cancelled() {
441 println!("\nInterrupted by user.");
442 }
443 println!(
444 "\nBatch complete: {successes} succeeded, {failures} failed, {cancelled} cancelled."
445 );
446 } else {
447 let rom_id = cmd.rom_id.ok_or_else(|| {
449 anyhow!(
450 "ROM ID is required (e.g. 'download 123' or 'download batch --search-term ...')"
451 )
452 })?;
453 let rom = client.call(&GetRom { id: rom_id }).await?;
454 let base_targets = build_base_rom_file_targets(&rom, &layout, &output_dir)?;
455
456 if !base_targets.is_empty() {
457 let summary = run_targets(base_targets, client, interrupt.clone(), 1).await?;
458 if summary.failures > 0 || summary.cancelled > 0 || summary.successes == 0 {
459 return Err(anyhow!(
460 "base game download failed; not prompting for updates/DLC"
461 ));
462 }
463 println!("Base game files downloaded.");
464 } else {
465 let console_dir = resolve_console_roms_dir(&layout, &output_dir, &rom)?;
466 tokio::fs::create_dir_all(&console_dir)
467 .await
468 .map_err(|e| anyhow!("create console download dir {:?}: {e}", console_dir))?;
469 let save_path = console_dir.join(format!("rom_{rom_id}.zip"));
470 let mp = MultiProgress::new();
471 let pb = mp.add(ProgressBar::new(0));
472 pb.set_style(make_progress_style());
473 if interrupt.is_cancelled() {
474 return Err(cancelled_error());
475 }
476 download_one(client, rom_id, &format!("ROM {rom_id}"), &save_path, pb).await?;
477 println!("Saved to {:?}", save_path);
478 }
479
480 let extras_targets =
481 build_update_dlc_targets_for_rom(client, &rom, &layout, &output_dir).await?;
482 if !extras_targets.is_empty() {
483 let include_extras = resolve_include_extras_choice(&cmd)?;
484 if include_extras {
485 run_targets(extras_targets, client, interrupt, cmd.jobs).await?;
486 }
487 }
488 }
489
490 Ok(())
491}
492
493async fn handle_extras(
494 cmd: DownloadExtrasCommand,
495 client: &RommClient,
496 interrupt: InterruptContext,
497 layout: &RomsLayoutConfig,
498 output_dir: PathBuf,
499 jobs: usize,
500) -> Result<()> {
501 let targets = build_extras_targets(client, cmd.rom_id, layout, &output_dir).await?;
502 run_targets(targets, client, interrupt, jobs).await?;
503 Ok(())
504}
505
506#[derive(Debug, Clone, Copy)]
507struct DownloadRunSummary {
508 successes: u32,
509 failures: u32,
510 cancelled: u32,
511}
512
513async fn run_targets(
514 targets: Vec<DownloadTarget>,
515 client: &RommClient,
516 interrupt: InterruptContext,
517 jobs: usize,
518) -> Result<DownloadRunSummary> {
519 if targets.is_empty() {
520 println!("No downloadable extras were found.");
521 return Ok(DownloadRunSummary {
522 successes: 0,
523 failures: 0,
524 cancelled: 0,
525 });
526 }
527
528 println!(
529 "Found {} download(s). Starting download with {} concurrent connections...",
530 targets.len(),
531 jobs
532 );
533
534 let mp = MultiProgress::new();
535 let semaphore = Arc::new(Semaphore::new(jobs));
536 let mut handles = Vec::new();
537
538 'enqueue: for target in targets {
539 if interrupt.is_cancelled() {
540 break 'enqueue;
541 }
542 let permit = semaphore
543 .clone()
544 .acquire_owned()
545 .await
546 .map_err(|_| anyhow!("download worker semaphore closed unexpectedly"))?;
547 let client = client.clone();
548 let interrupt = interrupt.clone();
549 let pb = mp.add(ProgressBar::new(0));
550 pb.set_style(make_progress_style());
551
552 handles.push(tokio::spawn(async move {
553 let result = download_target(&client, &target, &interrupt, pb).await;
554 drop(permit);
555 if let Err(err) = &result {
556 if !is_cancelled_error(err) {
557 eprintln!(
558 "error downloading {} ({:?}): {}",
559 target.title, target.kind, err
560 );
561 }
562 }
563 result
564 }));
565 }
566
567 let mut successes = 0u32;
568 let mut failures = 0u32;
569 let mut cancelled = 0u32;
570 for handle in handles {
571 let task_result = tokio::select! {
572 res = handle => res,
573 _ = interrupt.cancelled() => {
574 cancelled += 1;
575 continue;
576 }
577 };
578 match task_result {
579 Ok(Ok(())) => successes += 1,
580 Ok(Err(e)) if is_cancelled_error(&e) => cancelled += 1,
581 _ => failures += 1,
582 }
583 }
584
585 if interrupt.is_cancelled() {
586 println!("\nInterrupted by user.");
587 }
588 println!(
589 "\nDownload complete: {successes} succeeded, {failures} failed, {cancelled} cancelled."
590 );
591
592 Ok(DownloadRunSummary {
593 successes,
594 failures,
595 cancelled,
596 })
597}
598
599fn resolve_include_extras_choice(cmd: &DownloadCommand) -> Result<bool> {
600 if cmd.with_extras || cmd.yes {
601 return Ok(true);
602 }
603 if cmd.no_extras {
604 return Ok(false);
605 }
606 if !is_interactive_terminal() {
607 return Ok(false);
608 }
609 Confirm::new()
610 .with_prompt("Updates/DLC are available. Download them now as extras?")
611 .default(false)
612 .interact()
613 .map_err(|e| anyhow!("extras prompt failed: {e}"))
614}
615
616fn is_interactive_terminal() -> bool {
617 io::stdin().is_terminal() && io::stdout().is_terminal()
618}
619
620fn extraction_target_dir(
621 output_dir: &std::path::Path,
622 platform_slug: &str,
623 rom_stem: &str,
624 layout: ExtractLayout,
625) -> PathBuf {
626 let platform = utils::sanitize_filename(platform_slug);
627 let rom = utils::sanitize_filename(rom_stem);
628 match layout {
629 ExtractLayout::Platform => output_dir.join(platform),
630 ExtractLayout::Flat => output_dir.to_path_buf(),
631 ExtractLayout::Rom => output_dir.join(platform).join(rom),
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use crate::core::resolve::resolve_platform_id_from_list;
639 use clap::Parser;
640
641 use crate::commands::{Cli, Commands};
642 use crate::types::{Firmware, Platform};
643
644 #[test]
645 fn parse_download_batch_with_extract_flags() {
646 let cli = Cli::parse_from([
647 "romm-cli",
648 "download",
649 "batch",
650 "--search-term",
651 "Super Mario",
652 "--extract",
653 "--extract-layout",
654 "platform",
655 "--delete-zip-after-extract",
656 "--jobs",
657 "8",
658 ]);
659
660 let Commands::Download(cmd) = cli.command else {
661 panic!("expected download command");
662 };
663
664 assert!(matches!(cmd.action, Some(DownloadAction::Batch)));
665 assert_eq!(cmd.search_term.as_deref(), Some("Super Mario"));
666 assert!(cmd.extract);
667 assert_eq!(cmd.extract_layout, ExtractLayout::Platform);
668 assert!(cmd.delete_zip_after_extract);
669 assert_eq!(cmd.jobs, 8);
670 }
671
672 #[test]
673 fn parse_download_batch_extract_defaults() {
674 let cli = Cli::parse_from(["romm-cli", "download", "batch", "--search-term", "Metroid"]);
675
676 let Commands::Download(cmd) = cli.command else {
677 panic!("expected download command");
678 };
679
680 assert!(matches!(cmd.action, Some(DownloadAction::Batch)));
681 assert!(!cmd.extract);
682 assert_eq!(cmd.extract_layout, ExtractLayout::Platform);
683 assert!(!cmd.delete_zip_after_extract);
684 }
685
686 #[test]
687 fn parse_download_batch_with_platform_alias() {
688 let cli = Cli::parse_from([
689 "romm-cli",
690 "download",
691 "batch",
692 "--platform",
693 "3ds",
694 "--search-term",
695 "Mario",
696 ]);
697
698 let Commands::Download(cmd) = cli.command else {
699 panic!("expected download command");
700 };
701
702 assert_eq!(cmd.platform.as_deref(), Some("3ds"));
703 }
704
705 #[test]
706 fn parse_download_extras_command() {
707 let cli = Cli::parse_from(["romm-cli", "download", "extras", "42"]);
708
709 let Commands::Download(cmd) = cli.command else {
710 panic!("expected download command");
711 };
712
713 let Some(DownloadAction::Extras(extras)) = cmd.action else {
714 panic!("expected download extras");
715 };
716 assert_eq!(extras.rom_id, 42);
717 }
718
719 #[test]
720 fn parse_download_batch_rejects_platform_id_flag() {
721 let parsed = Cli::try_parse_from([
722 "romm-cli",
723 "download",
724 "batch",
725 "--platform",
726 "3ds",
727 "--platform-id",
728 "3",
729 ]);
730 assert!(parsed.is_err(), "expected clap parse failure");
731 }
732
733 #[test]
734 fn parse_download_rejects_zero_jobs() {
735 let parsed = Cli::try_parse_from(["romm-cli", "download", "42", "--jobs", "0"]);
736 assert!(parsed.is_err(), "expected --jobs 0 to fail");
737 }
738
739 #[test]
740 fn parse_download_single_with_extras_flags() {
741 let cli = Cli::parse_from(["romm-cli", "download", "42", "--with-extras", "--yes"]);
742 let Commands::Download(cmd) = cli.command else {
743 panic!("expected download command");
744 };
745 assert_eq!(cmd.rom_id, Some(42));
746 assert!(cmd.with_extras);
747 assert!(cmd.yes);
748 assert!(!cmd.no_extras);
749 }
750
751 #[test]
752 fn rom_file_download_candidates_use_official_romsfiles_endpoint() {
753 let target = DownloadTarget {
754 kind: crate::core::extras::DownloadAssetKind::RomFile,
755 title: "DLC".into(),
756 source_url: "/api/roms/12/files/content/dlc%2Ensp".into(),
757 source_query: Vec::new(),
758 destination: PathBuf::from("/tmp/dlc.nsp"),
759 expected_size_bytes: Some(12),
760 };
761
762 assert_eq!(
763 candidate_download_urls(&target),
764 vec![
765 "/api/roms/12/files/content/dlc%2Ensp".to_string(),
766 "/api/romsfiles/12/content/dlc%2Ensp".to_string(),
767 "/api/roms/files/12/content/dlc%2Ensp".to_string()
768 ]
769 );
770 }
771
772 #[test]
773 fn extraction_target_dir_platform_layout() {
774 let dir = PathBuf::from("/tmp/out");
775 let target = extraction_target_dir(
776 &dir,
777 "Nintendo Switch",
778 "Mario (USA)",
779 ExtractLayout::Platform,
780 );
781 assert_eq!(target, PathBuf::from("/tmp/out/Nintendo Switch"));
782 }
783
784 #[test]
785 fn extraction_target_dir_rom_layout() {
786 let dir = PathBuf::from("/tmp/out");
787 let target = extraction_target_dir(&dir, "SNES", "Super Mario World", ExtractLayout::Rom);
788 assert_eq!(target, PathBuf::from("/tmp/out/SNES/Super Mario World"));
789 }
790
791 #[test]
792 fn resolve_platform_query_matches_slug_first() {
793 let platforms = vec![platform_fixture(
794 3,
795 "3ds",
796 "3ds",
797 "Nintendo 3DS",
798 None,
799 None,
800 )];
801 let id = resolve_platform_id_from_list("3ds", &platforms).expect("slug should resolve");
802 assert_eq!(id, 3);
803 }
804
805 #[test]
806 fn resolve_platform_query_matches_name_case_insensitive() {
807 let platforms = vec![platform_fixture(
808 4,
809 "nintendo-3ds",
810 "3ds",
811 "Nintendo 3DS",
812 None,
813 None,
814 )];
815 let id =
816 resolve_platform_id_from_list("nintendo 3ds", &platforms).expect("name should resolve");
817 assert_eq!(id, 4);
818 }
819
820 #[test]
821 fn resolve_platform_query_errors_when_ambiguous() {
822 let platforms = vec![
823 platform_fixture(7, "foo-a", "foo-a", "Arcade", None, None),
824 platform_fixture(8, "foo-b", "foo-b", "Arcade", None, None),
825 ];
826 let err =
827 resolve_platform_id_from_list("Arcade", &platforms).expect_err("should be ambiguous");
828 assert!(
829 err.to_string().contains("ambiguous"),
830 "unexpected error: {err:#}"
831 );
832 }
833
834 #[test]
835 fn resolve_platform_query_errors_when_missing() {
836 let platforms = vec![platform_fixture(
837 2,
838 "gba",
839 "gba",
840 "Game Boy Advance",
841 None,
842 None,
843 )];
844 let err = resolve_platform_id_from_list("3ds", &platforms).expect_err("should not match");
845 assert!(
846 err.to_string().contains("No platform found"),
847 "unexpected error: {err:#}"
848 );
849 }
850
851 fn platform_fixture(
852 id: u64,
853 slug: &str,
854 fs_slug: &str,
855 name: &str,
856 display_name: Option<&str>,
857 custom_name: Option<&str>,
858 ) -> Platform {
859 Platform {
860 id,
861 slug: slug.to_string(),
862 fs_slug: fs_slug.to_string(),
863 rom_count: 0,
864 name: name.to_string(),
865 igdb_slug: None,
866 moby_slug: None,
867 hltb_slug: None,
868 custom_name: custom_name.map(ToString::to_string),
869 igdb_id: None,
870 sgdb_id: None,
871 moby_id: None,
872 launchbox_id: None,
873 ss_id: None,
874 ra_id: None,
875 hasheous_id: None,
876 tgdb_id: None,
877 flashpoint_id: None,
878 category: None,
879 generation: None,
880 family_name: None,
881 family_slug: None,
882 url: None,
883 url_logo: None,
884 firmware: Vec::<Firmware>::new(),
885 aspect_ratio: None,
886 created_at: "2026-01-01T00:00:00Z".to_string(),
887 updated_at: "2026-01-01T00:00:00Z".to_string(),
888 fs_size_bytes: 0,
889 is_unidentified: false,
890 is_identified: true,
891 missing_from_fs: false,
892 display_name: display_name.map(ToString::to_string),
893 }
894 }
895}