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