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