Skip to main content

romm_cli/commands/
download.rs

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