Skip to main content

romm_cli/commands/
download.rs

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