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