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}
341
342const UPLOAD_PROGRESS_PLAIN: &str =
343    "[{elapsed_precise}] {bar:40} {bytes}/{total_bytes} ({eta}) {msg}";
344const UPLOAD_PROGRESS_COLOR: &str =
345    "[{elapsed_precise}] {bar:40.cyan/blue} {bytes}/{total_bytes} ({eta}) {msg}";
346
347async fn upload_one(
348    client: &RommClient,
349    platform_id: u64,
350    file_path: std::path::PathBuf,
351    pb: ProgressBar,
352) -> Result<()> {
353    let filename = file_path
354        .file_name()
355        .and_then(|n| n.to_str())
356        .unwrap_or("file")
357        .to_string();
358
359    pb.set_message(format!("Uploading {}", filename));
360
361    client
362        .upload_rom(platform_id, &file_path, {
363            let pb = pb.clone();
364            move |uploaded, total| {
365                if pb.length() != Some(total) {
366                    pb.set_length(total);
367                }
368                pb.set_position(uploaded);
369            }
370        })
371        .await?;
372
373    pb.finish_with_message(format!("✓ Upload complete: {}", filename));
374    Ok(())
375}
376
377pub async fn handle(
378    cmd: RomsCommand,
379    client: &RommClient,
380    presentation: CliPresentation,
381) -> Result<()> {
382    let format = presentation.format;
383    match cmd.action {
384        None => {
385            let ep = build_get_roms(client, cmd.list.clone()).await?;
386            let results = client.call(&ep).await?;
387            match format {
388                OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&results)?),
389                OutputFormat::Text => print_roms_table(&results),
390            }
391        }
392        Some(RomsAction::Get { id }) => {
393            let rom = client.call(&GetRom { id }).await?;
394            match format {
395                OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&rom)?),
396                OutputFormat::Text => println!("{}", serde_json::to_string_pretty(&rom)?),
397            }
398        }
399        Some(RomsAction::Find {
400            crc,
401            md5,
402            sha1,
403            igdb_id,
404            moby_id,
405            ss_id,
406            ra_id,
407            launchbox_id,
408            hasheous_id,
409            tgdb_id,
410            flashpoint_id,
411            hltb_id,
412        }) => {
413            let hash_ep = GetRomByHash {
414                crc_hash: crc.clone(),
415                md5_hash: md5.clone(),
416                sha1_hash: sha1.clone(),
417            };
418            let has_hash = crc.is_some() || md5.is_some() || sha1.is_some();
419            let has_meta = igdb_id.is_some()
420                || moby_id.is_some()
421                || ss_id.is_some()
422                || ra_id.is_some()
423                || launchbox_id.is_some()
424                || hasheous_id.is_some()
425                || tgdb_id.is_some()
426                || flashpoint_id.is_some()
427                || hltb_id.is_some();
428            if has_hash == has_meta {
429                anyhow::bail!("Specify either hash flags (--crc/--md5/--sha1) or metadata id flags (--igdb-id, ...), not both.");
430            }
431            let v = if has_hash {
432                client.call(&hash_ep).await?
433            } else {
434                client
435                    .call(&GetRomByMetadataProvider {
436                        igdb_id,
437                        moby_id,
438                        ss_id,
439                        ra_id,
440                        launchbox_id,
441                        hasheous_id,
442                        tgdb_id,
443                        flashpoint_id,
444                        hltb_id,
445                    })
446                    .await?
447            };
448            println!("{}", serde_json::to_string_pretty(&v)?);
449        }
450        Some(RomsAction::Filters) => {
451            let v = client.call(&GetRomFilters).await?;
452            println!("{}", serde_json::to_string_pretty(&v)?);
453        }
454        Some(RomsAction::Delete {
455            rom_ids,
456            delete_from_fs,
457            yes,
458        }) => {
459            if !yes {
460                let ok = Confirm::new()
461                    .with_prompt(format!(
462                        "Delete {} ROM(s) from the database (and {} from disk)?",
463                        rom_ids.len(),
464                        delete_from_fs.len()
465                    ))
466                    .interact()?;
467                if !ok {
468                    return Ok(());
469                }
470            }
471            let v = client
472                .call(&DeleteRoms {
473                    roms: rom_ids,
474                    delete_from_fs,
475                })
476                .await?;
477            println!("{}", serde_json::to_string_pretty(&v)?);
478        }
479        Some(RomsAction::Props {
480            id,
481            is_main_sibling,
482            backlogged,
483            now_playing,
484            hidden,
485            rating,
486            difficulty,
487            completion,
488            status,
489            update_last_played,
490            remove_last_played,
491        }) => {
492            if update_last_played && remove_last_played {
493                anyhow::bail!(
494                    "--update-last-played and --remove-last-played are mutually exclusive."
495                );
496            }
497            let mut body = json!({});
498            let obj = body.as_object_mut().unwrap();
499            if let Some(b) = parse_opt_bool("is_main_sibling", &is_main_sibling)? {
500                obj.insert("is_main_sibling".into(), json!(b));
501            }
502            if let Some(b) = parse_opt_bool("backlogged", &backlogged)? {
503                obj.insert("backlogged".into(), json!(b));
504            }
505            if let Some(b) = parse_opt_bool("now_playing", &now_playing)? {
506                obj.insert("now_playing".into(), json!(b));
507            }
508            if let Some(b) = parse_opt_bool("hidden", &hidden)? {
509                obj.insert("hidden".into(), json!(b));
510            }
511            if let Some(r) = rating {
512                obj.insert("rating".into(), json!(r));
513            }
514            if let Some(d) = difficulty {
515                obj.insert("difficulty".into(), json!(d));
516            }
517            if let Some(c) = completion {
518                obj.insert("completion".into(), json!(c));
519            }
520            if let Some(ref s) = status {
521                if !s.is_empty() {
522                    obj.insert("status".into(), json!(s));
523                }
524            }
525            let v = client
526                .call(&PutRomUserProps {
527                    rom_id: id,
528                    body,
529                    update_last_played,
530                    remove_last_played,
531                })
532                .await?;
533            println!("{}", serde_json::to_string_pretty(&v)?);
534        }
535        Some(RomsAction::NotesList {
536            rom_id,
537            public_only,
538            search,
539            tags,
540        }) => {
541            let v = client
542                .call(&GetRomNotes {
543                    rom_id,
544                    public_only: parse_opt_bool("public_only", &public_only)?,
545                    search,
546                    tags,
547                })
548                .await?;
549            println!("{}", serde_json::to_string_pretty(&v)?);
550        }
551        Some(RomsAction::NotesAdd { rom_id, json: body }) => {
552            let parsed: serde_json::Value = serde_json::from_str(&body)?;
553            let v = client
554                .call(&PostRomNote {
555                    rom_id,
556                    body: parsed,
557                })
558                .await?;
559            println!("{}", serde_json::to_string_pretty(&v)?);
560        }
561        Some(RomsAction::NotesUpdate {
562            rom_id,
563            note_id,
564            json: body,
565        }) => {
566            let parsed: serde_json::Value = serde_json::from_str(&body)?;
567            let v = client
568                .call(&PutRomNote {
569                    rom_id,
570                    note_id,
571                    body: parsed,
572                })
573                .await?;
574            println!("{}", serde_json::to_string_pretty(&v)?);
575        }
576        Some(RomsAction::NotesDelete { rom_id, note_id }) => {
577            let v = client.call(&DeleteRomNote { rom_id, note_id }).await?;
578            println!("{}", serde_json::to_string_pretty(&v)?);
579        }
580        Some(RomsAction::ManualsAdd { rom_id, file }) => {
581            let v = client.upload_rom_manual(rom_id, &file).await?;
582            println!("{}", serde_json::to_string_pretty(&v)?);
583        }
584        Some(RomsAction::CoverSearch {
585            rom_id,
586            query,
587            search_by,
588        }) => {
589            let cover = client
590                .call(&GetSearchCover {
591                    search_term: query.clone(),
592                })
593                .await?;
594            let roms = client
595                .call(&GetSearchRoms {
596                    rom_id,
597                    search_term: Some(query),
598                    search_by: Some(search_by),
599                })
600                .await?;
601            let out = json!({ "cover": cover, "roms": roms });
602            println!("{}", serde_json::to_string_pretty(&out)?);
603        }
604        Some(RomsAction::Upload {
605            file,
606            platform,
607            scan,
608            wait,
609            wait_timeout_secs,
610        }) => {
611            let resolved_platform_id = match resolve_platform_id(client, Some(platform.trim()))
612                .await?
613            {
614                Some(id) => id,
615                None => {
616                    return Err(anyhow!(
617                        "`--platform` must not be empty (use a slug or name from `romm-cli platforms list`)"
618                    ));
619                }
620            };
621
622            if !file.exists() {
623                anyhow::bail!("File or directory does not exist: {:?}", file);
624            }
625
626            let mut files = Vec::new();
627            if file.is_dir() {
628                let mut entries = tokio::fs::read_dir(&file).await?;
629                while let Some(entry) = entries.next_entry().await? {
630                    let path = entry.path();
631                    if path.is_file() {
632                        files.push(path);
633                    }
634                }
635                files.sort();
636            } else {
637                files.push(file);
638            }
639
640            if files.is_empty() {
641                if presentation.is_json() {
642                    println!(
643                        "{}",
644                        serde_json::to_string_pretty(&serde_json::json!({
645                            "uploaded": 0,
646                            "failed": 0,
647                            "total": 0
648                        }))?
649                    );
650                } else {
651                    println!("No files found to upload.");
652                }
653                return Ok(());
654            }
655
656            if presentation.is_text() && files.len() > 1 {
657                println!("Found {} files to upload.", files.len());
658            }
659
660            let mp = presentation.multi_progress();
661            let style = presentation.progress_style(UPLOAD_PROGRESS_PLAIN, UPLOAD_PROGRESS_COLOR);
662            let total = files.len() as u32;
663            let mut successes = 0u32;
664            let mut failures = 0u32;
665            for path in files {
666                let pb = if let Some(ref mp) = mp {
667                    let pb = mp.add(ProgressBar::new(0));
668                    pb.set_style(style.clone());
669                    pb
670                } else {
671                    ProgressBar::hidden()
672                };
673                match upload_one(client, resolved_platform_id, path.clone(), pb).await {
674                    Ok(()) => successes += 1,
675                    Err(e) => {
676                        failures += 1;
677                        eprintln!("Error uploading {:?}: {}", path, e);
678                    }
679                }
680            }
681
682            if presentation.is_json() {
683                println!(
684                    "{}",
685                    serde_json::to_string_pretty(&serde_json::json!({
686                        "uploaded": successes,
687                        "failed": failures,
688                        "total": total
689                    }))?
690                );
691            }
692
693            if scan {
694                if successes == 0 {
695                    eprintln!("Skipping library scan: no uploads completed successfully.");
696                } else {
697                    let options = ScanLibraryOptions {
698                        wait,
699                        wait_timeout: Duration::from_secs(wait_timeout_secs.unwrap_or(3600)),
700                        cache_invalidate: if wait {
701                            ScanCacheInvalidate::Platform(resolved_platform_id)
702                        } else {
703                            ScanCacheInvalidate::None
704                        },
705                        task_kwargs: None,
706                    };
707                    run_scan_library_flow(client, options, presentation, None).await?;
708                }
709            }
710        }
711    }
712
713    Ok(())
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use clap::Parser;
720
721    use crate::commands::{Cli, Commands};
722
723    #[test]
724    fn parse_roms_list_with_platform_filter() {
725        let cli = Cli::parse_from(["romm-cli", "roms", "--platform", "3ds", "--limit", "10"]);
726        let Commands::Roms(cmd) = cli.command else {
727            panic!("expected roms command");
728        };
729        assert!(cmd.action.is_none());
730        assert_eq!(cmd.list.platform, vec!["3ds".to_string()]);
731        assert_eq!(cmd.list.limit, Some(10));
732    }
733
734    #[test]
735    fn parse_roms_get_rejects_list_only_filter() {
736        let parsed = Cli::try_parse_from(["romm-cli", "roms", "get", "1", "--platform", "3ds"]);
737        assert!(parsed.is_err(), "expected clap parse failure");
738    }
739
740    #[test]
741    fn parse_roms_list_rejects_platform_id_flag() {
742        let parsed = Cli::try_parse_from(["romm-cli", "roms", "--platform-id", "3"]);
743        assert!(parsed.is_err(), "expected clap parse failure");
744    }
745
746    #[test]
747    fn parse_roms_upload_requires_platform() {
748        let parsed = Cli::try_parse_from(["romm-cli", "roms", "upload", "foo.bin"]);
749        assert!(
750            parsed.is_err(),
751            "expected clap parse failure without --platform"
752        );
753    }
754
755    #[test]
756    fn parse_roms_upload_with_platform_and_file() {
757        let cli = Cli::parse_from(["romm-cli", "roms", "upload", "--platform", "3ds", "foo.bin"]);
758        let Commands::Roms(cmd) = cli.command else {
759            panic!("expected roms command");
760        };
761        let Some(RomsAction::Upload { platform, file, .. }) = cmd.action else {
762            panic!("expected roms upload");
763        };
764        assert_eq!(platform, "3ds");
765        assert_eq!(file, PathBuf::from("foo.bin"));
766    }
767}