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