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