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