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