Skip to main content

romm_cli/commands/
download.rs

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