Skip to main content

romm_cli/commands/
download.rs

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