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, ValueEnum, error::ErrorKind};
22use serde::{Serialize, de::DeserializeOwned};
23use serde_json::{Value, json};
24use thiserror::Error;
25use toko_feed::{
26 CollectionDetails, CollectionIngestReceipt, CollectionPage, CuratePokemonCardArgs,
27 CuratePokemonSetArgs, FeedError, FeedStatus, IngestReceipt, IngestSetCardsArgs,
28 IngestionRunPage, LockPokemonCardArgs, LockPokemonSetArgs, OperationalLogPage,
29 PokemonCardDetails, PokemonCardPage, PokemonCardSourcePage, PokemonCardSourceView,
30 PokemonCardView, PokemonSealedDetails, PokemonSealedPage, PokemonSetDetails,
31 PokemonSetEvidenceView, PokemonSetPage, PokemonSetSourcePage, PokemonSetView,
32 PriceObservationPage, SchedulerStatus, SealedPriceObservationPage, SetCardIngestReceipt,
33 SetIngestReceipt, SetLifecycleStatus, TimeCursor,
34};
35
36use backup::DEFAULT_CANONICAL_BACKUP_PATH;
37use cache::{
38 DEFAULT_PROVIDER_SOURCE_PATH, DEFAULT_SCRYDEX_SOURCE_PATH, DEFAULT_TCGDEX_SOURCE_PATH,
39};
40
41const DEFAULT_LIMIT: u16 = 20;
42const DEFAULT_MAX_PAGES: usize = 1_000;
43const MAX_QUERY_LIMIT: u16 = 100;
44const MAX_CARD_FEED_RECORDS_PER_INGEST: u16 = 10;
45const DEFAULT_PROVIDER_COMPARISON_CARDS: u16 = 10;
46const MAX_PROVIDER_SET_CARDS: u16 = 1_000;
47const MAX_RAW_REPLY_BYTES: usize = 16 * 1024 * 1024;
48static CALL_ARGUMENT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
49
50#[derive(Debug, Error)]
53pub enum CliError {
54 #[error(transparent)]
56 Clap(#[from] clap::Error),
57 #[error("{0}\n\nRun with --help for usage.")]
59 Usage(String),
60 #[error("could not start icp: {0}")]
62 StartIcp(#[source] io::Error),
63 #[error("icp call failed{status}: {message}")]
65 Icp {
66 status: String,
68 message: String,
70 },
71 #[error("could not encode arguments for `{method}`: {source}")]
73 Encode {
74 method: &'static str,
76 #[source]
78 source: candid::Error,
79 },
80 #[error("could not decode the typed reply from `{method}`: {source}")]
82 Decode {
83 method: &'static str,
85 #[source]
87 source: candid::Error,
88 },
89 #[error("icp returned an invalid raw reply: {0}")]
91 RawReply(&'static str),
92 #[error("could not prepare temporary Candid arguments: {0}")]
94 ArgumentFile(#[source] io::Error),
95 #[error("canister method `{method}` returned {error}")]
97 Canister {
98 method: &'static str,
100 error: String,
102 },
103 #[error("provider `{provider}` set `{set_id}` failed at card offset {offset}: {source}")]
106 ProviderCard {
107 provider: &'static str,
109 set_id: String,
111 offset: u64,
113 #[source]
115 source: Box<Self>,
116 },
117 #[error("could not process JSON: {0}")]
119 Json(#[from] serde_json::Error),
120 #[error("could not write output: {0}")]
122 Io(#[source] io::Error),
123 #[error("could not {action} provider cache `{}`: {source}", path.display())]
125 CacheIo {
126 action: &'static str,
128 path: PathBuf,
130 #[source]
132 source: io::Error,
133 },
134 #[error("could not {action} canonical backup `{}`: {source}", path.display())]
136 BackupIo {
137 action: &'static str,
139 path: PathBuf,
141 #[source]
143 source: io::Error,
144 },
145 #[error("invalid provider cache: {0}")]
147 InvalidCache(String),
148 #[error("pagination cursor did not advance: {0}")]
150 PaginationStalled(String),
151 #[error("listing still had another page after the --max-pages limit of {0}")]
153 PaginationLimit(usize),
154}
155
156impl CliError {
157 #[must_use]
159 pub fn exit_code(&self) -> i32 {
160 match self {
161 Self::Clap(error) => error.exit_code(),
162 Self::Usage(_) => 2,
163 _ => 1,
164 }
165 }
166
167 #[must_use]
169 pub fn is_broken_pipe(&self) -> bool {
170 matches!(self, Self::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)
171 }
172}
173
174#[derive(Clone, Debug, Eq, PartialEq, Args)]
175struct Target {
176 #[arg(
178 long,
179 default_value = "local",
180 value_name = "NAME",
181 help_heading = "Connection"
182 )]
183 environment: String,
184 #[arg(
186 long,
187 default_value = "toko-feed",
188 value_name = "NAME|ID",
189 help_heading = "Connection"
190 )]
191 canister: String,
192 #[arg(
194 long,
195 default_value = "anonymous",
196 value_name = "NAME",
197 help_heading = "Connection"
198 )]
199 identity: String,
200 #[arg(long, value_name = "PATH", help_heading = "Connection")]
202 identity_password_file: Option<PathBuf>,
203 #[arg(long, value_name = "PATH", help_heading = "Connection")]
205 project_root: Option<PathBuf>,
206 #[arg(
208 long,
209 default_value = "icp",
210 value_name = "PATH",
211 help_heading = "Connection"
212 )]
213 icp: PathBuf,
214 #[arg(long)]
216 compact: bool,
217}
218
219#[derive(Debug, Parser)]
220#[command(
221 name = "toko-feed",
222 version,
223 about = "Operate and query a Toko Feed canister",
224 long_about = None,
225 arg_required_else_help = true
226)]
227struct Cli {
228 #[command(flatten)]
229 target: Target,
230 #[command(subcommand)]
231 command: RootCommand,
232}
233
234#[derive(Debug, Subcommand)]
235enum RootCommand {
236 Bootstrap(BootstrapArgs),
238 Backup(BackupArgs),
240 Status,
242 Scheduler,
244 Runs(HistoryGroup),
246 Logs(HistoryGroup),
248 Collections(CollectionsGroup),
250 Sets(SetsGroup),
252 Cards(CardsGroup),
254 Sealed(SealedGroup),
256 Provider(ProviderGroup),
258}
259
260#[derive(Debug, Args)]
261struct BackupArgs {
262 #[arg(long, value_name = "PATH", default_value = DEFAULT_CANONICAL_BACKUP_PATH)]
264 output: PathBuf,
265}
266
267#[derive(Debug, Args)]
268struct BootstrapArgs {
269 #[arg(
271 long = "source",
272 visible_aliases = ["data", "cache"],
273 value_name = "PATH",
274 num_args = 0..=1,
275 default_missing_value = DEFAULT_PROVIDER_SOURCE_PATH
276 )]
277 source: Option<PathBuf>,
278 #[arg(long, requires = "source")]
280 refresh: bool,
281}
282
283#[derive(Debug, Args)]
284struct ProviderGroup {
285 #[command(subcommand)]
286 command: ProviderCommand,
287}
288
289#[derive(Debug, Subcommand)]
290enum ProviderCommand {
291 Set(ProviderSetArgs),
293}
294
295#[derive(Debug, Args)]
296struct ProviderSetArgs {
297 #[arg(value_enum, value_name = "PROVIDER")]
299 provider: Provider,
300 #[arg(value_name = "SET_ID")]
302 set_id: ProviderSetId,
303 #[arg(long = "source", visible_alias = "data", value_name = "PATH")]
305 source: Option<PathBuf>,
306 #[arg(long)]
308 refresh: bool,
309 #[arg(
311 long,
312 conflicts_with = "all",
313 value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_PROVIDER_SET_CARDS))
314 )]
315 cards: Option<u16>,
316 #[arg(long, conflicts_with = "cards")]
318 all: bool,
319}
320
321#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
322enum Provider {
323 #[value(name = "tcgdex")]
325 TcgDex,
326 Scrydex,
328}
329
330impl Provider {
331 const fn name(self) -> &'static str {
332 match self {
333 Self::TcgDex => "tcgdex",
334 Self::Scrydex => "scrydex",
335 }
336 }
337
338 const fn default_source(self) -> &'static str {
339 match self {
340 Self::TcgDex => DEFAULT_TCGDEX_SOURCE_PATH,
341 Self::Scrydex => DEFAULT_SCRYDEX_SOURCE_PATH,
342 }
343 }
344}
345
346#[derive(Debug, Args)]
347struct HistoryGroup {
348 #[command(subcommand)]
349 command: HistoryCommand,
350}
351
352#[derive(Debug, Subcommand)]
353enum HistoryCommand {
354 List(HistoryArgs),
356}
357
358#[derive(Debug, Args)]
359struct CollectionsGroup {
360 #[command(subcommand)]
361 command: CollectionsCommand,
362}
363
364#[derive(Debug, Subcommand)]
365enum CollectionsCommand {
366 Ingest,
368 List(ListArgs),
370 Get(IdArg),
372}
373
374#[derive(Debug, Args)]
375struct SetsGroup {
376 #[command(subcommand)]
377 command: SetsCommand,
378}
379
380#[derive(Debug, Subcommand)]
381enum SetsCommand {
382 Ingest,
384 List(ListArgs),
386 Get(IdArg),
388 Sources(SourcesGroup),
390 Curate(SetCurateArgs),
392 Lock(SetLockArgs),
394}
395
396#[derive(Debug, Args)]
397struct CardsGroup {
398 #[command(subcommand)]
399 command: CardsCommand,
400}
401
402#[derive(Debug, Subcommand)]
403enum CardsCommand {
404 Ingest(CardIngestArgs),
406 List(ListArgs),
408 Get(IdArg),
410 Sources(SourcesGroup),
412 Curate(CardCurateArgs),
414 Lock(CardLockArgs),
416 Prices(PriceArgs),
418}
419
420#[derive(Debug, Args)]
421struct SourcesGroup {
422 #[command(subcommand)]
423 command: SourcesCommand,
424}
425
426#[derive(Debug, Subcommand)]
427enum SourcesCommand {
428 List(ListArgs),
430 Get(IdArg),
432 Reject(IdArg),
434}
435
436#[derive(Debug, Args)]
437struct SealedGroup {
438 #[command(subcommand)]
439 command: SealedCommand,
440}
441
442#[derive(Debug, Subcommand)]
443enum SealedCommand {
444 List(ListArgs),
446 Get(IdArg),
448 Prices(PriceArgs),
450}
451
452#[derive(Clone, Debug, Eq, PartialEq)]
453struct LocalId(String);
454
455#[derive(Debug, Args)]
456struct IdArg {
457 #[arg(value_name = "ULID")]
459 id: LocalId,
460}
461
462#[derive(Debug, Args)]
463struct ListArgs {
464 #[arg(
466 long,
467 default_value_t = DEFAULT_LIMIT,
468 value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_QUERY_LIMIT))
469 )]
470 limit: u16,
471 #[arg(long, value_name = "ULID")]
473 after: Option<LocalId>,
474 #[arg(long)]
476 all: bool,
477 #[arg(
479 long,
480 default_value_t = DEFAULT_MAX_PAGES,
481 value_name = "COUNT",
482 requires = "all",
483 value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..)
484 )]
485 max_pages: usize,
486}
487
488#[derive(Debug, Args)]
489struct HistoryArgs {
490 #[arg(
492 long,
493 default_value_t = DEFAULT_LIMIT,
494 value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_QUERY_LIMIT))
495 )]
496 limit: u16,
497 #[arg(long, value_name = "INTEGER", requires = "before_id")]
499 before_time: Option<u64>,
500 #[arg(long, value_name = "ULID", requires = "before_time")]
502 before_id: Option<LocalId>,
503 #[arg(long)]
505 all: bool,
506 #[arg(
508 long,
509 default_value_t = DEFAULT_MAX_PAGES,
510 value_name = "COUNT",
511 requires = "all",
512 value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..)
513 )]
514 max_pages: usize,
515}
516
517#[derive(Debug, Args)]
518struct CardIngestArgs {
519 #[arg(long, value_name = "ULID")]
521 set: Option<LocalId>,
522 #[arg(long, default_value_t = 0, requires = "set")]
524 offset: u64,
525 #[arg(
527 long,
528 default_value_t = MAX_CARD_FEED_RECORDS_PER_INGEST,
529 requires = "set",
530 value_parser = clap::value_parser!(u16)
531 .range(1..=i64::from(MAX_CARD_FEED_RECORDS_PER_INGEST))
532 )]
533 limit: u16,
534}
535
536#[derive(Debug, Args)]
537struct SetCurateArgs {
538 #[arg(value_name = "ULID")]
540 source: LocalId,
541 #[arg(long, value_name = "ULID", requires = "revision")]
543 set: Option<LocalId>,
544 #[arg(long, value_name = "N", requires = "set")]
546 revision: Option<u64>,
547 #[arg(long)]
549 name: String,
550 #[command(flatten)]
551 release_date: ReleaseDateArgs,
552 #[command(flatten)]
553 lifecycle: LifecycleArgs,
554}
555
556#[derive(Debug, Args)]
557struct CardCurateArgs {
558 #[arg(value_name = "ULID")]
560 source: LocalId,
561 #[arg(long, value_name = "ULID", requires = "revision")]
563 card: Option<LocalId>,
564 #[arg(long, value_name = "N", requires = "card")]
566 revision: Option<u64>,
567 #[arg(long, value_name = "ULID")]
569 set: LocalId,
570 #[arg(long)]
572 name: String,
573 #[arg(long)]
575 collector_number: String,
576 #[command(flatten)]
577 rarity: RarityArgs,
578}
579
580#[derive(Debug, Args)]
581#[group(required = true, multiple = false)]
582struct RarityArgs {
583 #[arg(long)]
585 rarity: Option<String>,
586 #[arg(long)]
588 no_rarity: bool,
589}
590
591#[derive(Debug, Args)]
592#[group(required = true, multiple = false)]
593struct ReleaseDateArgs {
594 #[arg(long, value_name = "DATE")]
596 release_date: Option<String>,
597 #[arg(long)]
599 no_release_date: bool,
600}
601
602#[derive(Debug, Args)]
603#[group(multiple = false)]
604struct LifecycleArgs {
605 #[arg(long)]
607 active: bool,
608 #[arg(long)]
610 retired: bool,
611}
612
613#[derive(Debug, Args)]
614struct SetLockArgs {
615 #[arg(value_name = "ULID")]
617 id: LocalId,
618 #[arg(long, value_name = "N")]
620 revision: u64,
621}
622
623#[derive(Debug, Args)]
624struct CardLockArgs {
625 #[arg(value_name = "ULID")]
627 id: LocalId,
628 #[arg(long, value_name = "N")]
630 revision: u64,
631}
632
633#[derive(Debug, Args)]
634struct PriceArgs {
635 #[arg(value_name = "ULID")]
637 id: LocalId,
638 #[command(flatten)]
639 history: HistoryArgs,
640}
641
642#[derive(Clone, Copy, Debug, Eq, PartialEq)]
643enum Resource {
644 Collections,
645 Sets,
646 SetSources,
647 Cards,
648 CardSources,
649 Sealed,
650}
651
652impl Resource {
653 const fn collection_field(self) -> &'static str {
654 match self {
655 Self::Collections => "collections",
656 Self::Sets => "sets",
657 Self::SetSources | Self::CardSources => "sources",
658 Self::Cards => "cards",
659 Self::Sealed => "sealed",
660 }
661 }
662}
663
664#[derive(Clone, Debug, Eq, PartialEq)]
665struct ListOptions {
666 limit: u16,
667 after: Option<String>,
668 all: bool,
669 max_pages: usize,
670}
671
672impl Default for ListOptions {
673 fn default() -> Self {
674 Self {
675 limit: DEFAULT_LIMIT,
676 after: None,
677 all: false,
678 max_pages: DEFAULT_MAX_PAGES,
679 }
680 }
681}
682
683#[derive(Clone, Debug, Eq, PartialEq)]
684struct HistoryOptions {
685 limit: u16,
686 before: Option<TimeCursor>,
687 all: bool,
688 max_pages: usize,
689}
690
691impl Default for HistoryOptions {
692 fn default() -> Self {
693 Self {
694 limit: DEFAULT_LIMIT,
695 before: None,
696 all: false,
697 max_pages: DEFAULT_MAX_PAGES,
698 }
699 }
700}
701
702#[derive(Clone, Debug, Eq, PartialEq)]
703enum Action {
704 Bootstrap(BootstrapOptions),
705 Backup(PathBuf),
706 ProviderSet(ProviderSetOptions),
707 Status,
708 Scheduler,
709 Runs(HistoryOptions),
710 Logs(HistoryOptions),
711 Ingest(Resource),
712 IngestSetCards(IngestSetCardsArgs),
713 List(Resource, ListOptions),
714 Get(Resource, String),
715 CurateSet(CuratePokemonSetArgs),
716 LockSet(LockPokemonSetArgs),
717 CurateCard(CuratePokemonCardArgs),
718 LockCard(LockPokemonCardArgs),
719 RejectSource(Resource, String),
720 Prices(Resource, String, HistoryOptions),
721}
722
723#[derive(Clone, Debug, Eq, PartialEq)]
724struct BootstrapOptions {
725 source: Option<PathBuf>,
726 refresh: bool,
727}
728
729#[derive(Clone, Debug, Eq, PartialEq)]
730struct ProviderSetOptions {
731 provider: Provider,
732 set_id: String,
733 source: PathBuf,
734 refresh: bool,
735 cards: Option<u16>,
736}
737
738struct CallArgumentFile {
739 path: PathBuf,
740}
741
742impl CallArgumentFile {
743 fn create(arguments: &[u8]) -> Result<Self, CliError> {
744 for _ in 0..32 {
745 let sequence = CALL_ARGUMENT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
746 let path = std::env::temp_dir().join(format!(
747 "toko-feed-candid-{}-{sequence}.bin",
748 std::process::id()
749 ));
750 let mut options = OpenOptions::new();
751 options.write(true).create_new(true);
752 #[cfg(unix)]
753 {
754 use std::os::unix::fs::OpenOptionsExt as _;
755 options.mode(0o600);
756 }
757 let mut file = match options.open(&path) {
758 Ok(file) => file,
759 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
760 Err(error) => return Err(CliError::ArgumentFile(error)),
761 };
762 if let Err(source) = file.write_all(arguments) {
763 let _ = fs::remove_file(&path);
764 return Err(CliError::ArgumentFile(source));
765 }
766 return Ok(Self { path });
767 }
768 Err(CliError::ArgumentFile(io::Error::new(
769 io::ErrorKind::AlreadyExists,
770 "could not allocate a unique temporary path",
771 )))
772 }
773}
774
775impl Drop for CallArgumentFile {
776 fn drop(&mut self) {
777 let _ = fs::remove_file(&self.path);
778 }
779}
780
781#[derive(Clone, Debug, Eq, PartialEq)]
782struct Invocation {
783 target: Target,
784 action: Action,
785}
786
787pub fn run_from_env() -> Result<(), CliError> {
795 let cli = match Cli::try_parse() {
796 Ok(cli) => cli,
797 Err(error) if is_clap_display(&error) => {
798 return error.print().map_err(CliError::Io);
799 }
800 Err(error) => return Err(CliError::Clap(error)),
801 };
802 run(&cli.into_invocation())
803}
804
805fn run(invocation: &Invocation) -> Result<(), CliError> {
806 let output = execute(invocation)?;
807 let rendered = if invocation.target.compact {
808 serde_json::to_string(&output)?
809 } else {
810 serde_json::to_string_pretty(&output)?
811 };
812 write_text(&format!("{rendered}\n"))
813}
814
815fn write_text(text: &str) -> Result<(), CliError> {
816 io::stdout()
817 .lock()
818 .write_all(text.as_bytes())
819 .map_err(CliError::Io)
820}
821
822fn is_clap_display(error: &clap::Error) -> bool {
823 matches!(
824 error.kind(),
825 ErrorKind::DisplayHelp
826 | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
827 | ErrorKind::DisplayVersion
828 )
829}
830
831#[cfg(test)]
832fn parse_test_invocation(arguments: &[&str]) -> Result<Invocation, CliError> {
833 let cli = Cli::try_parse_from(std::iter::once("toko-feed").chain(arguments.iter().copied()))?;
834 Ok(cli.into_invocation())
835}
836
837impl Cli {
838 fn into_invocation(self) -> Invocation {
839 let action = self.command.into_action();
840 Invocation {
841 target: self.target,
842 action,
843 }
844 }
845}
846
847impl RootCommand {
848 fn into_action(self) -> Action {
849 match self {
850 Self::Bootstrap(args) => Action::Bootstrap(args.into_options()),
851 Self::Backup(args) => Action::Backup(args.output),
852 Self::Provider(group) => match group.command {
853 ProviderCommand::Set(args) => Action::ProviderSet(args.into_options()),
854 },
855 Self::Status => Action::Status,
856 Self::Scheduler => Action::Scheduler,
857 Self::Runs(group) => match group.command {
858 HistoryCommand::List(args) => Action::Runs(args.into_options()),
859 },
860 Self::Logs(group) => match group.command {
861 HistoryCommand::List(args) => Action::Logs(args.into_options()),
862 },
863 Self::Collections(group) => match group.command {
864 CollectionsCommand::Ingest => Action::Ingest(Resource::Collections),
865 CollectionsCommand::List(args) => {
866 Action::List(Resource::Collections, args.into_options())
867 }
868 CollectionsCommand::Get(args) => Action::Get(Resource::Collections, args.id.into()),
869 },
870 Self::Sets(group) => match group.command {
871 SetsCommand::Ingest => Action::Ingest(Resource::Sets),
872 SetsCommand::List(args) => Action::List(Resource::Sets, args.into_options()),
873 SetsCommand::Get(args) => Action::Get(Resource::Sets, args.id.into()),
874 SetsCommand::Sources(group) => group.into_action(Resource::SetSources),
875 SetsCommand::Curate(args) => Action::CurateSet(args.into_canister_args()),
876 SetsCommand::Lock(args) => Action::LockSet(args.into_canister_args()),
877 },
878 Self::Cards(group) => match group.command {
879 CardsCommand::Ingest(args) => args.into_action(),
880 CardsCommand::List(args) => Action::List(Resource::Cards, args.into_options()),
881 CardsCommand::Get(args) => Action::Get(Resource::Cards, args.id.into()),
882 CardsCommand::Sources(group) => group.into_action(Resource::CardSources),
883 CardsCommand::Curate(args) => Action::CurateCard(args.into_canister_args()),
884 CardsCommand::Lock(args) => Action::LockCard(args.into_canister_args()),
885 CardsCommand::Prices(args) => args.into_action(Resource::Cards),
886 },
887 Self::Sealed(group) => match group.command {
888 SealedCommand::List(args) => Action::List(Resource::Sealed, args.into_options()),
889 SealedCommand::Get(args) => Action::Get(Resource::Sealed, args.id.into()),
890 SealedCommand::Prices(args) => args.into_action(Resource::Sealed),
891 },
892 }
893 }
894}
895
896impl BootstrapArgs {
897 fn into_options(self) -> BootstrapOptions {
898 BootstrapOptions {
899 source: self.source,
900 refresh: self.refresh,
901 }
902 }
903}
904
905impl ProviderSetArgs {
906 fn into_options(self) -> ProviderSetOptions {
907 let source = self
908 .source
909 .unwrap_or_else(|| PathBuf::from(self.provider.default_source()));
910 ProviderSetOptions {
911 provider: self.provider,
912 set_id: self.set_id.into(),
913 source,
914 refresh: self.refresh,
915 cards: (!self.all).then_some(self.cards.unwrap_or(DEFAULT_PROVIDER_COMPARISON_CARDS)),
916 }
917 }
918}
919
920impl SourcesGroup {
921 fn into_action(self, resource: Resource) -> Action {
922 match self.command {
923 SourcesCommand::List(args) => Action::List(resource, args.into_options()),
924 SourcesCommand::Get(args) => Action::Get(resource, args.id.into()),
925 SourcesCommand::Reject(args) => Action::RejectSource(resource, args.id.into()),
926 }
927 }
928}
929
930impl ListArgs {
931 fn into_options(self) -> ListOptions {
932 ListOptions {
933 limit: self.limit,
934 after: self.after.map(Into::into),
935 all: self.all,
936 max_pages: self.max_pages,
937 }
938 }
939}
940
941impl HistoryArgs {
942 fn into_options(self) -> HistoryOptions {
943 HistoryOptions {
944 limit: self.limit,
945 before: self
946 .before_time
947 .zip(self.before_id)
948 .map(|(timestamp, id)| TimeCursor {
949 timestamp,
950 id: id.into(),
951 }),
952 all: self.all,
953 max_pages: self.max_pages,
954 }
955 }
956}
957
958impl CardIngestArgs {
959 fn into_action(self) -> Action {
960 let Some(pokemon_set_id) = self.set else {
961 return Action::Ingest(Resource::Cards);
962 };
963 Action::IngestSetCards(IngestSetCardsArgs {
964 pokemon_set_id: pokemon_set_id.into(),
965 offset: self.offset,
966 limit: self.limit,
967 })
968 }
969}
970
971impl SetCurateArgs {
972 fn into_canister_args(self) -> CuratePokemonSetArgs {
973 let ReleaseDateArgs {
974 release_date,
975 no_release_date: _,
976 } = self.release_date;
977 let LifecycleArgs { active: _, retired } = self.lifecycle;
978 let lifecycle_status = if retired {
979 SetLifecycleStatus::Retired
980 } else {
981 SetLifecycleStatus::Active
982 };
983 CuratePokemonSetArgs {
984 source_id: self.source.into(),
985 pokemon_set_id: self.set.map(Into::into),
986 expected_revision: self.revision,
987 name: self.name,
988 release_date,
989 lifecycle_status,
990 }
991 }
992}
993
994impl CardCurateArgs {
995 fn into_canister_args(self) -> CuratePokemonCardArgs {
996 let RarityArgs {
997 rarity,
998 no_rarity: _,
999 } = self.rarity;
1000 CuratePokemonCardArgs {
1001 source_id: self.source.into(),
1002 pokemon_card_id: self.card.map(Into::into),
1003 expected_revision: self.revision,
1004 pokemon_set_id: self.set.into(),
1005 name: self.name,
1006 collector_number: self.collector_number,
1007 rarity,
1008 }
1009 }
1010}
1011
1012impl SetLockArgs {
1013 fn into_canister_args(self) -> LockPokemonSetArgs {
1014 LockPokemonSetArgs {
1015 id: self.id.into(),
1016 expected_revision: self.revision,
1017 }
1018 }
1019}
1020
1021impl CardLockArgs {
1022 fn into_canister_args(self) -> LockPokemonCardArgs {
1023 LockPokemonCardArgs {
1024 id: self.id.into(),
1025 expected_revision: self.revision,
1026 }
1027 }
1028}
1029
1030impl PriceArgs {
1031 fn into_action(self, resource: Resource) -> Action {
1032 Action::Prices(resource, self.id.into(), self.history.into_options())
1033 }
1034}
1035
1036impl FromStr for LocalId {
1037 type Err = &'static str;
1038
1039 fn from_str(id: &str) -> Result<Self, Self::Err> {
1040 if is_valid_local_id(id) {
1041 Ok(Self(id.to_owned()))
1042 } else {
1043 Err("must be a 26-character uppercase ULID")
1044 }
1045 }
1046}
1047
1048impl From<LocalId> for String {
1049 fn from(id: LocalId) -> Self {
1050 id.0
1051 }
1052}
1053
1054#[derive(Clone, Debug, Eq, PartialEq)]
1055struct ProviderSetId(String);
1056
1057impl FromStr for ProviderSetId {
1058 type Err = &'static str;
1059
1060 fn from_str(id: &str) -> Result<Self, Self::Err> {
1061 let valid = !id.is_empty()
1062 && id
1063 .bytes()
1064 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
1065 if valid {
1066 Ok(Self(id.to_owned()))
1067 } else {
1068 Err("must contain only lowercase ASCII letters, digits, or hyphens")
1069 }
1070 }
1071}
1072
1073impl From<ProviderSetId> for String {
1074 fn from(id: ProviderSetId) -> Self {
1075 id.0
1076 }
1077}
1078
1079fn is_valid_local_id(id: &str) -> bool {
1080 id.len() == 26
1081 && id.bytes().all(|byte| {
1082 matches!(
1083 byte,
1084 b'0'..=b'9' | b'A'..=b'H' | b'J'..=b'K' | b'M'..=b'N' | b'P'..=b'T' | b'V'..=b'Z'
1085 )
1086 })
1087}
1088
1089fn execute(invocation: &Invocation) -> Result<Value, CliError> {
1090 match &invocation.action {
1091 Action::Bootstrap(options) => cache::execute_bootstrap(&invocation.target, options),
1092 Action::Backup(path) => backup::execute(&invocation.target, path),
1093 Action::ProviderSet(options) => cache::execute_provider_set(
1094 &invocation.target,
1095 options.provider,
1096 &options.set_id,
1097 &options.source,
1098 options.refresh,
1099 options.cards,
1100 ),
1101 Action::Status => output(call_empty::<FeedStatus>(
1102 &invocation.target,
1103 "toko_feed_status",
1104 true,
1105 )?),
1106 Action::Scheduler => output(call_empty::<SchedulerStatus>(
1107 &invocation.target,
1108 "toko_feed_scheduler",
1109 true,
1110 )?),
1111 Action::Runs(options) if options.all => list_all_runs(&invocation.target, options),
1112 Action::Logs(options) if options.all => list_all_logs(&invocation.target, options),
1113 Action::Prices(resource, item_id, options) => {
1114 execute_prices(&invocation.target, *resource, item_id, options)
1115 }
1116 Action::Runs(options) => output(fetch_run_page(
1117 &invocation.target,
1118 options.before.clone(),
1119 options.limit,
1120 )?),
1121 Action::Logs(options) => output(fetch_log_page(
1122 &invocation.target,
1123 options.before.clone(),
1124 options.limit,
1125 )?),
1126 Action::Ingest(resource) => execute_ingest(&invocation.target, *resource),
1127 Action::Get(resource, id) => execute_get(&invocation.target, *resource, id),
1128 Action::IngestSetCards(args) => output(call_one::<_, SetCardIngestReceipt>(
1129 &invocation.target,
1130 "toko_feed_ingest_set_cards",
1131 args.clone(),
1132 false,
1133 )?),
1134 Action::CurateSet(args) => set_mutation(&invocation.target, "toko_feed_curate_set", args),
1135 Action::LockSet(args) => set_mutation(&invocation.target, "toko_feed_lock_set", args),
1136 Action::CurateCard(args) => {
1137 card_mutation(&invocation.target, "toko_feed_curate_card", args)
1138 }
1139 Action::LockCard(args) => card_mutation(&invocation.target, "toko_feed_lock_card", args),
1140 Action::RejectSource(resource, id) => {
1141 execute_reject_source(&invocation.target, *resource, id)
1142 }
1143 Action::List(resource, options) => execute_list(&invocation.target, *resource, options),
1144 }
1145}
1146
1147fn execute_ingest(target: &Target, resource: Resource) -> Result<Value, CliError> {
1148 match resource {
1149 Resource::Collections => output(call_empty::<CollectionIngestReceipt>(
1150 target,
1151 "toko_feed_ingest_collections",
1152 false,
1153 )?),
1154 Resource::Sets => output(call_empty::<SetIngestReceipt>(
1155 target,
1156 "toko_feed_ingest_sets",
1157 false,
1158 )?),
1159 Resource::Cards => output(call_empty::<IngestReceipt>(
1160 target,
1161 "toko_feed_ingest",
1162 false,
1163 )?),
1164 Resource::Sealed => Err(CliError::Usage(
1165 "sealed products are imported through `cards ingest`".to_owned(),
1166 )),
1167 Resource::SetSources | Resource::CardSources => Err(CliError::Usage(
1168 "provider sources are imported through their parent resource".to_owned(),
1169 )),
1170 }
1171}
1172
1173fn execute_reject_source(target: &Target, resource: Resource, id: &str) -> Result<Value, CliError> {
1174 match resource {
1175 Resource::SetSources => output(call_one::<_, PokemonSetEvidenceView>(
1176 target,
1177 "toko_feed_reject_set_source",
1178 id.to_owned(),
1179 false,
1180 )?),
1181 Resource::CardSources => output(call_one::<_, PokemonCardSourceView>(
1182 target,
1183 "toko_feed_reject_card_source",
1184 id.to_owned(),
1185 false,
1186 )?),
1187 _ => Err(CliError::Usage(
1188 "only provider source records can be rejected".to_owned(),
1189 )),
1190 }
1191}
1192
1193fn execute_list(
1194 target: &Target,
1195 resource: Resource,
1196 options: &ListOptions,
1197) -> Result<Value, CliError> {
1198 if options.all {
1199 return match resource {
1200 Resource::Collections => list_all_collections(target, options),
1201 Resource::Sets => list_all_sets(target, options),
1202 Resource::SetSources => list_all_set_sources(target, options),
1203 Resource::Cards => list_all_cards(target, options),
1204 Resource::CardSources => list_all_card_sources(target, options),
1205 Resource::Sealed => list_all_sealed(target, options),
1206 };
1207 }
1208 match resource {
1209 Resource::Collections => output(fetch_collection_page(
1210 target,
1211 options.after.clone(),
1212 options.limit,
1213 )?),
1214 Resource::Sets => output(fetch_set_page(
1215 target,
1216 options.after.clone(),
1217 options.limit,
1218 )?),
1219 Resource::SetSources => output(fetch_set_source_page(
1220 target,
1221 options.after.clone(),
1222 options.limit,
1223 )?),
1224 Resource::Cards => output(fetch_card_page(
1225 target,
1226 options.after.clone(),
1227 options.limit,
1228 )?),
1229 Resource::CardSources => output(fetch_card_source_page(
1230 target,
1231 options.after.clone(),
1232 options.limit,
1233 )?),
1234 Resource::Sealed => output(fetch_sealed_page(
1235 target,
1236 options.after.clone(),
1237 options.limit,
1238 )?),
1239 }
1240}
1241
1242fn execute_get(target: &Target, resource: Resource, id: &str) -> Result<Value, CliError> {
1243 match resource {
1244 Resource::Collections => output(call_one::<_, Option<CollectionDetails>>(
1245 target,
1246 "toko_feed_collection",
1247 id.to_owned(),
1248 true,
1249 )?),
1250 Resource::Sets => output(call_one::<_, Option<PokemonSetDetails>>(
1251 target,
1252 "toko_feed_set",
1253 id.to_owned(),
1254 true,
1255 )?),
1256 Resource::Cards => output(call_one::<_, Option<PokemonCardDetails>>(
1257 target,
1258 "toko_feed_card",
1259 id.to_owned(),
1260 true,
1261 )?),
1262 Resource::SetSources => output(call_one::<_, Option<PokemonSetEvidenceView>>(
1263 target,
1264 "toko_feed_set_source",
1265 id.to_owned(),
1266 true,
1267 )?),
1268 Resource::CardSources => output(call_one::<_, Option<PokemonCardSourceView>>(
1269 target,
1270 "toko_feed_card_source",
1271 id.to_owned(),
1272 true,
1273 )?),
1274 Resource::Sealed => output(call_one::<_, Option<PokemonSealedDetails>>(
1275 target,
1276 "toko_feed_sealed_product",
1277 id.to_owned(),
1278 true,
1279 )?),
1280 }
1281}
1282
1283fn execute_prices(
1284 target: &Target,
1285 resource: Resource,
1286 item_id: &str,
1287 options: &HistoryOptions,
1288) -> Result<Value, CliError> {
1289 if options.all {
1290 return list_all_prices(target, resource, item_id, options);
1291 }
1292 match resource {
1293 Resource::Cards => output(fetch_card_price_page(
1294 target,
1295 item_id.to_owned(),
1296 options.before.clone(),
1297 options.limit,
1298 )?),
1299 Resource::Sealed => output(fetch_sealed_price_page(
1300 target,
1301 item_id.to_owned(),
1302 options.before.clone(),
1303 options.limit,
1304 )?),
1305 Resource::Collections | Resource::Sets | Resource::SetSources | Resource::CardSources => {
1306 Err(CliError::Usage(format!(
1307 "{} does not expose price history",
1308 resource.collection_field()
1309 )))
1310 }
1311 }
1312}
1313
1314fn output(value: impl Serialize) -> Result<Value, CliError> {
1315 serde_json::to_value(value).map_err(CliError::Json)
1316}
1317
1318fn set_mutation<A>(target: &Target, method: &'static str, args: &A) -> Result<Value, CliError>
1319where
1320 A: CandidType + Clone,
1321{
1322 output(call_one::<_, PokemonSetView>(
1323 target,
1324 method,
1325 args.clone(),
1326 false,
1327 )?)
1328}
1329
1330fn card_mutation<A>(target: &Target, method: &'static str, args: &A) -> Result<Value, CliError>
1331where
1332 A: CandidType + Clone,
1333{
1334 output(call_one::<_, PokemonCardView>(
1335 target,
1336 method,
1337 args.clone(),
1338 false,
1339 )?)
1340}
1341
1342fn call_empty<T>(target: &Target, method: &'static str, query: bool) -> Result<T, CliError>
1343where
1344 T: CandidType + DeserializeOwned,
1345{
1346 let arguments = encode_args(()).map_err(|source| CliError::Encode { method, source })?;
1347 call_and_decode(target, method, &arguments, query)
1348}
1349
1350fn call_one<A, T>(
1351 target: &Target,
1352 method: &'static str,
1353 argument: A,
1354 query: bool,
1355) -> Result<T, CliError>
1356where
1357 A: CandidType,
1358 T: CandidType + DeserializeOwned,
1359{
1360 let arguments =
1361 encode_args((argument,)).map_err(|source| CliError::Encode { method, source })?;
1362 call_and_decode(target, method, &arguments, query)
1363}
1364
1365fn fetch_set_page(
1366 target: &Target,
1367 after: Option<String>,
1368 limit: u16,
1369) -> Result<PokemonSetPage, CliError> {
1370 call_page(target, "toko_feed_sets", after, limit)
1371}
1372
1373fn fetch_collection_page(
1374 target: &Target,
1375 after: Option<String>,
1376 limit: u16,
1377) -> Result<CollectionPage, CliError> {
1378 call_page(target, "toko_feed_collections", after, limit)
1379}
1380
1381fn fetch_card_page(
1382 target: &Target,
1383 after: Option<String>,
1384 limit: u16,
1385) -> Result<PokemonCardPage, CliError> {
1386 call_page(target, "toko_feed_cards", after, limit)
1387}
1388
1389fn fetch_set_source_page(
1390 target: &Target,
1391 after: Option<String>,
1392 limit: u16,
1393) -> Result<PokemonSetSourcePage, CliError> {
1394 call_page(target, "toko_feed_set_sources", after, limit)
1395}
1396
1397fn fetch_card_source_page(
1398 target: &Target,
1399 after: Option<String>,
1400 limit: u16,
1401) -> Result<PokemonCardSourcePage, CliError> {
1402 call_page(target, "toko_feed_card_sources", after, limit)
1403}
1404
1405fn fetch_sealed_page(
1406 target: &Target,
1407 after: Option<String>,
1408 limit: u16,
1409) -> Result<PokemonSealedPage, CliError> {
1410 call_page(target, "toko_feed_sealed", after, limit)
1411}
1412
1413fn fetch_run_page(
1414 target: &Target,
1415 before: Option<TimeCursor>,
1416 limit: u16,
1417) -> Result<IngestionRunPage, CliError> {
1418 call_history_page(target, "toko_feed_runs", before, limit)
1419}
1420
1421fn fetch_log_page(
1422 target: &Target,
1423 before: Option<TimeCursor>,
1424 limit: u16,
1425) -> Result<OperationalLogPage, CliError> {
1426 call_history_page(target, "toko_feed_logs", before, limit)
1427}
1428
1429fn fetch_card_price_page(
1430 target: &Target,
1431 card_id: String,
1432 before: Option<TimeCursor>,
1433 limit: u16,
1434) -> Result<PriceObservationPage, CliError> {
1435 let method = "toko_feed_card_prices";
1436 let arguments = encode_args((card_id, before, limit))
1437 .map_err(|source| CliError::Encode { method, source })?;
1438 call_and_decode(target, method, &arguments, true)
1439}
1440
1441fn fetch_sealed_price_page(
1442 target: &Target,
1443 sealed_id: String,
1444 before: Option<TimeCursor>,
1445 limit: u16,
1446) -> Result<SealedPriceObservationPage, CliError> {
1447 let method = "toko_feed_sealed_prices";
1448 let arguments = encode_args((sealed_id, before, limit))
1449 .map_err(|source| CliError::Encode { method, source })?;
1450 call_and_decode(target, method, &arguments, true)
1451}
1452
1453fn call_history_page<T>(
1454 target: &Target,
1455 method: &'static str,
1456 before: Option<TimeCursor>,
1457 limit: u16,
1458) -> Result<T, CliError>
1459where
1460 T: CandidType + DeserializeOwned,
1461{
1462 let arguments =
1463 encode_args((before, limit)).map_err(|source| CliError::Encode { method, source })?;
1464 call_and_decode(target, method, &arguments, true)
1465}
1466
1467fn call_page<T>(
1468 target: &Target,
1469 method: &'static str,
1470 after: Option<String>,
1471 limit: u16,
1472) -> Result<T, CliError>
1473where
1474 T: CandidType + DeserializeOwned,
1475{
1476 let arguments =
1477 encode_args((after, limit)).map_err(|source| CliError::Encode { method, source })?;
1478 call_and_decode(target, method, &arguments, true)
1479}
1480
1481fn call_and_decode<T>(
1482 target: &Target,
1483 method: &'static str,
1484 arguments: &[u8],
1485 query: bool,
1486) -> Result<T, CliError>
1487where
1488 T: CandidType + DeserializeOwned,
1489{
1490 let reply = call_raw(target, method, arguments, query)?;
1491 decode_result(method, &reply)
1492}
1493
1494fn decode_result<T>(method: &'static str, reply: &[u8]) -> Result<T, CliError>
1495where
1496 T: CandidType + DeserializeOwned,
1497{
1498 let result = decode_one::<Result<T, FeedError>>(reply)
1499 .map_err(|source| CliError::Decode { method, source })?;
1500 result.map_err(|error| CliError::Canister {
1501 method,
1502 error: format!("{error:?}"),
1503 })
1504}
1505
1506fn call_raw(
1507 target: &Target,
1508 method: &'static str,
1509 arguments: &[u8],
1510 query: bool,
1511) -> Result<Vec<u8>, CliError> {
1512 let argument_file = CallArgumentFile::create(arguments)?;
1513 let mut command = Command::new(&target.icp);
1514 if let Some(project_root) = &target.project_root {
1515 command.arg("--project-root-override").arg(project_root);
1516 }
1517 if let Some(password_file) = &target.identity_password_file {
1518 command.arg("--identity-password-file").arg(password_file);
1519 }
1520 command
1521 .args(["canister", "call", "--environment"])
1522 .arg(&target.environment)
1523 .args(["--args-format", "bin", "--args-file"])
1524 .arg(&argument_file.path)
1525 .args(["--output", "hex"]);
1526 command.args(["--identity", &target.identity]);
1527 if query {
1528 command.arg("--query");
1529 }
1530 command.args([&target.canister, method]);
1531
1532 let output = command.output().map_err(CliError::StartIcp)?;
1533 if !output.status.success() {
1534 let status = output
1535 .status
1536 .code()
1537 .map_or_else(String::new, |code| format!(" (exit {code})"));
1538 let message = bounded_diagnostic(&output.stderr);
1539 return Err(CliError::Icp { status, message });
1540 }
1541 if output.stdout.len() > MAX_RAW_REPLY_BYTES * 2 + 2 {
1542 return Err(CliError::RawReply("hexadecimal response exceeded 16 MiB"));
1543 }
1544 decode_hex(&output.stdout)
1545}
1546
1547fn bounded_diagnostic(bytes: &[u8]) -> String {
1548 const MAX_DIAGNOSTIC_BYTES: usize = 8 * 1024;
1549 let visible = &bytes[..bytes.len().min(MAX_DIAGNOSTIC_BYTES)];
1550 let mut message = String::from_utf8_lossy(visible).trim().to_owned();
1551 if bytes.len() > MAX_DIAGNOSTIC_BYTES {
1552 message.push_str("…[truncated]");
1553 }
1554 if message.is_empty() {
1555 "no diagnostic was written to stderr".to_owned()
1556 } else {
1557 message
1558 }
1559}
1560
1561#[cfg(test)]
1562fn encode_hex(bytes: &[u8]) -> String {
1563 const DIGITS: &[u8; 16] = b"0123456789abcdef";
1564 let mut output = String::with_capacity(bytes.len() * 2);
1565 for byte in bytes {
1566 output.push(char::from(DIGITS[usize::from(byte >> 4)]));
1567 output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
1568 }
1569 output
1570}
1571
1572fn decode_hex(input: &[u8]) -> Result<Vec<u8>, CliError> {
1573 let input = std::str::from_utf8(input)
1574 .map_err(|_| CliError::RawReply("response was not UTF-8 hexadecimal text"))?
1575 .trim();
1576 let input = input.strip_prefix("0x").unwrap_or(input);
1577 if input.len() % 2 != 0 {
1578 return Err(CliError::RawReply(
1579 "hexadecimal response had an odd number of digits",
1580 ));
1581 }
1582
1583 input
1584 .as_bytes()
1585 .chunks_exact(2)
1586 .map(|pair| {
1587 let high = hex_digit(pair[0])?;
1588 let low = hex_digit(pair[1])?;
1589 Ok((high << 4) | low)
1590 })
1591 .collect()
1592}
1593
1594const fn hex_digit(byte: u8) -> Result<u8, CliError> {
1595 match byte {
1596 b'0'..=b'9' => Ok(byte - b'0'),
1597 b'a'..=b'f' => Ok(byte - b'a' + 10),
1598 b'A'..=b'F' => Ok(byte - b'A' + 10),
1599 _ => Err(CliError::RawReply(
1600 "response contained a non-hexadecimal character",
1601 )),
1602 }
1603}
1604
1605fn list_all_runs(target: &Target, options: &HistoryOptions) -> Result<Value, CliError> {
1606 let (runs, pages) = collect_history_pages(options, |before, limit| {
1607 let page = fetch_run_page(target, before, limit)?;
1608 Ok((page.runs, page.next_before))
1609 })?;
1610 Ok(json!({
1611 "runs": runs,
1612 "count": runs.len(),
1613 "pages": pages,
1614 "next_before": null,
1615 }))
1616}
1617
1618fn list_all_logs(target: &Target, options: &HistoryOptions) -> Result<Value, CliError> {
1619 let (logs, pages) = collect_history_pages(options, |before, limit| {
1620 let page = fetch_log_page(target, before, limit)?;
1621 Ok((page.logs, page.next_before))
1622 })?;
1623 Ok(json!({
1624 "logs": logs,
1625 "count": logs.len(),
1626 "pages": pages,
1627 "next_before": null,
1628 }))
1629}
1630
1631fn list_all_prices(
1632 target: &Target,
1633 resource: Resource,
1634 item_id: &str,
1635 options: &HistoryOptions,
1636) -> Result<Value, CliError> {
1637 let (observations, pages) = match resource {
1638 Resource::Cards => collect_history_pages(options, |before, limit| {
1639 let page = fetch_card_price_page(target, item_id.to_owned(), before, limit)?;
1640 Ok((page.observations, page.next_before))
1641 })?,
1642 Resource::Sealed => {
1643 let (observations, pages) = collect_history_pages(options, |before, limit| {
1644 let page = fetch_sealed_price_page(target, item_id.to_owned(), before, limit)?;
1645 Ok((page.observations, page.next_before))
1646 })?;
1647 return Ok(json!({
1648 "observations": observations,
1649 "count": observations.len(),
1650 "pages": pages,
1651 "next_before": null,
1652 }));
1653 }
1654 Resource::Collections | Resource::Sets | Resource::SetSources | Resource::CardSources => {
1655 return Err(CliError::Usage(format!(
1656 "{} does not expose price history",
1657 resource.collection_field()
1658 )));
1659 }
1660 };
1661 Ok(json!({
1662 "observations": observations,
1663 "count": observations.len(),
1664 "pages": pages,
1665 "next_before": null,
1666 }))
1667}
1668
1669fn collect_history_pages<T>(
1670 options: &HistoryOptions,
1671 mut fetch: impl FnMut(Option<TimeCursor>, u16) -> Result<(Vec<T>, Option<TimeCursor>), CliError>,
1672) -> Result<(Vec<T>, usize), CliError> {
1673 let mut before = options.before.clone();
1674 let mut seen = before
1675 .as_ref()
1676 .map(time_cursor_token)
1677 .into_iter()
1678 .collect::<HashSet<_>>();
1679 let mut items = Vec::new();
1680 let mut pages = 0usize;
1681 loop {
1682 enforce_page_budget(pages, options.max_pages)?;
1683 let (page_items, next_before) = fetch(before, options.limit)?;
1684 pages += 1;
1685 items.extend(page_items);
1686 let Some(next_before) = next_before else {
1687 return Ok((items, pages));
1688 };
1689 validate_time_cursor(&next_before, &mut seen)?;
1690 before = Some(next_before);
1691 }
1692}
1693
1694const fn enforce_page_budget(pages: usize, maximum: usize) -> Result<(), CliError> {
1695 if pages == maximum {
1696 Err(CliError::PaginationLimit(maximum))
1697 } else {
1698 Ok(())
1699 }
1700}
1701
1702fn validate_time_cursor(cursor: &TimeCursor, seen: &mut HashSet<String>) -> Result<(), CliError> {
1703 if !is_valid_local_id(&cursor.id) {
1704 return Err(CliError::RawReply(
1705 "next_before contained an invalid local ULID",
1706 ));
1707 }
1708 let token = time_cursor_token(cursor);
1709 if !seen.insert(token.clone()) {
1710 return Err(CliError::PaginationStalled(token));
1711 }
1712 Ok(())
1713}
1714
1715fn time_cursor_token(cursor: &TimeCursor) -> String {
1716 format!("{}:{}", cursor.timestamp, cursor.id)
1717}
1718
1719fn list_all_sets(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
1720 let (sets, pages) = collect_keyset_pages(options, |after, limit| {
1721 let page = fetch_set_page(target, after, limit)?;
1722 Ok((page.sets, page.next_after))
1723 })?;
1724 Ok(json!({
1725 "sets": sets,
1726 "count": sets.len(),
1727 "pages": pages,
1728 "next_after": null,
1729 }))
1730}
1731
1732fn list_all_collections(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
1733 let (collections, pages) = collect_keyset_pages(options, |after, limit| {
1734 let page = fetch_collection_page(target, after, limit)?;
1735 Ok((page.collections, page.next_after))
1736 })?;
1737 Ok(json!({
1738 "collections": collections,
1739 "count": collections.len(),
1740 "pages": pages,
1741 "next_after": null,
1742 }))
1743}
1744
1745fn list_all_cards(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
1746 let (cards, pages) = collect_keyset_pages(options, |after, limit| {
1747 let page = fetch_card_page(target, after, limit)?;
1748 Ok((page.cards, page.next_after))
1749 })?;
1750 Ok(json!({
1751 "cards": cards,
1752 "count": cards.len(),
1753 "pages": pages,
1754 "next_after": null,
1755 }))
1756}
1757
1758fn list_all_set_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
1759 let (sources, pages) = collect_keyset_pages(options, |after, limit| {
1760 let page = fetch_set_source_page(target, after, limit)?;
1761 Ok((page.sources, page.next_after))
1762 })?;
1763 Ok(json!({
1764 "sources": sources,
1765 "count": sources.len(),
1766 "pages": pages,
1767 "next_after": null,
1768 }))
1769}
1770
1771fn list_all_card_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
1772 let (sources, pages) = collect_keyset_pages(options, |after, limit| {
1773 let page = fetch_card_source_page(target, after, limit)?;
1774 Ok((page.sources, page.next_after))
1775 })?;
1776 Ok(json!({
1777 "sources": sources,
1778 "count": sources.len(),
1779 "pages": pages,
1780 "next_after": null,
1781 }))
1782}
1783
1784fn list_all_sealed(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
1785 let (sealed, pages) = collect_keyset_pages(options, |after, limit| {
1786 let page = fetch_sealed_page(target, after, limit)?;
1787 Ok((page.sealed, page.next_after))
1788 })?;
1789 Ok(json!({
1790 "sealed": sealed,
1791 "count": sealed.len(),
1792 "pages": pages,
1793 "next_after": null,
1794 }))
1795}
1796
1797fn collect_keyset_pages<T>(
1798 options: &ListOptions,
1799 mut fetch: impl FnMut(Option<String>, u16) -> Result<(Vec<T>, Option<String>), CliError>,
1800) -> Result<(Vec<T>, usize), CliError> {
1801 let mut after = options.after.clone();
1802 let mut seen = after.iter().cloned().collect::<HashSet<_>>();
1803 let mut items = Vec::new();
1804 let mut pages = 0usize;
1805 loop {
1806 enforce_page_budget(pages, options.max_pages)?;
1807 let (page_items, next_after) = fetch(after, options.limit)?;
1808 pages += 1;
1809 items.extend(page_items);
1810 let Some(next_after) = next_after else {
1811 return Ok((items, pages));
1812 };
1813 validate_reply_cursor(&next_after, &mut seen)?;
1814 after = Some(next_after);
1815 }
1816}
1817
1818fn validate_reply_cursor(cursor: &str, seen: &mut HashSet<String>) -> Result<(), CliError> {
1819 if !is_valid_local_id(cursor) {
1820 return Err(CliError::RawReply("next_after was not a valid local ULID"));
1821 }
1822 if !seen.insert(cursor.to_owned()) {
1823 return Err(CliError::PaginationStalled(cursor.to_owned()));
1824 }
1825 Ok(())
1826}
1827
1828#[cfg(test)]
1829mod tests {
1830 use candid::{decode_args, encode_one};
1831 use clap::CommandFactory;
1832
1833 use super::*;
1834
1835 fn invocation(values: &[&str]) -> Invocation {
1836 parse_test_invocation(values).expect("arguments should parse")
1837 }
1838
1839 fn clap_error(values: &[&str]) -> ErrorKind {
1840 match parse_test_invocation(values) {
1841 Err(CliError::Clap(error)) => error.kind(),
1842 Err(error) => panic!("expected a Clap error, got {error}"),
1843 Ok(_) => panic!("expected Clap to reject the arguments"),
1844 }
1845 }
1846
1847 #[test]
1848 fn parses_global_and_automatic_pagination_options() {
1849 let parsed = invocation(&[
1850 "--environment",
1851 "ic",
1852 "--canister",
1853 "aaaaa-aa",
1854 "--identity",
1855 "operator",
1856 "--project-root",
1857 "/srv/toko-feed",
1858 "--compact",
1859 "sets",
1860 "list",
1861 "--limit",
1862 "100",
1863 "--after",
1864 "01KZ9GFKW3SY1G000000000001",
1865 "--all",
1866 "--max-pages",
1867 "12",
1868 ]);
1869
1870 assert_eq!(parsed.target.environment, "ic");
1871 assert_eq!(parsed.target.canister, "aaaaa-aa");
1872 assert_eq!(parsed.target.identity, "operator");
1873 assert_eq!(
1874 parsed.target.project_root.as_deref(),
1875 Some(std::path::Path::new("/srv/toko-feed"))
1876 );
1877 assert!(parsed.target.compact);
1878 assert_eq!(
1879 parsed.action,
1880 Action::List(
1881 Resource::Sets,
1882 ListOptions {
1883 limit: 100,
1884 after: Some("01KZ9GFKW3SY1G000000000001".to_owned()),
1885 all: true,
1886 max_pages: 12,
1887 }
1888 )
1889 );
1890 }
1891
1892 #[test]
1893 fn parses_bootstrap_and_keeps_root_help_focused() {
1894 assert_eq!(
1895 invocation(&["backup"]).action,
1896 Action::Backup(PathBuf::from(DEFAULT_CANONICAL_BACKUP_PATH))
1897 );
1898 assert_eq!(
1899 invocation(&["bootstrap"]).action,
1900 Action::Bootstrap(BootstrapOptions {
1901 source: None,
1902 refresh: false,
1903 })
1904 );
1905 assert_eq!(
1906 invocation(&["bootstrap", "--source"]).action,
1907 Action::Bootstrap(BootstrapOptions {
1908 source: Some(PathBuf::from(DEFAULT_PROVIDER_SOURCE_PATH)),
1909 refresh: false,
1910 })
1911 );
1912 assert_eq!(
1913 invocation(&[
1914 "bootstrap",
1915 "--source",
1916 "/tmp/toko-feed-source",
1917 "--refresh",
1918 ])
1919 .action,
1920 Action::Bootstrap(BootstrapOptions {
1921 source: Some(PathBuf::from("/tmp/toko-feed-source")),
1922 refresh: true,
1923 })
1924 );
1925 assert!(parse_test_invocation(&["bootstrap", "--data"]).is_ok());
1926
1927 let help = Cli::command().render_help().to_string();
1928 assert!(help.contains("backup"));
1929 assert!(help.contains("bootstrap"));
1930 assert!(help.contains("Connection:"));
1931 assert!(!help.contains("tcgdex"));
1932 assert!(!help.contains("scrydex"));
1933 assert!(!help.contains("--before-time"));
1934 assert!(!help.contains("--release-date"));
1935 assert!(help.lines().count() < 40);
1936 }
1937
1938 #[test]
1939 fn parses_bounded_provider_set_refresh() {
1940 assert_eq!(
1941 invocation(&[
1942 "provider",
1943 "set",
1944 "tcgdex",
1945 "ecard2",
1946 "--source",
1947 "/tmp/tcgdex",
1948 "--refresh",
1949 "--cards",
1950 "12",
1951 ])
1952 .action,
1953 Action::ProviderSet(ProviderSetOptions {
1954 provider: Provider::TcgDex,
1955 set_id: "ecard2".to_owned(),
1956 source: PathBuf::from("/tmp/tcgdex"),
1957 refresh: true,
1958 cards: Some(12),
1959 })
1960 );
1961 assert_eq!(
1962 clap_error(&["provider", "set", "tcgdex", "ecard2", "--cards", "1001"]),
1963 ErrorKind::ValueValidation
1964 );
1965 assert_eq!(
1966 clap_error(&["provider", "set", "tcgdex", "../ecard2"]),
1967 ErrorKind::ValueValidation
1968 );
1969 assert_eq!(
1970 invocation(&[
1971 "provider",
1972 "set",
1973 "scrydex",
1974 "ecard2",
1975 "--source",
1976 "/tmp/scrydex",
1977 "--refresh",
1978 "--cards",
1979 "10",
1980 ])
1981 .action,
1982 Action::ProviderSet(ProviderSetOptions {
1983 provider: Provider::Scrydex,
1984 set_id: "ecard2".to_owned(),
1985 source: PathBuf::from("/tmp/scrydex"),
1986 refresh: true,
1987 cards: Some(10),
1988 })
1989 );
1990 assert_eq!(
1991 invocation(&["provider", "set", "scrydex", "ecard2"]).action,
1992 Action::ProviderSet(ProviderSetOptions {
1993 provider: Provider::Scrydex,
1994 set_id: "ecard2".to_owned(),
1995 source: PathBuf::from(DEFAULT_SCRYDEX_SOURCE_PATH),
1996 refresh: false,
1997 cards: Some(DEFAULT_PROVIDER_COMPARISON_CARDS),
1998 })
1999 );
2000 assert_eq!(
2001 clap_error(&["provider", "set", "unknown", "ecard2"]),
2002 ErrorKind::InvalidValue
2003 );
2004
2005 assert_eq!(
2006 invocation(&["provider", "set", "scrydex", "ecard2", "--all"]).action,
2007 Action::ProviderSet(ProviderSetOptions {
2008 provider: Provider::Scrydex,
2009 set_id: "ecard2".to_owned(),
2010 source: PathBuf::from(DEFAULT_SCRYDEX_SOURCE_PATH),
2011 refresh: false,
2012 cards: None,
2013 })
2014 );
2015 assert_eq!(
2016 clap_error(&[
2017 "provider", "set", "scrydex", "ecard2", "--all", "--cards", "10",
2018 ]),
2019 ErrorKind::ArgumentConflict
2020 );
2021 }
2022
2023 #[test]
2024 fn clap_owns_argument_relationships_and_validation() {
2025 Cli::command().debug_assert();
2026
2027 assert_eq!(
2028 clap_error(&["cards", "ingest", "--offset", "50"]),
2029 ErrorKind::MissingRequiredArgument
2030 );
2031 assert_eq!(
2032 clap_error(&["sets", "list", "--max-pages", "2"]),
2033 ErrorKind::MissingRequiredArgument
2034 );
2035 assert_eq!(
2036 clap_error(&[
2037 "sets",
2038 "curate",
2039 "01KZ9GFKW3SY1G000000000001",
2040 "--revision",
2041 "0",
2042 "--name",
2043 "Aquapolis",
2044 "--release-date",
2045 "2003-01-15",
2046 "--no-release-date",
2047 ]),
2048 ErrorKind::ArgumentConflict
2049 );
2050 assert_eq!(
2051 clap_error(&["cards", "get", "not-an-id",]),
2052 ErrorKind::ValueValidation
2053 );
2054 }
2055
2056 #[test]
2057 fn parses_card_ingest_and_get_commands() {
2058 assert_eq!(
2059 invocation(&["cards", "ingest"]).action,
2060 Action::Ingest(Resource::Cards)
2061 );
2062 assert_eq!(
2063 invocation(&["cards", "get", "01KZ9GFKW3SY1G000000000001"]).action,
2064 Action::Get(Resource::Cards, "01KZ9GFKW3SY1G000000000001".to_owned())
2065 );
2066 assert_eq!(
2067 invocation(&[
2068 "cards",
2069 "ingest",
2070 "--set",
2071 "01KZ9GFKW3SY1G000000000001",
2072 "--offset",
2073 "50",
2074 "--limit",
2075 "10",
2076 ])
2077 .action,
2078 Action::IngestSetCards(IngestSetCardsArgs {
2079 pokemon_set_id: "01KZ9GFKW3SY1G000000000001".to_owned(),
2080 offset: 50,
2081 limit: 10,
2082 })
2083 );
2084 }
2085
2086 #[test]
2087 fn targeted_card_ingest_rejects_invalid_limits() {
2088 let result = parse_test_invocation(&[
2089 "cards",
2090 "ingest",
2091 "--set",
2092 "01KZ9GFKW3SY1G000000000001",
2093 "--limit",
2094 "11",
2095 ]);
2096 let Err(error) = result else {
2097 panic!("oversized ingestion should be rejected");
2098 };
2099
2100 assert!(matches!(error, CliError::Clap(error) if error.to_string().contains("1..=10")));
2101 }
2102
2103 #[test]
2104 fn parses_collection_ingest_and_list_commands() {
2105 assert_eq!(
2106 invocation(&["collections", "ingest"]).action,
2107 Action::Ingest(Resource::Collections)
2108 );
2109 assert_eq!(
2110 invocation(&["collections", "list", "--all"]).action,
2111 Action::List(
2112 Resource::Collections,
2113 ListOptions {
2114 all: true,
2115 ..ListOptions::default()
2116 }
2117 )
2118 );
2119 }
2120
2121 #[test]
2122 fn parses_revisioned_set_curation_and_lock_commands() {
2123 let source_id = "01KZ9GFKW3SY1G000000000001";
2124 let set_id = "01KZ9GFKW3SY1G000000000002";
2125 assert_eq!(
2126 invocation(&[
2127 "sets",
2128 "curate",
2129 source_id,
2130 "--set",
2131 set_id,
2132 "--revision",
2133 "7",
2134 "--name",
2135 "Base Set",
2136 "--release-date",
2137 "1999-01-09",
2138 "--retired",
2139 ])
2140 .action,
2141 Action::CurateSet(CuratePokemonSetArgs {
2142 source_id: source_id.to_owned(),
2143 pokemon_set_id: Some(set_id.to_owned()),
2144 expected_revision: Some(7),
2145 name: "Base Set".to_owned(),
2146 release_date: Some("1999-01-09".to_owned()),
2147 lifecycle_status: SetLifecycleStatus::Retired,
2148 })
2149 );
2150 assert_eq!(
2151 invocation(&["sets", "lock", set_id, "--revision", "8"]).action,
2152 Action::LockSet(LockPokemonSetArgs {
2153 id: set_id.to_owned(),
2154 expected_revision: 8,
2155 })
2156 );
2157 assert!(
2158 parse_test_invocation(&[
2159 "sets",
2160 "curate",
2161 source_id,
2162 "--revision",
2163 "7",
2164 "--name",
2165 "Base Set",
2166 "--no-release-date",
2167 ])
2168 .is_err()
2169 );
2170 }
2171
2172 #[test]
2173 fn parses_new_card_curation_and_source_review_commands() {
2174 let source_id = "01KZ9GFKW3SY1G000000000001";
2175 let set_id = "01KZ9GFKW3SY1G000000000002";
2176 let card_id = "01KZ9GFKW3SY1G000000000003";
2177
2178 assert_eq!(
2179 invocation(&[
2180 "cards",
2181 "curate",
2182 source_id,
2183 "--set",
2184 set_id,
2185 "--name",
2186 "Lugia",
2187 "--collector-number",
2188 "149/147",
2189 "--rarity",
2190 "Secret Rare",
2191 ])
2192 .action,
2193 Action::CurateCard(CuratePokemonCardArgs {
2194 source_id: source_id.to_owned(),
2195 pokemon_card_id: None,
2196 expected_revision: None,
2197 pokemon_set_id: set_id.to_owned(),
2198 name: "Lugia".to_owned(),
2199 collector_number: "149/147".to_owned(),
2200 rarity: Some("Secret Rare".to_owned()),
2201 })
2202 );
2203 assert_eq!(
2204 invocation(&["cards", "lock", card_id, "--revision", "1"]).action,
2205 Action::LockCard(LockPokemonCardArgs {
2206 id: card_id.to_owned(),
2207 expected_revision: 1,
2208 })
2209 );
2210 assert_eq!(
2211 invocation(&["sets", "sources", "reject", source_id]).action,
2212 Action::RejectSource(Resource::SetSources, source_id.to_owned())
2213 );
2214 }
2215
2216 #[test]
2217 fn parses_scheduler_history_and_price_commands() {
2218 assert_eq!(invocation(&["scheduler"]).action, Action::Scheduler);
2219 assert_eq!(
2220 invocation(&[
2221 "runs",
2222 "list",
2223 "--before-time",
2224 "123",
2225 "--before-id",
2226 "01KZ9GFKW3SY1G000000000001",
2227 "--all",
2228 ])
2229 .action,
2230 Action::Runs(HistoryOptions {
2231 before: Some(TimeCursor {
2232 timestamp: 123,
2233 id: "01KZ9GFKW3SY1G000000000001".to_owned(),
2234 }),
2235 all: true,
2236 ..HistoryOptions::default()
2237 })
2238 );
2239 assert!(matches!(
2240 invocation(&[
2241 "cards",
2242 "prices",
2243 "01KZ9GFKW3SY1G000000000001",
2244 "--limit",
2245 "100",
2246 ])
2247 .action,
2248 Action::Prices(Resource::Cards, _, HistoryOptions { limit: 100, .. })
2249 ));
2250 assert!(matches!(
2251 invocation(&["sealed", "prices", "01KZ9GFKW3SY1G000000000001", "--all",]).action,
2252 Action::Prices(Resource::Sealed, _, HistoryOptions { all: true, .. })
2253 ));
2254 assert!(parse_test_invocation(&["logs", "list", "--before-time", "123",]).is_err());
2255 }
2256
2257 #[test]
2258 fn parses_sealed_queries_and_rejects_separate_ingestion() {
2259 assert_eq!(
2260 invocation(&["sealed", "list", "--all"]).action,
2261 Action::List(
2262 Resource::Sealed,
2263 ListOptions {
2264 all: true,
2265 ..ListOptions::default()
2266 }
2267 )
2268 );
2269 assert_eq!(
2270 invocation(&["sealed", "get", "01KZ9GFKW3SY1G000000000001"]).action,
2271 Action::Get(Resource::Sealed, "01KZ9GFKW3SY1G000000000001".to_owned())
2272 );
2273 assert!(parse_test_invocation(&["sealed", "ingest"]).is_err());
2274 }
2275
2276 #[test]
2277 fn rejects_invalid_bounds_and_identifiers() {
2278 assert!(parse_test_invocation(&["sets", "list", "--limit", "0"]).is_err());
2279 assert!(parse_test_invocation(&["sets", "list", "--max-pages", "2"]).is_err());
2280 assert!(parse_test_invocation(&["sets", "get", "not-an-id"]).is_err());
2281 }
2282
2283 #[test]
2284 fn accepts_help_at_any_command_depth_and_version_at_the_top_level() {
2285 assert!(matches!(
2286 parse_test_invocation(&["sets", "list", "--help"]),
2287 Err(CliError::Clap(error)) if error.kind() == ErrorKind::DisplayHelp
2288 ));
2289 assert!(matches!(
2290 parse_test_invocation(&["--version"]),
2291 Err(CliError::Clap(error)) if error.kind() == ErrorKind::DisplayVersion
2292 ));
2293 }
2294
2295 #[test]
2296 fn encodes_typed_page_arguments_without_candid_text() {
2297 let bytes = encode_args((Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100_u16))
2298 .expect("encode page arguments");
2299 let decoded = decode_args::<(Option<String>, u16)>(&bytes).expect("decode page arguments");
2300
2301 assert_eq!(
2302 decoded,
2303 (Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100)
2304 );
2305 }
2306
2307 #[test]
2308 fn hexadecimal_transport_round_trips_raw_candid() {
2309 let bytes = encode_args((None::<String>, 20_u16)).expect("encode arguments");
2310 assert_eq!(
2311 decode_hex(format!("0x{}\n", encode_hex(&bytes)).as_bytes()).expect("decode hex"),
2312 bytes
2313 );
2314 }
2315
2316 #[test]
2317 fn binary_call_arguments_are_removed_after_use() {
2318 let arguments = b"bounded candid payload";
2319 let argument_file =
2320 CallArgumentFile::create(arguments).expect("argument file should be created");
2321 let path = argument_file.path.clone();
2322 assert_eq!(
2323 fs::read(&path).expect("argument file should be readable"),
2324 arguments
2325 );
2326 #[cfg(unix)]
2327 {
2328 use std::os::unix::fs::PermissionsExt as _;
2329 let mode = fs::metadata(&path)
2330 .expect("argument metadata should be readable")
2331 .permissions()
2332 .mode();
2333 assert_eq!(mode & 0o777, 0o600);
2334 }
2335 drop(argument_file);
2336 assert!(!path.exists());
2337 }
2338
2339 #[test]
2340 fn decodes_a_typed_canister_result() {
2341 let status = FeedStatus {
2342 configured: true,
2343 scrydex_configured: true,
2344 next_offset: 50,
2345 sets_next_offset: 20,
2346 ingesting: false,
2347 last_error_code: None,
2348 updated_at_ns: 123,
2349 };
2350 let reply = encode_one(Ok::<_, FeedError>(status.clone())).expect("encode reply");
2351
2352 assert_eq!(
2353 decode_result::<FeedStatus>("toko_feed_status", &reply).expect("decode result"),
2354 status
2355 );
2356 }
2357
2358 #[test]
2359 fn rejects_non_hexadecimal_transport_output() {
2360 assert!(decode_hex(b"not-hex").is_err());
2361 assert!(decode_hex(b"abc").is_err());
2362 }
2363
2364 #[test]
2365 fn keyset_page_collection_preserves_order_and_rejects_stalled_cursors() {
2366 const CURSOR: &str = "01KZ9GFKW3SY1G000000000001";
2367 let options = ListOptions {
2368 after: None,
2369 limit: 2,
2370 all: true,
2371 max_pages: 3,
2372 };
2373 let (items, pages) = collect_keyset_pages(&options, |after, limit| {
2374 assert_eq!(limit, 2);
2375 match after.as_deref() {
2376 None => Ok((vec![1, 2], Some(CURSOR.to_owned()))),
2377 Some(CURSOR) => Ok((vec![3], None)),
2378 Some(_) => Err(CliError::RawReply("unexpected test cursor")),
2379 }
2380 })
2381 .expect("bounded pages should collect");
2382 assert_eq!(items, vec![1, 2, 3]);
2383 assert_eq!(pages, 2);
2384
2385 let stalled = ListOptions {
2386 after: Some(CURSOR.to_owned()),
2387 ..options
2388 };
2389 assert!(matches!(
2390 collect_keyset_pages::<u8>(&stalled, |_, _| Ok((vec![1], Some(CURSOR.to_owned())))),
2391 Err(CliError::PaginationStalled(cursor)) if cursor == CURSOR
2392 ));
2393 }
2394}