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