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