mod backup;
mod cache;
mod output;
mod pagination;
mod table;
mod transport;
use std::{
collections::HashSet,
io::{self, Write},
path::PathBuf,
str::FromStr,
};
use candid::{CandidType, encode_args};
use clap::{Args, Parser, Subcommand, error::ErrorKind};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::{Value, json};
use thiserror::Error;
use toko_feed::{
CatalogStatusArgs, CatalogStatusPage, Collection, CollectionDetails, CollectionPage,
CollectionSourcePage, CollectionSourceView, CuratePokemonCardMetadataArgs, IngestionRunPage,
LockPokemonCardArgs, LockPokemonSetArgs, OperationalLogPage, PokemonCardDetails,
PokemonCardMetadataDetails, PokemonCardPage, PokemonCardPrintingPage, PokemonCardPrintingView,
PokemonCardReconciliationPage, PokemonCardSearchPage, PokemonCardSourcePage,
PokemonCardSourceView, PokemonCardView, PokemonSealedDetails, PokemonSealedPage,
PokemonSealedSourcePage, PokemonSealedSourceView, PokemonSetDetails, PokemonSetEvidenceView,
PokemonSetPage, PokemonSetReconciliationPage, PokemonSetSourcePage, PokemonSetView,
PokemonType, PriceObservationPage, Provider, ReconcilePokemonCardArgs,
ReconcilePokemonCardPrintingArgs, ReconcilePokemonCardsArgs, ReconcilePokemonCardsReceipt,
ReconcilePokemonSetArgs, SchedulerStatus, SealedPriceObservationPage, SetLifecycleStatus,
TimeCursor,
};
use backup::DEFAULT_CANONICAL_BACKUP_PATH;
use cache::{
DEFAULT_PROVIDER_SOURCE_PATH, DEFAULT_SCRYDEX_MAGIC_SOURCE_PATH, DEFAULT_SCRYDEX_SOURCE_PATH,
DEFAULT_TCGDEX_SOURCE_PATH,
};
use output::{OutputFormat, ReportKind};
use pagination::collect_pages;
use transport::{
call_and_decode, call_empty, call_history_page, call_item_history_page, call_one, call_page,
};
const DEFAULT_LIMIT: u16 = 20;
const DEFAULT_MAX_PAGES: usize = 1_000;
const MAX_QUERY_LIMIT: u16 = 100;
const DEFAULT_PROVIDER_COMPARISON_CARDS: u16 = 10;
const MAX_PROVIDER_SET_CARDS: u16 = 1_000;
const MAX_RECONCILIATION_SOURCES: usize = 8;
const MAX_SAFE_RECONCILIATION_CARDS: u16 = 10;
#[derive(Debug, Error)]
pub enum CliError {
#[error(transparent)]
Clap(#[from] clap::Error),
#[error("{0}\n\nRun with --help for usage.")]
Usage(String),
#[error("could not start icp: {0}")]
StartIcp(#[source] io::Error),
#[error("icp call failed{status}: {message}")]
Icp {
status: String,
message: String,
},
#[error("could not encode arguments for `{method}`: {source}")]
Encode {
method: &'static str,
#[source]
source: candid::Error,
},
#[error("could not decode the typed reply from `{method}`: {source}")]
Decode {
method: &'static str,
#[source]
source: candid::Error,
},
#[error("icp returned an invalid raw reply: {0}")]
RawReply(&'static str),
#[error("could not prepare temporary Candid arguments: {0}")]
ArgumentFile(#[source] io::Error),
#[error("canister method `{method}` returned {error}")]
Canister {
method: &'static str,
error: String,
},
#[error("provider `{provider}` set `{set_id}` failed at card offset {offset}: {source}")]
ProviderCard {
provider: &'static str,
set_id: String,
offset: u64,
#[source]
source: Box<Self>,
},
#[error(
"provider cache replay for `{provider}` {kind} records {first_record}-{last_record}{set} failed: {source}"
)]
ProviderReplay {
provider: String,
kind: &'static str,
set: String,
first_record: usize,
last_record: usize,
#[source]
source: Box<Self>,
},
#[error("could not process JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("could not write output: {0}")]
Io(#[source] io::Error),
#[error("could not {action} provider cache `{}`: {source}", path.display())]
CacheIo {
action: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
#[error("could not {action} canonical backup `{}`: {source}", path.display())]
BackupIo {
action: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
#[error("invalid provider cache: {0}")]
InvalidCache(String),
#[error("pagination cursor did not advance: {0}")]
PaginationStalled(String),
#[error("listing still had another page after the --max-pages limit of {0}")]
PaginationLimit(usize),
}
impl CliError {
#[must_use]
pub fn exit_code(&self) -> i32 {
match self {
Self::Clap(error) => error.exit_code(),
Self::Usage(_) => 2,
_ => 1,
}
}
#[must_use]
pub fn is_broken_pipe(&self) -> bool {
matches!(self, Self::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Args)]
struct Target {
#[arg(
long,
default_value = "local",
value_name = "NAME",
help_heading = "Connection"
)]
environment: String,
#[arg(
long,
default_value = "toko-feed",
value_name = "NAME|ID",
help_heading = "Connection"
)]
canister: String,
#[arg(
long,
default_value = "anonymous",
value_name = "NAME",
help_heading = "Connection"
)]
identity: String,
#[arg(long, value_name = "PATH", help_heading = "Connection")]
identity_password_file: Option<PathBuf>,
#[arg(long, value_name = "PATH", help_heading = "Connection")]
project_root: Option<PathBuf>,
#[arg(
long,
default_value = "icp",
value_name = "PATH",
help_heading = "Connection"
)]
icp: PathBuf,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Args)]
struct OutputArgs {
#[arg(long, global = true, help_heading = "Output")]
json: bool,
#[arg(long, global = true, requires = "json", help_heading = "Output")]
compact: bool,
}
impl OutputArgs {
const fn format(self) -> OutputFormat {
if self.json {
OutputFormat::Json
} else {
OutputFormat::Text
}
}
}
#[derive(Debug, Parser)]
#[command(
name = "toko-feed",
version,
about = "Operate and query a Toko Feed canister",
long_about = None,
arg_required_else_help = true
)]
struct Cli {
#[command(flatten)]
target: Target,
#[command(flatten)]
output: OutputArgs,
#[command(subcommand)]
command: RootCommand,
}
#[derive(Debug, Subcommand)]
enum RootCommand {
Bootstrap,
Backup(BackupArgs),
Status(StatusArgs),
Scheduler,
Runs(HistoryArgs),
Logs(HistoryArgs),
Collections(CollectionsGroup),
Sets(SetsGroup),
Cards(CardsGroup),
Sealed(SealedGroup),
Sources(SourceKindsGroup),
Provider(ProviderGroup),
Reconcile(ReconcileGroup),
}
#[derive(Debug, Args)]
struct ReconcileGroup {
#[command(subcommand)]
command: ReconcileCommand,
}
#[derive(Debug, Subcommand)]
enum ReconcileCommand {
Sets(ReconcileSetsGroup),
Cards(ReconcileCardsGroup),
}
#[derive(Debug, Args)]
struct ReconcileSetsGroup {
#[command(subcommand)]
command: ReconcileSetsCommand,
}
#[derive(Debug, Subcommand)]
enum ReconcileSetsCommand {
Plan(ReconciliationPlanArgs),
AcceptSafe(MatchKeyArg),
Accept(SetReconcileArgs),
}
#[derive(Debug, Args)]
struct ReconcileCardsGroup {
#[command(subcommand)]
command: ReconcileCardsCommand,
}
#[derive(Debug, Subcommand)]
enum ReconcileCardsCommand {
Plan(CardReconciliationPlanArgs),
AcceptSafe(CardSafeReconcileArgs),
Accept(CardReconcileArgs),
AcceptPrinting(CardPrintingReconcileArgs),
AcceptMetadata(CardMetadataReconcileArgs),
}
#[derive(Debug, Args)]
struct BackupArgs {
#[arg(long, value_name = "PATH", default_value = DEFAULT_CANONICAL_BACKUP_PATH)]
output: PathBuf,
}
#[derive(Debug, Args)]
struct StatusArgs {
#[arg(long, value_name = "COLLECTION")]
collection: Option<Collection>,
#[arg(long, value_name = "SET", requires = "collection")]
set: Option<ProviderSetId>,
#[command(flatten)]
list: ListArgs,
}
#[derive(Debug, Args)]
struct ProviderGroup {
#[command(subcommand)]
command: ProviderCommand,
}
#[derive(Debug, Subcommand)]
enum ProviderCommand {
Set(ProviderSetArgs),
}
#[derive(Debug, Args)]
struct ProviderSetArgs {
#[arg(value_name = "PROVIDER")]
provider: Provider,
#[arg(long, default_value = "pokemon", value_name = "COLLECTION")]
collection: Collection,
#[arg(value_name = "SET")]
set_id: ProviderSetId,
#[arg(long = "source", visible_alias = "data", value_name = "PATH")]
source: Option<PathBuf>,
#[arg(long)]
refresh: bool,
#[arg(
long,
conflicts_with = "all",
value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_PROVIDER_SET_CARDS))
)]
cards: Option<u16>,
#[arg(long, conflicts_with = "cards")]
all: bool,
}
fn default_provider_source(provider: Provider, collection: &str) -> PathBuf {
match (provider, collection) {
(Provider::JustTcg, "pokemon") => PathBuf::from(DEFAULT_PROVIDER_SOURCE_PATH),
(Provider::JustTcg, collection) => {
PathBuf::from(DEFAULT_PROVIDER_SOURCE_PATH).join(collection)
}
(Provider::TcgDex, _) => PathBuf::from(DEFAULT_TCGDEX_SOURCE_PATH),
(Provider::Scrydex, "pokemon") => PathBuf::from(DEFAULT_SCRYDEX_SOURCE_PATH),
(Provider::Scrydex, _) => PathBuf::from(DEFAULT_SCRYDEX_MAGIC_SOURCE_PATH),
}
}
#[derive(Debug, Args)]
struct CollectionsGroup {
#[command(subcommand)]
command: CollectionsCommand,
}
#[derive(Debug, Subcommand)]
enum CollectionsCommand {
List(ListArgs),
Get(IdArg),
}
#[derive(Debug, Args)]
struct SetsGroup {
#[command(subcommand)]
command: SetsCommand,
}
#[derive(Debug, Subcommand)]
enum SetsCommand {
List(ListArgs),
Get(IdArg),
Lock(LockArgs),
}
#[derive(Debug, Args)]
struct CardsGroup {
#[command(subcommand)]
command: CardsCommand,
}
#[derive(Debug, Subcommand)]
enum CardsCommand {
List(ListArgs),
Get(IdArg),
Lock(LockArgs),
Prices(PriceArgs),
ByType(CardTypeListArgs),
}
#[derive(Debug, Args)]
struct CardTypeListArgs {
#[arg(value_name = "TYPE")]
pokemon_type: PokemonType,
#[command(flatten)]
list: ListArgs,
}
#[derive(Debug, Args)]
struct SourceKindsGroup {
#[command(subcommand)]
command: SourceKindCommand,
}
#[derive(Debug, Subcommand)]
enum SourceKindCommand {
Collections(ReadOnlySourcesGroup),
Sets(ReviewableSourcesGroup),
Cards(ReviewableSourcesGroup),
Sealed(ReadOnlySourcesGroup),
}
#[derive(Debug, Args)]
struct ReadOnlySourcesGroup {
#[command(subcommand)]
command: ReadOnlySourcesCommand,
}
#[derive(Debug, Subcommand)]
enum ReadOnlySourcesCommand {
List(ListArgs),
Get(IdArg),
}
#[derive(Debug, Args)]
struct ReviewableSourcesGroup {
#[command(subcommand)]
command: ReviewableSourcesCommand,
}
#[derive(Debug, Subcommand)]
enum ReviewableSourcesCommand {
List(ListArgs),
Get(IdArg),
Reject(IdArg),
}
#[derive(Debug, Args)]
struct SealedGroup {
#[command(subcommand)]
command: SealedCommand,
}
#[derive(Debug, Subcommand)]
enum SealedCommand {
List(ListArgs),
Get(IdArg),
Prices(PriceArgs),
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct LocalId(String);
#[derive(Debug, Args)]
struct IdArg {
#[arg(value_name = "ULID")]
id: LocalId,
}
#[derive(Debug, Args)]
struct PaginationArgs {
#[arg(
long,
default_value_t = DEFAULT_LIMIT,
value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_QUERY_LIMIT))
)]
limit: u16,
#[command(flatten)]
pages: AllPagesArgs,
}
#[derive(Debug, Args)]
struct ListArgs {
#[command(flatten)]
pagination: PaginationArgs,
#[arg(long, value_name = "ULID")]
after: Option<LocalId>,
}
#[derive(Debug, Args)]
struct HistoryArgs {
#[command(flatten)]
pagination: PaginationArgs,
#[arg(long, value_name = "INTEGER", requires = "before_id")]
before_time: Option<u64>,
#[arg(long, value_name = "ULID", requires = "before_time")]
before_id: Option<LocalId>,
}
#[derive(Debug, Args)]
struct ReconciliationPlanArgs {
#[command(flatten)]
pagination: PaginationArgs,
#[arg(long, value_name = "MATCH_KEY")]
after: Option<String>,
}
#[derive(Debug, Args)]
struct CardReconciliationPlanArgs {
#[arg(long, value_name = "ULID")]
set: LocalId,
#[command(flatten)]
plan: ReconciliationPlanArgs,
}
#[derive(Debug, Args)]
struct MatchKeyArg {
#[arg(value_name = "MATCH_KEY")]
match_key: String,
}
#[derive(Debug, Args)]
struct SetReconcileArgs {
#[arg(long, value_name = "ULID", required = true, num_args = 1..=MAX_RECONCILIATION_SOURCES)]
source: Vec<LocalId>,
#[arg(long, value_name = "ULID", requires = "revision")]
set: Option<LocalId>,
#[arg(long, value_name = "N", requires = "set")]
revision: Option<u64>,
#[arg(long)]
name: String,
#[command(flatten)]
release_date: ReleaseDateArgs,
#[command(flatten)]
lifecycle: LifecycleArgs,
}
#[derive(Debug, Args)]
struct CardReconcileArgs {
#[arg(long, value_name = "ULID", required = true, num_args = 1..=MAX_RECONCILIATION_SOURCES)]
source: Vec<LocalId>,
#[arg(long, value_name = "ULID", requires = "revision")]
card: Option<LocalId>,
#[arg(long, value_name = "N", requires = "card")]
revision: Option<u64>,
#[arg(long, value_name = "ULID")]
set: LocalId,
#[arg(long)]
name: String,
#[arg(long)]
collector_number: String,
#[command(flatten)]
rarity: RarityArgs,
}
#[derive(Debug, Args)]
struct CardSafeReconcileArgs {
#[arg(long, value_name = "ULID")]
set: LocalId,
#[arg(long, value_name = "MATCH_KEY")]
after: Option<String>,
#[arg(
long,
default_value_t = MAX_SAFE_RECONCILIATION_CARDS,
value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_SAFE_RECONCILIATION_CARDS))
)]
limit: u16,
#[command(flatten)]
pages: AllPagesArgs,
}
#[derive(Debug, Args)]
struct AllPagesArgs {
#[arg(long)]
all: bool,
#[arg(
long,
default_value_t = DEFAULT_MAX_PAGES,
value_name = "COUNT",
requires = "all",
value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..)
)]
max_pages: usize,
}
#[derive(Debug, Args)]
struct CardPrintingReconcileArgs {
#[arg(long, value_name = "ULID", required = true, num_args = 1..=MAX_RECONCILIATION_SOURCES)]
source: Vec<LocalId>,
#[arg(long, value_name = "ULID")]
card: LocalId,
#[arg(long)]
collector_number: String,
#[arg(long)]
variant: String,
}
#[derive(Debug, Args)]
struct CardMetadataReconcileArgs {
#[arg(long, value_name = "ULID")]
card: LocalId,
#[arg(long, value_name = "ULID")]
source: LocalId,
}
#[derive(Debug, Args)]
#[group(required = true, multiple = false)]
struct RarityArgs {
#[arg(long)]
rarity: Option<String>,
#[arg(long)]
no_rarity: bool,
}
#[derive(Debug, Args)]
#[group(required = true, multiple = false)]
struct ReleaseDateArgs {
#[arg(long, value_name = "DATE")]
release_date: Option<String>,
#[arg(long)]
no_release_date: bool,
}
#[derive(Debug, Args)]
#[group(multiple = false)]
struct LifecycleArgs {
#[arg(long)]
active: bool,
#[arg(long)]
retired: bool,
}
#[derive(Debug, Args)]
struct LockArgs {
#[arg(value_name = "ULID")]
id: LocalId,
#[arg(long, value_name = "N")]
revision: u64,
}
#[derive(Debug, Args)]
struct PriceArgs {
#[arg(value_name = "ULID")]
id: LocalId,
#[command(flatten)]
history: HistoryArgs,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Resource {
Collections,
CollectionSources,
Sets,
SetSources,
Cards,
CardSources,
Sealed,
SealedSources,
}
impl Resource {
const fn collection_field(self) -> &'static str {
match self {
Self::Collections => "collections",
Self::Sets => "sets",
Self::CollectionSources
| Self::SetSources
| Self::CardSources
| Self::SealedSources => "sources",
Self::Cards => "cards",
Self::Sealed => "sealed",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ListOptions {
limit: u16,
after: Option<String>,
all: bool,
max_pages: usize,
}
impl Default for ListOptions {
fn default() -> Self {
Self {
limit: DEFAULT_LIMIT,
after: None,
all: false,
max_pages: DEFAULT_MAX_PAGES,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct HistoryOptions {
limit: u16,
before: Option<TimeCursor>,
all: bool,
max_pages: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ReconciliationPlanOptions {
after: Option<String>,
limit: u16,
all: bool,
max_pages: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CardReconciliationPlanOptions {
pokemon_set_id: String,
plan: ReconciliationPlanOptions,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct SafeCardReconciliationOptions {
args: ReconcilePokemonCardsArgs,
all: bool,
max_pages: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct StatusOptions {
collection: Option<String>,
set: Option<String>,
list: ListOptions,
}
impl Default for HistoryOptions {
fn default() -> Self {
Self {
limit: DEFAULT_LIMIT,
before: None,
all: false,
max_pages: DEFAULT_MAX_PAGES,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Action {
Bootstrap,
Backup(PathBuf),
ProviderSet(ProviderSetOptions),
Status(StatusOptions),
Scheduler,
Runs(HistoryOptions),
Logs(HistoryOptions),
List(Resource, ListOptions),
ListCardsByType(PokemonType, ListOptions),
Get(Resource, String),
LockSet(LockPokemonSetArgs),
LockCard(LockPokemonCardArgs),
RejectSource(Resource, String),
Prices(Resource, String, HistoryOptions),
PlanSets(ReconciliationPlanOptions),
ReconcileSafeSet(String),
ReconcileSet(ReconcilePokemonSetArgs),
PlanCards(CardReconciliationPlanOptions),
ReconcileSafeCards(SafeCardReconciliationOptions),
ReconcileCard(ReconcilePokemonCardArgs),
ReconcileCardPrinting(ReconcilePokemonCardPrintingArgs),
ReconcileCardMetadata(CuratePokemonCardMetadataArgs),
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ProviderSetOptions {
provider: Provider,
collection: String,
set_id: String,
source: PathBuf,
refresh: bool,
cards: Option<u16>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Invocation {
target: Target,
output: OutputArgs,
action: Action,
}
pub fn run_from_env() -> Result<(), CliError> {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(error) if is_clap_display(&error) => {
return error.print().map_err(CliError::Io);
}
Err(error) => return Err(CliError::Clap(error)),
};
run(&cli.into_invocation())
}
fn run(invocation: &Invocation) -> Result<(), CliError> {
let value = execute(invocation)?;
let rendered = output::render(
invocation.output.format(),
invocation.action.report_kind(),
&value,
invocation.output.compact,
)?;
write_text(&format!("{rendered}\n"))
}
fn write_text(text: &str) -> Result<(), CliError> {
io::stdout()
.lock()
.write_all(text.as_bytes())
.map_err(CliError::Io)
}
fn is_clap_display(error: &clap::Error) -> bool {
matches!(
error.kind(),
ErrorKind::DisplayHelp
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
| ErrorKind::DisplayVersion
)
}
#[cfg(test)]
fn parse_test_invocation(arguments: &[&str]) -> Result<Invocation, CliError> {
let cli = Cli::try_parse_from(std::iter::once("toko-feed").chain(arguments.iter().copied()))?;
Ok(cli.into_invocation())
}
impl Cli {
fn into_invocation(self) -> Invocation {
let action = self.command.into_action();
Invocation {
target: self.target,
output: self.output,
action,
}
}
}
impl Action {
const fn report_kind(&self) -> ReportKind {
match self {
Self::Status(_) => ReportKind::Status,
_ => ReportKind::Generic,
}
}
}
impl RootCommand {
fn into_action(self) -> Action {
match self {
Self::Bootstrap => Action::Bootstrap,
Self::Backup(args) => Action::Backup(args.output),
Self::Provider(group) => match group.command {
ProviderCommand::Set(args) => Action::ProviderSet(args.into_options()),
},
Self::Reconcile(group) => match group.command {
ReconcileCommand::Sets(group) => match group.command {
ReconcileSetsCommand::Plan(args) => Action::PlanSets(args.into_options()),
ReconcileSetsCommand::AcceptSafe(args) => {
Action::ReconcileSafeSet(args.match_key)
}
ReconcileSetsCommand::Accept(args) => {
Action::ReconcileSet(args.into_canister_args())
}
},
ReconcileCommand::Cards(group) => match group.command {
ReconcileCardsCommand::Plan(args) => Action::PlanCards(args.into_options()),
ReconcileCardsCommand::AcceptSafe(args) => {
Action::ReconcileSafeCards(args.into_options())
}
ReconcileCardsCommand::Accept(args) => {
Action::ReconcileCard(args.into_canister_args())
}
ReconcileCardsCommand::AcceptPrinting(args) => {
Action::ReconcileCardPrinting(args.into_canister_args())
}
ReconcileCardsCommand::AcceptMetadata(args) => {
Action::ReconcileCardMetadata(args.into_canister_args())
}
},
},
Self::Status(args) => Action::Status(StatusOptions {
collection: args
.collection
.map(|collection| collection.slug().to_owned()),
set: args.set.map(Into::into),
list: args.list.into_options(),
}),
Self::Scheduler => Action::Scheduler,
Self::Runs(args) => Action::Runs(args.into_options()),
Self::Logs(args) => Action::Logs(args.into_options()),
Self::Collections(group) => match group.command {
CollectionsCommand::List(args) => {
Action::List(Resource::Collections, args.into_options())
}
CollectionsCommand::Get(args) => Action::Get(Resource::Collections, args.id.into()),
},
Self::Sets(group) => match group.command {
SetsCommand::List(args) => Action::List(Resource::Sets, args.into_options()),
SetsCommand::Get(args) => Action::Get(Resource::Sets, args.id.into()),
SetsCommand::Lock(args) => Action::LockSet(args.into_set_args()),
},
Self::Cards(group) => match group.command {
CardsCommand::List(args) => Action::List(Resource::Cards, args.into_options()),
CardsCommand::Get(args) => Action::Get(Resource::Cards, args.id.into()),
CardsCommand::Lock(args) => Action::LockCard(args.into_card_args()),
CardsCommand::Prices(args) => args.into_action(Resource::Cards),
CardsCommand::ByType(args) => {
Action::ListCardsByType(args.pokemon_type, args.list.into_options())
}
},
Self::Sealed(group) => match group.command {
SealedCommand::List(args) => Action::List(Resource::Sealed, args.into_options()),
SealedCommand::Get(args) => Action::Get(Resource::Sealed, args.id.into()),
SealedCommand::Prices(args) => args.into_action(Resource::Sealed),
},
Self::Sources(group) => group.into_action(),
}
}
}
impl ProviderSetArgs {
fn into_options(self) -> ProviderSetOptions {
let collection = self.collection.slug().to_owned();
let source = self
.source
.unwrap_or_else(|| default_provider_source(self.provider, &collection));
ProviderSetOptions {
provider: self.provider,
collection,
set_id: self.set_id.into(),
source,
refresh: self.refresh,
cards: (!self.all).then_some(self.cards.unwrap_or(DEFAULT_PROVIDER_COMPARISON_CARDS)),
}
}
}
impl SourceKindsGroup {
fn into_action(self) -> Action {
match self.command {
SourceKindCommand::Collections(group) => group.into_action(Resource::CollectionSources),
SourceKindCommand::Sets(group) => group.into_action(Resource::SetSources),
SourceKindCommand::Cards(group) => group.into_action(Resource::CardSources),
SourceKindCommand::Sealed(group) => group.into_action(Resource::SealedSources),
}
}
}
impl ReadOnlySourcesGroup {
fn into_action(self, resource: Resource) -> Action {
match self.command {
ReadOnlySourcesCommand::List(args) => Action::List(resource, args.into_options()),
ReadOnlySourcesCommand::Get(args) => Action::Get(resource, args.id.into()),
}
}
}
impl ReviewableSourcesGroup {
fn into_action(self, resource: Resource) -> Action {
match self.command {
ReviewableSourcesCommand::List(args) => Action::List(resource, args.into_options()),
ReviewableSourcesCommand::Get(args) => Action::Get(resource, args.id.into()),
ReviewableSourcesCommand::Reject(args) => {
Action::RejectSource(resource, args.id.into())
}
}
}
}
impl ListArgs {
fn into_options(self) -> ListOptions {
ListOptions {
limit: self.pagination.limit,
after: self.after.map(Into::into),
all: self.pagination.pages.all,
max_pages: self.pagination.pages.max_pages,
}
}
}
impl HistoryArgs {
fn into_options(self) -> HistoryOptions {
HistoryOptions {
limit: self.pagination.limit,
before: self
.before_time
.zip(self.before_id)
.map(|(timestamp, id)| TimeCursor {
timestamp,
id: id.into(),
}),
all: self.pagination.pages.all,
max_pages: self.pagination.pages.max_pages,
}
}
}
impl ReconciliationPlanArgs {
fn into_options(self) -> ReconciliationPlanOptions {
ReconciliationPlanOptions {
after: self.after,
limit: self.pagination.limit,
all: self.pagination.pages.all,
max_pages: self.pagination.pages.max_pages,
}
}
}
impl CardReconciliationPlanArgs {
fn into_options(self) -> CardReconciliationPlanOptions {
CardReconciliationPlanOptions {
pokemon_set_id: self.set.into(),
plan: self.plan.into_options(),
}
}
}
impl SetReconcileArgs {
fn into_canister_args(self) -> ReconcilePokemonSetArgs {
ReconcilePokemonSetArgs {
source_ids: self.source.into_iter().map(Into::into).collect(),
pokemon_set_id: self.set.map(Into::into),
expected_revision: self.revision,
name: self.name,
release_date: self.release_date.release_date,
lifecycle_status: if self.lifecycle.retired {
SetLifecycleStatus::Retired
} else {
SetLifecycleStatus::Active
},
}
}
}
impl CardReconcileArgs {
fn into_canister_args(self) -> ReconcilePokemonCardArgs {
ReconcilePokemonCardArgs {
source_ids: self.source.into_iter().map(Into::into).collect(),
pokemon_card_id: self.card.map(Into::into),
expected_revision: self.revision,
pokemon_set_id: self.set.into(),
name: self.name,
collector_number: self.collector_number,
rarity: self.rarity.rarity,
}
}
}
impl CardSafeReconcileArgs {
fn into_options(self) -> SafeCardReconciliationOptions {
SafeCardReconciliationOptions {
args: ReconcilePokemonCardsArgs {
pokemon_set_id: self.set.into(),
after_match_key: self.after,
limit: self.limit,
},
all: self.pages.all,
max_pages: self.pages.max_pages,
}
}
}
impl CardPrintingReconcileArgs {
fn into_canister_args(self) -> ReconcilePokemonCardPrintingArgs {
ReconcilePokemonCardPrintingArgs {
source_ids: self.source.into_iter().map(Into::into).collect(),
pokemon_card_id: self.card.into(),
collector_number: self.collector_number,
variant_code: self.variant,
}
}
}
impl CardMetadataReconcileArgs {
fn into_canister_args(self) -> CuratePokemonCardMetadataArgs {
CuratePokemonCardMetadataArgs {
pokemon_card_id: self.card.into(),
pokemon_card_source_id: self.source.into(),
}
}
}
impl LockArgs {
fn into_set_args(self) -> LockPokemonSetArgs {
LockPokemonSetArgs {
id: self.id.into(),
expected_revision: self.revision,
}
}
fn into_card_args(self) -> LockPokemonCardArgs {
LockPokemonCardArgs {
id: self.id.into(),
expected_revision: self.revision,
}
}
}
impl PriceArgs {
fn into_action(self, resource: Resource) -> Action {
Action::Prices(resource, self.id.into(), self.history.into_options())
}
}
impl FromStr for LocalId {
type Err = &'static str;
fn from_str(id: &str) -> Result<Self, Self::Err> {
if is_valid_local_id(id) {
Ok(Self(id.to_owned()))
} else {
Err("must be a 26-character uppercase ULID")
}
}
}
impl From<LocalId> for String {
fn from(id: LocalId) -> Self {
id.0
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ProviderSetId(String);
impl FromStr for ProviderSetId {
type Err = &'static str;
fn from_str(id: &str) -> Result<Self, Self::Err> {
let id = id.trim();
let valid = !id.is_empty()
&& id.len() <= 256
&& !id.chars().any(char::is_control)
&& !id.contains(['/', '\\']);
if valid {
Ok(Self(id.to_owned()))
} else {
Err("must be 1-256 characters without control characters")
}
}
}
impl From<ProviderSetId> for String {
fn from(id: ProviderSetId) -> Self {
id.0
}
}
fn is_valid_local_id(id: &str) -> bool {
id.len() == 26
&& id.bytes().all(|byte| {
matches!(
byte,
b'0'..=b'9' | b'A'..=b'H' | b'J'..=b'K' | b'M'..=b'N' | b'P'..=b'T' | b'V'..=b'Z'
)
})
}
fn execute(invocation: &Invocation) -> Result<Value, CliError> {
match &invocation.action {
Action::Bootstrap => cache::execute_bootstrap(&invocation.target),
Action::Backup(path) => backup::execute(&invocation.target, path),
Action::ProviderSet(options) => cache::execute_provider_set(
&invocation.target,
options.provider,
&options.collection,
&options.set_id,
&options.source,
options.refresh,
options.cards,
),
Action::Status(options) if options.list.all => {
list_all_catalog_status(&invocation.target, options)
}
Action::Status(options) => output(fetch_catalog_status_page(
&invocation.target,
options,
options.list.after.clone(),
)?),
Action::Scheduler => output(call_empty::<SchedulerStatus>(
&invocation.target,
"toko_feed_scheduler",
true,
)?),
Action::Runs(options) if options.all => list_all_runs(&invocation.target, options),
Action::Logs(options) if options.all => list_all_logs(&invocation.target, options),
Action::Prices(resource, item_id, options) => {
execute_prices(&invocation.target, *resource, item_id, options)
}
Action::Runs(options) => output(fetch_run_page(
&invocation.target,
options.before.clone(),
options.limit,
)?),
Action::Logs(options) => output(fetch_log_page(
&invocation.target,
options.before.clone(),
options.limit,
)?),
Action::Get(resource, id) => execute_get(&invocation.target, *resource, id),
Action::LockSet(args) => {
mutation::<_, PokemonSetView>(&invocation.target, "toko_feed_lock_set", args)
}
Action::LockCard(args) => {
mutation::<_, PokemonCardView>(&invocation.target, "toko_feed_lock_card", args)
}
Action::RejectSource(resource, id) => {
execute_reject_source(&invocation.target, *resource, id)
}
Action::List(resource, options) => execute_list(&invocation.target, *resource, options),
Action::ListCardsByType(pokemon_type, options) => {
execute_cards_by_type(&invocation.target, *pokemon_type, options)
}
Action::PlanSets(options) => execute_set_reconciliation_plan(&invocation.target, options),
Action::ReconcileSafeSet(match_key) => mutation::<_, PokemonSetView>(
&invocation.target,
"toko_feed_reconcile_safe_set",
match_key,
),
Action::ReconcileSet(args) => {
mutation::<_, PokemonSetView>(&invocation.target, "toko_feed_reconcile_set", args)
}
Action::PlanCards(options) => execute_card_reconciliation_plan(&invocation.target, options),
Action::ReconcileSafeCards(options) => {
execute_safe_card_reconciliation(&invocation.target, options)
}
Action::ReconcileCard(args) => {
mutation::<_, PokemonCardView>(&invocation.target, "toko_feed_reconcile_card", args)
}
Action::ReconcileCardPrinting(args) => output(call_one::<_, PokemonCardPrintingView>(
&invocation.target,
"toko_feed_reconcile_card_printing",
args.clone(),
false,
)?),
Action::ReconcileCardMetadata(args) => output(call_one::<_, PokemonCardMetadataDetails>(
&invocation.target,
"toko_feed_curate_card_metadata",
args.clone(),
false,
)?),
}
}
fn execute_set_reconciliation_plan(
target: &Target,
options: &ReconciliationPlanOptions,
) -> Result<Value, CliError> {
if !options.all {
return output(fetch_set_reconciliation_page(
target,
options.after.clone(),
options.limit,
)?);
}
let (candidates, _) = collect_pages(
options.after.clone(),
options.limit,
options.max_pages,
|after, limit| {
let page = fetch_set_reconciliation_page(target, after, limit)?;
Ok((page.candidates, page.next_after))
},
|previous, next| ensure_cursor_advanced(previous.map(String::as_str), next),
)?;
output(PokemonSetReconciliationPage {
candidates,
next_after: None,
})
}
fn execute_card_reconciliation_plan(
target: &Target,
options: &CardReconciliationPlanOptions,
) -> Result<Value, CliError> {
if !options.plan.all {
return output(fetch_card_reconciliation_page(
target,
options.pokemon_set_id.clone(),
options.plan.after.clone(),
options.plan.limit,
)?);
}
let (candidates, _) = collect_pages(
options.plan.after.clone(),
options.plan.limit,
options.plan.max_pages,
|after, limit| {
let page = fetch_card_reconciliation_page(
target,
options.pokemon_set_id.clone(),
after,
limit,
)?;
Ok((page.candidates, page.next_after))
},
|previous, next| ensure_cursor_advanced(previous.map(String::as_str), next),
)?;
output(PokemonCardReconciliationPage {
candidates,
next_after: None,
})
}
fn execute_safe_card_reconciliation(
target: &Target,
options: &SafeCardReconciliationOptions,
) -> Result<Value, CliError> {
if !options.all {
return output(call_one::<_, ReconcilePokemonCardsReceipt>(
target,
"toko_feed_reconcile_cards",
options.args.clone(),
false,
)?);
}
let mut args = options.args.clone();
let mut receipts = Vec::new();
let mut cards_reconciled = 0_u64;
let mut sources_mapped = 0_u64;
let mut candidates_skipped = 0_u64;
for _ in 0..options.max_pages {
let receipt = call_one::<_, ReconcilePokemonCardsReceipt>(
target,
"toko_feed_reconcile_cards",
args.clone(),
false,
)?;
cards_reconciled = cards_reconciled.saturating_add(u64::from(receipt.cards_reconciled));
sources_mapped = sources_mapped.saturating_add(u64::from(receipt.sources_mapped));
candidates_skipped =
candidates_skipped.saturating_add(u64::from(receipt.candidates_skipped));
let next = receipt.next_after.clone();
receipts.push(receipt);
let Some(next) = next else {
return Ok(json!({
"cards_reconciled": cards_reconciled,
"sources_mapped": sources_mapped,
"candidates_skipped": candidates_skipped,
"receipts": receipts,
}));
};
ensure_cursor_advanced(args.after_match_key.as_deref(), &next)?;
args.after_match_key = Some(next);
}
Err(CliError::PaginationLimit(options.max_pages))
}
fn ensure_cursor_advanced(previous: Option<&str>, next: &str) -> Result<(), CliError> {
if previous.is_some_and(|previous| previous >= next) {
Err(CliError::PaginationStalled(next.to_owned()))
} else {
Ok(())
}
}
fn execute_reject_source(target: &Target, resource: Resource, id: &str) -> Result<Value, CliError> {
match resource {
Resource::SetSources => output(call_one::<_, PokemonSetEvidenceView>(
target,
"toko_feed_reject_set_source",
id.to_owned(),
false,
)?),
Resource::CardSources => output(call_one::<_, PokemonCardSourceView>(
target,
"toko_feed_reject_card_source",
id.to_owned(),
false,
)?),
_ => Err(CliError::Usage(
"only provider source records can be rejected".to_owned(),
)),
}
}
fn execute_list(
target: &Target,
resource: Resource,
options: &ListOptions,
) -> Result<Value, CliError> {
if options.all {
return match resource {
Resource::Collections => list_all_collections(target, options),
Resource::CollectionSources => list_all_collection_sources(target, options),
Resource::Sets => list_all_sets(target, options),
Resource::SetSources => list_all_set_sources(target, options),
Resource::Cards => list_all_cards(target, options),
Resource::CardSources => list_all_card_sources(target, options),
Resource::Sealed => list_all_sealed(target, options),
Resource::SealedSources => list_all_sealed_sources(target, options),
};
}
match resource {
Resource::Collections => output(fetch_collection_page(
target,
options.after.clone(),
options.limit,
)?),
Resource::CollectionSources => output(fetch_collection_source_page(
target,
options.after.clone(),
options.limit,
)?),
Resource::Sets => output(fetch_set_page(
target,
options.after.clone(),
options.limit,
)?),
Resource::SetSources => output(fetch_set_source_page(
target,
options.after.clone(),
options.limit,
)?),
Resource::Cards => output(fetch_card_page(
target,
options.after.clone(),
options.limit,
)?),
Resource::CardSources => output(fetch_card_source_page(
target,
options.after.clone(),
options.limit,
)?),
Resource::Sealed => output(fetch_sealed_page(
target,
options.after.clone(),
options.limit,
)?),
Resource::SealedSources => output(fetch_sealed_source_page(
target,
options.after.clone(),
options.limit,
)?),
}
}
fn execute_cards_by_type(
target: &Target,
pokemon_type: PokemonType,
options: &ListOptions,
) -> Result<Value, CliError> {
if !options.all {
return output(fetch_card_type_page(
target,
pokemon_type,
options.after.clone(),
options.limit,
)?);
}
let (cards, pages) = collect_keyset_pages(options, |after, limit| {
let page = fetch_card_type_page(target, pokemon_type, after, limit)?;
Ok((page.cards, page.next_after))
})?;
Ok(json!({
"cards": cards,
"count": cards.len(),
"pages": pages,
"next_after": null,
"pokemon_type": pokemon_type,
}))
}
fn execute_get(target: &Target, resource: Resource, id: &str) -> Result<Value, CliError> {
match resource {
Resource::Collections => output(call_one::<_, Option<CollectionDetails>>(
target,
"toko_feed_collection",
id.to_owned(),
true,
)?),
Resource::CollectionSources => output(call_one::<_, Option<CollectionSourceView>>(
target,
"toko_feed_collection_source",
id.to_owned(),
true,
)?),
Resource::Sets => output(call_one::<_, Option<PokemonSetDetails>>(
target,
"toko_feed_set",
id.to_owned(),
true,
)?),
Resource::Cards => output(call_one::<_, Option<PokemonCardDetails>>(
target,
"toko_feed_card",
id.to_owned(),
true,
)?),
Resource::SetSources => output(call_one::<_, Option<PokemonSetEvidenceView>>(
target,
"toko_feed_set_source",
id.to_owned(),
true,
)?),
Resource::CardSources => output(call_one::<_, Option<PokemonCardSourceView>>(
target,
"toko_feed_card_source",
id.to_owned(),
true,
)?),
Resource::Sealed => output(call_one::<_, Option<PokemonSealedDetails>>(
target,
"toko_feed_sealed_product",
id.to_owned(),
true,
)?),
Resource::SealedSources => output(call_one::<_, Option<PokemonSealedSourceView>>(
target,
"toko_feed_sealed_source",
id.to_owned(),
true,
)?),
}
}
fn execute_prices(
target: &Target,
resource: Resource,
item_id: &str,
options: &HistoryOptions,
) -> Result<Value, CliError> {
if options.all {
return list_all_prices(target, resource, item_id, options);
}
match resource {
Resource::Cards => output(fetch_card_price_page(
target,
item_id.to_owned(),
options.before.clone(),
options.limit,
)?),
Resource::Sealed => output(fetch_sealed_price_page(
target,
item_id.to_owned(),
options.before.clone(),
options.limit,
)?),
Resource::Collections
| Resource::CollectionSources
| Resource::Sets
| Resource::SetSources
| Resource::CardSources
| Resource::SealedSources => Err(CliError::Usage(format!(
"{} does not expose price history",
resource.collection_field()
))),
}
}
fn output(value: impl Serialize) -> Result<Value, CliError> {
serde_json::to_value(value).map_err(CliError::Json)
}
fn mutation<A, T>(target: &Target, method: &'static str, args: &A) -> Result<Value, CliError>
where
A: CandidType + Clone,
T: CandidType + DeserializeOwned + Serialize,
{
output(call_one::<_, T>(target, method, args.clone(), false)?)
}
fn fetch_set_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<PokemonSetPage, CliError> {
call_page(target, "toko_feed_sets", after, limit)
}
fn fetch_catalog_status_page(
target: &Target,
options: &StatusOptions,
after: Option<String>,
) -> Result<CatalogStatusPage, CliError> {
call_one(
target,
"toko_feed_catalog_status",
CatalogStatusArgs {
collection: options.collection.clone(),
set: options.set.clone(),
after,
limit: options.list.limit,
},
true,
)
}
fn fetch_collection_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<CollectionPage, CliError> {
call_page(target, "toko_feed_collections", after, limit)
}
fn fetch_collection_source_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<CollectionSourcePage, CliError> {
call_page(target, "toko_feed_collection_sources", after, limit)
}
fn fetch_card_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<PokemonCardPage, CliError> {
call_page(target, "toko_feed_cards", after, limit)
}
fn fetch_card_type_page(
target: &Target,
pokemon_type: PokemonType,
after: Option<String>,
limit: u16,
) -> Result<PokemonCardSearchPage, CliError> {
const METHOD: &str = "toko_feed_cards_by_type";
let arguments =
encode_args((pokemon_type, after, limit)).map_err(|source| CliError::Encode {
method: METHOD,
source,
})?;
call_and_decode(target, METHOD, &arguments, true)
}
fn fetch_card_printing_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<PokemonCardPrintingPage, CliError> {
call_page(target, "toko_feed_card_printings", after, limit)
}
fn fetch_set_source_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<PokemonSetSourcePage, CliError> {
call_page(target, "toko_feed_set_sources", after, limit)
}
fn fetch_card_source_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<PokemonCardSourcePage, CliError> {
call_page(target, "toko_feed_card_sources", after, limit)
}
fn fetch_sealed_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<PokemonSealedPage, CliError> {
call_page(target, "toko_feed_sealed", after, limit)
}
fn fetch_sealed_source_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<PokemonSealedSourcePage, CliError> {
call_page(target, "toko_feed_sealed_sources", after, limit)
}
fn fetch_set_reconciliation_page(
target: &Target,
after_match_key: Option<String>,
limit: u16,
) -> Result<PokemonSetReconciliationPage, CliError> {
let method = "toko_feed_set_reconciliation";
let arguments = encode_args((after_match_key, limit))
.map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, true)
}
fn fetch_card_reconciliation_page(
target: &Target,
pokemon_set_id: String,
after_match_key: Option<String>,
limit: u16,
) -> Result<PokemonCardReconciliationPage, CliError> {
let method = "toko_feed_card_reconciliation";
let arguments = encode_args((pokemon_set_id, after_match_key, limit))
.map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, true)
}
fn fetch_run_page(
target: &Target,
before: Option<TimeCursor>,
limit: u16,
) -> Result<IngestionRunPage, CliError> {
call_history_page(target, "toko_feed_runs", before, limit)
}
fn fetch_log_page(
target: &Target,
before: Option<TimeCursor>,
limit: u16,
) -> Result<OperationalLogPage, CliError> {
call_history_page(target, "toko_feed_logs", before, limit)
}
fn fetch_card_price_page(
target: &Target,
card_id: String,
before: Option<TimeCursor>,
limit: u16,
) -> Result<PriceObservationPage, CliError> {
call_item_history_page(target, "toko_feed_card_prices", card_id, before, limit)
}
fn fetch_sealed_price_page(
target: &Target,
sealed_id: String,
before: Option<TimeCursor>,
limit: u16,
) -> Result<SealedPriceObservationPage, CliError> {
call_item_history_page(target, "toko_feed_sealed_prices", sealed_id, before, limit)
}
fn list_all_runs(target: &Target, options: &HistoryOptions) -> Result<Value, CliError> {
let (runs, pages) = collect_history_pages(options, |before, limit| {
let page = fetch_run_page(target, before, limit)?;
Ok((page.runs, page.next_before))
})?;
complete_page_output("runs", runs, pages, "next_before")
}
fn list_all_logs(target: &Target, options: &HistoryOptions) -> Result<Value, CliError> {
let (logs, pages) = collect_history_pages(options, |before, limit| {
let page = fetch_log_page(target, before, limit)?;
Ok((page.logs, page.next_before))
})?;
complete_page_output("logs", logs, pages, "next_before")
}
fn list_all_prices(
target: &Target,
resource: Resource,
item_id: &str,
options: &HistoryOptions,
) -> Result<Value, CliError> {
let (observations, pages) = match resource {
Resource::Cards => collect_history_pages(options, |before, limit| {
let page = fetch_card_price_page(target, item_id.to_owned(), before, limit)?;
Ok((page.observations, page.next_before))
})?,
Resource::Sealed => {
let (observations, pages) = collect_history_pages(options, |before, limit| {
let page = fetch_sealed_price_page(target, item_id.to_owned(), before, limit)?;
Ok((page.observations, page.next_before))
})?;
return complete_page_output("observations", observations, pages, "next_before");
}
Resource::Collections
| Resource::CollectionSources
| Resource::Sets
| Resource::SetSources
| Resource::CardSources
| Resource::SealedSources => {
return Err(CliError::Usage(format!(
"{} does not expose price history",
resource.collection_field()
)));
}
};
complete_page_output("observations", observations, pages, "next_before")
}
fn collect_history_pages<T>(
options: &HistoryOptions,
fetch: impl FnMut(Option<TimeCursor>, u16) -> Result<(Vec<T>, Option<TimeCursor>), CliError>,
) -> Result<(Vec<T>, usize), CliError> {
let mut seen = options
.before
.as_ref()
.map(time_cursor_token)
.into_iter()
.collect::<HashSet<_>>();
collect_pages(
options.before.clone(),
options.limit,
options.max_pages,
fetch,
|_, next| validate_time_cursor(next, &mut seen),
)
}
fn validate_time_cursor(cursor: &TimeCursor, seen: &mut HashSet<String>) -> Result<(), CliError> {
if !is_valid_local_id(&cursor.id) {
return Err(CliError::RawReply(
"next_before contained an invalid local ULID",
));
}
let token = time_cursor_token(cursor);
if !seen.insert(token.clone()) {
return Err(CliError::PaginationStalled(token));
}
Ok(())
}
fn time_cursor_token(cursor: &TimeCursor) -> String {
format!("{}:{}", cursor.timestamp, cursor.id)
}
fn list_all_sets(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let (sets, pages) = collect_keyset_pages(options, |after, limit| {
let page = fetch_set_page(target, after, limit)?;
Ok((page.sets, page.next_after))
})?;
complete_page_output("sets", sets, pages, "next_after")
}
fn list_all_catalog_status(target: &Target, options: &StatusOptions) -> Result<Value, CliError> {
let (sets, pages) = collect_keyset_pages(&options.list, |after, _limit| {
let page = fetch_catalog_status_page(target, options, after)?;
Ok((page.sets, page.next_after))
})?;
complete_page_output("sets", sets, pages, "next_after")
}
fn list_all_collections(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let (collections, pages) = collect_keyset_pages(options, |after, limit| {
let page = fetch_collection_page(target, after, limit)?;
Ok((page.collections, page.next_after))
})?;
complete_page_output("collections", collections, pages, "next_after")
}
fn list_all_collection_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let (sources, pages) = collect_keyset_pages(options, |after, limit| {
let page = fetch_collection_source_page(target, after, limit)?;
Ok((page.sources, page.next_after))
})?;
complete_page_output("sources", sources, pages, "next_after")
}
fn list_all_cards(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let (cards, pages) = collect_keyset_pages(options, |after, limit| {
let page = fetch_card_page(target, after, limit)?;
Ok((page.cards, page.next_after))
})?;
complete_page_output("cards", cards, pages, "next_after")
}
fn list_all_set_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let (sources, pages) = collect_keyset_pages(options, |after, limit| {
let page = fetch_set_source_page(target, after, limit)?;
Ok((page.sources, page.next_after))
})?;
complete_page_output("sources", sources, pages, "next_after")
}
fn list_all_card_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let (sources, pages) = collect_keyset_pages(options, |after, limit| {
let page = fetch_card_source_page(target, after, limit)?;
Ok((page.sources, page.next_after))
})?;
complete_page_output("sources", sources, pages, "next_after")
}
fn list_all_sealed(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let (sealed, pages) = collect_keyset_pages(options, |after, limit| {
let page = fetch_sealed_page(target, after, limit)?;
Ok((page.sealed, page.next_after))
})?;
complete_page_output("sealed", sealed, pages, "next_after")
}
fn list_all_sealed_sources(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let (sources, pages) = collect_keyset_pages(options, |after, limit| {
let page = fetch_sealed_source_page(target, after, limit)?;
Ok((page.sources, page.next_after))
})?;
complete_page_output("sources", sources, pages, "next_after")
}
fn complete_page_output<T>(
field: &'static str,
items: Vec<T>,
pages: usize,
cursor_field: &'static str,
) -> Result<Value, CliError>
where
T: Serialize,
{
let count = items.len();
let mut object = serde_json::Map::new();
object.insert(field.to_owned(), serde_json::to_value(items)?);
object.insert("count".to_owned(), json!(count));
object.insert("pages".to_owned(), json!(pages));
object.insert(cursor_field.to_owned(), Value::Null);
Ok(Value::Object(object))
}
fn collect_keyset_pages<T>(
options: &ListOptions,
fetch: impl FnMut(Option<String>, u16) -> Result<(Vec<T>, Option<String>), CliError>,
) -> Result<(Vec<T>, usize), CliError> {
let mut seen = options.after.iter().cloned().collect::<HashSet<_>>();
collect_pages(
options.after.clone(),
options.limit,
options.max_pages,
fetch,
|_, next| validate_reply_cursor(next, &mut seen),
)
}
fn validate_reply_cursor(cursor: &str, seen: &mut HashSet<String>) -> Result<(), CliError> {
if !is_valid_local_id(cursor) {
return Err(CliError::RawReply("next_after was not a valid local ULID"));
}
if !seen.insert(cursor.to_owned()) {
return Err(CliError::PaginationStalled(cursor.to_owned()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use candid::decode_args;
use clap::CommandFactory;
use super::*;
fn invocation(values: &[&str]) -> Invocation {
parse_test_invocation(values).expect("arguments should parse")
}
fn clap_error(values: &[&str]) -> ErrorKind {
match parse_test_invocation(values) {
Err(CliError::Clap(error)) => error.kind(),
Err(error) => panic!("expected a Clap error, got {error}"),
Ok(_) => panic!("expected Clap to reject the arguments"),
}
}
#[test]
fn parses_global_and_automatic_pagination_options() {
let parsed = invocation(&[
"--environment",
"ic",
"--canister",
"aaaaa-aa",
"--identity",
"operator",
"--project-root",
"/srv/toko-feed",
"--json",
"--compact",
"sets",
"list",
"--limit",
"100",
"--after",
"01KZ9GFKW3SY1G000000000001",
"--all",
"--max-pages",
"12",
]);
assert_eq!(parsed.target.environment, "ic");
assert_eq!(parsed.target.canister, "aaaaa-aa");
assert_eq!(parsed.target.identity, "operator");
assert_eq!(
parsed.target.project_root.as_deref(),
Some(std::path::Path::new("/srv/toko-feed"))
);
assert_eq!(parsed.output.format(), OutputFormat::Json);
assert!(parsed.output.compact);
assert_eq!(
parsed.action,
Action::List(
Resource::Sets,
ListOptions {
limit: 100,
after: Some("01KZ9GFKW3SY1G000000000001".to_owned()),
all: true,
max_pages: 12,
}
)
);
}
#[test]
fn parses_bounded_status_drill_down() {
assert_eq!(
invocation(&[
"status",
"--collection",
"magic",
"--set",
"the",
"--limit",
"50",
"--all",
])
.action,
Action::Status(StatusOptions {
collection: Some("magic-the-gathering".to_owned()),
set: Some("the".to_owned()),
list: ListOptions {
limit: 50,
after: None,
all: true,
max_pages: DEFAULT_MAX_PAGES,
},
})
);
assert_eq!(
clap_error(&["status", "--set", "aquapolis"]),
ErrorKind::MissingRequiredArgument
);
let json = invocation(&["status", "--collection", "poke", "--json"]);
assert_eq!(json.output.format(), OutputFormat::Json);
assert!(!json.output.compact);
assert_eq!(
clap_error(&["status", "--compact"]),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn parses_bootstrap_and_keeps_root_help_focused() {
assert_eq!(
invocation(&["backup"]).action,
Action::Backup(PathBuf::from(DEFAULT_CANONICAL_BACKUP_PATH))
);
assert_eq!(invocation(&["bootstrap"]).action, Action::Bootstrap);
assert!(parse_test_invocation(&["bootstrap", "--source", "/tmp/cache"]).is_err());
assert!(parse_test_invocation(&["bootstrap", "--refresh"]).is_err());
let help = Cli::command().render_help().to_string();
assert!(help.contains("backup"));
assert!(help.contains("bootstrap"));
assert!(help.contains("Connection:"));
assert!(!help.contains("tcgdex"));
assert!(!help.contains("scrydex"));
assert!(!help.contains("--before-time"));
assert!(!help.contains("--release-date"));
assert!(help.lines().count() < 45);
}
#[test]
fn parses_bounded_provider_set_refresh() {
assert_eq!(
invocation(&[
"provider",
"set",
"tcgdex",
"ecard2",
"--source",
"/tmp/tcgdex",
"--refresh",
"--cards",
"12",
])
.action,
Action::ProviderSet(ProviderSetOptions {
provider: Provider::TcgDex,
collection: "pokemon".to_owned(),
set_id: "ecard2".to_owned(),
source: PathBuf::from("/tmp/tcgdex"),
refresh: true,
cards: Some(12),
})
);
assert_eq!(
clap_error(&["provider", "set", "tcgdex", "ecard2", "--cards", "1001"]),
ErrorKind::ValueValidation
);
assert_eq!(
clap_error(&["provider", "set", "tcgdex", "../ecard2"]),
ErrorKind::ValueValidation
);
}
#[test]
fn parses_provider_prefixes_defaults_and_full_acquisition() {
assert_eq!(
invocation(&[
"provider",
"set",
"scrydex",
"ecard2",
"--source",
"/tmp/scrydex",
"--refresh",
"--cards",
"10",
])
.action,
Action::ProviderSet(ProviderSetOptions {
provider: Provider::Scrydex,
collection: "pokemon".to_owned(),
set_id: "ecard2".to_owned(),
source: PathBuf::from("/tmp/scrydex"),
refresh: true,
cards: Some(10),
})
);
assert_eq!(
invocation(&["provider", "set", "scrydex", "ecard2"]).action,
Action::ProviderSet(ProviderSetOptions {
provider: Provider::Scrydex,
collection: "pokemon".to_owned(),
set_id: "ecard2".to_owned(),
source: PathBuf::from(DEFAULT_SCRYDEX_SOURCE_PATH),
refresh: false,
cards: Some(DEFAULT_PROVIDER_COMPARISON_CARDS),
})
);
assert_eq!(
invocation(&["provider", "set", "scry", "ecard2"]).action,
Action::ProviderSet(ProviderSetOptions {
provider: Provider::Scrydex,
collection: "pokemon".to_owned(),
set_id: "ecard2".to_owned(),
source: PathBuf::from(DEFAULT_SCRYDEX_SOURCE_PATH),
refresh: false,
cards: Some(DEFAULT_PROVIDER_COMPARISON_CARDS),
})
);
assert_eq!(
invocation(&[
"provider",
"set",
"scry",
"DRK",
"--collection",
"magic",
"--all",
])
.action,
Action::ProviderSet(ProviderSetOptions {
provider: Provider::Scrydex,
collection: "magic-the-gathering".to_owned(),
set_id: "DRK".to_owned(),
source: PathBuf::from(DEFAULT_SCRYDEX_MAGIC_SOURCE_PATH),
refresh: false,
cards: None,
})
);
assert_eq!(
invocation(&["provider", "set", "just-tcg", "aquapolis-pokemon", "--all",]).action,
Action::ProviderSet(ProviderSetOptions {
provider: Provider::JustTcg,
collection: "pokemon".to_owned(),
set_id: "aquapolis-pokemon".to_owned(),
source: PathBuf::from(DEFAULT_PROVIDER_SOURCE_PATH),
refresh: false,
cards: None,
})
);
assert_eq!(
clap_error(&["provider", "set", "unknown", "ecard2"]),
ErrorKind::ValueValidation
);
assert_eq!(
invocation(&["provider", "set", "scrydex", "ecard2", "--all"]).action,
Action::ProviderSet(ProviderSetOptions {
provider: Provider::Scrydex,
collection: "pokemon".to_owned(),
set_id: "ecard2".to_owned(),
source: PathBuf::from(DEFAULT_SCRYDEX_SOURCE_PATH),
refresh: false,
cards: None,
})
);
assert_eq!(
clap_error(&[
"provider", "set", "scrydex", "ecard2", "--all", "--cards", "10",
]),
ErrorKind::ArgumentConflict
);
}
#[test]
fn clap_validates_name_prefix_matching() {
for collection in ["poke", "Pokemon", "Pokémon", "POKE"] {
assert!(matches!(
invocation(&["status", "--collection", collection]).action,
Action::Status(StatusOptions {
collection: Some(ref value),
..
}) if value == "pokemon"
));
}
for collection in ["unknown", "mtg", "dragon-ball-super"] {
assert_eq!(
clap_error(&["provider", "set", "scry", "DRK", "--collection", collection,]),
ErrorKind::ValueValidation
);
}
for provider in ["unknown", "jt"] {
assert_eq!(
clap_error(&["provider", "set", provider, "DRK"]),
ErrorKind::ValueValidation
);
}
}
#[test]
fn clap_owns_argument_relationships_and_validation() {
Cli::command().debug_assert();
assert!(parse_test_invocation(&["cards", "ingest"]).is_err());
assert!(parse_test_invocation(&["sets", "curate"]).is_err());
assert_eq!(
clap_error(&["sets", "list", "--max-pages", "2"]),
ErrorKind::MissingRequiredArgument
);
assert_eq!(
clap_error(&["cards", "get", "not-an-id",]),
ErrorKind::ValueValidation
);
}
#[test]
fn parses_canonical_card_queries_and_rejects_legacy_ingestion() {
assert_eq!(
invocation(&["cards", "get", "01KZ9GFKW3SY1G000000000001"]).action,
Action::Get(Resource::Cards, "01KZ9GFKW3SY1G000000000001".to_owned())
);
assert!(parse_test_invocation(&["cards", "ingest"]).is_err());
assert!(parse_test_invocation(&["cards", "curate"]).is_err());
assert_eq!(
invocation(&["cards", "by-type", "fire", "--limit", "25", "--all",]).action,
Action::ListCardsByType(
PokemonType::Fire,
ListOptions {
limit: 25,
all: true,
..ListOptions::default()
}
)
);
assert!(parse_test_invocation(&["cards", "by-type", "steam"]).is_err());
}
#[test]
fn parses_canonical_collection_queries() {
assert_eq!(
invocation(&["collections", "list", "--all"]).action,
Action::List(
Resource::Collections,
ListOptions {
all: true,
..ListOptions::default()
}
)
);
assert!(parse_test_invocation(&["collections", "ingest"]).is_err());
}
#[test]
fn parses_revisioned_set_lock_and_rejects_legacy_curation() {
let set_id = "01KZ9GFKW3SY1G000000000002";
assert_eq!(
invocation(&["sets", "lock", set_id, "--revision", "8"]).action,
Action::LockSet(LockPokemonSetArgs {
id: set_id.to_owned(),
expected_revision: 8,
})
);
assert!(parse_test_invocation(&["sets", "curate"]).is_err());
}
#[test]
fn parses_source_first_evidence_commands() {
let source_id = "01KZ9GFKW3SY1G000000000001";
let card_id = "01KZ9GFKW3SY1G000000000003";
assert_eq!(
invocation(&["sources", "collections", "get", source_id]).action,
Action::Get(Resource::CollectionSources, source_id.to_owned())
);
assert_eq!(
invocation(&["sources", "sets", "reject", source_id]).action,
Action::RejectSource(Resource::SetSources, source_id.to_owned())
);
assert_eq!(
invocation(&["sources", "cards", "list", "--all"]).action,
Action::List(
Resource::CardSources,
ListOptions {
all: true,
..ListOptions::default()
}
)
);
assert_eq!(
invocation(&["sources", "sealed", "get", source_id]).action,
Action::Get(Resource::SealedSources, source_id.to_owned())
);
assert_eq!(
invocation(&["cards", "lock", card_id, "--revision", "1"]).action,
Action::LockCard(LockPokemonCardArgs {
id: card_id.to_owned(),
expected_revision: 1,
})
);
assert!(parse_test_invocation(&["sets", "sources", "list"]).is_err());
assert!(parse_test_invocation(&["cards", "sources", "list"]).is_err());
assert!(parse_test_invocation(&["sources", "sealed", "reject", source_id]).is_err());
}
#[test]
fn parses_scheduler_history_and_price_commands() {
assert_eq!(invocation(&["scheduler"]).action, Action::Scheduler);
assert_eq!(
invocation(&[
"runs",
"--before-time",
"123",
"--before-id",
"01KZ9GFKW3SY1G000000000001",
"--all",
])
.action,
Action::Runs(HistoryOptions {
before: Some(TimeCursor {
timestamp: 123,
id: "01KZ9GFKW3SY1G000000000001".to_owned(),
}),
all: true,
..HistoryOptions::default()
})
);
assert!(matches!(
invocation(&[
"cards",
"prices",
"01KZ9GFKW3SY1G000000000001",
"--limit",
"100",
])
.action,
Action::Prices(Resource::Cards, _, HistoryOptions { limit: 100, .. })
));
assert!(matches!(
invocation(&["sealed", "prices", "01KZ9GFKW3SY1G000000000001", "--all",]).action,
Action::Prices(Resource::Sealed, _, HistoryOptions { all: true, .. })
));
assert!(parse_test_invocation(&["runs", "list"]).is_err());
assert!(parse_test_invocation(&["logs", "list"]).is_err());
assert!(parse_test_invocation(&["logs", "--before-time", "123",]).is_err());
}
#[test]
fn parses_sealed_queries_and_rejects_separate_ingestion() {
assert_eq!(
invocation(&["sealed", "list", "--all"]).action,
Action::List(
Resource::Sealed,
ListOptions {
all: true,
..ListOptions::default()
}
)
);
assert_eq!(
invocation(&["sealed", "get", "01KZ9GFKW3SY1G000000000001"]).action,
Action::Get(Resource::Sealed, "01KZ9GFKW3SY1G000000000001".to_owned())
);
assert!(parse_test_invocation(&["sealed", "ingest"]).is_err());
}
#[test]
fn rejects_invalid_bounds_and_identifiers() {
assert!(parse_test_invocation(&["sets", "list", "--limit", "0"]).is_err());
assert!(parse_test_invocation(&["sets", "list", "--max-pages", "2"]).is_err());
assert!(parse_test_invocation(&["sets", "get", "not-an-id"]).is_err());
}
#[test]
fn accepts_help_at_any_command_depth_and_version_at_the_top_level() {
assert!(matches!(
parse_test_invocation(&["sets", "list", "--help"]),
Err(CliError::Clap(error)) if error.kind() == ErrorKind::DisplayHelp
));
assert!(matches!(
parse_test_invocation(&["--version"]),
Err(CliError::Clap(error)) if error.kind() == ErrorKind::DisplayVersion
));
}
#[test]
fn encodes_typed_page_arguments_without_candid_text() {
let bytes = encode_args((Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100_u16))
.expect("encode page arguments");
let decoded = decode_args::<(Option<String>, u16)>(&bytes).expect("decode page arguments");
assert_eq!(
decoded,
(Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100)
);
}
#[test]
fn parses_provider_neutral_reconciliation_commands() {
const SET: &str = "01KZ9GFKW3SY1G000000000001";
const SOURCE_ONE: &str = "01KZ9GFKW3SY1G000000000002";
const SOURCE_TWO: &str = "01KZ9GFKW3SY1G000000000003";
const CARD: &str = "01KZ9GFKW3SY1G000000000004";
assert_eq!(
invocation(&[
"reconcile",
"cards",
"plan",
"--set",
SET,
"--limit",
"50",
"--all",
])
.action,
Action::PlanCards(CardReconciliationPlanOptions {
pokemon_set_id: SET.to_owned(),
plan: ReconciliationPlanOptions {
after: None,
limit: 50,
all: true,
max_pages: DEFAULT_MAX_PAGES,
},
})
);
assert_eq!(
invocation(&[
"reconcile",
"cards",
"accept-metadata",
"--card",
CARD,
"--source",
SOURCE_ONE,
])
.action,
Action::ReconcileCardMetadata(CuratePokemonCardMetadataArgs {
pokemon_card_id: CARD.to_owned(),
pokemon_card_source_id: SOURCE_ONE.to_owned(),
})
);
assert_eq!(
invocation(&["reconcile", "cards", "accept-safe", "--set", SET, "--all",]).action,
Action::ReconcileSafeCards(SafeCardReconciliationOptions {
args: ReconcilePokemonCardsArgs {
pokemon_set_id: SET.to_owned(),
after_match_key: None,
limit: MAX_SAFE_RECONCILIATION_CARDS,
},
all: true,
max_pages: DEFAULT_MAX_PAGES,
})
);
assert_eq!(
invocation(&[
"reconcile",
"cards",
"accept-printing",
"--source",
SOURCE_ONE,
SOURCE_TWO,
"--card",
CARD,
"--collector-number",
"50a",
"--variant",
"a",
])
.action,
Action::ReconcileCardPrinting(ReconcilePokemonCardPrintingArgs {
source_ids: vec![SOURCE_ONE.to_owned(), SOURCE_TWO.to_owned()],
pokemon_card_id: CARD.to_owned(),
collector_number: "50a".to_owned(),
variant_code: "a".to_owned(),
})
);
assert_eq!(
invocation(&[
"reconcile",
"sets",
"accept-safe",
"pokemon-set:v1:pokemon:aquapolis:2003-01-15",
])
.action,
Action::ReconcileSafeSet("pokemon-set:v1:pokemon:aquapolis:2003-01-15".to_owned())
);
assert_eq!(
clap_error(&[
"reconcile",
"cards",
"accept-safe",
"--set",
SET,
"--limit",
"11",
]),
ErrorKind::ValueValidation
);
}
#[test]
fn keyset_page_collection_preserves_order_and_rejects_stalled_cursors() {
const CURSOR: &str = "01KZ9GFKW3SY1G000000000001";
let options = ListOptions {
after: None,
limit: 2,
all: true,
max_pages: 3,
};
let (items, pages) = collect_keyset_pages(&options, |after, limit| {
assert_eq!(limit, 2);
match after.as_deref() {
None => Ok((vec![1, 2], Some(CURSOR.to_owned()))),
Some(CURSOR) => Ok((vec![3], None)),
Some(_) => Err(CliError::RawReply("unexpected test cursor")),
}
})
.expect("bounded pages should collect");
assert_eq!(items, vec![1, 2, 3]);
assert_eq!(pages, 2);
let stalled = ListOptions {
after: Some(CURSOR.to_owned()),
..options
};
assert!(matches!(
collect_keyset_pages::<u8>(&stalled, |_, _| Ok((vec![1], Some(CURSOR.to_owned())))),
Err(CliError::PaginationStalled(cursor)) if cursor == CURSOR
));
}
}