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