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