Skip to main content

toko_feed_cli/
lib.rs

1//! Installed operator CLI for a Toko Feed canister.
2//!
3//! The process boundary delegates identity and network handling to `icp`, but
4//! encodes and decodes the canister protocol with shared Rust types. Users do
5//! not need to write Candid arguments or interpret Candid values.
6
7mod backup;
8mod cache;
9mod output;
10mod pagination;
11mod table;
12mod transport;
13
14use std::{
15    collections::HashSet,
16    io::{self, Write},
17    path::PathBuf,
18    str::FromStr,
19};
20
21use candid::{CandidType, encode_args};
22use clap::{Args, Parser, Subcommand, ValueEnum, error::ErrorKind};
23use serde::{Serialize, de::DeserializeOwned};
24use serde_json::{Value, json};
25use thiserror::Error;
26use toko_feed::{
27    CatalogStatusArgs, CatalogStatusPage, Collection, CollectionDetails, CollectionPage,
28    CollectionSourcePage, CollectionSourceView, CuratePokemonCardMetadataArgs, IngestionRunPage,
29    ItemFinish, LockPokemonCardArgs, LockPokemonSetArgs, OperationalLogPage, PokemonCardDetails,
30    PokemonCardMetadataDetails, PokemonCardPage, PokemonCardPrintingPage, PokemonCardPrintingView,
31    PokemonCardReconciliationPage, PokemonCardSearchPage, PokemonCardSourcePage,
32    PokemonCardSourceView, PokemonCardView, PokemonLanguage, PokemonSealedDetails,
33    PokemonSealedPage, PokemonSealedSourcePage, PokemonSealedSourceView, PokemonSetDetails,
34    PokemonSetEvidenceView, PokemonSetPage, PokemonSetReconciliationPage, PokemonSetSourcePage,
35    PokemonSetView, PokemonType, PriceObservationPage, Provider, ReconcilePokemonCardArgs,
36    ReconcilePokemonCardPrintingArgs, ReconcilePokemonCardsArgs, ReconcilePokemonCardsReceipt,
37    ReconcilePokemonSetArgs, ReconcileSourceSetArgs, ReconcileSourceSetReceipt, SchedulerStatus,
38    SealedPriceObservationPage, SetLifecycleStatus, TimeCursor, UnlockCanonicalArgs,
39    VerifyCanonicalArgs,
40};
41
42use backup::DEFAULT_CANONICAL_BACKUP_PATH;
43use cache::default_provider_source;
44use output::{OutputFormat, ReportKind};
45use pagination::collect_pages;
46use transport::{
47    call_and_decode, call_empty, call_history_page, call_item_history_page, call_one, call_page,
48};
49
50const DEFAULT_LIMIT: u16 = 20;
51const DEFAULT_MAX_PAGES: usize = 1_000;
52const MAX_QUERY_LIMIT: u16 = 100;
53const DEFAULT_PROVIDER_COMPARISON_RECORDS: u16 = 10;
54const MAX_PROVIDER_SET_RECORDS: u16 = 1_000;
55const MAX_RECONCILIATION_SOURCES: usize = 8;
56const MAX_SAFE_RECONCILIATION_CARDS: u16 = 10;
57
58/// Failure reported by CLI parsing, the ICP process boundary, or the typed
59/// canister protocol.
60#[derive(Debug, Error)]
61pub enum CliError {
62    /// Clap rejected the command line before any canister call was attempted.
63    #[error(transparent)]
64    Clap(#[from] clap::Error),
65    /// Command-line arguments did not describe a valid operation.
66    #[error("{0}\n\nRun with --help for usage.")]
67    Usage(String),
68    /// The ICP CLI process could not be started.
69    #[error("could not start icp: {0}")]
70    StartIcp(#[source] io::Error),
71    /// The ICP CLI process reported a failed call.
72    #[error("icp call failed{status}: {message}")]
73    Icp {
74        /// Numeric process exit status, when the platform supplied one.
75        status: String,
76        /// Bounded diagnostic written by the ICP CLI.
77        message: String,
78    },
79    /// Typed Candid argument encoding failed before the process was started.
80    #[error("could not encode arguments for `{method}`: {source}")]
81    Encode {
82        /// Canister method being prepared.
83        method: &'static str,
84        /// Candid codec failure.
85        #[source]
86        source: candid::Error,
87    },
88    /// The canister reply was not valid for the shared interface type.
89    #[error("could not decode the typed reply from `{method}`: {source}")]
90    Decode {
91        /// Canister method whose reply was being decoded.
92        method: &'static str,
93        /// Candid codec failure.
94        #[source]
95        source: candid::Error,
96    },
97    /// The ICP CLI returned something other than one bounded hexadecimal reply.
98    #[error("icp returned an invalid raw reply: {0}")]
99    RawReply(&'static str),
100    /// A temporary binary Candid argument file could not be prepared.
101    #[error("could not prepare temporary Candid arguments: {0}")]
102    ArgumentFile(#[source] io::Error),
103    /// A typed application error was returned by the canister.
104    #[error("canister method `{method}` returned {error}")]
105    Canister {
106        /// Called canister method.
107        method: &'static str,
108        /// Credential-free bounded application error.
109        error: String,
110    },
111    /// A provider-set card acquisition failed after earlier cards were
112    /// checkpointed successfully.
113    #[error("provider `{provider}` set `{set_id}` failed at card offset {offset}: {source}")]
114    ProviderCard {
115        /// Provider adapter being acquired.
116        provider: &'static str,
117        /// Provider-owned set identifier.
118        set_id: String,
119        /// Zero-based card position in the provider set index.
120        offset: u64,
121        /// Bounded canister or transport failure.
122        #[source]
123        source: Box<Self>,
124    },
125    /// Replaying a bounded group of retained provider records failed.
126    #[error(
127        "provider cache replay for `{provider}` {kind} records {first_record}-{last_record}{set} failed: {source}"
128    )]
129    ProviderReplay {
130        /// Provider adapter represented by the retained records.
131        provider: String,
132        /// Human-readable cache partition kind.
133        kind: &'static str,
134        /// Optional provider-owned set context.
135        set: String,
136        /// One-based first record position in this cache partition.
137        first_record: usize,
138        /// One-based last record position in this cache partition.
139        last_record: usize,
140        /// Bounded canister or transport failure.
141        #[source]
142        source: Box<Self>,
143    },
144    /// JSON input or output could not be decoded or encoded.
145    #[error("could not process JSON: {0}")]
146    Json(#[from] serde_json::Error),
147    /// Writing the final report failed.
148    #[error("could not write output: {0}")]
149    Io(#[source] io::Error),
150    /// Reading or atomically replacing a provider cache failed.
151    #[error("could not {action} provider cache `{}`: {source}", path.display())]
152    CacheIo {
153        /// Filesystem operation being attempted.
154        action: &'static str,
155        /// Cache artifact path.
156        path: PathBuf,
157        /// Underlying filesystem failure.
158        #[source]
159        source: io::Error,
160    },
161    /// Writing a canonical JSON backup failed.
162    #[error("could not {action} canonical backup `{}`: {source}", path.display())]
163    BackupIo {
164        /// Filesystem operation being attempted.
165        action: &'static str,
166        /// Backup path involved in the failed operation.
167        path: PathBuf,
168        /// Underlying filesystem failure.
169        #[source]
170        source: io::Error,
171    },
172    /// A provider cache did not satisfy its bounded format contract.
173    #[error("invalid provider cache: {0}")]
174    InvalidCache(String),
175    /// A canister returned a continuation that had already been observed.
176    #[error("pagination cursor did not advance: {0}")]
177    PaginationStalled(String),
178    /// Automatic pagination reached its operator-provided safety bound.
179    #[error("listing still had another page after the --max-pages limit of {0}")]
180    PaginationLimit(usize),
181}
182
183impl CliError {
184    /// Shell exit status for this failure category.
185    #[must_use]
186    pub fn exit_code(&self) -> i32 {
187        match self {
188            Self::Clap(error) => error.exit_code(),
189            Self::Usage(_) => 2,
190            _ => 1,
191        }
192    }
193
194    /// Whether a downstream pipeline closed standard output intentionally.
195    #[must_use]
196    pub fn is_broken_pipe(&self) -> bool {
197        matches!(self, Self::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)
198    }
199}
200
201#[derive(Clone, Debug, Eq, PartialEq, Args)]
202struct Target {
203    /// ICP environment to use.
204    #[arg(
205        long,
206        default_value = "local",
207        value_name = "NAME",
208        help_heading = "Connection"
209    )]
210    environment: String,
211    /// Canister name or principal.
212    #[arg(
213        long,
214        default_value = "toko-feed",
215        value_name = "NAME|ID",
216        help_heading = "Connection"
217    )]
218    canister: String,
219    /// ICP identity used for calls.
220    #[arg(
221        long,
222        default_value = "anonymous",
223        value_name = "NAME",
224        help_heading = "Connection"
225    )]
226    identity: String,
227    /// Read an encrypted identity password from this file.
228    #[arg(long, value_name = "PATH", help_heading = "Connection")]
229    identity_password_file: Option<PathBuf>,
230    /// Override ICP project discovery.
231    #[arg(long, value_name = "PATH", help_heading = "Connection")]
232    project_root: Option<PathBuf>,
233    /// ICP CLI executable.
234    #[arg(
235        long,
236        default_value = "icp",
237        value_name = "PATH",
238        help_heading = "Connection"
239    )]
240    icp: PathBuf,
241}
242
243#[derive(Clone, Copy, Debug, Eq, PartialEq, Args)]
244struct OutputArgs {
245    /// Print raw JSON instead of human-readable text.
246    #[arg(long, global = true, help_heading = "Output")]
247    json: bool,
248    /// Print JSON on one line.
249    #[arg(long, global = true, requires = "json", help_heading = "Output")]
250    compact: bool,
251}
252
253impl OutputArgs {
254    const fn format(self) -> OutputFormat {
255        if self.json {
256            OutputFormat::Json
257        } else {
258            OutputFormat::Text
259        }
260    }
261}
262
263#[derive(Debug, Parser)]
264#[command(
265    name = "toko-feed",
266    version,
267    about = "Operate and query a Toko Feed canister",
268    long_about = None,
269    arg_required_else_help = true
270)]
271struct Cli {
272    #[command(flatten)]
273    target: Target,
274    #[command(flatten)]
275    output: OutputArgs,
276    #[command(subcommand)]
277    command: RootCommand,
278}
279
280#[derive(Debug, Subcommand)]
281enum RootCommand {
282    /// Replay every retained provider-source cache without network access.
283    Bootstrap,
284    /// Back up canonical truth as tracked JSON.
285    Backup(BackupArgs),
286    /// Restore canonical truth from a tracked JSON backup.
287    Restore(RestoreArgs),
288    /// Show canonical, source, lock, and next-action progress by collection/set.
289    Status(StatusArgs),
290    /// Show durable scheduler state.
291    Scheduler,
292    /// Inspect ingestion run history.
293    Runs(HistoryArgs),
294    /// Inspect sanitized operational logs.
295    Logs(HistoryArgs),
296    /// Query canonical collectible collections.
297    Collections(CollectionsGroup),
298    /// Query, verify, lock, or unlock canonical sets.
299    Sets(SetsGroup),
300    /// Query, verify, lock, or unlock canonical cards.
301    Cards(CardsGroup),
302    /// Query current sealed-product projections.
303    Sealed(SealedGroup),
304    /// Inspect provider evidence independently of canonical truth.
305    Sources(SourceKindsGroup),
306    /// Acquire or replay bounded provider evidence.
307    Provider(ProviderGroup),
308    /// Compare provider evidence and explicitly accept canonical truth.
309    Reconcile(ReconcileGroup),
310}
311
312#[derive(Debug, Args)]
313struct ReconcileGroup {
314    #[command(subcommand)]
315    command: ReconcileCommand,
316}
317
318#[derive(Debug, Subcommand)]
319enum ReconcileCommand {
320    /// Reconcile set evidence without naming a provider adapter.
321    Sets(ReconcileSetsGroup),
322    /// Reconcile card evidence without naming a provider adapter.
323    Cards(ReconcileCardsGroup),
324}
325
326#[derive(Debug, Args)]
327struct ReconcileSetsGroup {
328    #[command(subcommand)]
329    command: ReconcileSetsCommand,
330}
331
332#[derive(Debug, Subcommand)]
333enum ReconcileSetsCommand {
334    /// Preview dynamically grouped provider evidence.
335    Plan(ReconciliationPlanArgs),
336    /// Normalize one provider set into provisional collection truth.
337    AcceptSource(SourceSetReconcileArgs),
338    /// Accept one corroborated plan using its comparison key.
339    AcceptSafe(MatchKeyArg),
340    /// Accept selected sources with curator-supplied canonical fields.
341    Accept(SetReconcileArgs),
342}
343
344#[derive(Debug, Args)]
345struct ReconcileCardsGroup {
346    #[command(subcommand)]
347    command: ReconcileCardsCommand,
348}
349
350#[derive(Debug, Subcommand)]
351enum ReconcileCardsCommand {
352    /// Preview dynamically grouped evidence for one canonical set.
353    Plan(CardReconciliationPlanArgs),
354    /// Accept one or all bounded pages of safely corroborated cards.
355    AcceptSafe(CardSafeReconcileArgs),
356    /// Accept selected sources with curator-supplied canonical fields.
357    Accept(CardReconcileArgs),
358    /// Attach provider-specific collector variants beneath one card.
359    AcceptPrinting(CardPrintingReconcileArgs),
360    /// Promote one mapped provider payload into provisional structured metadata.
361    AcceptMetadata(CardMetadataReconcileArgs),
362}
363
364#[derive(Debug, Args)]
365struct BackupArgs {
366    /// Destination for the canonical JSON snapshot.
367    #[arg(long, value_name = "PATH", default_value = DEFAULT_CANONICAL_BACKUP_PATH)]
368    output: PathBuf,
369}
370
371#[derive(Debug, Args)]
372struct RestoreArgs {
373    /// Canonical JSON snapshot to restore.
374    #[arg(long, value_name = "PATH", default_value = DEFAULT_CANONICAL_BACKUP_PATH)]
375    input: PathBuf,
376}
377
378#[derive(Debug, Args)]
379struct StatusArgs {
380    /// Restrict rows to one canonical collection name or unique leading fragment.
381    #[arg(long, value_name = "COLLECTION")]
382    collection: Option<Collection>,
383    /// Restrict rows to one canonical/provider set ID or unique leading name fragment.
384    #[arg(long, value_name = "SET", requires = "collection")]
385    set: Option<ProviderSetId>,
386    #[command(flatten)]
387    list: ListArgs,
388}
389
390#[derive(Debug, Args)]
391struct ProviderGroup {
392    #[command(subcommand)]
393    command: ProviderCommand,
394}
395
396#[derive(Debug, Subcommand)]
397enum ProviderCommand {
398    /// Synchronize one provider set with its local source cache.
399    Set(ProviderSetArgs),
400}
401
402#[derive(Debug, Args)]
403struct ProviderSetArgs {
404    /// Provider name or unique leading fragment, such as `just`, `tcgdex`, or `scry`.
405    #[arg(value_name = "PROVIDER")]
406    provider: Provider,
407    /// Collection name, slug, or unique leading fragment.
408    #[arg(long, default_value = "pokemon", value_name = "COLLECTION")]
409    collection: Collection,
410    /// Provider-owned set identifier or an unambiguous display name.
411    #[arg(value_name = "SET")]
412    set_id: ProviderSetId,
413    /// Provider-evidence cache; derived from PROVIDER and COLLECTION by default.
414    #[arg(long, value_name = "PATH")]
415    source: Option<PathBuf>,
416    /// Contact the provider through the canister for records absent from --source.
417    #[arg(long)]
418    refresh: bool,
419    /// Provider records to retain for a bounded comparison sample.
420    #[arg(
421        long,
422        conflicts_with = "all",
423        value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_PROVIDER_SET_RECORDS))
424    )]
425    records: Option<u16>,
426    /// Retain every available record for the provider set.
427    #[arg(long, conflicts_with = "records")]
428    all: bool,
429}
430
431#[derive(Debug, Args)]
432struct CollectionsGroup {
433    #[command(subcommand)]
434    command: CollectionsCommand,
435}
436
437#[derive(Debug, Subcommand)]
438enum CollectionsCommand {
439    /// List canonical collections.
440    List(ListArgs),
441    /// Get one canonical collection.
442    Get(IdArg),
443}
444
445#[derive(Debug, Args)]
446struct SetsGroup {
447    #[command(subcommand)]
448    command: SetsCommand,
449}
450
451#[derive(Debug, Subcommand)]
452enum SetsCommand {
453    /// List canonical sets.
454    List(ListArgs),
455    /// Get one canonical set and its current evidence.
456    Get(IdArg),
457    /// Mark a reviewed provisional set as verified.
458    Verify(RevisionArgs),
459    /// Protect a verified set from canonical edits.
460    Lock(RevisionArgs),
461    /// Return a locked set to verified state.
462    Unlock(RevisionArgs),
463}
464
465#[derive(Debug, Args)]
466struct CardsGroup {
467    #[command(subcommand)]
468    command: CardsCommand,
469}
470
471#[derive(Debug, Subcommand)]
472enum CardsCommand {
473    /// List canonical card identities.
474    List(ListArgs),
475    /// Get one card and its current provider variants.
476    Get(IdArg),
477    /// Mark a reviewed provisional card as verified.
478    Verify(RevisionArgs),
479    /// Protect a verified card from canonical edits.
480    Lock(RevisionArgs),
481    /// Return a locked card to verified state.
482    Unlock(RevisionArgs),
483    /// Read one card's price history.
484    Prices(PriceArgs),
485    /// List canonical cards through the indexed Pokémon-type relation.
486    ByType(CardTypeListArgs),
487}
488
489#[derive(Debug, Args)]
490struct CardTypeListArgs {
491    /// Canonical Pokémon type, such as Fire or Lightning.
492    #[arg(value_name = "TYPE")]
493    pokemon_type: PokemonType,
494    #[command(flatten)]
495    list: ListArgs,
496}
497
498#[derive(Debug, Args)]
499struct SourceKindsGroup {
500    #[command(subcommand)]
501    command: SourceKindCommand,
502}
503
504#[derive(Debug, Subcommand)]
505enum SourceKindCommand {
506    /// Inspect provider collection mappings.
507    Collections(ReadOnlySourcesGroup),
508    /// Inspect or reject provider set evidence.
509    Sets(ReviewableSourcesGroup),
510    /// Inspect or reject provider card evidence.
511    Cards(ReviewableSourcesGroup),
512    /// Inspect provider sealed-product evidence.
513    Sealed(ReadOnlySourcesGroup),
514}
515
516#[derive(Debug, Args)]
517struct ReadOnlySourcesGroup {
518    #[command(subcommand)]
519    command: ReadOnlySourcesCommand,
520}
521
522#[derive(Debug, Subcommand)]
523enum ReadOnlySourcesCommand {
524    /// List provider source records.
525    List(ListArgs),
526    /// Get one provider source record.
527    Get(IdArg),
528}
529
530#[derive(Debug, Args)]
531struct ReviewableSourcesGroup {
532    #[command(subcommand)]
533    command: ReviewableSourcesCommand,
534}
535
536#[derive(Debug, Subcommand)]
537enum ReviewableSourcesCommand {
538    /// List provider source records.
539    List(ListArgs),
540    /// Get one provider source record.
541    Get(IdArg),
542    /// Reject one unresolved provider source record.
543    Reject(IdArg),
544}
545
546#[derive(Debug, Args)]
547struct SealedGroup {
548    #[command(subcommand)]
549    command: SealedCommand,
550}
551
552#[derive(Debug, Subcommand)]
553enum SealedCommand {
554    /// List sealed products.
555    List(ListArgs),
556    /// Get one sealed product and its current provider variants.
557    Get(IdArg),
558    /// Read one sealed product's price history.
559    Prices(PriceArgs),
560}
561
562#[derive(Clone, Debug, Eq, PartialEq)]
563struct LocalId(String);
564
565#[derive(Debug, Args)]
566struct IdArg {
567    /// Toko-owned local identifier.
568    #[arg(value_name = "ULID")]
569    id: LocalId,
570}
571
572#[derive(Debug, Args)]
573struct PaginationArgs {
574    /// Records requested per canister call.
575    #[arg(
576        long,
577        default_value_t = DEFAULT_LIMIT,
578        value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_QUERY_LIMIT))
579    )]
580    limit: u16,
581    #[command(flatten)]
582    pages: AllPagesArgs,
583}
584
585#[derive(Debug, Args)]
586struct ListArgs {
587    #[command(flatten)]
588    pagination: PaginationArgs,
589    /// Start strictly after this local identifier.
590    #[arg(long, value_name = "ULID")]
591    after: Option<LocalId>,
592}
593
594#[derive(Debug, Args)]
595struct HistoryArgs {
596    #[command(flatten)]
597    pagination: PaginationArgs,
598    /// Timestamp from a returned descending history cursor.
599    #[arg(long, value_name = "INTEGER", requires = "before_id")]
600    before_time: Option<u64>,
601    /// ULID from the same descending history cursor.
602    #[arg(long, value_name = "ULID", requires = "before_time")]
603    before_id: Option<LocalId>,
604}
605
606#[derive(Debug, Args)]
607struct ReconciliationPlanArgs {
608    #[command(flatten)]
609    pagination: PaginationArgs,
610    /// Comparison key after which the page starts.
611    #[arg(long, value_name = "MATCH_KEY")]
612    after: Option<String>,
613}
614
615#[derive(Debug, Args)]
616struct CardReconciliationPlanArgs {
617    /// Toko-owned canonical set ULID.
618    #[arg(long, value_name = "ULID")]
619    set: LocalId,
620    #[command(flatten)]
621    plan: ReconciliationPlanArgs,
622}
623
624#[derive(Debug, Args)]
625struct MatchKeyArg {
626    /// Exact comparison key shown by `reconcile sets plan`.
627    #[arg(value_name = "MATCH_KEY")]
628    match_key: String,
629}
630
631#[derive(Debug, Args)]
632struct SourceSetReconcileArgs {
633    /// Canonical collection name or unique leading fragment.
634    #[arg(long)]
635    collection: Collection,
636    /// Provider name or unique leading fragment.
637    #[arg(long)]
638    provider: Provider,
639    /// Provider-owned set identifier.
640    #[arg(long, value_name = "PROVIDER_SET_ID")]
641    set: ProviderSetId,
642    /// Provider-record ULID returned by the previous page.
643    #[arg(long, value_name = "ULID")]
644    after: Option<LocalId>,
645    /// Source cards normalized per update call.
646    #[arg(
647        long,
648        default_value_t = MAX_SAFE_RECONCILIATION_CARDS,
649        value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_SAFE_RECONCILIATION_CARDS))
650    )]
651    limit: u16,
652    #[command(flatten)]
653    pages: AllPagesArgs,
654}
655
656#[derive(Debug, Args)]
657struct SetReconcileArgs {
658    /// Provider set-source ULIDs to accept as one identity.
659    #[arg(long, value_name = "ULID", required = true, num_args = 1..=MAX_RECONCILIATION_SOURCES)]
660    source: Vec<LocalId>,
661    /// Existing canonical set to revise.
662    #[arg(long, value_name = "ULID", requires = "revision")]
663    set: Option<LocalId>,
664    /// Expected current revision of --set.
665    #[arg(long, value_name = "N", requires = "set")]
666    revision: Option<u64>,
667    /// Canonical set name.
668    #[arg(long)]
669    name: String,
670    #[command(flatten)]
671    release_date: ReleaseDateArgs,
672    #[command(flatten)]
673    lifecycle: LifecycleArgs,
674}
675
676#[derive(Debug, Args)]
677struct CardReconcileArgs {
678    /// Provider card-source ULIDs to accept as one identity.
679    #[arg(long, value_name = "ULID", required = true, num_args = 1..=MAX_RECONCILIATION_SOURCES)]
680    source: Vec<LocalId>,
681    /// Existing canonical card to revise.
682    #[arg(long, value_name = "ULID", requires = "revision")]
683    card: Option<LocalId>,
684    /// Expected current revision of --card.
685    #[arg(long, value_name = "N", requires = "card")]
686    revision: Option<u64>,
687    /// Required canonical set relation.
688    #[arg(long, value_name = "ULID")]
689    set: LocalId,
690    /// Canonical card name.
691    #[arg(long)]
692    name: String,
693    /// Canonical collector number.
694    #[arg(long)]
695    collector_number: String,
696    #[command(flatten)]
697    rarity: RarityArgs,
698}
699
700#[derive(Debug, Args)]
701struct CardSafeReconcileArgs {
702    /// Toko-owned canonical set ULID.
703    #[arg(long, value_name = "ULID")]
704    set: LocalId,
705    /// Comparison key after which reconciliation starts.
706    #[arg(long, value_name = "MATCH_KEY")]
707    after: Option<String>,
708    /// Candidate groups processed per update call.
709    #[arg(
710        long,
711        default_value_t = MAX_SAFE_RECONCILIATION_CARDS,
712        value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_SAFE_RECONCILIATION_CARDS))
713    )]
714    limit: u16,
715    #[command(flatten)]
716    pages: AllPagesArgs,
717}
718
719#[derive(Debug, Args)]
720struct AllPagesArgs {
721    /// Follow every returned continuation.
722    #[arg(long)]
723    all: bool,
724    /// Safety bound for --all.
725    #[arg(
726        long,
727        default_value_t = DEFAULT_MAX_PAGES,
728        value_name = "COUNT",
729        requires = "all",
730        value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..)
731    )]
732    max_pages: usize,
733}
734
735#[derive(Debug, Args)]
736struct CardPrintingReconcileArgs {
737    /// Provider card-source ULIDs for this physical printing.
738    #[arg(long, value_name = "ULID", required = true, num_args = 1..=MAX_RECONCILIATION_SOURCES)]
739    source: Vec<LocalId>,
740    /// Parent canonical card ULID.
741    #[arg(long, value_name = "ULID")]
742    card: LocalId,
743    /// Curated language of this physical variant.
744    #[arg(long, value_enum)]
745    language: ItemLanguageArg,
746    /// Curated manufacturing finish.
747    #[arg(long, value_enum)]
748    finish: ItemFinishArg,
749    /// Optional publisher-defined edition marker.
750    #[arg(long)]
751    edition: Option<String>,
752}
753
754#[derive(Clone, Copy, Debug, ValueEnum)]
755enum ItemLanguageArg {
756    English,
757    Japanese,
758    French,
759    German,
760    Italian,
761    Spanish,
762    Portuguese,
763    Dutch,
764    Polish,
765    Russian,
766    Korean,
767    TraditionalChinese,
768    SimplifiedChinese,
769    Thai,
770    Indonesian,
771}
772
773impl From<ItemLanguageArg> for PokemonLanguage {
774    fn from(value: ItemLanguageArg) -> Self {
775        match value {
776            ItemLanguageArg::English => Self::English,
777            ItemLanguageArg::Japanese => Self::Japanese,
778            ItemLanguageArg::French => Self::French,
779            ItemLanguageArg::German => Self::German,
780            ItemLanguageArg::Italian => Self::Italian,
781            ItemLanguageArg::Spanish => Self::Spanish,
782            ItemLanguageArg::Portuguese => Self::Portuguese,
783            ItemLanguageArg::Dutch => Self::Dutch,
784            ItemLanguageArg::Polish => Self::Polish,
785            ItemLanguageArg::Russian => Self::Russian,
786            ItemLanguageArg::Korean => Self::Korean,
787            ItemLanguageArg::TraditionalChinese => Self::TraditionalChinese,
788            ItemLanguageArg::SimplifiedChinese => Self::SimplifiedChinese,
789            ItemLanguageArg::Thai => Self::Thai,
790            ItemLanguageArg::Indonesian => Self::Indonesian,
791        }
792    }
793}
794
795#[derive(Clone, Copy, Debug, ValueEnum)]
796enum ItemFinishArg {
797    Standard,
798    Holofoil,
799    ReverseHolofoil,
800    Foil,
801    EtchedFoil,
802}
803
804impl From<ItemFinishArg> for ItemFinish {
805    fn from(value: ItemFinishArg) -> Self {
806        match value {
807            ItemFinishArg::Standard => Self::Standard,
808            ItemFinishArg::Holofoil => Self::Holofoil,
809            ItemFinishArg::ReverseHolofoil => Self::ReverseHolofoil,
810            ItemFinishArg::Foil => Self::Foil,
811            ItemFinishArg::EtchedFoil => Self::EtchedFoil,
812        }
813    }
814}
815
816#[derive(Debug, Args)]
817struct CardMetadataReconcileArgs {
818    /// Parent canonical card ULID.
819    #[arg(long, value_name = "ULID")]
820    card: LocalId,
821    /// Mapped `TCGdex` or `Scrydex` card-source ULID selected as provisional evidence.
822    #[arg(long, value_name = "ULID")]
823    source: LocalId,
824}
825
826#[derive(Debug, Args)]
827#[group(required = true, multiple = false)]
828struct RarityArgs {
829    /// Curator-approved rarity.
830    #[arg(long)]
831    rarity: Option<String>,
832    /// Explicitly record no rarity.
833    #[arg(long)]
834    no_rarity: bool,
835}
836
837#[derive(Debug, Args)]
838#[group(required = true, multiple = false)]
839struct ReleaseDateArgs {
840    /// Curated release date.
841    #[arg(long, value_name = "DATE")]
842    release_date: Option<String>,
843    /// Explicitly record no release date.
844    #[arg(long)]
845    no_release_date: bool,
846}
847
848#[derive(Debug, Args)]
849#[group(multiple = false)]
850struct LifecycleArgs {
851    /// Keep the set active.
852    #[arg(long)]
853    active: bool,
854    /// Mark the set retired.
855    #[arg(long)]
856    retired: bool,
857}
858
859#[derive(Debug, Args)]
860struct RevisionArgs {
861    /// Toko-owned canonical identifier.
862    #[arg(value_name = "ULID")]
863    id: LocalId,
864    /// Expected current canonical revision.
865    #[arg(long, value_name = "N")]
866    revision: u64,
867}
868
869#[derive(Debug, Args)]
870struct PriceArgs {
871    /// Toko-owned card or sealed-product identifier.
872    #[arg(value_name = "ULID")]
873    id: LocalId,
874    #[command(flatten)]
875    history: HistoryArgs,
876}
877
878#[derive(Clone, Copy, Debug, Eq, PartialEq)]
879enum Resource {
880    Collections,
881    CollectionSources,
882    Sets,
883    SetSources,
884    Cards,
885    CardSources,
886    Sealed,
887    SealedSources,
888}
889
890#[derive(Clone, Copy, Debug, Eq, PartialEq)]
891enum RejectableSource {
892    Set,
893    Card,
894}
895
896#[derive(Clone, Copy, Debug, Eq, PartialEq)]
897enum PriceTarget {
898    Card,
899    Sealed,
900}
901
902#[derive(Clone, Debug, Eq, PartialEq)]
903struct ListOptions {
904    limit: u16,
905    after: Option<String>,
906    all: bool,
907    max_pages: usize,
908}
909
910impl Default for ListOptions {
911    fn default() -> Self {
912        Self {
913            limit: DEFAULT_LIMIT,
914            after: None,
915            all: false,
916            max_pages: DEFAULT_MAX_PAGES,
917        }
918    }
919}
920
921#[derive(Clone, Debug, Eq, PartialEq)]
922struct HistoryOptions {
923    limit: u16,
924    before: Option<TimeCursor>,
925    all: bool,
926    max_pages: usize,
927}
928
929#[derive(Clone, Debug, Eq, PartialEq)]
930struct ReconciliationPlanOptions {
931    after: Option<String>,
932    limit: u16,
933    all: bool,
934    max_pages: usize,
935}
936
937#[derive(Clone, Debug, Eq, PartialEq)]
938struct CardReconciliationPlanOptions {
939    pokemon_set_id: String,
940    plan: ReconciliationPlanOptions,
941}
942
943#[derive(Clone, Debug, Eq, PartialEq)]
944struct SafeCardReconciliationOptions {
945    args: ReconcilePokemonCardsArgs,
946    all: bool,
947    max_pages: usize,
948}
949
950#[derive(Clone, Debug, Eq, PartialEq)]
951struct SourceSetReconciliationOptions {
952    args: ReconcileSourceSetArgs,
953    all: bool,
954    max_pages: usize,
955}
956
957#[derive(Clone, Debug, Eq, PartialEq)]
958struct StatusOptions {
959    collection: Option<String>,
960    set: Option<String>,
961    list: ListOptions,
962}
963
964impl Default for HistoryOptions {
965    fn default() -> Self {
966        Self {
967            limit: DEFAULT_LIMIT,
968            before: None,
969            all: false,
970            max_pages: DEFAULT_MAX_PAGES,
971        }
972    }
973}
974
975#[derive(Clone, Debug, Eq, PartialEq)]
976enum Action {
977    Bootstrap,
978    Backup(PathBuf),
979    Restore(PathBuf),
980    ProviderSet(ProviderSetOptions),
981    Status(StatusOptions),
982    Scheduler,
983    Runs(HistoryOptions),
984    Logs(HistoryOptions),
985    List(Resource, ListOptions),
986    ListCardsByType(PokemonType, ListOptions),
987    Get(Resource, String),
988    LockSet(LockPokemonSetArgs),
989    LockCard(LockPokemonCardArgs),
990    VerifySet(VerifyCanonicalArgs),
991    VerifyCard(VerifyCanonicalArgs),
992    UnlockSet(UnlockCanonicalArgs),
993    UnlockCard(UnlockCanonicalArgs),
994    RejectSource(RejectableSource, String),
995    Prices(PriceTarget, String, HistoryOptions),
996    PlanSets(ReconciliationPlanOptions),
997    ReconcileSourceSet(SourceSetReconciliationOptions),
998    ReconcileSafeSet(String),
999    ReconcileSet(ReconcilePokemonSetArgs),
1000    PlanCards(CardReconciliationPlanOptions),
1001    ReconcileSafeCards(SafeCardReconciliationOptions),
1002    ReconcileCard(ReconcilePokemonCardArgs),
1003    ReconcileCardPrinting(ReconcilePokemonCardPrintingArgs),
1004    ReconcileCardMetadata(CuratePokemonCardMetadataArgs),
1005}
1006
1007#[derive(Clone, Debug, Eq, PartialEq)]
1008struct ProviderSetOptions {
1009    provider: Provider,
1010    collection: String,
1011    set_id: String,
1012    source: PathBuf,
1013    refresh: bool,
1014    records: Option<u16>,
1015}
1016
1017#[derive(Clone, Debug, Eq, PartialEq)]
1018struct Invocation {
1019    target: Target,
1020    output: OutputArgs,
1021    action: Action,
1022}
1023
1024/// Run the CLI from the current process arguments.
1025///
1026/// # Errors
1027///
1028/// Returns a bounded [`CliError`] when arguments are invalid, the `icp`
1029/// process fails, the canister rejects the request, or output cannot be
1030/// rendered.
1031pub fn run_from_env() -> Result<(), CliError> {
1032    let cli = match Cli::try_parse() {
1033        Ok(cli) => cli,
1034        Err(error) if is_clap_display(&error) => {
1035            return error.print().map_err(CliError::Io);
1036        }
1037        Err(error) => return Err(CliError::Clap(error)),
1038    };
1039    run(&cli.into_invocation())
1040}
1041
1042fn run(invocation: &Invocation) -> Result<(), CliError> {
1043    let value = execute(invocation)?;
1044    let rendered = output::render(
1045        invocation.output.format(),
1046        invocation.action.report_kind(),
1047        &value,
1048        invocation.output.compact,
1049    )?;
1050    write_text(&format!("{rendered}\n"))
1051}
1052
1053fn write_text(text: &str) -> Result<(), CliError> {
1054    io::stdout()
1055        .lock()
1056        .write_all(text.as_bytes())
1057        .map_err(CliError::Io)
1058}
1059
1060fn is_clap_display(error: &clap::Error) -> bool {
1061    matches!(
1062        error.kind(),
1063        ErrorKind::DisplayHelp
1064            | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
1065            | ErrorKind::DisplayVersion
1066    )
1067}
1068
1069#[cfg(test)]
1070fn parse_test_invocation(arguments: &[&str]) -> Result<Invocation, CliError> {
1071    let cli = Cli::try_parse_from(std::iter::once("toko-feed").chain(arguments.iter().copied()))?;
1072    Ok(cli.into_invocation())
1073}
1074
1075impl Cli {
1076    fn into_invocation(self) -> Invocation {
1077        let action = self.command.into_action();
1078        Invocation {
1079            target: self.target,
1080            output: self.output,
1081            action,
1082        }
1083    }
1084}
1085
1086impl Action {
1087    const fn report_kind(&self) -> ReportKind {
1088        match self {
1089            Self::Status(_) => ReportKind::Status,
1090            _ => ReportKind::Generic,
1091        }
1092    }
1093}
1094
1095impl RootCommand {
1096    fn into_action(self) -> Action {
1097        match self {
1098            Self::Bootstrap => Action::Bootstrap,
1099            Self::Backup(args) => Action::Backup(args.output),
1100            Self::Restore(args) => Action::Restore(args.input),
1101            Self::Provider(group) => match group.command {
1102                ProviderCommand::Set(args) => Action::ProviderSet(args.into_options()),
1103            },
1104            Self::Reconcile(group) => match group.command {
1105                ReconcileCommand::Sets(group) => match group.command {
1106                    ReconcileSetsCommand::Plan(args) => Action::PlanSets(args.into_options()),
1107                    ReconcileSetsCommand::AcceptSource(args) => {
1108                        Action::ReconcileSourceSet(args.into_options())
1109                    }
1110                    ReconcileSetsCommand::AcceptSafe(args) => {
1111                        Action::ReconcileSafeSet(args.match_key)
1112                    }
1113                    ReconcileSetsCommand::Accept(args) => {
1114                        Action::ReconcileSet(args.into_canister_args())
1115                    }
1116                },
1117                ReconcileCommand::Cards(group) => match group.command {
1118                    ReconcileCardsCommand::Plan(args) => Action::PlanCards(args.into_options()),
1119                    ReconcileCardsCommand::AcceptSafe(args) => {
1120                        Action::ReconcileSafeCards(args.into_options())
1121                    }
1122                    ReconcileCardsCommand::Accept(args) => {
1123                        Action::ReconcileCard(args.into_canister_args())
1124                    }
1125                    ReconcileCardsCommand::AcceptPrinting(args) => {
1126                        Action::ReconcileCardPrinting(args.into_canister_args())
1127                    }
1128                    ReconcileCardsCommand::AcceptMetadata(args) => {
1129                        Action::ReconcileCardMetadata(args.into_canister_args())
1130                    }
1131                },
1132            },
1133            Self::Status(args) => Action::Status(StatusOptions {
1134                collection: args
1135                    .collection
1136                    .map(|collection| collection.slug().to_owned()),
1137                set: args.set.map(Into::into),
1138                list: args.list.into_options(),
1139            }),
1140            Self::Scheduler => Action::Scheduler,
1141            Self::Runs(args) => Action::Runs(args.into_options()),
1142            Self::Logs(args) => Action::Logs(args.into_options()),
1143            Self::Collections(group) => match group.command {
1144                CollectionsCommand::List(args) => {
1145                    Action::List(Resource::Collections, args.into_options())
1146                }
1147                CollectionsCommand::Get(args) => Action::Get(Resource::Collections, args.id.into()),
1148            },
1149            Self::Sets(group) => match group.command {
1150                SetsCommand::List(args) => Action::List(Resource::Sets, args.into_options()),
1151                SetsCommand::Get(args) => Action::Get(Resource::Sets, args.id.into()),
1152                SetsCommand::Verify(args) => Action::VerifySet(args.into_verify_args()),
1153                SetsCommand::Lock(args) => Action::LockSet(args.into_set_args()),
1154                SetsCommand::Unlock(args) => Action::UnlockSet(args.into_unlock_args()),
1155            },
1156            Self::Cards(group) => match group.command {
1157                CardsCommand::List(args) => Action::List(Resource::Cards, args.into_options()),
1158                CardsCommand::Get(args) => Action::Get(Resource::Cards, args.id.into()),
1159                CardsCommand::Verify(args) => Action::VerifyCard(args.into_verify_args()),
1160                CardsCommand::Lock(args) => Action::LockCard(args.into_card_args()),
1161                CardsCommand::Unlock(args) => Action::UnlockCard(args.into_unlock_args()),
1162                CardsCommand::Prices(args) => args.into_action(PriceTarget::Card),
1163                CardsCommand::ByType(args) => {
1164                    Action::ListCardsByType(args.pokemon_type, args.list.into_options())
1165                }
1166            },
1167            Self::Sealed(group) => match group.command {
1168                SealedCommand::List(args) => Action::List(Resource::Sealed, args.into_options()),
1169                SealedCommand::Get(args) => Action::Get(Resource::Sealed, args.id.into()),
1170                SealedCommand::Prices(args) => args.into_action(PriceTarget::Sealed),
1171            },
1172            Self::Sources(group) => group.into_action(),
1173        }
1174    }
1175}
1176
1177impl ProviderSetArgs {
1178    fn into_options(self) -> ProviderSetOptions {
1179        let collection = self.collection.slug().to_owned();
1180        let source = self
1181            .source
1182            .unwrap_or_else(|| default_provider_source(self.provider, &collection));
1183        ProviderSetOptions {
1184            provider: self.provider,
1185            collection,
1186            set_id: self.set_id.into(),
1187            source,
1188            refresh: self.refresh,
1189            records: (!self.all)
1190                .then_some(self.records.unwrap_or(DEFAULT_PROVIDER_COMPARISON_RECORDS)),
1191        }
1192    }
1193}
1194
1195impl SourceKindsGroup {
1196    fn into_action(self) -> Action {
1197        match self.command {
1198            SourceKindCommand::Collections(group) => group.into_action(Resource::CollectionSources),
1199            SourceKindCommand::Sets(group) => {
1200                group.into_action(Resource::SetSources, RejectableSource::Set)
1201            }
1202            SourceKindCommand::Cards(group) => {
1203                group.into_action(Resource::CardSources, RejectableSource::Card)
1204            }
1205            SourceKindCommand::Sealed(group) => group.into_action(Resource::SealedSources),
1206        }
1207    }
1208}
1209
1210impl ReadOnlySourcesGroup {
1211    fn into_action(self, resource: Resource) -> Action {
1212        match self.command {
1213            ReadOnlySourcesCommand::List(args) => Action::List(resource, args.into_options()),
1214            ReadOnlySourcesCommand::Get(args) => Action::Get(resource, args.id.into()),
1215        }
1216    }
1217}
1218
1219impl ReviewableSourcesGroup {
1220    fn into_action(self, resource: Resource, rejectable: RejectableSource) -> Action {
1221        match self.command {
1222            ReviewableSourcesCommand::List(args) => Action::List(resource, args.into_options()),
1223            ReviewableSourcesCommand::Get(args) => Action::Get(resource, args.id.into()),
1224            ReviewableSourcesCommand::Reject(args) => {
1225                Action::RejectSource(rejectable, args.id.into())
1226            }
1227        }
1228    }
1229}
1230
1231impl ListArgs {
1232    fn into_options(self) -> ListOptions {
1233        ListOptions {
1234            limit: self.pagination.limit,
1235            after: self.after.map(Into::into),
1236            all: self.pagination.pages.all,
1237            max_pages: self.pagination.pages.max_pages,
1238        }
1239    }
1240}
1241
1242impl HistoryArgs {
1243    fn into_options(self) -> HistoryOptions {
1244        HistoryOptions {
1245            limit: self.pagination.limit,
1246            before: self
1247                .before_time
1248                .zip(self.before_id)
1249                .map(|(timestamp, id)| TimeCursor {
1250                    timestamp,
1251                    id: id.into(),
1252                }),
1253            all: self.pagination.pages.all,
1254            max_pages: self.pagination.pages.max_pages,
1255        }
1256    }
1257}
1258
1259impl ReconciliationPlanArgs {
1260    fn into_options(self) -> ReconciliationPlanOptions {
1261        ReconciliationPlanOptions {
1262            after: self.after,
1263            limit: self.pagination.limit,
1264            all: self.pagination.pages.all,
1265            max_pages: self.pagination.pages.max_pages,
1266        }
1267    }
1268}
1269
1270impl SourceSetReconcileArgs {
1271    fn into_options(self) -> SourceSetReconciliationOptions {
1272        SourceSetReconciliationOptions {
1273            args: ReconcileSourceSetArgs {
1274                collection: self.collection.slug().to_owned(),
1275                provider: self.provider.name().to_owned(),
1276                provider_set_id: self.set.into(),
1277                after: self.after.map(Into::into),
1278                limit: self.limit,
1279            },
1280            all: self.pages.all,
1281            max_pages: self.pages.max_pages,
1282        }
1283    }
1284}
1285
1286impl CardReconciliationPlanArgs {
1287    fn into_options(self) -> CardReconciliationPlanOptions {
1288        CardReconciliationPlanOptions {
1289            pokemon_set_id: self.set.into(),
1290            plan: self.plan.into_options(),
1291        }
1292    }
1293}
1294
1295impl SetReconcileArgs {
1296    fn into_canister_args(self) -> ReconcilePokemonSetArgs {
1297        ReconcilePokemonSetArgs {
1298            source_ids: self.source.into_iter().map(Into::into).collect(),
1299            pokemon_set_id: self.set.map(Into::into),
1300            expected_revision: self.revision,
1301            name: self.name,
1302            release_date: self.release_date.release_date,
1303            lifecycle_status: if self.lifecycle.retired {
1304                SetLifecycleStatus::Retired
1305            } else {
1306                SetLifecycleStatus::Active
1307            },
1308        }
1309    }
1310}
1311
1312impl CardReconcileArgs {
1313    fn into_canister_args(self) -> ReconcilePokemonCardArgs {
1314        ReconcilePokemonCardArgs {
1315            source_ids: self.source.into_iter().map(Into::into).collect(),
1316            pokemon_card_id: self.card.map(Into::into),
1317            expected_revision: self.revision,
1318            pokemon_set_id: self.set.into(),
1319            name: self.name,
1320            collector_number: self.collector_number,
1321            rarity: self.rarity.rarity,
1322        }
1323    }
1324}
1325
1326impl CardSafeReconcileArgs {
1327    fn into_options(self) -> SafeCardReconciliationOptions {
1328        SafeCardReconciliationOptions {
1329            args: ReconcilePokemonCardsArgs {
1330                pokemon_set_id: self.set.into(),
1331                after_match_key: self.after,
1332                limit: self.limit,
1333            },
1334            all: self.pages.all,
1335            max_pages: self.pages.max_pages,
1336        }
1337    }
1338}
1339
1340impl CardPrintingReconcileArgs {
1341    fn into_canister_args(self) -> ReconcilePokemonCardPrintingArgs {
1342        ReconcilePokemonCardPrintingArgs {
1343            source_ids: self.source.into_iter().map(Into::into).collect(),
1344            pokemon_card_id: self.card.into(),
1345            language: self.language.into(),
1346            finish: self.finish.into(),
1347            edition_code: self.edition,
1348        }
1349    }
1350}
1351
1352impl CardMetadataReconcileArgs {
1353    fn into_canister_args(self) -> CuratePokemonCardMetadataArgs {
1354        CuratePokemonCardMetadataArgs {
1355            pokemon_card_id: self.card.into(),
1356            pokemon_card_source_id: self.source.into(),
1357        }
1358    }
1359}
1360
1361impl RevisionArgs {
1362    fn into_verify_args(self) -> VerifyCanonicalArgs {
1363        VerifyCanonicalArgs {
1364            id: self.id.into(),
1365            expected_revision: self.revision,
1366        }
1367    }
1368
1369    fn into_set_args(self) -> LockPokemonSetArgs {
1370        LockPokemonSetArgs {
1371            id: self.id.into(),
1372            expected_revision: self.revision,
1373        }
1374    }
1375
1376    fn into_card_args(self) -> LockPokemonCardArgs {
1377        LockPokemonCardArgs {
1378            id: self.id.into(),
1379            expected_revision: self.revision,
1380        }
1381    }
1382
1383    fn into_unlock_args(self) -> UnlockCanonicalArgs {
1384        UnlockCanonicalArgs {
1385            id: self.id.into(),
1386            expected_revision: self.revision,
1387        }
1388    }
1389}
1390
1391impl PriceArgs {
1392    fn into_action(self, target: PriceTarget) -> Action {
1393        Action::Prices(target, self.id.into(), self.history.into_options())
1394    }
1395}
1396
1397impl FromStr for LocalId {
1398    type Err = &'static str;
1399
1400    fn from_str(id: &str) -> Result<Self, Self::Err> {
1401        if is_valid_local_id(id) {
1402            Ok(Self(id.to_owned()))
1403        } else {
1404            Err("must be a 26-character uppercase ULID")
1405        }
1406    }
1407}
1408
1409impl From<LocalId> for String {
1410    fn from(id: LocalId) -> Self {
1411        id.0
1412    }
1413}
1414
1415#[derive(Clone, Debug, Eq, PartialEq)]
1416struct ProviderSetId(String);
1417
1418impl FromStr for ProviderSetId {
1419    type Err = &'static str;
1420
1421    fn from_str(id: &str) -> Result<Self, Self::Err> {
1422        let id = id.trim();
1423        let valid = !id.is_empty()
1424            && id.len() <= 256
1425            && !id.chars().any(char::is_control)
1426            && !id.contains(['/', '\\']);
1427        if valid {
1428            Ok(Self(id.to_owned()))
1429        } else {
1430            Err("must be 1-256 characters without control characters")
1431        }
1432    }
1433}
1434
1435impl From<ProviderSetId> for String {
1436    fn from(id: ProviderSetId) -> Self {
1437        id.0
1438    }
1439}
1440
1441fn is_valid_local_id(id: &str) -> bool {
1442    id.len() == 26
1443        && id.bytes().all(|byte| {
1444            matches!(
1445                byte,
1446                b'0'..=b'9' | b'A'..=b'H' | b'J'..=b'K' | b'M'..=b'N' | b'P'..=b'T' | b'V'..=b'Z'
1447            )
1448        })
1449}
1450
1451fn execute(invocation: &Invocation) -> Result<Value, CliError> {
1452    match &invocation.action {
1453        Action::Bootstrap => cache::execute_bootstrap(&invocation.target),
1454        Action::Backup(path) => backup::execute(&invocation.target, path),
1455        Action::Restore(path) => backup::restore(&invocation.target, path),
1456        Action::ProviderSet(options) => cache::execute_provider_set(
1457            &invocation.target,
1458            options.provider,
1459            &options.collection,
1460            &options.set_id,
1461            &options.source,
1462            options.refresh,
1463            options.records,
1464        ),
1465        Action::Status(options) if options.list.all => {
1466            list_all_catalog_status(&invocation.target, options)
1467        }
1468        Action::Status(options) => output(fetch_catalog_status_page(
1469            &invocation.target,
1470            options,
1471            options.list.after.clone(),
1472        )?),
1473        Action::Scheduler => output(call_empty::<SchedulerStatus>(
1474            &invocation.target,
1475            "toko_feed_scheduler",
1476            true,
1477        )?),
1478        Action::Runs(options) if options.all => list_all_runs(&invocation.target, options),
1479        Action::Logs(options) if options.all => list_all_logs(&invocation.target, options),
1480        Action::Prices(resource, item_id, options) => {
1481            execute_prices(&invocation.target, *resource, item_id, options)
1482        }
1483        Action::Runs(options) => output(fetch_run_page(
1484            &invocation.target,
1485            options.before.clone(),
1486            options.limit,
1487        )?),
1488        Action::Logs(options) => output(fetch_log_page(
1489            &invocation.target,
1490            options.before.clone(),
1491            options.limit,
1492        )?),
1493        Action::Get(resource, id) => execute_get(&invocation.target, *resource, id),
1494        Action::LockSet(args) => {
1495            mutation::<_, PokemonSetView>(&invocation.target, "toko_feed_lock_set", args)
1496        }
1497        Action::LockCard(args) => {
1498            mutation::<_, PokemonCardView>(&invocation.target, "toko_feed_lock_card", args)
1499        }
1500        Action::VerifySet(args) => {
1501            mutation::<_, PokemonSetView>(&invocation.target, "toko_feed_verify_set", args)
1502        }
1503        Action::VerifyCard(args) => {
1504            mutation::<_, PokemonCardView>(&invocation.target, "toko_feed_verify_card", args)
1505        }
1506        Action::UnlockSet(args) => {
1507            mutation::<_, PokemonSetView>(&invocation.target, "toko_feed_unlock_set", args)
1508        }
1509        Action::UnlockCard(args) => {
1510            mutation::<_, PokemonCardView>(&invocation.target, "toko_feed_unlock_card", args)
1511        }
1512        Action::RejectSource(resource, id) => {
1513            execute_reject_source(&invocation.target, *resource, id)
1514        }
1515        Action::List(resource, options) => execute_list(&invocation.target, *resource, options),
1516        Action::ListCardsByType(pokemon_type, options) => {
1517            execute_cards_by_type(&invocation.target, *pokemon_type, options)
1518        }
1519        Action::PlanSets(options) => execute_set_reconciliation_plan(&invocation.target, options),
1520        Action::ReconcileSourceSet(options) => {
1521            execute_source_set_reconciliation(&invocation.target, options)
1522        }
1523        Action::ReconcileSafeSet(match_key) => mutation::<_, PokemonSetView>(
1524            &invocation.target,
1525            "toko_feed_reconcile_safe_set",
1526            match_key,
1527        ),
1528        Action::ReconcileSet(args) => {
1529            mutation::<_, PokemonSetView>(&invocation.target, "toko_feed_reconcile_set", args)
1530        }
1531        Action::PlanCards(options) => execute_card_reconciliation_plan(&invocation.target, options),
1532        Action::ReconcileSafeCards(options) => {
1533            execute_safe_card_reconciliation(&invocation.target, options)
1534        }
1535        Action::ReconcileCard(args) => {
1536            mutation::<_, PokemonCardView>(&invocation.target, "toko_feed_reconcile_card", args)
1537        }
1538        Action::ReconcileCardPrinting(args) => output(call_one::<_, PokemonCardPrintingView>(
1539            &invocation.target,
1540            "toko_feed_reconcile_card_printing",
1541            args.clone(),
1542            false,
1543        )?),
1544        Action::ReconcileCardMetadata(args) => output(call_one::<_, PokemonCardMetadataDetails>(
1545            &invocation.target,
1546            "toko_feed_curate_card_metadata",
1547            args.clone(),
1548            false,
1549        )?),
1550    }
1551}
1552
1553fn execute_set_reconciliation_plan(
1554    target: &Target,
1555    options: &ReconciliationPlanOptions,
1556) -> Result<Value, CliError> {
1557    if !options.all {
1558        return output(fetch_set_reconciliation_page(
1559            target,
1560            options.after.clone(),
1561            options.limit,
1562        )?);
1563    }
1564    let (candidates, _) = collect_pages(
1565        options.after.clone(),
1566        options.limit,
1567        options.max_pages,
1568        |after, limit| {
1569            let page = fetch_set_reconciliation_page(target, after, limit)?;
1570            Ok((page.candidates, page.next_after))
1571        },
1572        |previous, next| ensure_cursor_advanced(previous.map(String::as_str), next),
1573    )?;
1574    output(PokemonSetReconciliationPage {
1575        candidates,
1576        next_after: None,
1577    })
1578}
1579
1580fn execute_source_set_reconciliation(
1581    target: &Target,
1582    options: &SourceSetReconciliationOptions,
1583) -> Result<Value, CliError> {
1584    if !options.all {
1585        return output(call_one::<_, ReconcileSourceSetReceipt>(
1586            target,
1587            "toko_feed_reconcile_source_set",
1588            options.args.clone(),
1589            false,
1590        )?);
1591    }
1592    let mut args = options.args.clone();
1593    let mut receipts = Vec::new();
1594    let mut cards_processed = 0_u64;
1595    let mut cards_inserted = 0_u64;
1596    let mut cards_unchanged = 0_u64;
1597    let mut sources_mapped = 0_u64;
1598    for _ in 0..options.max_pages {
1599        let receipt = call_one::<_, ReconcileSourceSetReceipt>(
1600            target,
1601            "toko_feed_reconcile_source_set",
1602            args.clone(),
1603            false,
1604        )?;
1605        cards_processed = cards_processed.saturating_add(u64::from(receipt.cards_processed));
1606        cards_inserted = cards_inserted.saturating_add(u64::from(receipt.cards_inserted));
1607        cards_unchanged = cards_unchanged.saturating_add(u64::from(receipt.cards_unchanged));
1608        sources_mapped = sources_mapped.saturating_add(u64::from(receipt.sources_mapped));
1609        let next = receipt.next_after.clone();
1610        receipts.push(receipt);
1611        let Some(next) = next else {
1612            return Ok(json!({
1613                "cards_processed": cards_processed,
1614                "cards_inserted": cards_inserted,
1615                "cards_unchanged": cards_unchanged,
1616                "sources_mapped": sources_mapped,
1617                "receipts": receipts,
1618            }));
1619        };
1620        ensure_cursor_advanced(args.after.as_deref(), &next)?;
1621        args.after = Some(next);
1622    }
1623    Err(CliError::PaginationLimit(options.max_pages))
1624}
1625
1626fn execute_card_reconciliation_plan(
1627    target: &Target,
1628    options: &CardReconciliationPlanOptions,
1629) -> Result<Value, CliError> {
1630    if !options.plan.all {
1631        return output(fetch_card_reconciliation_page(
1632            target,
1633            options.pokemon_set_id.clone(),
1634            options.plan.after.clone(),
1635            options.plan.limit,
1636        )?);
1637    }
1638    let (candidates, _) = collect_pages(
1639        options.plan.after.clone(),
1640        options.plan.limit,
1641        options.plan.max_pages,
1642        |after, limit| {
1643            let page = fetch_card_reconciliation_page(
1644                target,
1645                options.pokemon_set_id.clone(),
1646                after,
1647                limit,
1648            )?;
1649            Ok((page.candidates, page.next_after))
1650        },
1651        |previous, next| ensure_cursor_advanced(previous.map(String::as_str), next),
1652    )?;
1653    output(PokemonCardReconciliationPage {
1654        candidates,
1655        next_after: None,
1656    })
1657}
1658
1659fn execute_safe_card_reconciliation(
1660    target: &Target,
1661    options: &SafeCardReconciliationOptions,
1662) -> Result<Value, CliError> {
1663    if !options.all {
1664        return output(call_one::<_, ReconcilePokemonCardsReceipt>(
1665            target,
1666            "toko_feed_reconcile_cards",
1667            options.args.clone(),
1668            false,
1669        )?);
1670    }
1671    let mut args = options.args.clone();
1672    let mut receipts = Vec::new();
1673    let mut cards_reconciled = 0_u64;
1674    let mut sources_mapped = 0_u64;
1675    let mut candidates_skipped = 0_u64;
1676    for _ in 0..options.max_pages {
1677        let receipt = call_one::<_, ReconcilePokemonCardsReceipt>(
1678            target,
1679            "toko_feed_reconcile_cards",
1680            args.clone(),
1681            false,
1682        )?;
1683        cards_reconciled = cards_reconciled.saturating_add(u64::from(receipt.cards_reconciled));
1684        sources_mapped = sources_mapped.saturating_add(u64::from(receipt.sources_mapped));
1685        candidates_skipped =
1686            candidates_skipped.saturating_add(u64::from(receipt.candidates_skipped));
1687        let next = receipt.next_after.clone();
1688        receipts.push(receipt);
1689        let Some(next) = next else {
1690            return Ok(json!({
1691                "cards_reconciled": cards_reconciled,
1692                "sources_mapped": sources_mapped,
1693                "candidates_skipped": candidates_skipped,
1694                "receipts": receipts,
1695            }));
1696        };
1697        ensure_cursor_advanced(args.after_match_key.as_deref(), &next)?;
1698        args.after_match_key = Some(next);
1699    }
1700    Err(CliError::PaginationLimit(options.max_pages))
1701}
1702
1703fn ensure_cursor_advanced(previous: Option<&str>, next: &str) -> Result<(), CliError> {
1704    if previous.is_some_and(|previous| previous >= next) {
1705        Err(CliError::PaginationStalled(next.to_owned()))
1706    } else {
1707        Ok(())
1708    }
1709}
1710
1711fn execute_reject_source(
1712    target: &Target,
1713    source: RejectableSource,
1714    id: &str,
1715) -> Result<Value, CliError> {
1716    match source {
1717        RejectableSource::Set => output(call_one::<_, PokemonSetEvidenceView>(
1718            target,
1719            "toko_feed_reject_set_source",
1720            id.to_owned(),
1721            false,
1722        )?),
1723        RejectableSource::Card => output(call_one::<_, PokemonCardSourceView>(
1724            target,
1725            "toko_feed_reject_card_source",
1726            id.to_owned(),
1727            false,
1728        )?),
1729    }
1730}
1731
1732fn execute_list(
1733    target: &Target,
1734    resource: Resource,
1735    options: &ListOptions,
1736) -> Result<Value, CliError> {
1737    if options.all {
1738        return match resource {
1739            Resource::Collections => list_all_collections(target, options),
1740            Resource::CollectionSources => list_all_collection_sources(target, options),
1741            Resource::Sets => list_all_sets(target, options),
1742            Resource::SetSources => list_all_set_sources(target, options),
1743            Resource::Cards => list_all_cards(target, options),
1744            Resource::CardSources => list_all_card_sources(target, options),
1745            Resource::Sealed => list_all_sealed(target, options),
1746            Resource::SealedSources => list_all_sealed_sources(target, options),
1747        };
1748    }
1749    match resource {
1750        Resource::Collections => output(fetch_collection_page(
1751            target,
1752            options.after.clone(),
1753            options.limit,
1754        )?),
1755        Resource::CollectionSources => output(fetch_collection_source_page(
1756            target,
1757            options.after.clone(),
1758            options.limit,
1759        )?),
1760        Resource::Sets => output(fetch_set_page(
1761            target,
1762            options.after.clone(),
1763            options.limit,
1764        )?),
1765        Resource::SetSources => output(fetch_set_source_page(
1766            target,
1767            options.after.clone(),
1768            options.limit,
1769        )?),
1770        Resource::Cards => output(fetch_card_page(
1771            target,
1772            options.after.clone(),
1773            options.limit,
1774        )?),
1775        Resource::CardSources => output(fetch_card_source_page(
1776            target,
1777            options.after.clone(),
1778            options.limit,
1779        )?),
1780        Resource::Sealed => output(fetch_sealed_page(
1781            target,
1782            options.after.clone(),
1783            options.limit,
1784        )?),
1785        Resource::SealedSources => output(fetch_sealed_source_page(
1786            target,
1787            options.after.clone(),
1788            options.limit,
1789        )?),
1790    }
1791}
1792
1793fn execute_cards_by_type(
1794    target: &Target,
1795    pokemon_type: PokemonType,
1796    options: &ListOptions,
1797) -> Result<Value, CliError> {
1798    if !options.all {
1799        return output(fetch_card_type_page(
1800            target,
1801            pokemon_type,
1802            options.after.clone(),
1803            options.limit,
1804        )?);
1805    }
1806    let (cards, pages) = collect_keyset_pages(options, |after, limit| {
1807        let page = fetch_card_type_page(target, pokemon_type, after, limit)?;
1808        Ok((page.cards, page.next_after))
1809    })?;
1810    Ok(json!({
1811        "cards": cards,
1812        "count": cards.len(),
1813        "pages": pages,
1814        "next_after": null,
1815        "pokemon_type": pokemon_type,
1816    }))
1817}
1818
1819fn execute_get(target: &Target, resource: Resource, id: &str) -> Result<Value, CliError> {
1820    match resource {
1821        Resource::Collections => output(call_one::<_, Option<CollectionDetails>>(
1822            target,
1823            "toko_feed_collection",
1824            id.to_owned(),
1825            true,
1826        )?),
1827        Resource::CollectionSources => output(call_one::<_, Option<CollectionSourceView>>(
1828            target,
1829            "toko_feed_collection_source",
1830            id.to_owned(),
1831            true,
1832        )?),
1833        Resource::Sets => output(call_one::<_, Option<PokemonSetDetails>>(
1834            target,
1835            "toko_feed_set",
1836            id.to_owned(),
1837            true,
1838        )?),
1839        Resource::Cards => output(call_one::<_, Option<PokemonCardDetails>>(
1840            target,
1841            "toko_feed_card",
1842            id.to_owned(),
1843            true,
1844        )?),
1845        Resource::SetSources => output(call_one::<_, Option<PokemonSetEvidenceView>>(
1846            target,
1847            "toko_feed_set_source",
1848            id.to_owned(),
1849            true,
1850        )?),
1851        Resource::CardSources => output(call_one::<_, Option<PokemonCardSourceView>>(
1852            target,
1853            "toko_feed_card_source",
1854            id.to_owned(),
1855            true,
1856        )?),
1857        Resource::Sealed => output(call_one::<_, Option<PokemonSealedDetails>>(
1858            target,
1859            "toko_feed_sealed_product",
1860            id.to_owned(),
1861            true,
1862        )?),
1863        Resource::SealedSources => output(call_one::<_, Option<PokemonSealedSourceView>>(
1864            target,
1865            "toko_feed_sealed_source",
1866            id.to_owned(),
1867            true,
1868        )?),
1869    }
1870}
1871
1872fn execute_prices(
1873    target: &Target,
1874    price_target: PriceTarget,
1875    item_id: &str,
1876    options: &HistoryOptions,
1877) -> Result<Value, CliError> {
1878    if options.all {
1879        return list_all_prices(target, price_target, item_id, options);
1880    }
1881    match price_target {
1882        PriceTarget::Card => output(fetch_card_price_page(
1883            target,
1884            item_id.to_owned(),
1885            options.before.clone(),
1886            options.limit,
1887        )?),
1888        PriceTarget::Sealed => output(fetch_sealed_price_page(
1889            target,
1890            item_id.to_owned(),
1891            options.before.clone(),
1892            options.limit,
1893        )?),
1894    }
1895}
1896
1897fn output(value: impl Serialize) -> Result<Value, CliError> {
1898    serde_json::to_value(value).map_err(CliError::Json)
1899}
1900
1901fn mutation<A, T>(target: &Target, method: &'static str, args: &A) -> Result<Value, CliError>
1902where
1903    A: CandidType + Clone,
1904    T: CandidType + DeserializeOwned + Serialize,
1905{
1906    output(call_one::<_, T>(target, method, args.clone(), false)?)
1907}
1908
1909fn fetch_set_page(
1910    target: &Target,
1911    after: Option<String>,
1912    limit: u16,
1913) -> Result<PokemonSetPage, CliError> {
1914    call_page(target, "toko_feed_sets", after, limit)
1915}
1916
1917fn fetch_catalog_status_page(
1918    target: &Target,
1919    options: &StatusOptions,
1920    after: Option<String>,
1921) -> Result<CatalogStatusPage, CliError> {
1922    call_one(
1923        target,
1924        "toko_feed_catalog_status",
1925        CatalogStatusArgs {
1926            collection: options.collection.clone(),
1927            set: options.set.clone(),
1928            after,
1929            limit: options.list.limit,
1930        },
1931        true,
1932    )
1933}
1934
1935fn fetch_collection_page(
1936    target: &Target,
1937    after: Option<String>,
1938    limit: u16,
1939) -> Result<CollectionPage, CliError> {
1940    call_page(target, "toko_feed_collections", after, limit)
1941}
1942
1943fn fetch_collection_source_page(
1944    target: &Target,
1945    after: Option<String>,
1946    limit: u16,
1947) -> Result<CollectionSourcePage, CliError> {
1948    call_page(target, "toko_feed_collection_sources", after, limit)
1949}
1950
1951fn fetch_card_page(
1952    target: &Target,
1953    after: Option<String>,
1954    limit: u16,
1955) -> Result<PokemonCardPage, CliError> {
1956    call_page(target, "toko_feed_cards", after, limit)
1957}
1958
1959fn fetch_card_type_page(
1960    target: &Target,
1961    pokemon_type: PokemonType,
1962    after: Option<String>,
1963    limit: u16,
1964) -> Result<PokemonCardSearchPage, CliError> {
1965    const METHOD: &str = "toko_feed_cards_by_type";
1966    let arguments =
1967        encode_args((pokemon_type, after, limit)).map_err(|source| CliError::Encode {
1968            method: METHOD,
1969            source,
1970        })?;
1971    call_and_decode(target, METHOD, &arguments, true)
1972}
1973
1974fn fetch_card_printing_page(
1975    target: &Target,
1976    after: Option<String>,
1977    limit: u16,
1978) -> Result<PokemonCardPrintingPage, CliError> {
1979    call_page(target, "toko_feed_card_printings", after, limit)
1980}
1981
1982fn fetch_set_source_page(
1983    target: &Target,
1984    after: Option<String>,
1985    limit: u16,
1986) -> Result<PokemonSetSourcePage, CliError> {
1987    call_page(target, "toko_feed_set_sources", after, limit)
1988}
1989
1990fn fetch_card_source_page(
1991    target: &Target,
1992    after: Option<String>,
1993    limit: u16,
1994) -> Result<PokemonCardSourcePage, CliError> {
1995    call_page(target, "toko_feed_card_sources", after, limit)
1996}
1997
1998fn fetch_sealed_page(
1999    target: &Target,
2000    after: Option<String>,
2001    limit: u16,
2002) -> Result<PokemonSealedPage, CliError> {
2003    call_page(target, "toko_feed_sealed", after, limit)
2004}
2005
2006fn fetch_sealed_source_page(
2007    target: &Target,
2008    after: Option<String>,
2009    limit: u16,
2010) -> Result<PokemonSealedSourcePage, CliError> {
2011    call_page(target, "toko_feed_sealed_sources", after, limit)
2012}
2013
2014fn fetch_set_reconciliation_page(
2015    target: &Target,
2016    after_match_key: Option<String>,
2017    limit: u16,
2018) -> Result<PokemonSetReconciliationPage, CliError> {
2019    let method = "toko_feed_set_reconciliation";
2020    let arguments = encode_args((after_match_key, limit))
2021        .map_err(|source| CliError::Encode { method, source })?;
2022    call_and_decode(target, method, &arguments, true)
2023}
2024
2025fn fetch_card_reconciliation_page(
2026    target: &Target,
2027    pokemon_set_id: String,
2028    after_match_key: Option<String>,
2029    limit: u16,
2030) -> Result<PokemonCardReconciliationPage, CliError> {
2031    let method = "toko_feed_card_reconciliation";
2032    let arguments = encode_args((pokemon_set_id, after_match_key, limit))
2033        .map_err(|source| CliError::Encode { method, source })?;
2034    call_and_decode(target, method, &arguments, true)
2035}
2036
2037fn fetch_run_page(
2038    target: &Target,
2039    before: Option<TimeCursor>,
2040    limit: u16,
2041) -> Result<IngestionRunPage, CliError> {
2042    call_history_page(target, "toko_feed_runs", before, limit)
2043}
2044
2045fn fetch_log_page(
2046    target: &Target,
2047    before: Option<TimeCursor>,
2048    limit: u16,
2049) -> Result<OperationalLogPage, CliError> {
2050    call_history_page(target, "toko_feed_logs", before, limit)
2051}
2052
2053fn fetch_card_price_page(
2054    target: &Target,
2055    card_id: String,
2056    before: Option<TimeCursor>,
2057    limit: u16,
2058) -> Result<PriceObservationPage, CliError> {
2059    call_item_history_page(target, "toko_feed_card_prices", card_id, before, limit)
2060}
2061
2062fn fetch_sealed_price_page(
2063    target: &Target,
2064    sealed_id: String,
2065    before: Option<TimeCursor>,
2066    limit: u16,
2067) -> Result<SealedPriceObservationPage, CliError> {
2068    call_item_history_page(target, "toko_feed_sealed_prices", sealed_id, before, limit)
2069}
2070
2071fn list_all_runs(target: &Target, options: &HistoryOptions) -> Result<Value, CliError> {
2072    let (runs, pages) = collect_history_pages(options, |before, limit| {
2073        let page = fetch_run_page(target, before, limit)?;
2074        Ok((page.runs, page.next_before))
2075    })?;
2076    complete_page_output("runs", runs, pages, "next_before")
2077}
2078
2079fn list_all_logs(target: &Target, options: &HistoryOptions) -> Result<Value, CliError> {
2080    let (logs, pages) = collect_history_pages(options, |before, limit| {
2081        let page = fetch_log_page(target, before, limit)?;
2082        Ok((page.logs, page.next_before))
2083    })?;
2084    complete_page_output("logs", logs, pages, "next_before")
2085}
2086
2087fn list_all_prices(
2088    target: &Target,
2089    price_target: PriceTarget,
2090    item_id: &str,
2091    options: &HistoryOptions,
2092) -> Result<Value, CliError> {
2093    match price_target {
2094        PriceTarget::Card => {
2095            let (observations, pages) = collect_history_pages(options, |before, limit| {
2096                let page = fetch_card_price_page(target, item_id.to_owned(), before, limit)?;
2097                Ok((page.observations, page.next_before))
2098            })?;
2099            complete_page_output("observations", observations, pages, "next_before")
2100        }
2101        PriceTarget::Sealed => {
2102            let (observations, pages) = collect_history_pages(options, |before, limit| {
2103                let page = fetch_sealed_price_page(target, item_id.to_owned(), before, limit)?;
2104                Ok((page.observations, page.next_before))
2105            })?;
2106            complete_page_output("observations", observations, pages, "next_before")
2107        }
2108    }
2109}
2110
2111fn collect_history_pages<T>(
2112    options: &HistoryOptions,
2113    fetch: impl FnMut(Option<TimeCursor>, u16) -> Result<(Vec<T>, Option<TimeCursor>), CliError>,
2114) -> Result<(Vec<T>, usize), CliError> {
2115    let mut seen = options
2116        .before
2117        .as_ref()
2118        .map(time_cursor_token)
2119        .into_iter()
2120        .collect::<HashSet<_>>();
2121    collect_pages(
2122        options.before.clone(),
2123        options.limit,
2124        options.max_pages,
2125        fetch,
2126        |_, next| validate_time_cursor(next, &mut seen),
2127    )
2128}
2129
2130fn validate_time_cursor(cursor: &TimeCursor, seen: &mut HashSet<String>) -> Result<(), CliError> {
2131    if !is_valid_local_id(&cursor.id) {
2132        return Err(CliError::RawReply(
2133            "next_before contained an invalid local ULID",
2134        ));
2135    }
2136    let token = time_cursor_token(cursor);
2137    if !seen.insert(token.clone()) {
2138        return Err(CliError::PaginationStalled(token));
2139    }
2140    Ok(())
2141}
2142
2143fn time_cursor_token(cursor: &TimeCursor) -> String {
2144    format!("{}:{}", cursor.timestamp, cursor.id)
2145}
2146
2147fn list_all_sets(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2148    let (sets, pages) = collect_keyset_pages(options, |after, limit| {
2149        let page = fetch_set_page(target, after, limit)?;
2150        Ok((page.sets, page.next_after))
2151    })?;
2152    complete_page_output("sets", sets, pages, "next_after")
2153}
2154
2155fn list_all_catalog_status(target: &Target, options: &StatusOptions) -> Result<Value, CliError> {
2156    let (sets, pages) = collect_keyset_pages(&options.list, |after, _limit| {
2157        let page = fetch_catalog_status_page(target, options, after)?;
2158        Ok((page.sets, page.next_after))
2159    })?;
2160    complete_page_output("sets", sets, pages, "next_after")
2161}
2162
2163fn list_all_collections(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2164    let (collections, pages) = collect_keyset_pages(options, |after, limit| {
2165        let page = fetch_collection_page(target, after, limit)?;
2166        Ok((page.collections, page.next_after))
2167    })?;
2168    complete_page_output("collections", collections, pages, "next_after")
2169}
2170
2171fn list_all_collection_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2172    let (sources, pages) = collect_keyset_pages(options, |after, limit| {
2173        let page = fetch_collection_source_page(target, after, limit)?;
2174        Ok((page.sources, page.next_after))
2175    })?;
2176    complete_page_output("sources", sources, pages, "next_after")
2177}
2178
2179fn list_all_cards(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2180    let (cards, pages) = collect_keyset_pages(options, |after, limit| {
2181        let page = fetch_card_page(target, after, limit)?;
2182        Ok((page.cards, page.next_after))
2183    })?;
2184    complete_page_output("cards", cards, pages, "next_after")
2185}
2186
2187fn list_all_set_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2188    let (sources, pages) = collect_keyset_pages(options, |after, limit| {
2189        let page = fetch_set_source_page(target, after, limit)?;
2190        Ok((page.sources, page.next_after))
2191    })?;
2192    complete_page_output("sources", sources, pages, "next_after")
2193}
2194
2195fn list_all_card_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2196    let (sources, pages) = collect_keyset_pages(options, |after, limit| {
2197        let page = fetch_card_source_page(target, after, limit)?;
2198        Ok((page.sources, page.next_after))
2199    })?;
2200    complete_page_output("sources", sources, pages, "next_after")
2201}
2202
2203fn list_all_sealed(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2204    let (sealed, pages) = collect_keyset_pages(options, |after, limit| {
2205        let page = fetch_sealed_page(target, after, limit)?;
2206        Ok((page.sealed, page.next_after))
2207    })?;
2208    complete_page_output("sealed", sealed, pages, "next_after")
2209}
2210
2211fn list_all_sealed_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2212    let (sources, pages) = collect_keyset_pages(options, |after, limit| {
2213        let page = fetch_sealed_source_page(target, after, limit)?;
2214        Ok((page.sources, page.next_after))
2215    })?;
2216    complete_page_output("sources", sources, pages, "next_after")
2217}
2218
2219fn complete_page_output<T>(
2220    field: &'static str,
2221    items: Vec<T>,
2222    pages: usize,
2223    cursor_field: &'static str,
2224) -> Result<Value, CliError>
2225where
2226    T: Serialize,
2227{
2228    let count = items.len();
2229    let mut object = serde_json::Map::new();
2230    object.insert(field.to_owned(), serde_json::to_value(items)?);
2231    object.insert("count".to_owned(), json!(count));
2232    object.insert("pages".to_owned(), json!(pages));
2233    object.insert(cursor_field.to_owned(), Value::Null);
2234    Ok(Value::Object(object))
2235}
2236
2237fn collect_keyset_pages<T>(
2238    options: &ListOptions,
2239    fetch: impl FnMut(Option<String>, u16) -> Result<(Vec<T>, Option<String>), CliError>,
2240) -> Result<(Vec<T>, usize), CliError> {
2241    let mut seen = options.after.iter().cloned().collect::<HashSet<_>>();
2242    collect_pages(
2243        options.after.clone(),
2244        options.limit,
2245        options.max_pages,
2246        fetch,
2247        |_, next| validate_reply_cursor(next, &mut seen),
2248    )
2249}
2250
2251fn validate_reply_cursor(cursor: &str, seen: &mut HashSet<String>) -> Result<(), CliError> {
2252    if !is_valid_local_id(cursor) {
2253        return Err(CliError::RawReply("next_after was not a valid local ULID"));
2254    }
2255    if !seen.insert(cursor.to_owned()) {
2256        return Err(CliError::PaginationStalled(cursor.to_owned()));
2257    }
2258    Ok(())
2259}
2260
2261#[cfg(test)]
2262mod tests {
2263    use candid::decode_args;
2264    use clap::CommandFactory;
2265
2266    use super::*;
2267
2268    fn invocation(values: &[&str]) -> Invocation {
2269        parse_test_invocation(values).expect("arguments should parse")
2270    }
2271
2272    fn clap_error(values: &[&str]) -> ErrorKind {
2273        match parse_test_invocation(values) {
2274            Err(CliError::Clap(error)) => error.kind(),
2275            Err(error) => panic!("expected a Clap error, got {error}"),
2276            Ok(_) => panic!("expected Clap to reject the arguments"),
2277        }
2278    }
2279
2280    #[test]
2281    fn parses_global_and_automatic_pagination_options() {
2282        let parsed = invocation(&[
2283            "--environment",
2284            "ic",
2285            "--canister",
2286            "aaaaa-aa",
2287            "--identity",
2288            "operator",
2289            "--project-root",
2290            "/srv/toko-feed",
2291            "--json",
2292            "--compact",
2293            "sets",
2294            "list",
2295            "--limit",
2296            "100",
2297            "--after",
2298            "01KZ9GFKW3SY1G000000000001",
2299            "--all",
2300            "--max-pages",
2301            "12",
2302        ]);
2303
2304        assert_eq!(parsed.target.environment, "ic");
2305        assert_eq!(parsed.target.canister, "aaaaa-aa");
2306        assert_eq!(parsed.target.identity, "operator");
2307        assert_eq!(
2308            parsed.target.project_root.as_deref(),
2309            Some(std::path::Path::new("/srv/toko-feed"))
2310        );
2311        assert_eq!(parsed.output.format(), OutputFormat::Json);
2312        assert!(parsed.output.compact);
2313        assert_eq!(
2314            parsed.action,
2315            Action::List(
2316                Resource::Sets,
2317                ListOptions {
2318                    limit: 100,
2319                    after: Some("01KZ9GFKW3SY1G000000000001".to_owned()),
2320                    all: true,
2321                    max_pages: 12,
2322                }
2323            )
2324        );
2325    }
2326
2327    #[test]
2328    fn parses_bounded_status_drill_down() {
2329        assert_eq!(
2330            invocation(&[
2331                "status",
2332                "--collection",
2333                "magic",
2334                "--set",
2335                "the",
2336                "--limit",
2337                "50",
2338                "--all",
2339            ])
2340            .action,
2341            Action::Status(StatusOptions {
2342                collection: Some("magic-the-gathering".to_owned()),
2343                set: Some("the".to_owned()),
2344                list: ListOptions {
2345                    limit: 50,
2346                    after: None,
2347                    all: true,
2348                    max_pages: DEFAULT_MAX_PAGES,
2349                },
2350            })
2351        );
2352        assert_eq!(
2353            clap_error(&["status", "--set", "aquapolis"]),
2354            ErrorKind::MissingRequiredArgument
2355        );
2356        let json = invocation(&["status", "--collection", "poke", "--json"]);
2357        assert_eq!(json.output.format(), OutputFormat::Json);
2358        assert!(!json.output.compact);
2359        assert_eq!(
2360            clap_error(&["status", "--compact"]),
2361            ErrorKind::MissingRequiredArgument
2362        );
2363    }
2364
2365    #[test]
2366    fn parses_bootstrap_and_keeps_root_help_focused() {
2367        assert_eq!(
2368            invocation(&["backup"]).action,
2369            Action::Backup(PathBuf::from(DEFAULT_CANONICAL_BACKUP_PATH))
2370        );
2371        assert_eq!(
2372            invocation(&["restore"]).action,
2373            Action::Restore(PathBuf::from(DEFAULT_CANONICAL_BACKUP_PATH))
2374        );
2375        assert_eq!(
2376            invocation(&["restore", "--input", "/tmp/canonical.json"]).action,
2377            Action::Restore(PathBuf::from("/tmp/canonical.json"))
2378        );
2379        assert_eq!(invocation(&["bootstrap"]).action, Action::Bootstrap);
2380        assert!(parse_test_invocation(&["bootstrap", "--source", "/tmp/cache"]).is_err());
2381        assert!(parse_test_invocation(&["bootstrap", "--refresh"]).is_err());
2382
2383        let help = Cli::command().render_help().to_string();
2384        assert!(help.contains("backup"));
2385        assert!(help.contains("bootstrap"));
2386        assert!(help.contains("restore"));
2387        assert!(help.contains("Connection:"));
2388        assert!(!help.contains("tcgdex"));
2389        assert!(!help.contains("scrydex"));
2390        assert!(!help.contains("Pokemon"));
2391        assert!(!help.contains("Pokémon"));
2392        assert!(!help.contains("--before-time"));
2393        assert!(!help.contains("--release-date"));
2394        assert!(help.lines().count() < 45);
2395    }
2396
2397    #[test]
2398    fn parses_bounded_provider_set_refresh() {
2399        assert_eq!(
2400            invocation(&[
2401                "provider",
2402                "set",
2403                "tcgdex",
2404                "ecard2",
2405                "--source",
2406                "/tmp/tcgdex",
2407                "--refresh",
2408                "--records",
2409                "12",
2410            ])
2411            .action,
2412            Action::ProviderSet(ProviderSetOptions {
2413                provider: Provider::TcgDex,
2414                collection: "pokemon".to_owned(),
2415                set_id: "ecard2".to_owned(),
2416                source: PathBuf::from("/tmp/tcgdex"),
2417                refresh: true,
2418                records: Some(12),
2419            })
2420        );
2421        assert_eq!(
2422            clap_error(&["provider", "set", "tcgdex", "ecard2", "--records", "1001"]),
2423            ErrorKind::ValueValidation
2424        );
2425        assert_eq!(
2426            clap_error(&["provider", "set", "tcgdex", "../ecard2"]),
2427            ErrorKind::ValueValidation
2428        );
2429
2430        let mut command = Cli::command();
2431        let provider = command
2432            .find_subcommand_mut("provider")
2433            .expect("provider command");
2434        let set = provider.find_subcommand_mut("set").expect("set command");
2435        let help = set.render_help().to_string();
2436        assert!(help.contains("--records"));
2437        assert!(!help.contains("--cards"));
2438        assert!(!help.contains("--data"));
2439        assert_eq!(
2440            clap_error(&["provider", "set", "tcgdex", "ecard2", "--cards", "10"]),
2441            ErrorKind::UnknownArgument
2442        );
2443        assert_eq!(
2444            clap_error(&["provider", "set", "tcgdex", "ecard2", "--data", "/tmp"]),
2445            ErrorKind::UnknownArgument
2446        );
2447    }
2448
2449    #[test]
2450    fn parses_provider_defaults_and_custom_source() {
2451        assert_eq!(
2452            invocation(&[
2453                "provider",
2454                "set",
2455                "scrydex",
2456                "ecard2",
2457                "--source",
2458                "/tmp/scrydex",
2459                "--refresh",
2460                "--records",
2461                "10",
2462            ])
2463            .action,
2464            Action::ProviderSet(ProviderSetOptions {
2465                provider: Provider::Scrydex,
2466                collection: "pokemon".to_owned(),
2467                set_id: "ecard2".to_owned(),
2468                source: PathBuf::from("/tmp/scrydex"),
2469                refresh: true,
2470                records: Some(10),
2471            })
2472        );
2473        assert_eq!(
2474            invocation(&["provider", "set", "scrydex", "ecard2"]).action,
2475            Action::ProviderSet(ProviderSetOptions {
2476                provider: Provider::Scrydex,
2477                collection: "pokemon".to_owned(),
2478                set_id: "ecard2".to_owned(),
2479                source: PathBuf::from("source/providers/scrydex/pokemon/v1/en"),
2480                refresh: false,
2481                records: Some(DEFAULT_PROVIDER_COMPARISON_RECORDS),
2482            })
2483        );
2484        assert_eq!(
2485            invocation(&["provider", "set", "scry", "ecard2"]).action,
2486            Action::ProviderSet(ProviderSetOptions {
2487                provider: Provider::Scrydex,
2488                collection: "pokemon".to_owned(),
2489                set_id: "ecard2".to_owned(),
2490                source: PathBuf::from("source/providers/scrydex/pokemon/v1/en"),
2491                refresh: false,
2492                records: Some(DEFAULT_PROVIDER_COMPARISON_RECORDS),
2493            })
2494        );
2495    }
2496
2497    #[test]
2498    fn parses_provider_full_acquisition() {
2499        assert_eq!(
2500            invocation(&[
2501                "provider",
2502                "set",
2503                "scry",
2504                "DRK",
2505                "--collection",
2506                "magic",
2507                "--all",
2508            ])
2509            .action,
2510            Action::ProviderSet(ProviderSetOptions {
2511                provider: Provider::Scrydex,
2512                collection: "magic-the-gathering".to_owned(),
2513                set_id: "DRK".to_owned(),
2514                source: PathBuf::from("source/providers/scrydex/magic-the-gathering/v1"),
2515                refresh: false,
2516                records: None,
2517            })
2518        );
2519        assert_eq!(
2520            invocation(&["provider", "set", "just-tcg", "aquapolis-pokemon", "--all",]).action,
2521            Action::ProviderSet(ProviderSetOptions {
2522                provider: Provider::JustTcg,
2523                collection: "pokemon".to_owned(),
2524                set_id: "aquapolis-pokemon".to_owned(),
2525                source: PathBuf::from("source/providers/just-tcg/pokemon/v1"),
2526                refresh: false,
2527                records: None,
2528            })
2529        );
2530        assert_eq!(
2531            clap_error(&["provider", "set", "unknown", "ecard2"]),
2532            ErrorKind::ValueValidation
2533        );
2534        assert_eq!(
2535            invocation(&["provider", "set", "scrydex", "ecard2", "--all"]).action,
2536            Action::ProviderSet(ProviderSetOptions {
2537                provider: Provider::Scrydex,
2538                collection: "pokemon".to_owned(),
2539                set_id: "ecard2".to_owned(),
2540                source: PathBuf::from("source/providers/scrydex/pokemon/v1/en"),
2541                refresh: false,
2542                records: None,
2543            })
2544        );
2545        assert_eq!(
2546            clap_error(&[
2547                "provider",
2548                "set",
2549                "scrydex",
2550                "ecard2",
2551                "--all",
2552                "--records",
2553                "10",
2554            ]),
2555            ErrorKind::ArgumentConflict
2556        );
2557    }
2558
2559    #[test]
2560    fn parses_provider_neutral_source_set_reconciliation() {
2561        assert_eq!(
2562            invocation(&[
2563                "reconcile",
2564                "sets",
2565                "accept-source",
2566                "--collection",
2567                "magic",
2568                "--provider",
2569                "scry",
2570                "--set",
2571                "DRK",
2572                "--all",
2573            ])
2574            .action,
2575            Action::ReconcileSourceSet(SourceSetReconciliationOptions {
2576                args: ReconcileSourceSetArgs {
2577                    collection: "magic-the-gathering".to_owned(),
2578                    provider: "scrydex".to_owned(),
2579                    provider_set_id: "DRK".to_owned(),
2580                    after: None,
2581                    limit: MAX_SAFE_RECONCILIATION_CARDS,
2582                },
2583                all: true,
2584                max_pages: DEFAULT_MAX_PAGES,
2585            })
2586        );
2587        assert_eq!(
2588            clap_error(&[
2589                "reconcile",
2590                "sets",
2591                "accept-source",
2592                "--collection",
2593                "magic",
2594                "--provider",
2595                "scry",
2596                "--set",
2597                "DRK",
2598                "--limit",
2599                "11",
2600            ]),
2601            ErrorKind::ValueValidation
2602        );
2603    }
2604
2605    #[test]
2606    fn clap_validates_name_prefix_matching() {
2607        for collection in ["poke", "Pokemon", "Pokémon", "POKE"] {
2608            assert!(matches!(
2609                invocation(&["status", "--collection", collection]).action,
2610                Action::Status(StatusOptions {
2611                    collection: Some(ref value),
2612                    ..
2613                }) if value == "pokemon"
2614            ));
2615        }
2616
2617        for collection in ["unknown", "mtg", "dragon-ball-super"] {
2618            assert_eq!(
2619                clap_error(&["provider", "set", "scry", "DRK", "--collection", collection,]),
2620                ErrorKind::ValueValidation
2621            );
2622        }
2623
2624        for provider in ["unknown", "jt"] {
2625            assert_eq!(
2626                clap_error(&["provider", "set", provider, "DRK"]),
2627                ErrorKind::ValueValidation
2628            );
2629        }
2630    }
2631
2632    #[test]
2633    fn clap_owns_argument_relationships_and_validation() {
2634        Cli::command().debug_assert();
2635
2636        assert!(parse_test_invocation(&["cards", "ingest"]).is_err());
2637        assert!(parse_test_invocation(&["sets", "curate"]).is_err());
2638        assert_eq!(
2639            clap_error(&["sets", "list", "--max-pages", "2"]),
2640            ErrorKind::MissingRequiredArgument
2641        );
2642        assert_eq!(
2643            clap_error(&["cards", "get", "not-an-id",]),
2644            ErrorKind::ValueValidation
2645        );
2646    }
2647
2648    #[test]
2649    fn parses_canonical_card_queries_and_rejects_legacy_ingestion() {
2650        assert_eq!(
2651            invocation(&["cards", "get", "01KZ9GFKW3SY1G000000000001"]).action,
2652            Action::Get(Resource::Cards, "01KZ9GFKW3SY1G000000000001".to_owned())
2653        );
2654        assert!(parse_test_invocation(&["cards", "ingest"]).is_err());
2655        assert!(parse_test_invocation(&["cards", "curate"]).is_err());
2656        assert_eq!(
2657            invocation(&["cards", "by-type", "fire", "--limit", "25", "--all",]).action,
2658            Action::ListCardsByType(
2659                PokemonType::Fire,
2660                ListOptions {
2661                    limit: 25,
2662                    all: true,
2663                    ..ListOptions::default()
2664                }
2665            )
2666        );
2667        assert!(parse_test_invocation(&["cards", "by-type", "steam"]).is_err());
2668    }
2669
2670    #[test]
2671    fn parses_canonical_collection_queries() {
2672        assert_eq!(
2673            invocation(&["collections", "list", "--all"]).action,
2674            Action::List(
2675                Resource::Collections,
2676                ListOptions {
2677                    all: true,
2678                    ..ListOptions::default()
2679                }
2680            )
2681        );
2682        assert!(parse_test_invocation(&["collections", "ingest"]).is_err());
2683    }
2684
2685    #[test]
2686    fn parses_revisioned_set_lock_unlock_and_rejects_legacy_curation() {
2687        let set_id = "01KZ9GFKW3SY1G000000000002";
2688        assert_eq!(
2689            invocation(&["sets", "verify", set_id, "--revision", "7"]).action,
2690            Action::VerifySet(VerifyCanonicalArgs {
2691                id: set_id.to_owned(),
2692                expected_revision: 7,
2693            })
2694        );
2695        assert_eq!(
2696            invocation(&["sets", "lock", set_id, "--revision", "8"]).action,
2697            Action::LockSet(LockPokemonSetArgs {
2698                id: set_id.to_owned(),
2699                expected_revision: 8,
2700            })
2701        );
2702        assert_eq!(
2703            invocation(&["sets", "unlock", set_id, "--revision", "9"]).action,
2704            Action::UnlockSet(UnlockCanonicalArgs {
2705                id: set_id.to_owned(),
2706                expected_revision: 9,
2707            })
2708        );
2709        assert!(parse_test_invocation(&["sets", "curate"]).is_err());
2710    }
2711
2712    #[test]
2713    fn parses_source_first_evidence_commands() {
2714        let source_id = "01KZ9GFKW3SY1G000000000001";
2715        let card_id = "01KZ9GFKW3SY1G000000000003";
2716
2717        assert_eq!(
2718            invocation(&["sources", "collections", "get", source_id]).action,
2719            Action::Get(Resource::CollectionSources, source_id.to_owned())
2720        );
2721        assert_eq!(
2722            invocation(&["sources", "sets", "reject", source_id]).action,
2723            Action::RejectSource(RejectableSource::Set, source_id.to_owned())
2724        );
2725        assert_eq!(
2726            invocation(&["sources", "cards", "list", "--all"]).action,
2727            Action::List(
2728                Resource::CardSources,
2729                ListOptions {
2730                    all: true,
2731                    ..ListOptions::default()
2732                }
2733            )
2734        );
2735        assert_eq!(
2736            invocation(&["sources", "sealed", "get", source_id]).action,
2737            Action::Get(Resource::SealedSources, source_id.to_owned())
2738        );
2739        assert_eq!(
2740            invocation(&["cards", "verify", card_id, "--revision", "0"]).action,
2741            Action::VerifyCard(VerifyCanonicalArgs {
2742                id: card_id.to_owned(),
2743                expected_revision: 0,
2744            })
2745        );
2746        assert_eq!(
2747            invocation(&["cards", "lock", card_id, "--revision", "1"]).action,
2748            Action::LockCard(LockPokemonCardArgs {
2749                id: card_id.to_owned(),
2750                expected_revision: 1,
2751            })
2752        );
2753        assert_eq!(
2754            invocation(&["cards", "unlock", card_id, "--revision", "2"]).action,
2755            Action::UnlockCard(UnlockCanonicalArgs {
2756                id: card_id.to_owned(),
2757                expected_revision: 2,
2758            })
2759        );
2760        assert!(parse_test_invocation(&["sets", "sources", "list"]).is_err());
2761        assert!(parse_test_invocation(&["cards", "sources", "list"]).is_err());
2762        assert!(parse_test_invocation(&["sources", "sealed", "reject", source_id]).is_err());
2763    }
2764
2765    #[test]
2766    fn parses_scheduler_history_and_price_commands() {
2767        assert_eq!(invocation(&["scheduler"]).action, Action::Scheduler);
2768        assert_eq!(
2769            invocation(&[
2770                "runs",
2771                "--before-time",
2772                "123",
2773                "--before-id",
2774                "01KZ9GFKW3SY1G000000000001",
2775                "--all",
2776            ])
2777            .action,
2778            Action::Runs(HistoryOptions {
2779                before: Some(TimeCursor {
2780                    timestamp: 123,
2781                    id: "01KZ9GFKW3SY1G000000000001".to_owned(),
2782                }),
2783                all: true,
2784                ..HistoryOptions::default()
2785            })
2786        );
2787        assert!(matches!(
2788            invocation(&[
2789                "cards",
2790                "prices",
2791                "01KZ9GFKW3SY1G000000000001",
2792                "--limit",
2793                "100",
2794            ])
2795            .action,
2796            Action::Prices(PriceTarget::Card, _, HistoryOptions { limit: 100, .. })
2797        ));
2798        assert!(matches!(
2799            invocation(&["sealed", "prices", "01KZ9GFKW3SY1G000000000001", "--all",]).action,
2800            Action::Prices(PriceTarget::Sealed, _, HistoryOptions { all: true, .. })
2801        ));
2802        assert!(parse_test_invocation(&["runs", "list"]).is_err());
2803        assert!(parse_test_invocation(&["logs", "list"]).is_err());
2804        assert!(parse_test_invocation(&["logs", "--before-time", "123",]).is_err());
2805    }
2806
2807    #[test]
2808    fn parses_sealed_queries_and_rejects_separate_ingestion() {
2809        assert_eq!(
2810            invocation(&["sealed", "list", "--all"]).action,
2811            Action::List(
2812                Resource::Sealed,
2813                ListOptions {
2814                    all: true,
2815                    ..ListOptions::default()
2816                }
2817            )
2818        );
2819        assert_eq!(
2820            invocation(&["sealed", "get", "01KZ9GFKW3SY1G000000000001"]).action,
2821            Action::Get(Resource::Sealed, "01KZ9GFKW3SY1G000000000001".to_owned())
2822        );
2823        assert!(parse_test_invocation(&["sealed", "ingest"]).is_err());
2824    }
2825
2826    #[test]
2827    fn rejects_invalid_bounds_and_identifiers() {
2828        assert!(parse_test_invocation(&["sets", "list", "--limit", "0"]).is_err());
2829        assert!(parse_test_invocation(&["sets", "list", "--max-pages", "2"]).is_err());
2830        assert!(parse_test_invocation(&["sets", "get", "not-an-id"]).is_err());
2831    }
2832
2833    #[test]
2834    fn accepts_help_at_any_command_depth_and_version_at_the_top_level() {
2835        assert!(matches!(
2836            parse_test_invocation(&["sets", "list", "--help"]),
2837            Err(CliError::Clap(error)) if error.kind() == ErrorKind::DisplayHelp
2838        ));
2839        assert!(matches!(
2840            parse_test_invocation(&["--version"]),
2841            Err(CliError::Clap(error)) if error.kind() == ErrorKind::DisplayVersion
2842        ));
2843    }
2844
2845    #[test]
2846    fn encodes_typed_page_arguments_without_candid_text() {
2847        let bytes = encode_args((Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100_u16))
2848            .expect("encode page arguments");
2849        let decoded = decode_args::<(Option<String>, u16)>(&bytes).expect("decode page arguments");
2850
2851        assert_eq!(
2852            decoded,
2853            (Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100)
2854        );
2855    }
2856
2857    #[test]
2858    fn parses_provider_neutral_reconciliation_commands() {
2859        const SET: &str = "01KZ9GFKW3SY1G000000000001";
2860        const SOURCE_ONE: &str = "01KZ9GFKW3SY1G000000000002";
2861        const CARD: &str = "01KZ9GFKW3SY1G000000000004";
2862
2863        assert_eq!(
2864            invocation(&[
2865                "reconcile",
2866                "cards",
2867                "plan",
2868                "--set",
2869                SET,
2870                "--limit",
2871                "50",
2872                "--all",
2873            ])
2874            .action,
2875            Action::PlanCards(CardReconciliationPlanOptions {
2876                pokemon_set_id: SET.to_owned(),
2877                plan: ReconciliationPlanOptions {
2878                    after: None,
2879                    limit: 50,
2880                    all: true,
2881                    max_pages: DEFAULT_MAX_PAGES,
2882                },
2883            })
2884        );
2885        assert_eq!(
2886            invocation(&[
2887                "reconcile",
2888                "cards",
2889                "accept-metadata",
2890                "--card",
2891                CARD,
2892                "--source",
2893                SOURCE_ONE,
2894            ])
2895            .action,
2896            Action::ReconcileCardMetadata(CuratePokemonCardMetadataArgs {
2897                pokemon_card_id: CARD.to_owned(),
2898                pokemon_card_source_id: SOURCE_ONE.to_owned(),
2899            })
2900        );
2901        assert_eq!(
2902            invocation(&["reconcile", "cards", "accept-safe", "--set", SET, "--all",]).action,
2903            Action::ReconcileSafeCards(SafeCardReconciliationOptions {
2904                args: ReconcilePokemonCardsArgs {
2905                    pokemon_set_id: SET.to_owned(),
2906                    after_match_key: None,
2907                    limit: MAX_SAFE_RECONCILIATION_CARDS,
2908                },
2909                all: true,
2910                max_pages: DEFAULT_MAX_PAGES,
2911            })
2912        );
2913        assert_eq!(
2914            invocation(&[
2915                "reconcile",
2916                "sets",
2917                "accept-safe",
2918                "pokemon-set:v1:pokemon:aquapolis:2003-01-15",
2919            ])
2920            .action,
2921            Action::ReconcileSafeSet("pokemon-set:v1:pokemon:aquapolis:2003-01-15".to_owned())
2922        );
2923        assert_eq!(
2924            clap_error(&[
2925                "reconcile",
2926                "cards",
2927                "accept-safe",
2928                "--set",
2929                SET,
2930                "--limit",
2931                "11",
2932            ]),
2933            ErrorKind::ValueValidation
2934        );
2935    }
2936
2937    #[test]
2938    fn parses_typed_item_variant_reconciliation() {
2939        const SOURCE_ONE: &str = "01KZ9GFKW3SY1G000000000002";
2940        const SOURCE_TWO: &str = "01KZ9GFKW3SY1G000000000003";
2941        const CARD: &str = "01KZ9GFKW3SY1G000000000004";
2942
2943        assert_eq!(
2944            invocation(&[
2945                "reconcile",
2946                "cards",
2947                "accept-printing",
2948                "--source",
2949                SOURCE_ONE,
2950                SOURCE_TWO,
2951                "--card",
2952                CARD,
2953                "--language",
2954                "english",
2955                "--finish",
2956                "standard",
2957                "--edition",
2958                "a",
2959            ])
2960            .action,
2961            Action::ReconcileCardPrinting(ReconcilePokemonCardPrintingArgs {
2962                source_ids: vec![SOURCE_ONE.to_owned(), SOURCE_TWO.to_owned()],
2963                pokemon_card_id: CARD.to_owned(),
2964                language: PokemonLanguage::English,
2965                finish: ItemFinish::Standard,
2966                edition_code: Some("a".to_owned()),
2967            })
2968        );
2969    }
2970
2971    #[test]
2972    fn keyset_page_collection_preserves_order_and_rejects_stalled_cursors() {
2973        const CURSOR: &str = "01KZ9GFKW3SY1G000000000001";
2974        let options = ListOptions {
2975            after: None,
2976            limit: 2,
2977            all: true,
2978            max_pages: 3,
2979        };
2980        let (items, pages) = collect_keyset_pages(&options, |after, limit| {
2981            assert_eq!(limit, 2);
2982            match after.as_deref() {
2983                None => Ok((vec![1, 2], Some(CURSOR.to_owned()))),
2984                Some(CURSOR) => Ok((vec![3], None)),
2985                Some(_) => Err(CliError::RawReply("unexpected test cursor")),
2986            }
2987        })
2988        .expect("bounded pages should collect");
2989        assert_eq!(items, vec![1, 2, 3]);
2990        assert_eq!(pages, 2);
2991
2992        let stalled = ListOptions {
2993            after: Some(CURSOR.to_owned()),
2994            ..options
2995        };
2996        assert!(matches!(
2997            collect_keyset_pages::<u8>(&stalled, |_, _| Ok((vec![1], Some(CURSOR.to_owned())))),
2998            Err(CliError::PaginationStalled(cursor)) if cursor == CURSOR
2999        ));
3000    }
3001}