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, 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_set_source_page(
1975    target: &Target,
1976    after: Option<String>,
1977    limit: u16,
1978) -> Result<PokemonSetSourcePage, CliError> {
1979    call_page(target, "toko_feed_set_sources", after, limit)
1980}
1981
1982fn fetch_card_source_page(
1983    target: &Target,
1984    after: Option<String>,
1985    limit: u16,
1986) -> Result<PokemonCardSourcePage, CliError> {
1987    call_page(target, "toko_feed_card_sources", after, limit)
1988}
1989
1990fn fetch_sealed_page(
1991    target: &Target,
1992    after: Option<String>,
1993    limit: u16,
1994) -> Result<PokemonSealedPage, CliError> {
1995    call_page(target, "toko_feed_sealed", after, limit)
1996}
1997
1998fn fetch_sealed_source_page(
1999    target: &Target,
2000    after: Option<String>,
2001    limit: u16,
2002) -> Result<PokemonSealedSourcePage, CliError> {
2003    call_page(target, "toko_feed_sealed_sources", after, limit)
2004}
2005
2006fn fetch_set_reconciliation_page(
2007    target: &Target,
2008    after_match_key: Option<String>,
2009    limit: u16,
2010) -> Result<PokemonSetReconciliationPage, CliError> {
2011    let method = "toko_feed_set_reconciliation";
2012    let arguments = encode_args((after_match_key, limit))
2013        .map_err(|source| CliError::Encode { method, source })?;
2014    call_and_decode(target, method, &arguments, true)
2015}
2016
2017fn fetch_card_reconciliation_page(
2018    target: &Target,
2019    pokemon_set_id: String,
2020    after_match_key: Option<String>,
2021    limit: u16,
2022) -> Result<PokemonCardReconciliationPage, CliError> {
2023    let method = "toko_feed_card_reconciliation";
2024    let arguments = encode_args((pokemon_set_id, after_match_key, limit))
2025        .map_err(|source| CliError::Encode { method, source })?;
2026    call_and_decode(target, method, &arguments, true)
2027}
2028
2029fn fetch_run_page(
2030    target: &Target,
2031    before: Option<TimeCursor>,
2032    limit: u16,
2033) -> Result<IngestionRunPage, CliError> {
2034    call_history_page(target, "toko_feed_runs", before, limit)
2035}
2036
2037fn fetch_log_page(
2038    target: &Target,
2039    before: Option<TimeCursor>,
2040    limit: u16,
2041) -> Result<OperationalLogPage, CliError> {
2042    call_history_page(target, "toko_feed_logs", before, limit)
2043}
2044
2045fn fetch_card_price_page(
2046    target: &Target,
2047    card_id: String,
2048    before: Option<TimeCursor>,
2049    limit: u16,
2050) -> Result<PriceObservationPage, CliError> {
2051    call_item_history_page(target, "toko_feed_card_prices", card_id, before, limit)
2052}
2053
2054fn fetch_sealed_price_page(
2055    target: &Target,
2056    sealed_id: String,
2057    before: Option<TimeCursor>,
2058    limit: u16,
2059) -> Result<SealedPriceObservationPage, CliError> {
2060    call_item_history_page(target, "toko_feed_sealed_prices", sealed_id, before, limit)
2061}
2062
2063fn list_all_runs(target: &Target, options: &HistoryOptions) -> Result<Value, CliError> {
2064    let (runs, pages) = collect_history_pages(options, |before, limit| {
2065        let page = fetch_run_page(target, before, limit)?;
2066        Ok((page.runs, page.next_before))
2067    })?;
2068    complete_page_output("runs", runs, pages, "next_before")
2069}
2070
2071fn list_all_logs(target: &Target, options: &HistoryOptions) -> Result<Value, CliError> {
2072    let (logs, pages) = collect_history_pages(options, |before, limit| {
2073        let page = fetch_log_page(target, before, limit)?;
2074        Ok((page.logs, page.next_before))
2075    })?;
2076    complete_page_output("logs", logs, pages, "next_before")
2077}
2078
2079fn list_all_prices(
2080    target: &Target,
2081    price_target: PriceTarget,
2082    item_id: &str,
2083    options: &HistoryOptions,
2084) -> Result<Value, CliError> {
2085    match price_target {
2086        PriceTarget::Card => {
2087            let (observations, pages) = collect_history_pages(options, |before, limit| {
2088                let page = fetch_card_price_page(target, item_id.to_owned(), before, limit)?;
2089                Ok((page.observations, page.next_before))
2090            })?;
2091            complete_page_output("observations", observations, pages, "next_before")
2092        }
2093        PriceTarget::Sealed => {
2094            let (observations, pages) = collect_history_pages(options, |before, limit| {
2095                let page = fetch_sealed_price_page(target, item_id.to_owned(), before, limit)?;
2096                Ok((page.observations, page.next_before))
2097            })?;
2098            complete_page_output("observations", observations, pages, "next_before")
2099        }
2100    }
2101}
2102
2103fn collect_history_pages<T>(
2104    options: &HistoryOptions,
2105    fetch: impl FnMut(Option<TimeCursor>, u16) -> Result<(Vec<T>, Option<TimeCursor>), CliError>,
2106) -> Result<(Vec<T>, usize), CliError> {
2107    let mut seen = options
2108        .before
2109        .as_ref()
2110        .map(time_cursor_token)
2111        .into_iter()
2112        .collect::<HashSet<_>>();
2113    collect_pages(
2114        options.before.clone(),
2115        options.limit,
2116        options.max_pages,
2117        fetch,
2118        |_, next| validate_time_cursor(next, &mut seen),
2119    )
2120}
2121
2122fn validate_time_cursor(cursor: &TimeCursor, seen: &mut HashSet<String>) -> Result<(), CliError> {
2123    if !is_valid_local_id(&cursor.id) {
2124        return Err(CliError::RawReply(
2125            "next_before contained an invalid local ULID",
2126        ));
2127    }
2128    let token = time_cursor_token(cursor);
2129    if !seen.insert(token.clone()) {
2130        return Err(CliError::PaginationStalled(token));
2131    }
2132    Ok(())
2133}
2134
2135fn time_cursor_token(cursor: &TimeCursor) -> String {
2136    format!("{}:{}", cursor.timestamp, cursor.id)
2137}
2138
2139fn list_all_sets(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2140    let (sets, pages) = collect_keyset_pages(options, |after, limit| {
2141        let page = fetch_set_page(target, after, limit)?;
2142        Ok((page.sets, page.next_after))
2143    })?;
2144    complete_page_output("sets", sets, pages, "next_after")
2145}
2146
2147fn list_all_catalog_status(target: &Target, options: &StatusOptions) -> Result<Value, CliError> {
2148    let (sets, pages) = collect_keyset_pages(&options.list, |after, _limit| {
2149        let page = fetch_catalog_status_page(target, options, after)?;
2150        Ok((page.sets, page.next_after))
2151    })?;
2152    complete_page_output("sets", sets, pages, "next_after")
2153}
2154
2155fn list_all_collections(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2156    let (collections, pages) = collect_keyset_pages(options, |after, limit| {
2157        let page = fetch_collection_page(target, after, limit)?;
2158        Ok((page.collections, page.next_after))
2159    })?;
2160    complete_page_output("collections", collections, pages, "next_after")
2161}
2162
2163fn list_all_collection_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2164    let (sources, pages) = collect_keyset_pages(options, |after, limit| {
2165        let page = fetch_collection_source_page(target, after, limit)?;
2166        Ok((page.sources, page.next_after))
2167    })?;
2168    complete_page_output("sources", sources, pages, "next_after")
2169}
2170
2171fn list_all_cards(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2172    let (cards, pages) = collect_keyset_pages(options, |after, limit| {
2173        let page = fetch_card_page(target, after, limit)?;
2174        Ok((page.cards, page.next_after))
2175    })?;
2176    complete_page_output("cards", cards, pages, "next_after")
2177}
2178
2179fn list_all_set_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2180    let (sources, pages) = collect_keyset_pages(options, |after, limit| {
2181        let page = fetch_set_source_page(target, after, limit)?;
2182        Ok((page.sources, page.next_after))
2183    })?;
2184    complete_page_output("sources", sources, pages, "next_after")
2185}
2186
2187fn list_all_card_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2188    let (sources, pages) = collect_keyset_pages(options, |after, limit| {
2189        let page = fetch_card_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_sealed(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2196    let (sealed, pages) = collect_keyset_pages(options, |after, limit| {
2197        let page = fetch_sealed_page(target, after, limit)?;
2198        Ok((page.sealed, page.next_after))
2199    })?;
2200    complete_page_output("sealed", sealed, pages, "next_after")
2201}
2202
2203fn list_all_sealed_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
2204    let (sources, pages) = collect_keyset_pages(options, |after, limit| {
2205        let page = fetch_sealed_source_page(target, after, limit)?;
2206        Ok((page.sources, page.next_after))
2207    })?;
2208    complete_page_output("sources", sources, pages, "next_after")
2209}
2210
2211fn complete_page_output<T>(
2212    field: &'static str,
2213    items: Vec<T>,
2214    pages: usize,
2215    cursor_field: &'static str,
2216) -> Result<Value, CliError>
2217where
2218    T: Serialize,
2219{
2220    let count = items.len();
2221    let mut object = serde_json::Map::new();
2222    object.insert(field.to_owned(), serde_json::to_value(items)?);
2223    object.insert("count".to_owned(), json!(count));
2224    object.insert("pages".to_owned(), json!(pages));
2225    object.insert(cursor_field.to_owned(), Value::Null);
2226    Ok(Value::Object(object))
2227}
2228
2229fn collect_keyset_pages<T>(
2230    options: &ListOptions,
2231    fetch: impl FnMut(Option<String>, u16) -> Result<(Vec<T>, Option<String>), CliError>,
2232) -> Result<(Vec<T>, usize), CliError> {
2233    let mut seen = options.after.iter().cloned().collect::<HashSet<_>>();
2234    collect_pages(
2235        options.after.clone(),
2236        options.limit,
2237        options.max_pages,
2238        fetch,
2239        |_, next| validate_reply_cursor(next, &mut seen),
2240    )
2241}
2242
2243fn validate_reply_cursor(cursor: &str, seen: &mut HashSet<String>) -> Result<(), CliError> {
2244    if !is_valid_local_id(cursor) {
2245        return Err(CliError::RawReply("next_after was not a valid local ULID"));
2246    }
2247    if !seen.insert(cursor.to_owned()) {
2248        return Err(CliError::PaginationStalled(cursor.to_owned()));
2249    }
2250    Ok(())
2251}
2252
2253#[cfg(test)]
2254mod tests {
2255    use candid::decode_args;
2256    use clap::CommandFactory;
2257
2258    use super::*;
2259
2260    fn invocation(values: &[&str]) -> Invocation {
2261        parse_test_invocation(values).expect("arguments should parse")
2262    }
2263
2264    fn clap_error(values: &[&str]) -> ErrorKind {
2265        match parse_test_invocation(values) {
2266            Err(CliError::Clap(error)) => error.kind(),
2267            Err(error) => panic!("expected a Clap error, got {error}"),
2268            Ok(_) => panic!("expected Clap to reject the arguments"),
2269        }
2270    }
2271
2272    #[test]
2273    fn parses_global_and_automatic_pagination_options() {
2274        let parsed = invocation(&[
2275            "--environment",
2276            "ic",
2277            "--canister",
2278            "aaaaa-aa",
2279            "--identity",
2280            "operator",
2281            "--project-root",
2282            "/srv/toko-feed",
2283            "--json",
2284            "--compact",
2285            "sets",
2286            "list",
2287            "--limit",
2288            "100",
2289            "--after",
2290            "01KZ9GFKW3SY1G000000000001",
2291            "--all",
2292            "--max-pages",
2293            "12",
2294        ]);
2295
2296        assert_eq!(parsed.target.environment, "ic");
2297        assert_eq!(parsed.target.canister, "aaaaa-aa");
2298        assert_eq!(parsed.target.identity, "operator");
2299        assert_eq!(
2300            parsed.target.project_root.as_deref(),
2301            Some(std::path::Path::new("/srv/toko-feed"))
2302        );
2303        assert_eq!(parsed.output.format(), OutputFormat::Json);
2304        assert!(parsed.output.compact);
2305        assert_eq!(
2306            parsed.action,
2307            Action::List(
2308                Resource::Sets,
2309                ListOptions {
2310                    limit: 100,
2311                    after: Some("01KZ9GFKW3SY1G000000000001".to_owned()),
2312                    all: true,
2313                    max_pages: 12,
2314                }
2315            )
2316        );
2317    }
2318
2319    #[test]
2320    fn parses_bounded_status_drill_down() {
2321        assert_eq!(
2322            invocation(&[
2323                "status",
2324                "--collection",
2325                "magic",
2326                "--set",
2327                "the",
2328                "--limit",
2329                "50",
2330                "--all",
2331            ])
2332            .action,
2333            Action::Status(StatusOptions {
2334                collection: Some("magic-the-gathering".to_owned()),
2335                set: Some("the".to_owned()),
2336                list: ListOptions {
2337                    limit: 50,
2338                    after: None,
2339                    all: true,
2340                    max_pages: DEFAULT_MAX_PAGES,
2341                },
2342            })
2343        );
2344        assert_eq!(
2345            clap_error(&["status", "--set", "aquapolis"]),
2346            ErrorKind::MissingRequiredArgument
2347        );
2348        let json = invocation(&["status", "--collection", "poke", "--json"]);
2349        assert_eq!(json.output.format(), OutputFormat::Json);
2350        assert!(!json.output.compact);
2351        assert_eq!(
2352            clap_error(&["status", "--compact"]),
2353            ErrorKind::MissingRequiredArgument
2354        );
2355    }
2356
2357    #[test]
2358    fn parses_bootstrap_and_keeps_root_help_focused() {
2359        assert_eq!(
2360            invocation(&["backup"]).action,
2361            Action::Backup(PathBuf::from(DEFAULT_CANONICAL_BACKUP_PATH))
2362        );
2363        assert_eq!(
2364            invocation(&["restore"]).action,
2365            Action::Restore(PathBuf::from(DEFAULT_CANONICAL_BACKUP_PATH))
2366        );
2367        assert_eq!(
2368            invocation(&["restore", "--input", "/tmp/canonical.json"]).action,
2369            Action::Restore(PathBuf::from("/tmp/canonical.json"))
2370        );
2371        assert_eq!(invocation(&["bootstrap"]).action, Action::Bootstrap);
2372        assert!(parse_test_invocation(&["bootstrap", "--source", "/tmp/cache"]).is_err());
2373        assert!(parse_test_invocation(&["bootstrap", "--refresh"]).is_err());
2374
2375        let help = Cli::command().render_help().to_string();
2376        assert!(help.contains("backup"));
2377        assert!(help.contains("bootstrap"));
2378        assert!(help.contains("restore"));
2379        assert!(help.contains("Connection:"));
2380        assert!(!help.contains("tcgdex"));
2381        assert!(!help.contains("scrydex"));
2382        assert!(!help.contains("Pokemon"));
2383        assert!(!help.contains("Pokémon"));
2384        assert!(!help.contains("--before-time"));
2385        assert!(!help.contains("--release-date"));
2386        assert!(help.lines().count() < 45);
2387    }
2388
2389    #[test]
2390    fn parses_bounded_provider_set_refresh() {
2391        assert_eq!(
2392            invocation(&[
2393                "provider",
2394                "set",
2395                "tcgdex",
2396                "ecard2",
2397                "--source",
2398                "/tmp/tcgdex",
2399                "--refresh",
2400                "--records",
2401                "12",
2402            ])
2403            .action,
2404            Action::ProviderSet(ProviderSetOptions {
2405                provider: Provider::TcgDex,
2406                collection: "pokemon".to_owned(),
2407                set_id: "ecard2".to_owned(),
2408                source: PathBuf::from("/tmp/tcgdex"),
2409                refresh: true,
2410                records: Some(12),
2411            })
2412        );
2413        assert_eq!(
2414            clap_error(&["provider", "set", "tcgdex", "ecard2", "--records", "1001"]),
2415            ErrorKind::ValueValidation
2416        );
2417        assert_eq!(
2418            clap_error(&["provider", "set", "tcgdex", "../ecard2"]),
2419            ErrorKind::ValueValidation
2420        );
2421
2422        let mut command = Cli::command();
2423        let provider = command
2424            .find_subcommand_mut("provider")
2425            .expect("provider command");
2426        let set = provider.find_subcommand_mut("set").expect("set command");
2427        let help = set.render_help().to_string();
2428        assert!(help.contains("--records"));
2429        assert!(!help.contains("--cards"));
2430        assert!(!help.contains("--data"));
2431        assert_eq!(
2432            clap_error(&["provider", "set", "tcgdex", "ecard2", "--cards", "10"]),
2433            ErrorKind::UnknownArgument
2434        );
2435        assert_eq!(
2436            clap_error(&["provider", "set", "tcgdex", "ecard2", "--data", "/tmp"]),
2437            ErrorKind::UnknownArgument
2438        );
2439    }
2440
2441    #[test]
2442    fn parses_provider_defaults_and_custom_source() {
2443        assert_eq!(
2444            invocation(&[
2445                "provider",
2446                "set",
2447                "scrydex",
2448                "ecard2",
2449                "--source",
2450                "/tmp/scrydex",
2451                "--refresh",
2452                "--records",
2453                "10",
2454            ])
2455            .action,
2456            Action::ProviderSet(ProviderSetOptions {
2457                provider: Provider::Scrydex,
2458                collection: "pokemon".to_owned(),
2459                set_id: "ecard2".to_owned(),
2460                source: PathBuf::from("/tmp/scrydex"),
2461                refresh: true,
2462                records: Some(10),
2463            })
2464        );
2465        assert_eq!(
2466            invocation(&["provider", "set", "scrydex", "ecard2"]).action,
2467            Action::ProviderSet(ProviderSetOptions {
2468                provider: Provider::Scrydex,
2469                collection: "pokemon".to_owned(),
2470                set_id: "ecard2".to_owned(),
2471                source: PathBuf::from("source/providers/scrydex/pokemon/v1/en"),
2472                refresh: false,
2473                records: Some(DEFAULT_PROVIDER_COMPARISON_RECORDS),
2474            })
2475        );
2476        assert_eq!(
2477            invocation(&["provider", "set", "scry", "ecard2"]).action,
2478            Action::ProviderSet(ProviderSetOptions {
2479                provider: Provider::Scrydex,
2480                collection: "pokemon".to_owned(),
2481                set_id: "ecard2".to_owned(),
2482                source: PathBuf::from("source/providers/scrydex/pokemon/v1/en"),
2483                refresh: false,
2484                records: Some(DEFAULT_PROVIDER_COMPARISON_RECORDS),
2485            })
2486        );
2487    }
2488
2489    #[test]
2490    fn parses_provider_full_acquisition() {
2491        assert_eq!(
2492            invocation(&[
2493                "provider",
2494                "set",
2495                "scry",
2496                "DRK",
2497                "--collection",
2498                "magic",
2499                "--all",
2500            ])
2501            .action,
2502            Action::ProviderSet(ProviderSetOptions {
2503                provider: Provider::Scrydex,
2504                collection: "magic-the-gathering".to_owned(),
2505                set_id: "DRK".to_owned(),
2506                source: PathBuf::from("source/providers/scrydex/magic-the-gathering/v1"),
2507                refresh: false,
2508                records: None,
2509            })
2510        );
2511        assert_eq!(
2512            invocation(&["provider", "set", "just-tcg", "aquapolis-pokemon", "--all",]).action,
2513            Action::ProviderSet(ProviderSetOptions {
2514                provider: Provider::JustTcg,
2515                collection: "pokemon".to_owned(),
2516                set_id: "aquapolis-pokemon".to_owned(),
2517                source: PathBuf::from("source/providers/just-tcg/pokemon/v1"),
2518                refresh: false,
2519                records: None,
2520            })
2521        );
2522        assert_eq!(
2523            clap_error(&["provider", "set", "unknown", "ecard2"]),
2524            ErrorKind::ValueValidation
2525        );
2526        assert_eq!(
2527            invocation(&["provider", "set", "scrydex", "ecard2", "--all"]).action,
2528            Action::ProviderSet(ProviderSetOptions {
2529                provider: Provider::Scrydex,
2530                collection: "pokemon".to_owned(),
2531                set_id: "ecard2".to_owned(),
2532                source: PathBuf::from("source/providers/scrydex/pokemon/v1/en"),
2533                refresh: false,
2534                records: None,
2535            })
2536        );
2537        assert_eq!(
2538            clap_error(&[
2539                "provider",
2540                "set",
2541                "scrydex",
2542                "ecard2",
2543                "--all",
2544                "--records",
2545                "10",
2546            ]),
2547            ErrorKind::ArgumentConflict
2548        );
2549    }
2550
2551    #[test]
2552    fn parses_provider_neutral_source_set_reconciliation() {
2553        assert_eq!(
2554            invocation(&[
2555                "reconcile",
2556                "sets",
2557                "accept-source",
2558                "--collection",
2559                "magic",
2560                "--provider",
2561                "scry",
2562                "--set",
2563                "DRK",
2564                "--all",
2565            ])
2566            .action,
2567            Action::ReconcileSourceSet(SourceSetReconciliationOptions {
2568                args: ReconcileSourceSetArgs {
2569                    collection: "magic-the-gathering".to_owned(),
2570                    provider: "scrydex".to_owned(),
2571                    provider_set_id: "DRK".to_owned(),
2572                    after: None,
2573                    limit: MAX_SAFE_RECONCILIATION_CARDS,
2574                },
2575                all: true,
2576                max_pages: DEFAULT_MAX_PAGES,
2577            })
2578        );
2579        assert_eq!(
2580            clap_error(&[
2581                "reconcile",
2582                "sets",
2583                "accept-source",
2584                "--collection",
2585                "magic",
2586                "--provider",
2587                "scry",
2588                "--set",
2589                "DRK",
2590                "--limit",
2591                "11",
2592            ]),
2593            ErrorKind::ValueValidation
2594        );
2595    }
2596
2597    #[test]
2598    fn clap_validates_name_prefix_matching() {
2599        for collection in ["poke", "Pokemon", "Pokémon", "POKE"] {
2600            assert!(matches!(
2601                invocation(&["status", "--collection", collection]).action,
2602                Action::Status(StatusOptions {
2603                    collection: Some(ref value),
2604                    ..
2605                }) if value == "pokemon"
2606            ));
2607        }
2608
2609        for collection in ["unknown", "mtg", "dragon-ball-super"] {
2610            assert_eq!(
2611                clap_error(&["provider", "set", "scry", "DRK", "--collection", collection,]),
2612                ErrorKind::ValueValidation
2613            );
2614        }
2615
2616        for provider in ["unknown", "jt"] {
2617            assert_eq!(
2618                clap_error(&["provider", "set", provider, "DRK"]),
2619                ErrorKind::ValueValidation
2620            );
2621        }
2622    }
2623
2624    #[test]
2625    fn clap_owns_argument_relationships_and_validation() {
2626        Cli::command().debug_assert();
2627
2628        assert!(parse_test_invocation(&["cards", "ingest"]).is_err());
2629        assert!(parse_test_invocation(&["sets", "curate"]).is_err());
2630        assert_eq!(
2631            clap_error(&["sets", "list", "--max-pages", "2"]),
2632            ErrorKind::MissingRequiredArgument
2633        );
2634        assert_eq!(
2635            clap_error(&["cards", "get", "not-an-id",]),
2636            ErrorKind::ValueValidation
2637        );
2638    }
2639
2640    #[test]
2641    fn parses_canonical_card_queries_and_rejects_legacy_ingestion() {
2642        assert_eq!(
2643            invocation(&["cards", "get", "01KZ9GFKW3SY1G000000000001"]).action,
2644            Action::Get(Resource::Cards, "01KZ9GFKW3SY1G000000000001".to_owned())
2645        );
2646        assert!(parse_test_invocation(&["cards", "ingest"]).is_err());
2647        assert!(parse_test_invocation(&["cards", "curate"]).is_err());
2648        assert_eq!(
2649            invocation(&["cards", "by-type", "fire", "--limit", "25", "--all",]).action,
2650            Action::ListCardsByType(
2651                PokemonType::Fire,
2652                ListOptions {
2653                    limit: 25,
2654                    all: true,
2655                    ..ListOptions::default()
2656                }
2657            )
2658        );
2659        assert!(parse_test_invocation(&["cards", "by-type", "steam"]).is_err());
2660    }
2661
2662    #[test]
2663    fn parses_canonical_collection_queries() {
2664        assert_eq!(
2665            invocation(&["collections", "list", "--all"]).action,
2666            Action::List(
2667                Resource::Collections,
2668                ListOptions {
2669                    all: true,
2670                    ..ListOptions::default()
2671                }
2672            )
2673        );
2674        assert!(parse_test_invocation(&["collections", "ingest"]).is_err());
2675    }
2676
2677    #[test]
2678    fn parses_revisioned_set_lock_unlock_and_rejects_legacy_curation() {
2679        let set_id = "01KZ9GFKW3SY1G000000000002";
2680        assert_eq!(
2681            invocation(&["sets", "verify", set_id, "--revision", "7"]).action,
2682            Action::VerifySet(VerifyCanonicalArgs {
2683                id: set_id.to_owned(),
2684                expected_revision: 7,
2685            })
2686        );
2687        assert_eq!(
2688            invocation(&["sets", "lock", set_id, "--revision", "8"]).action,
2689            Action::LockSet(LockPokemonSetArgs {
2690                id: set_id.to_owned(),
2691                expected_revision: 8,
2692            })
2693        );
2694        assert_eq!(
2695            invocation(&["sets", "unlock", set_id, "--revision", "9"]).action,
2696            Action::UnlockSet(UnlockCanonicalArgs {
2697                id: set_id.to_owned(),
2698                expected_revision: 9,
2699            })
2700        );
2701        assert!(parse_test_invocation(&["sets", "curate"]).is_err());
2702    }
2703
2704    #[test]
2705    fn parses_source_first_evidence_commands() {
2706        let source_id = "01KZ9GFKW3SY1G000000000001";
2707        let card_id = "01KZ9GFKW3SY1G000000000003";
2708
2709        assert_eq!(
2710            invocation(&["sources", "collections", "get", source_id]).action,
2711            Action::Get(Resource::CollectionSources, source_id.to_owned())
2712        );
2713        assert_eq!(
2714            invocation(&["sources", "sets", "reject", source_id]).action,
2715            Action::RejectSource(RejectableSource::Set, source_id.to_owned())
2716        );
2717        assert_eq!(
2718            invocation(&["sources", "cards", "list", "--all"]).action,
2719            Action::List(
2720                Resource::CardSources,
2721                ListOptions {
2722                    all: true,
2723                    ..ListOptions::default()
2724                }
2725            )
2726        );
2727        assert_eq!(
2728            invocation(&["sources", "sealed", "get", source_id]).action,
2729            Action::Get(Resource::SealedSources, source_id.to_owned())
2730        );
2731        assert_eq!(
2732            invocation(&["cards", "verify", card_id, "--revision", "0"]).action,
2733            Action::VerifyCard(VerifyCanonicalArgs {
2734                id: card_id.to_owned(),
2735                expected_revision: 0,
2736            })
2737        );
2738        assert_eq!(
2739            invocation(&["cards", "lock", card_id, "--revision", "1"]).action,
2740            Action::LockCard(LockPokemonCardArgs {
2741                id: card_id.to_owned(),
2742                expected_revision: 1,
2743            })
2744        );
2745        assert_eq!(
2746            invocation(&["cards", "unlock", card_id, "--revision", "2"]).action,
2747            Action::UnlockCard(UnlockCanonicalArgs {
2748                id: card_id.to_owned(),
2749                expected_revision: 2,
2750            })
2751        );
2752        assert!(parse_test_invocation(&["sets", "sources", "list"]).is_err());
2753        assert!(parse_test_invocation(&["cards", "sources", "list"]).is_err());
2754        assert!(parse_test_invocation(&["sources", "sealed", "reject", source_id]).is_err());
2755    }
2756
2757    #[test]
2758    fn parses_scheduler_history_and_price_commands() {
2759        assert_eq!(invocation(&["scheduler"]).action, Action::Scheduler);
2760        assert_eq!(
2761            invocation(&[
2762                "runs",
2763                "--before-time",
2764                "123",
2765                "--before-id",
2766                "01KZ9GFKW3SY1G000000000001",
2767                "--all",
2768            ])
2769            .action,
2770            Action::Runs(HistoryOptions {
2771                before: Some(TimeCursor {
2772                    timestamp: 123,
2773                    id: "01KZ9GFKW3SY1G000000000001".to_owned(),
2774                }),
2775                all: true,
2776                ..HistoryOptions::default()
2777            })
2778        );
2779        assert!(matches!(
2780            invocation(&[
2781                "cards",
2782                "prices",
2783                "01KZ9GFKW3SY1G000000000001",
2784                "--limit",
2785                "100",
2786            ])
2787            .action,
2788            Action::Prices(PriceTarget::Card, _, HistoryOptions { limit: 100, .. })
2789        ));
2790        assert!(matches!(
2791            invocation(&["sealed", "prices", "01KZ9GFKW3SY1G000000000001", "--all",]).action,
2792            Action::Prices(PriceTarget::Sealed, _, HistoryOptions { all: true, .. })
2793        ));
2794        assert!(parse_test_invocation(&["runs", "list"]).is_err());
2795        assert!(parse_test_invocation(&["logs", "list"]).is_err());
2796        assert!(parse_test_invocation(&["logs", "--before-time", "123",]).is_err());
2797    }
2798
2799    #[test]
2800    fn parses_sealed_queries_and_rejects_separate_ingestion() {
2801        assert_eq!(
2802            invocation(&["sealed", "list", "--all"]).action,
2803            Action::List(
2804                Resource::Sealed,
2805                ListOptions {
2806                    all: true,
2807                    ..ListOptions::default()
2808                }
2809            )
2810        );
2811        assert_eq!(
2812            invocation(&["sealed", "get", "01KZ9GFKW3SY1G000000000001"]).action,
2813            Action::Get(Resource::Sealed, "01KZ9GFKW3SY1G000000000001".to_owned())
2814        );
2815        assert!(parse_test_invocation(&["sealed", "ingest"]).is_err());
2816    }
2817
2818    #[test]
2819    fn rejects_invalid_bounds_and_identifiers() {
2820        assert!(parse_test_invocation(&["sets", "list", "--limit", "0"]).is_err());
2821        assert!(parse_test_invocation(&["sets", "list", "--max-pages", "2"]).is_err());
2822        assert!(parse_test_invocation(&["sets", "get", "not-an-id"]).is_err());
2823    }
2824
2825    #[test]
2826    fn accepts_help_at_any_command_depth_and_version_at_the_top_level() {
2827        assert!(matches!(
2828            parse_test_invocation(&["sets", "list", "--help"]),
2829            Err(CliError::Clap(error)) if error.kind() == ErrorKind::DisplayHelp
2830        ));
2831        assert!(matches!(
2832            parse_test_invocation(&["--version"]),
2833            Err(CliError::Clap(error)) if error.kind() == ErrorKind::DisplayVersion
2834        ));
2835    }
2836
2837    #[test]
2838    fn encodes_typed_page_arguments_without_candid_text() {
2839        let bytes = encode_args((Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100_u16))
2840            .expect("encode page arguments");
2841        let decoded = decode_args::<(Option<String>, u16)>(&bytes).expect("decode page arguments");
2842
2843        assert_eq!(
2844            decoded,
2845            (Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100)
2846        );
2847    }
2848
2849    #[test]
2850    fn parses_provider_neutral_reconciliation_commands() {
2851        const SET: &str = "01KZ9GFKW3SY1G000000000001";
2852        const SOURCE_ONE: &str = "01KZ9GFKW3SY1G000000000002";
2853        const CARD: &str = "01KZ9GFKW3SY1G000000000004";
2854
2855        assert_eq!(
2856            invocation(&[
2857                "reconcile",
2858                "cards",
2859                "plan",
2860                "--set",
2861                SET,
2862                "--limit",
2863                "50",
2864                "--all",
2865            ])
2866            .action,
2867            Action::PlanCards(CardReconciliationPlanOptions {
2868                pokemon_set_id: SET.to_owned(),
2869                plan: ReconciliationPlanOptions {
2870                    after: None,
2871                    limit: 50,
2872                    all: true,
2873                    max_pages: DEFAULT_MAX_PAGES,
2874                },
2875            })
2876        );
2877        assert_eq!(
2878            invocation(&[
2879                "reconcile",
2880                "cards",
2881                "accept-metadata",
2882                "--card",
2883                CARD,
2884                "--source",
2885                SOURCE_ONE,
2886            ])
2887            .action,
2888            Action::ReconcileCardMetadata(CuratePokemonCardMetadataArgs {
2889                pokemon_card_id: CARD.to_owned(),
2890                pokemon_card_source_id: SOURCE_ONE.to_owned(),
2891            })
2892        );
2893        assert_eq!(
2894            invocation(&["reconcile", "cards", "accept-safe", "--set", SET, "--all",]).action,
2895            Action::ReconcileSafeCards(SafeCardReconciliationOptions {
2896                args: ReconcilePokemonCardsArgs {
2897                    pokemon_set_id: SET.to_owned(),
2898                    after_match_key: None,
2899                    limit: MAX_SAFE_RECONCILIATION_CARDS,
2900                },
2901                all: true,
2902                max_pages: DEFAULT_MAX_PAGES,
2903            })
2904        );
2905        assert_eq!(
2906            invocation(&[
2907                "reconcile",
2908                "sets",
2909                "accept-safe",
2910                "pokemon-set:v1:pokemon:aquapolis:2003-01-15",
2911            ])
2912            .action,
2913            Action::ReconcileSafeSet("pokemon-set:v1:pokemon:aquapolis:2003-01-15".to_owned())
2914        );
2915        assert_eq!(
2916            clap_error(&[
2917                "reconcile",
2918                "cards",
2919                "accept-safe",
2920                "--set",
2921                SET,
2922                "--limit",
2923                "11",
2924            ]),
2925            ErrorKind::ValueValidation
2926        );
2927    }
2928
2929    #[test]
2930    fn parses_typed_item_variant_reconciliation() {
2931        const SOURCE_ONE: &str = "01KZ9GFKW3SY1G000000000002";
2932        const SOURCE_TWO: &str = "01KZ9GFKW3SY1G000000000003";
2933        const CARD: &str = "01KZ9GFKW3SY1G000000000004";
2934
2935        assert_eq!(
2936            invocation(&[
2937                "reconcile",
2938                "cards",
2939                "accept-printing",
2940                "--source",
2941                SOURCE_ONE,
2942                SOURCE_TWO,
2943                "--card",
2944                CARD,
2945                "--language",
2946                "english",
2947                "--finish",
2948                "standard",
2949                "--edition",
2950                "a",
2951            ])
2952            .action,
2953            Action::ReconcileCardPrinting(ReconcilePokemonCardPrintingArgs {
2954                source_ids: vec![SOURCE_ONE.to_owned(), SOURCE_TWO.to_owned()],
2955                pokemon_card_id: CARD.to_owned(),
2956                language: PokemonLanguage::English,
2957                finish: ItemFinish::Standard,
2958                edition_code: Some("a".to_owned()),
2959            })
2960        );
2961    }
2962
2963    #[test]
2964    fn keyset_page_collection_preserves_order_and_rejects_stalled_cursors() {
2965        const CURSOR: &str = "01KZ9GFKW3SY1G000000000001";
2966        let options = ListOptions {
2967            after: None,
2968            limit: 2,
2969            all: true,
2970            max_pages: 3,
2971        };
2972        let (items, pages) = collect_keyset_pages(&options, |after, limit| {
2973            assert_eq!(limit, 2);
2974            match after.as_deref() {
2975                None => Ok((vec![1, 2], Some(CURSOR.to_owned()))),
2976                Some(CURSOR) => Ok((vec![3], None)),
2977                Some(_) => Err(CliError::RawReply("unexpected test cursor")),
2978            }
2979        })
2980        .expect("bounded pages should collect");
2981        assert_eq!(items, vec![1, 2, 3]);
2982        assert_eq!(pages, 2);
2983
2984        let stalled = ListOptions {
2985            after: Some(CURSOR.to_owned()),
2986            ..options
2987        };
2988        assert!(matches!(
2989            collect_keyset_pages::<u8>(&stalled, |_, _| Ok((vec![1], Some(CURSOR.to_owned())))),
2990            Err(CliError::PaginationStalled(cursor)) if cursor == CURSOR
2991        ));
2992    }
2993}