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