Skip to main content

romm_cli/commands/
download.rs

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