Skip to main content

romm_cli/commands/
roms.rs

1use std::path::PathBuf;
2use std::time::Duration;
3
4use anyhow::{anyhow, Result};
5use clap::{Args, Subcommand};
6use dialoguer::Confirm;
7use indicatif::ProgressBar;
8use serde_json::json;
9
10use crate::cli_presentation::CliPresentation;
11use crate::commands::library_scan::{
12    run_scan_library_flow, ScanCacheInvalidate, ScanLibraryOptions,
13};
14use crate::commands::print::print_roms_table;
15use crate::commands::OutputFormat;
16use romm_api::client::RommClient;
17use romm_api::core::resolve::{
18    resolve_manual_collection_id, resolve_platform_id, resolve_platform_ids,
19    resolve_smart_collection_id,
20};
21use romm_api::endpoints::roms::GetRom;
22use romm_api::endpoints::roms::{
23    DeleteRomNote, DeleteRoms, GetRomByHash, GetRomByMetadataProvider, GetRomFilters, GetRomNotes,
24    GetRoms, GetSearchCover, GetSearchRoms, PostRomNote, PutRomNote, PutRomUserProps,
25};
26
27/// Optional tri-state: CLI passes `true` / `false` / `yes` / `no` / `1` / `0`.
28fn parse_opt_bool(label: &str, raw: &Option<String>) -> Result<Option<bool>> {
29    let Some(s) = raw else {
30        return Ok(None);
31    };
32    let t = s.trim().to_ascii_lowercase();
33    if t.is_empty() {
34        return Ok(None);
35    }
36    match t.as_str() {
37        "true" | "1" | "yes" | "y" => Ok(Some(true)),
38        "false" | "0" | "no" | "n" => Ok(Some(false)),
39        _ => Err(anyhow!(
40            "Invalid boolean for {}: {:?} (use true or false)",
41            label,
42            s
43        )),
44    }
45}
46
47/// `roms list` flags (also used as default when no subcommand).
48#[derive(Args, Debug, Clone, Default)]
49pub struct RomListArgs {
50    #[arg(long, visible_aliases = ["query", "q"])]
51    pub search_term: Option<String>,
52    /// Platform slug or name; repeat for multiple `platform_ids`
53    #[arg(long, action = clap::ArgAction::Append, visible_alias = "p")]
54    pub platform: Vec<String>,
55    /// Manual collection id or exact name
56    #[arg(long)]
57    pub collection: Option<String>,
58    /// Smart collection id or exact name
59    #[arg(long)]
60    pub smart_collection: Option<String>,
61    /// Virtual collection id (e.g. recent)
62    #[arg(long)]
63    pub virtual_collection: Option<String>,
64    #[arg(long)]
65    pub limit: Option<u32>,
66    #[arg(long)]
67    pub offset: Option<u32>,
68    #[arg(long)]
69    pub matched: Option<String>,
70    #[arg(long)]
71    pub favorite: Option<String>,
72    #[arg(long)]
73    pub duplicate: Option<String>,
74    #[arg(long)]
75    pub last_played: Option<String>,
76    #[arg(long)]
77    pub playable: Option<String>,
78    #[arg(long)]
79    pub missing: Option<String>,
80    #[arg(long)]
81    pub has_ra: Option<String>,
82    #[arg(long)]
83    pub verified: Option<String>,
84    #[arg(long)]
85    pub group_by_meta_id: Option<String>,
86    #[arg(long)]
87    pub with_char_index: Option<String>,
88    #[arg(long)]
89    pub with_filter_values: Option<String>,
90    #[arg(long = "genre", action = clap::ArgAction::Append)]
91    pub genres: Vec<String>,
92    #[arg(long = "franchise", action = clap::ArgAction::Append)]
93    pub franchises: Vec<String>,
94    #[arg(long = "collection-tag", action = clap::ArgAction::Append)]
95    pub collection_tags: Vec<String>,
96    #[arg(long = "company", action = clap::ArgAction::Append)]
97    pub companies: Vec<String>,
98    #[arg(long = "age-rating", action = clap::ArgAction::Append)]
99    pub age_ratings: Vec<String>,
100    #[arg(long = "status", action = clap::ArgAction::Append)]
101    pub statuses: Vec<String>,
102    #[arg(long = "region", action = clap::ArgAction::Append)]
103    pub regions: Vec<String>,
104    #[arg(long = "language", action = clap::ArgAction::Append)]
105    pub languages: Vec<String>,
106    #[arg(long = "player-count", action = clap::ArgAction::Append)]
107    pub player_counts: Vec<String>,
108    #[arg(long)]
109    pub genres_logic: Option<String>,
110    #[arg(long)]
111    pub franchises_logic: Option<String>,
112    #[arg(long)]
113    pub collections_logic: Option<String>,
114    #[arg(long)]
115    pub companies_logic: Option<String>,
116    #[arg(long)]
117    pub age_ratings_logic: Option<String>,
118    #[arg(long)]
119    pub regions_logic: Option<String>,
120    #[arg(long)]
121    pub languages_logic: Option<String>,
122    #[arg(long)]
123    pub statuses_logic: Option<String>,
124    #[arg(long)]
125    pub player_counts_logic: Option<String>,
126    #[arg(long)]
127    pub order_by: Option<String>,
128    #[arg(long)]
129    pub order_dir: Option<String>,
130    #[arg(long)]
131    pub updated_after: Option<String>,
132}
133
134async fn build_get_roms(client: &RommClient, a: RomListArgs) -> Result<GetRoms> {
135    let platform_ids = resolve_platform_ids(client, &a.platform).await?;
136    let mut platform_id = None;
137    let mut extra = platform_ids;
138    if extra.len() == 1 {
139        platform_id = Some(extra[0]);
140        extra.clear();
141    } else if extra.len() > 1 {
142        platform_id = None;
143    }
144
145    Ok(GetRoms {
146        search_term: a.search_term,
147        platform_id,
148        platform_ids: extra,
149        collection_id: resolve_manual_collection_id(client, a.collection.as_deref()).await?,
150        smart_collection_id: resolve_smart_collection_id(client, a.smart_collection.as_deref())
151            .await?,
152        virtual_collection_id: a
153            .virtual_collection
154            .map(|s| s.trim().to_string())
155            .filter(|s| !s.is_empty()),
156        matched: parse_opt_bool("matched", &a.matched)?,
157        favorite: parse_opt_bool("favorite", &a.favorite)?,
158        duplicate: parse_opt_bool("duplicate", &a.duplicate)?,
159        last_played: parse_opt_bool("last_played", &a.last_played)?,
160        playable: parse_opt_bool("playable", &a.playable)?,
161        missing: parse_opt_bool("missing", &a.missing)?,
162        has_ra: parse_opt_bool("has_ra", &a.has_ra)?,
163        verified: parse_opt_bool("verified", &a.verified)?,
164        group_by_meta_id: parse_opt_bool("group_by_meta_id", &a.group_by_meta_id)?,
165        with_char_index: parse_opt_bool("with_char_index", &a.with_char_index)?,
166        with_filter_values: parse_opt_bool("with_filter_values", &a.with_filter_values)?,
167        genres: a.genres,
168        franchises: a.franchises,
169        collections: a.collection_tags,
170        companies: a.companies,
171        age_ratings: a.age_ratings,
172        statuses: a.statuses,
173        regions: a.regions,
174        languages: a.languages,
175        player_counts: a.player_counts,
176        genres_logic: a.genres_logic,
177        franchises_logic: a.franchises_logic,
178        collections_logic: a.collections_logic,
179        companies_logic: a.companies_logic,
180        age_ratings_logic: a.age_ratings_logic,
181        regions_logic: a.regions_logic,
182        languages_logic: a.languages_logic,
183        statuses_logic: a.statuses_logic,
184        player_counts_logic: a.player_counts_logic,
185        order_by: a.order_by,
186        order_dir: a.order_dir,
187        updated_after: a.updated_after,
188        limit: a.limit,
189        offset: a.offset,
190    })
191}
192
193/// CLI entrypoint for listing/searching ROMs via `/api/roms`.
194#[derive(Args, Debug)]
195pub struct RomsCommand {
196    /// Output as JSON (overrides global --json when set).
197    #[arg(long, global = true)]
198    pub json: bool,
199
200    /// Flags for the default `list` action (`romm-cli roms` with no subcommand, or before a subcommand).
201    #[command(flatten)]
202    pub list: RomListArgs,
203
204    #[command(subcommand)]
205    pub action: Option<RomsAction>,
206}
207
208#[derive(Subcommand, Debug)]
209pub enum RomsAction {
210    /// Get detailed information for a single ROM
211    #[command(visible_alias = "info")]
212    Get {
213        /// The ID of the ROM
214        id: u64,
215    },
216    /// Lookup ROM by file hash or metadata provider id
217    Find {
218        #[arg(long)]
219        crc: Option<String>,
220        #[arg(long)]
221        md5: Option<String>,
222        #[arg(long)]
223        sha1: Option<String>,
224        #[arg(long)]
225        igdb_id: Option<i64>,
226        #[arg(long)]
227        moby_id: Option<i64>,
228        #[arg(long)]
229        ss_id: Option<i64>,
230        #[arg(long)]
231        ra_id: Option<i64>,
232        #[arg(long)]
233        launchbox_id: Option<i64>,
234        #[arg(long)]
235        hasheous_id: Option<i64>,
236        #[arg(long)]
237        tgdb_id: Option<i64>,
238        #[arg(long)]
239        flashpoint_id: Option<String>,
240        #[arg(long)]
241        hltb_id: Option<i64>,
242    },
243    /// Print canonical filter values from `GET /api/roms/filters`
244    Filters,
245    /// Delete ROMs from the database (optional filesystem delete)
246    Delete {
247        /// ROM ids to remove from the database
248        #[arg(required = true)]
249        rom_ids: Vec<u64>,
250        /// Also delete these ROM ids from disk (repeat ids as needed)
251        #[arg(long, action = clap::ArgAction::Append)]
252        delete_from_fs: Vec<u64>,
253        /// Skip confirmation
254        #[arg(long)]
255        yes: bool,
256    },
257    /// Update per-user ROM properties (`PUT /api/roms/{id}/props`)
258    Props {
259        id: u64,
260        #[arg(long)]
261        is_main_sibling: Option<String>,
262        #[arg(long)]
263        backlogged: Option<String>,
264        #[arg(long)]
265        now_playing: Option<String>,
266        #[arg(long)]
267        hidden: Option<String>,
268        #[arg(long)]
269        rating: Option<u8>,
270        #[arg(long)]
271        difficulty: Option<u8>,
272        #[arg(long)]
273        completion: Option<u8>,
274        #[arg(long)]
275        status: Option<String>,
276        #[arg(long)]
277        update_last_played: bool,
278        #[arg(long)]
279        remove_last_played: bool,
280    },
281    /// List notes for a ROM
282    NotesList {
283        rom_id: u64,
284        #[arg(long)]
285        public_only: Option<String>,
286        #[arg(long)]
287        search: Option<String>,
288        #[arg(long = "tag", action = clap::ArgAction::Append)]
289        tags: Vec<String>,
290    },
291    /// Add a note (JSON body string, e.g. {\"title\":\"t\",\"content\":\"c\"})
292    NotesAdd {
293        rom_id: u64,
294        /// JSON object
295        #[arg(long)]
296        json: String,
297    },
298    /// Update a note
299    NotesUpdate {
300        rom_id: u64,
301        note_id: u64,
302        #[arg(long)]
303        json: String,
304    },
305    /// Delete a note
306    NotesDelete { rom_id: u64, note_id: u64 },
307    /// Upload a manual file (`POST /api/roms/{id}/manuals`)
308    ManualsAdd { rom_id: u64, file: PathBuf },
309    /// Search covers and metadata matches
310    CoverSearch {
311        rom_id: u64,
312        #[arg(long)]
313        query: String,
314        #[arg(long, default_value = "name")]
315        search_by: String,
316    },
317    /// Upload a ROM file to a platform
318    #[command(
319        visible_alias = "up",
320        after_help = "Examples:\n  \
321          romm-cli roms upload --platform gba ./games/\n  \
322          romm-cli roms upload --platform 3ds game.3ds --scan --wait"
323    )]
324    Upload {
325        /// Platform slug or name (e.g. "3ds", "Nintendo 3DS")
326        #[arg(long)]
327        platform: String,
328        /// File or directory to upload
329        file: PathBuf,
330        /// Trigger a library scan after upload completes
331        #[arg(short, long)]
332        scan: bool,
333        /// Wait until the library scan finishes (requires `--scan`; polls every 2 seconds)
334        #[arg(long, requires = "scan")]
335        wait: bool,
336        /// Max seconds to wait when `--wait` is set (default: 3600)
337        #[arg(long, requires = "wait")]
338        wait_timeout_secs: Option<u64>,
339    },
340    /// Match, edit, and unmatch game metadata (IGDB, etc.)
341    #[command(visible_alias = "meta")]
342    Metadata(super::metadata::MetadataCommand),
343}
344
345const UPLOAD_PROGRESS_PLAIN: &str =
346    "[{elapsed_precise}] {bar:40} {bytes}/{total_bytes} ({eta}) {msg}";
347const UPLOAD_PROGRESS_COLOR: &str =
348    "[{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} ({eta}) {msg}";
349
350async fn upload_one(
351    client: &RommClient,
352    platform_id: u64,
353    file_path: std::path::PathBuf,
354    pb: ProgressBar,
355) -> Result<()> {
356    let filename = file_path
357        .file_name()
358        .and_then(|n| n.to_str())
359        .unwrap_or("file")
360        .to_string();
361
362    pb.set_message(format!("Uploading {}", filename));
363
364    client
365        .upload_rom(platform_id, &file_path, {
366            let pb = pb.clone();
367            move |uploaded, total| {
368                if pb.length() != Some(total) {
369                    pb.set_length(total);
370                }
371                pb.set_position(uploaded);
372            }
373        })
374        .await?;
375
376    pb.finish_with_message(format!("✓ Upload complete: {}", filename));
377    Ok(())
378}
379
380pub async fn handle(
381    cmd: RomsCommand,
382    client: &RommClient,
383    presentation: CliPresentation,
384) -> Result<()> {
385    let format = presentation.format;
386    match cmd.action {
387        None => {
388            let ep = build_get_roms(client, cmd.list.clone()).await?;
389            let results = client.call(&ep).await?;
390            match format {
391                OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&results)?),
392                OutputFormat::Text => print_roms_table(&results),
393            }
394        }
395        Some(RomsAction::Get { id }) => {
396            let rom = client.call(&GetRom { id }).await?;
397            match format {
398                OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&rom)?),
399                OutputFormat::Text => println!("{}", serde_json::to_string_pretty(&rom)?),
400            }
401        }
402        Some(RomsAction::Find {
403            crc,
404            md5,
405            sha1,
406            igdb_id,
407            moby_id,
408            ss_id,
409            ra_id,
410            launchbox_id,
411            hasheous_id,
412            tgdb_id,
413            flashpoint_id,
414            hltb_id,
415        }) => {
416            let hash_ep = GetRomByHash {
417                crc_hash: crc.clone(),
418                md5_hash: md5.clone(),
419                sha1_hash: sha1.clone(),
420            };
421            let has_hash = crc.is_some() || md5.is_some() || sha1.is_some();
422            let has_meta = igdb_id.is_some()
423                || moby_id.is_some()
424                || ss_id.is_some()
425                || ra_id.is_some()
426                || launchbox_id.is_some()
427                || hasheous_id.is_some()
428                || tgdb_id.is_some()
429                || flashpoint_id.is_some()
430                || hltb_id.is_some();
431            if has_hash == has_meta {
432                anyhow::bail!("Specify either hash flags (--crc/--md5/--sha1) or metadata id flags (--igdb-id, ...), not both.");
433            }
434            let v = if has_hash {
435                client.call(&hash_ep).await?
436            } else {
437                client
438                    .call(&GetRomByMetadataProvider {
439                        igdb_id,
440                        moby_id,
441                        ss_id,
442                        ra_id,
443                        launchbox_id,
444                        hasheous_id,
445                        tgdb_id,
446                        flashpoint_id,
447                        hltb_id,
448                    })
449                    .await?
450            };
451            println!("{}", serde_json::to_string_pretty(&v)?);
452        }
453        Some(RomsAction::Filters) => {
454            let v = client.call(&GetRomFilters).await?;
455            println!("{}", serde_json::to_string_pretty(&v)?);
456        }
457        Some(RomsAction::Delete {
458            rom_ids,
459            delete_from_fs,
460            yes,
461        }) => {
462            if !yes {
463                let ok = Confirm::new()
464                    .with_prompt(format!(
465                        "Delete {} ROM(s) from the database (and {} from disk)?",
466                        rom_ids.len(),
467                        delete_from_fs.len()
468                    ))
469                    .interact()?;
470                if !ok {
471                    return Ok(());
472                }
473            }
474            let v = client
475                .call(&DeleteRoms {
476                    roms: rom_ids,
477                    delete_from_fs,
478                })
479                .await?;
480            println!("{}", serde_json::to_string_pretty(&v)?);
481        }
482        Some(RomsAction::Props {
483            id,
484            is_main_sibling,
485            backlogged,
486            now_playing,
487            hidden,
488            rating,
489            difficulty,
490            completion,
491            status,
492            update_last_played,
493            remove_last_played,
494        }) => {
495            if update_last_played && remove_last_played {
496                anyhow::bail!(
497                    "--update-last-played and --remove-last-played are mutually exclusive."
498                );
499            }
500            let mut body = json!({});
501            let obj = body.as_object_mut().unwrap();
502            if let Some(b) = parse_opt_bool("is_main_sibling", &is_main_sibling)? {
503                obj.insert("is_main_sibling".into(), json!(b));
504            }
505            if let Some(b) = parse_opt_bool("backlogged", &backlogged)? {
506                obj.insert("backlogged".into(), json!(b));
507            }
508            if let Some(b) = parse_opt_bool("now_playing", &now_playing)? {
509                obj.insert("now_playing".into(), json!(b));
510            }
511            if let Some(b) = parse_opt_bool("hidden", &hidden)? {
512                obj.insert("hidden".into(), json!(b));
513            }
514            if let Some(r) = rating {
515                obj.insert("rating".into(), json!(r));
516            }
517            if let Some(d) = difficulty {
518                obj.insert("difficulty".into(), json!(d));
519            }
520            if let Some(c) = completion {
521                obj.insert("completion".into(), json!(c));
522            }
523            if let Some(ref s) = status {
524                if !s.is_empty() {
525                    obj.insert("status".into(), json!(s));
526                }
527            }
528            let v = client
529                .call(&PutRomUserProps {
530                    rom_id: id,
531                    body,
532                    update_last_played,
533                    remove_last_played,
534                })
535                .await?;
536            println!("{}", serde_json::to_string_pretty(&v)?);
537        }
538        Some(RomsAction::NotesList {
539            rom_id,
540            public_only,
541            search,
542            tags,
543        }) => {
544            let v = client
545                .call(&GetRomNotes {
546                    rom_id,
547                    public_only: parse_opt_bool("public_only", &public_only)?,
548                    search,
549                    tags,
550                })
551                .await?;
552            println!("{}", serde_json::to_string_pretty(&v)?);
553        }
554        Some(RomsAction::NotesAdd { rom_id, json: body }) => {
555            let parsed: serde_json::Value = serde_json::from_str(&body)?;
556            let v = client
557                .call(&PostRomNote {
558                    rom_id,
559                    body: parsed,
560                })
561                .await?;
562            println!("{}", serde_json::to_string_pretty(&v)?);
563        }
564        Some(RomsAction::NotesUpdate {
565            rom_id,
566            note_id,
567            json: body,
568        }) => {
569            let parsed: serde_json::Value = serde_json::from_str(&body)?;
570            let v = client
571                .call(&PutRomNote {
572                    rom_id,
573                    note_id,
574                    body: parsed,
575                })
576                .await?;
577            println!("{}", serde_json::to_string_pretty(&v)?);
578        }
579        Some(RomsAction::NotesDelete { rom_id, note_id }) => {
580            let v = client.call(&DeleteRomNote { rom_id, note_id }).await?;
581            println!("{}", serde_json::to_string_pretty(&v)?);
582        }
583        Some(RomsAction::ManualsAdd { rom_id, file }) => {
584            let v = client.upload_rom_manual(rom_id, &file).await?;
585            println!("{}", serde_json::to_string_pretty(&v)?);
586        }
587        Some(RomsAction::CoverSearch {
588            rom_id,
589            query,
590            search_by,
591        }) => {
592            let cover = client
593                .call(&GetSearchCover {
594                    search_term: query.clone(),
595                })
596                .await?;
597            let roms = client
598                .call(&GetSearchRoms {
599                    rom_id,
600                    search_term: Some(query),
601                    search_by: Some(search_by),
602                })
603                .await?;
604            let out = json!({ "cover": cover, "roms": roms });
605            println!("{}", serde_json::to_string_pretty(&out)?);
606        }
607        Some(RomsAction::Metadata(cmd)) => {
608            super::metadata::handle(cmd, client, presentation).await?;
609        }
610        Some(RomsAction::Upload {
611            file,
612            platform,
613            scan,
614            wait,
615            wait_timeout_secs,
616        }) => {
617            let resolved_platform_id = match resolve_platform_id(client, Some(platform.trim()))
618                .await?
619            {
620                Some(id) => id,
621                None => {
622                    return Err(anyhow!(
623                        "`--platform` must not be empty (use a slug or name from `romm-cli platforms list`)"
624                    ));
625                }
626            };
627
628            if !file.exists() {
629                anyhow::bail!("File or directory does not exist: {:?}", file);
630            }
631
632            let mut files = Vec::new();
633            if file.is_dir() {
634                let mut entries = tokio::fs::read_dir(&file).await?;
635                while let Some(entry) = entries.next_entry().await? {
636                    let path = entry.path();
637                    if path.is_file() {
638                        files.push(path);
639                    }
640                }
641                files.sort();
642            } else {
643                files.push(file);
644            }
645
646            if files.is_empty() {
647                if presentation.is_json() {
648                    println!(
649                        "{}",
650                        serde_json::to_string_pretty(&serde_json::json!({
651                            "uploaded": 0,
652                            "failed": 0,
653                            "total": 0
654                        }))?
655                    );
656                } else {
657                    println!("No files found to upload.");
658                }
659                return Ok(());
660            }
661
662            if presentation.is_text() && files.len() > 1 {
663                println!("Found {} files to upload.", files.len());
664            }
665
666            let mp = presentation.multi_progress();
667            let style = presentation.progress_style(UPLOAD_PROGRESS_PLAIN, UPLOAD_PROGRESS_COLOR);
668            let total = files.len() as u32;
669            let mut successes = 0u32;
670            let mut failures = 0u32;
671            for path in files {
672                let pb = if let Some(ref mp) = mp {
673                    let pb = mp.add(ProgressBar::new(0));
674                    pb.set_style(style.clone());
675                    pb
676                } else {
677                    ProgressBar::hidden()
678                };
679                match upload_one(client, resolved_platform_id, path.clone(), pb).await {
680                    Ok(()) => successes += 1,
681                    Err(e) => {
682                        failures += 1;
683                        eprintln!("Error uploading {:?}: {}", path, e);
684                    }
685                }
686            }
687
688            if presentation.is_json() {
689                println!(
690                    "{}",
691                    serde_json::to_string_pretty(&serde_json::json!({
692                        "uploaded": successes,
693                        "failed": failures,
694                        "total": total
695                    }))?
696                );
697            }
698
699            if scan {
700                if successes == 0 {
701                    eprintln!("Skipping library scan: no uploads completed successfully.");
702                } else {
703                    let options = ScanLibraryOptions {
704                        wait,
705                        wait_timeout: Duration::from_secs(wait_timeout_secs.unwrap_or(3600)),
706                        cache_invalidate: if wait {
707                            ScanCacheInvalidate::Platform(resolved_platform_id)
708                        } else {
709                            ScanCacheInvalidate::None
710                        },
711                        task_kwargs: None,
712                    };
713                    run_scan_library_flow(client, options, presentation, None).await?;
714                }
715            }
716        }
717    }
718
719    Ok(())
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use clap::Parser;
726
727    use crate::commands::{Cli, Commands};
728
729    #[test]
730    fn parse_roms_list_with_platform_filter() {
731        let cli = Cli::parse_from(["romm-cli", "roms", "--platform", "3ds", "--limit", "10"]);
732        let Commands::Roms(cmd) = cli.command else {
733            panic!("expected roms command");
734        };
735        assert!(cmd.action.is_none());
736        assert_eq!(cmd.list.platform, vec!["3ds".to_string()]);
737        assert_eq!(cmd.list.limit, Some(10));
738    }
739
740    #[test]
741    fn parse_roms_get_rejects_list_only_filter() {
742        let parsed = Cli::try_parse_from(["romm-cli", "roms", "get", "1", "--platform", "3ds"]);
743        assert!(parsed.is_err(), "expected clap parse failure");
744    }
745
746    #[test]
747    fn parse_roms_list_rejects_platform_id_flag() {
748        let parsed = Cli::try_parse_from(["romm-cli", "roms", "--platform-id", "3"]);
749        assert!(parsed.is_err(), "expected clap parse failure");
750    }
751
752    #[test]
753    fn parse_roms_upload_requires_platform() {
754        let parsed = Cli::try_parse_from(["romm-cli", "roms", "upload", "foo.bin"]);
755        assert!(
756            parsed.is_err(),
757            "expected clap parse failure without --platform"
758        );
759    }
760
761    #[test]
762    fn parse_roms_upload_with_platform_and_file() {
763        let cli = Cli::parse_from(["romm-cli", "roms", "upload", "--platform", "3ds", "foo.bin"]);
764        let Commands::Roms(cmd) = cli.command else {
765            panic!("expected roms command");
766        };
767        let Some(RomsAction::Upload { platform, file, .. }) = cmd.action else {
768            panic!("expected roms upload");
769        };
770        assert_eq!(platform, "3ds");
771        assert_eq!(file, PathBuf::from("foo.bin"));
772    }
773}