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