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