mod backup;
mod cache;
use std::{
collections::HashSet,
fs::{self, OpenOptions},
io::{self, Write},
path::PathBuf,
process::Command,
str::FromStr,
sync::atomic::{AtomicU64, Ordering},
};
use candid::{CandidType, decode_one, encode_args};
use clap::{Args, Parser, Subcommand, ValueEnum, error::ErrorKind};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::{Value, json};
use thiserror::Error;
use toko_feed::{
CollectionDetails, CollectionIngestReceipt, CollectionPage, CuratePokemonCardArgs,
CuratePokemonSetArgs, FeedError, FeedStatus, IngestReceipt, IngestSetCardsArgs,
IngestionRunPage, LockPokemonCardArgs, LockPokemonSetArgs, OperationalLogPage,
PokemonCardDetails, PokemonCardPage, PokemonCardSourcePage, PokemonCardSourceView,
PokemonCardView, PokemonSealedDetails, PokemonSealedPage, PokemonSetDetails,
PokemonSetEvidenceView, PokemonSetPage, PokemonSetSourcePage, PokemonSetView,
PriceObservationPage, SchedulerStatus, SealedPriceObservationPage, SetCardIngestReceipt,
SetIngestReceipt, SetLifecycleStatus, TimeCursor,
};
use backup::DEFAULT_CANONICAL_BACKUP_PATH;
use cache::{
DEFAULT_PROVIDER_SOURCE_PATH, DEFAULT_SCRYDEX_SOURCE_PATH, DEFAULT_TCGDEX_SOURCE_PATH,
};
const DEFAULT_LIMIT: u16 = 20;
const DEFAULT_MAX_PAGES: usize = 1_000;
const MAX_QUERY_LIMIT: u16 = 100;
const MAX_CARD_FEED_RECORDS_PER_INGEST: u16 = 10;
const DEFAULT_PROVIDER_COMPARISON_CARDS: u16 = 10;
const MAX_PROVIDER_SET_CARDS: u16 = 1_000;
const MAX_RAW_REPLY_BYTES: usize = 16 * 1024 * 1024;
static CALL_ARGUMENT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[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("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,
#[arg(long)]
compact: bool,
}
#[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(subcommand)]
command: RootCommand,
}
#[derive(Debug, Subcommand)]
enum RootCommand {
Bootstrap(BootstrapArgs),
Backup(BackupArgs),
Status,
Scheduler,
Runs(HistoryGroup),
Logs(HistoryGroup),
Collections(CollectionsGroup),
Sets(SetsGroup),
Cards(CardsGroup),
Sealed(SealedGroup),
Provider(ProviderGroup),
}
#[derive(Debug, Args)]
struct BackupArgs {
#[arg(long, value_name = "PATH", default_value = DEFAULT_CANONICAL_BACKUP_PATH)]
output: PathBuf,
}
#[derive(Debug, Args)]
struct BootstrapArgs {
#[arg(
long = "source",
visible_aliases = ["data", "cache"],
value_name = "PATH",
num_args = 0..=1,
default_missing_value = DEFAULT_PROVIDER_SOURCE_PATH
)]
source: Option<PathBuf>,
#[arg(long, requires = "source")]
refresh: bool,
}
#[derive(Debug, Args)]
struct ProviderGroup {
#[command(subcommand)]
command: ProviderCommand,
}
#[derive(Debug, Subcommand)]
enum ProviderCommand {
Set(ProviderSetArgs),
}
#[derive(Debug, Args)]
struct ProviderSetArgs {
#[arg(value_enum, value_name = "PROVIDER")]
provider: Provider,
#[arg(value_name = "SET_ID")]
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,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum Provider {
#[value(name = "tcgdex")]
TcgDex,
Scrydex,
}
impl Provider {
const fn name(self) -> &'static str {
match self {
Self::TcgDex => "tcgdex",
Self::Scrydex => "scrydex",
}
}
const fn default_source(self) -> &'static str {
match self {
Self::TcgDex => DEFAULT_TCGDEX_SOURCE_PATH,
Self::Scrydex => DEFAULT_SCRYDEX_SOURCE_PATH,
}
}
}
#[derive(Debug, Args)]
struct HistoryGroup {
#[command(subcommand)]
command: HistoryCommand,
}
#[derive(Debug, Subcommand)]
enum HistoryCommand {
List(HistoryArgs),
}
#[derive(Debug, Args)]
struct CollectionsGroup {
#[command(subcommand)]
command: CollectionsCommand,
}
#[derive(Debug, Subcommand)]
enum CollectionsCommand {
Ingest,
List(ListArgs),
Get(IdArg),
}
#[derive(Debug, Args)]
struct SetsGroup {
#[command(subcommand)]
command: SetsCommand,
}
#[derive(Debug, Subcommand)]
enum SetsCommand {
Ingest,
List(ListArgs),
Get(IdArg),
Sources(SourcesGroup),
Curate(SetCurateArgs),
Lock(SetLockArgs),
}
#[derive(Debug, Args)]
struct CardsGroup {
#[command(subcommand)]
command: CardsCommand,
}
#[derive(Debug, Subcommand)]
enum CardsCommand {
Ingest(CardIngestArgs),
List(ListArgs),
Get(IdArg),
Sources(SourcesGroup),
Curate(CardCurateArgs),
Lock(CardLockArgs),
Prices(PriceArgs),
}
#[derive(Debug, Args)]
struct SourcesGroup {
#[command(subcommand)]
command: SourcesCommand,
}
#[derive(Debug, Subcommand)]
enum SourcesCommand {
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 ListArgs {
#[arg(
long,
default_value_t = DEFAULT_LIMIT,
value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_QUERY_LIMIT))
)]
limit: u16,
#[arg(long, value_name = "ULID")]
after: Option<LocalId>,
#[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 HistoryArgs {
#[arg(
long,
default_value_t = DEFAULT_LIMIT,
value_parser = clap::value_parser!(u16).range(1..=i64::from(MAX_QUERY_LIMIT))
)]
limit: u16,
#[arg(long, value_name = "INTEGER", requires = "before_id")]
before_time: Option<u64>,
#[arg(long, value_name = "ULID", requires = "before_time")]
before_id: Option<LocalId>,
#[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 CardIngestArgs {
#[arg(long, value_name = "ULID")]
set: Option<LocalId>,
#[arg(long, default_value_t = 0, requires = "set")]
offset: u64,
#[arg(
long,
default_value_t = MAX_CARD_FEED_RECORDS_PER_INGEST,
requires = "set",
value_parser = clap::value_parser!(u16)
.range(1..=i64::from(MAX_CARD_FEED_RECORDS_PER_INGEST))
)]
limit: u16,
}
#[derive(Debug, Args)]
struct SetCurateArgs {
#[arg(value_name = "ULID")]
source: 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 CardCurateArgs {
#[arg(value_name = "ULID")]
source: 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)]
#[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 SetLockArgs {
#[arg(value_name = "ULID")]
id: LocalId,
#[arg(long, value_name = "N")]
revision: u64,
}
#[derive(Debug, Args)]
struct CardLockArgs {
#[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,
Sets,
SetSources,
Cards,
CardSources,
Sealed,
}
impl Resource {
const fn collection_field(self) -> &'static str {
match self {
Self::Collections => "collections",
Self::Sets => "sets",
Self::SetSources | Self::CardSources => "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,
}
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(BootstrapOptions),
Backup(PathBuf),
ProviderSet(ProviderSetOptions),
Status,
Scheduler,
Runs(HistoryOptions),
Logs(HistoryOptions),
Ingest(Resource),
IngestSetCards(IngestSetCardsArgs),
List(Resource, ListOptions),
Get(Resource, String),
CurateSet(CuratePokemonSetArgs),
LockSet(LockPokemonSetArgs),
CurateCard(CuratePokemonCardArgs),
LockCard(LockPokemonCardArgs),
RejectSource(Resource, String),
Prices(Resource, String, HistoryOptions),
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct BootstrapOptions {
source: Option<PathBuf>,
refresh: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ProviderSetOptions {
provider: Provider,
set_id: String,
source: PathBuf,
refresh: bool,
cards: Option<u16>,
}
struct CallArgumentFile {
path: PathBuf,
}
impl CallArgumentFile {
fn create(arguments: &[u8]) -> Result<Self, CliError> {
for _ in 0..32 {
let sequence = CALL_ARGUMENT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"toko-feed-candid-{}-{sequence}.bin",
std::process::id()
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = match options.open(&path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(CliError::ArgumentFile(error)),
};
if let Err(source) = file.write_all(arguments) {
let _ = fs::remove_file(&path);
return Err(CliError::ArgumentFile(source));
}
return Ok(Self { path });
}
Err(CliError::ArgumentFile(io::Error::new(
io::ErrorKind::AlreadyExists,
"could not allocate a unique temporary path",
)))
}
}
impl Drop for CallArgumentFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Invocation {
target: Target,
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 output = execute(invocation)?;
let rendered = if invocation.target.compact {
serde_json::to_string(&output)?
} else {
serde_json::to_string_pretty(&output)?
};
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,
action,
}
}
}
impl RootCommand {
fn into_action(self) -> Action {
match self {
Self::Bootstrap(args) => Action::Bootstrap(args.into_options()),
Self::Backup(args) => Action::Backup(args.output),
Self::Provider(group) => match group.command {
ProviderCommand::Set(args) => Action::ProviderSet(args.into_options()),
},
Self::Status => Action::Status,
Self::Scheduler => Action::Scheduler,
Self::Runs(group) => match group.command {
HistoryCommand::List(args) => Action::Runs(args.into_options()),
},
Self::Logs(group) => match group.command {
HistoryCommand::List(args) => Action::Logs(args.into_options()),
},
Self::Collections(group) => match group.command {
CollectionsCommand::Ingest => Action::Ingest(Resource::Collections),
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::Ingest => Action::Ingest(Resource::Sets),
SetsCommand::List(args) => Action::List(Resource::Sets, args.into_options()),
SetsCommand::Get(args) => Action::Get(Resource::Sets, args.id.into()),
SetsCommand::Sources(group) => group.into_action(Resource::SetSources),
SetsCommand::Curate(args) => Action::CurateSet(args.into_canister_args()),
SetsCommand::Lock(args) => Action::LockSet(args.into_canister_args()),
},
Self::Cards(group) => match group.command {
CardsCommand::Ingest(args) => args.into_action(),
CardsCommand::List(args) => Action::List(Resource::Cards, args.into_options()),
CardsCommand::Get(args) => Action::Get(Resource::Cards, args.id.into()),
CardsCommand::Sources(group) => group.into_action(Resource::CardSources),
CardsCommand::Curate(args) => Action::CurateCard(args.into_canister_args()),
CardsCommand::Lock(args) => Action::LockCard(args.into_canister_args()),
CardsCommand::Prices(args) => args.into_action(Resource::Cards),
},
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),
},
}
}
}
impl BootstrapArgs {
fn into_options(self) -> BootstrapOptions {
BootstrapOptions {
source: self.source,
refresh: self.refresh,
}
}
}
impl ProviderSetArgs {
fn into_options(self) -> ProviderSetOptions {
let source = self
.source
.unwrap_or_else(|| PathBuf::from(self.provider.default_source()));
ProviderSetOptions {
provider: self.provider,
set_id: self.set_id.into(),
source,
refresh: self.refresh,
cards: (!self.all).then_some(self.cards.unwrap_or(DEFAULT_PROVIDER_COMPARISON_CARDS)),
}
}
}
impl SourcesGroup {
fn into_action(self, resource: Resource) -> Action {
match self.command {
SourcesCommand::List(args) => Action::List(resource, args.into_options()),
SourcesCommand::Get(args) => Action::Get(resource, args.id.into()),
SourcesCommand::Reject(args) => Action::RejectSource(resource, args.id.into()),
}
}
}
impl ListArgs {
fn into_options(self) -> ListOptions {
ListOptions {
limit: self.limit,
after: self.after.map(Into::into),
all: self.all,
max_pages: self.max_pages,
}
}
}
impl HistoryArgs {
fn into_options(self) -> HistoryOptions {
HistoryOptions {
limit: self.limit,
before: self
.before_time
.zip(self.before_id)
.map(|(timestamp, id)| TimeCursor {
timestamp,
id: id.into(),
}),
all: self.all,
max_pages: self.max_pages,
}
}
}
impl CardIngestArgs {
fn into_action(self) -> Action {
let Some(pokemon_set_id) = self.set else {
return Action::Ingest(Resource::Cards);
};
Action::IngestSetCards(IngestSetCardsArgs {
pokemon_set_id: pokemon_set_id.into(),
offset: self.offset,
limit: self.limit,
})
}
}
impl SetCurateArgs {
fn into_canister_args(self) -> CuratePokemonSetArgs {
let ReleaseDateArgs {
release_date,
no_release_date: _,
} = self.release_date;
let LifecycleArgs { active: _, retired } = self.lifecycle;
let lifecycle_status = if retired {
SetLifecycleStatus::Retired
} else {
SetLifecycleStatus::Active
};
CuratePokemonSetArgs {
source_id: self.source.into(),
pokemon_set_id: self.set.map(Into::into),
expected_revision: self.revision,
name: self.name,
release_date,
lifecycle_status,
}
}
}
impl CardCurateArgs {
fn into_canister_args(self) -> CuratePokemonCardArgs {
let RarityArgs {
rarity,
no_rarity: _,
} = self.rarity;
CuratePokemonCardArgs {
source_id: self.source.into(),
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,
}
}
}
impl SetLockArgs {
fn into_canister_args(self) -> LockPokemonSetArgs {
LockPokemonSetArgs {
id: self.id.into(),
expected_revision: self.revision,
}
}
}
impl CardLockArgs {
fn into_canister_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 valid = !id.is_empty()
&& id
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
if valid {
Ok(Self(id.to_owned()))
} else {
Err("must contain only lowercase ASCII letters, digits, or hyphens")
}
}
}
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(options) => cache::execute_bootstrap(&invocation.target, options),
Action::Backup(path) => backup::execute(&invocation.target, path),
Action::ProviderSet(options) => cache::execute_provider_set(
&invocation.target,
options.provider,
&options.set_id,
&options.source,
options.refresh,
options.cards,
),
Action::Status => output(call_empty::<FeedStatus>(
&invocation.target,
"toko_feed_status",
true,
)?),
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::Ingest(resource) => execute_ingest(&invocation.target, *resource),
Action::Get(resource, id) => execute_get(&invocation.target, *resource, id),
Action::IngestSetCards(args) => output(call_one::<_, SetCardIngestReceipt>(
&invocation.target,
"toko_feed_ingest_set_cards",
args.clone(),
false,
)?),
Action::CurateSet(args) => set_mutation(&invocation.target, "toko_feed_curate_set", args),
Action::LockSet(args) => set_mutation(&invocation.target, "toko_feed_lock_set", args),
Action::CurateCard(args) => {
card_mutation(&invocation.target, "toko_feed_curate_card", args)
}
Action::LockCard(args) => card_mutation(&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),
}
}
fn execute_ingest(target: &Target, resource: Resource) -> Result<Value, CliError> {
match resource {
Resource::Collections => output(call_empty::<CollectionIngestReceipt>(
target,
"toko_feed_ingest_collections",
false,
)?),
Resource::Sets => output(call_empty::<SetIngestReceipt>(
target,
"toko_feed_ingest_sets",
false,
)?),
Resource::Cards => output(call_empty::<IngestReceipt>(
target,
"toko_feed_ingest",
false,
)?),
Resource::Sealed => Err(CliError::Usage(
"sealed products are imported through `cards ingest`".to_owned(),
)),
Resource::SetSources | Resource::CardSources => Err(CliError::Usage(
"provider sources are imported through their parent resource".to_owned(),
)),
}
}
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::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),
};
}
match resource {
Resource::Collections => output(fetch_collection_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,
)?),
}
}
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::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,
)?),
}
}
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::Sets | Resource::SetSources | Resource::CardSources => {
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 set_mutation<A>(target: &Target, method: &'static str, args: &A) -> Result<Value, CliError>
where
A: CandidType + Clone,
{
output(call_one::<_, PokemonSetView>(
target,
method,
args.clone(),
false,
)?)
}
fn card_mutation<A>(target: &Target, method: &'static str, args: &A) -> Result<Value, CliError>
where
A: CandidType + Clone,
{
output(call_one::<_, PokemonCardView>(
target,
method,
args.clone(),
false,
)?)
}
fn call_empty<T>(target: &Target, method: &'static str, query: bool) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let arguments = encode_args(()).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, query)
}
fn call_one<A, T>(
target: &Target,
method: &'static str,
argument: A,
query: bool,
) -> Result<T, CliError>
where
A: CandidType,
T: CandidType + DeserializeOwned,
{
let arguments =
encode_args((argument,)).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, query)
}
fn fetch_set_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<PokemonSetPage, CliError> {
call_page(target, "toko_feed_sets", after, limit)
}
fn fetch_collection_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<CollectionPage, CliError> {
call_page(target, "toko_feed_collections", 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_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_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> {
let method = "toko_feed_card_prices";
let arguments = encode_args((card_id, before, limit))
.map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, true)
}
fn fetch_sealed_price_page(
target: &Target,
sealed_id: String,
before: Option<TimeCursor>,
limit: u16,
) -> Result<SealedPriceObservationPage, CliError> {
let method = "toko_feed_sealed_prices";
let arguments = encode_args((sealed_id, before, limit))
.map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, true)
}
fn call_history_page<T>(
target: &Target,
method: &'static str,
before: Option<TimeCursor>,
limit: u16,
) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let arguments =
encode_args((before, limit)).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, true)
}
fn call_page<T>(
target: &Target,
method: &'static str,
after: Option<String>,
limit: u16,
) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let arguments =
encode_args((after, limit)).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, true)
}
fn call_and_decode<T>(
target: &Target,
method: &'static str,
arguments: &[u8],
query: bool,
) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let reply = call_raw(target, method, arguments, query)?;
decode_result(method, &reply)
}
fn decode_result<T>(method: &'static str, reply: &[u8]) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let result = decode_one::<Result<T, FeedError>>(reply)
.map_err(|source| CliError::Decode { method, source })?;
result.map_err(|error| CliError::Canister {
method,
error: format!("{error:?}"),
})
}
fn call_raw(
target: &Target,
method: &'static str,
arguments: &[u8],
query: bool,
) -> Result<Vec<u8>, CliError> {
let argument_file = CallArgumentFile::create(arguments)?;
let mut command = Command::new(&target.icp);
if let Some(project_root) = &target.project_root {
command.arg("--project-root-override").arg(project_root);
}
if let Some(password_file) = &target.identity_password_file {
command.arg("--identity-password-file").arg(password_file);
}
command
.args(["canister", "call", "--environment"])
.arg(&target.environment)
.args(["--args-format", "bin", "--args-file"])
.arg(&argument_file.path)
.args(["--output", "hex"]);
command.args(["--identity", &target.identity]);
if query {
command.arg("--query");
}
command.args([&target.canister, method]);
let output = command.output().map_err(CliError::StartIcp)?;
if !output.status.success() {
let status = output
.status
.code()
.map_or_else(String::new, |code| format!(" (exit {code})"));
let message = bounded_diagnostic(&output.stderr);
return Err(CliError::Icp { status, message });
}
if output.stdout.len() > MAX_RAW_REPLY_BYTES * 2 + 2 {
return Err(CliError::RawReply("hexadecimal response exceeded 16 MiB"));
}
decode_hex(&output.stdout)
}
fn bounded_diagnostic(bytes: &[u8]) -> String {
const MAX_DIAGNOSTIC_BYTES: usize = 8 * 1024;
let visible = &bytes[..bytes.len().min(MAX_DIAGNOSTIC_BYTES)];
let mut message = String::from_utf8_lossy(visible).trim().to_owned();
if bytes.len() > MAX_DIAGNOSTIC_BYTES {
message.push_str("…[truncated]");
}
if message.is_empty() {
"no diagnostic was written to stderr".to_owned()
} else {
message
}
}
#[cfg(test)]
fn encode_hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from(DIGITS[usize::from(byte >> 4)]));
output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
}
output
}
fn decode_hex(input: &[u8]) -> Result<Vec<u8>, CliError> {
let input = std::str::from_utf8(input)
.map_err(|_| CliError::RawReply("response was not UTF-8 hexadecimal text"))?
.trim();
let input = input.strip_prefix("0x").unwrap_or(input);
if input.len() % 2 != 0 {
return Err(CliError::RawReply(
"hexadecimal response had an odd number of digits",
));
}
input
.as_bytes()
.chunks_exact(2)
.map(|pair| {
let high = hex_digit(pair[0])?;
let low = hex_digit(pair[1])?;
Ok((high << 4) | low)
})
.collect()
}
const fn hex_digit(byte: u8) -> Result<u8, CliError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err(CliError::RawReply(
"response contained a non-hexadecimal character",
)),
}
}
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))
})?;
Ok(json!({
"runs": runs,
"count": runs.len(),
"pages": pages,
"next_before": null,
}))
}
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))
})?;
Ok(json!({
"logs": logs,
"count": logs.len(),
"pages": pages,
"next_before": null,
}))
}
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 Ok(json!({
"observations": observations,
"count": observations.len(),
"pages": pages,
"next_before": null,
}));
}
Resource::Collections | Resource::Sets | Resource::SetSources | Resource::CardSources => {
return Err(CliError::Usage(format!(
"{} does not expose price history",
resource.collection_field()
)));
}
};
Ok(json!({
"observations": observations,
"count": observations.len(),
"pages": pages,
"next_before": null,
}))
}
fn collect_history_pages<T>(
options: &HistoryOptions,
mut fetch: impl FnMut(Option<TimeCursor>, u16) -> Result<(Vec<T>, Option<TimeCursor>), CliError>,
) -> Result<(Vec<T>, usize), CliError> {
let mut before = options.before.clone();
let mut seen = before
.as_ref()
.map(time_cursor_token)
.into_iter()
.collect::<HashSet<_>>();
let mut items = Vec::new();
let mut pages = 0usize;
loop {
enforce_page_budget(pages, options.max_pages)?;
let (page_items, next_before) = fetch(before, options.limit)?;
pages += 1;
items.extend(page_items);
let Some(next_before) = next_before else {
return Ok((items, pages));
};
validate_time_cursor(&next_before, &mut seen)?;
before = Some(next_before);
}
}
const fn enforce_page_budget(pages: usize, maximum: usize) -> Result<(), CliError> {
if pages == maximum {
Err(CliError::PaginationLimit(maximum))
} else {
Ok(())
}
}
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))
})?;
Ok(json!({
"sets": sets,
"count": sets.len(),
"pages": pages,
"next_after": null,
}))
}
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))
})?;
Ok(json!({
"collections": collections,
"count": collections.len(),
"pages": pages,
"next_after": null,
}))
}
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))
})?;
Ok(json!({
"cards": cards,
"count": cards.len(),
"pages": pages,
"next_after": null,
}))
}
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))
})?;
Ok(json!({
"sources": sources,
"count": sources.len(),
"pages": pages,
"next_after": null,
}))
}
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))
})?;
Ok(json!({
"sources": sources,
"count": sources.len(),
"pages": pages,
"next_after": null,
}))
}
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))
})?;
Ok(json!({
"sealed": sealed,
"count": sealed.len(),
"pages": pages,
"next_after": null,
}))
}
fn collect_keyset_pages<T>(
options: &ListOptions,
mut fetch: impl FnMut(Option<String>, u16) -> Result<(Vec<T>, Option<String>), CliError>,
) -> Result<(Vec<T>, usize), CliError> {
let mut after = options.after.clone();
let mut seen = after.iter().cloned().collect::<HashSet<_>>();
let mut items = Vec::new();
let mut pages = 0usize;
loop {
enforce_page_budget(pages, options.max_pages)?;
let (page_items, next_after) = fetch(after, options.limit)?;
pages += 1;
items.extend(page_items);
let Some(next_after) = next_after else {
return Ok((items, pages));
};
validate_reply_cursor(&next_after, &mut seen)?;
after = Some(next_after);
}
}
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, encode_one};
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",
"--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!(parsed.target.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_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(BootstrapOptions {
source: None,
refresh: false,
})
);
assert_eq!(
invocation(&["bootstrap", "--source"]).action,
Action::Bootstrap(BootstrapOptions {
source: Some(PathBuf::from(DEFAULT_PROVIDER_SOURCE_PATH)),
refresh: false,
})
);
assert_eq!(
invocation(&[
"bootstrap",
"--source",
"/tmp/toko-feed-source",
"--refresh",
])
.action,
Action::Bootstrap(BootstrapOptions {
source: Some(PathBuf::from("/tmp/toko-feed-source")),
refresh: true,
})
);
assert!(parse_test_invocation(&["bootstrap", "--data"]).is_ok());
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() < 40);
}
#[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,
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
);
assert_eq!(
invocation(&[
"provider",
"set",
"scrydex",
"ecard2",
"--source",
"/tmp/scrydex",
"--refresh",
"--cards",
"10",
])
.action,
Action::ProviderSet(ProviderSetOptions {
provider: Provider::Scrydex,
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,
set_id: "ecard2".to_owned(),
source: PathBuf::from(DEFAULT_SCRYDEX_SOURCE_PATH),
refresh: false,
cards: Some(DEFAULT_PROVIDER_COMPARISON_CARDS),
})
);
assert_eq!(
clap_error(&["provider", "set", "unknown", "ecard2"]),
ErrorKind::InvalidValue
);
assert_eq!(
invocation(&["provider", "set", "scrydex", "ecard2", "--all"]).action,
Action::ProviderSet(ProviderSetOptions {
provider: Provider::Scrydex,
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_owns_argument_relationships_and_validation() {
Cli::command().debug_assert();
assert_eq!(
clap_error(&["cards", "ingest", "--offset", "50"]),
ErrorKind::MissingRequiredArgument
);
assert_eq!(
clap_error(&["sets", "list", "--max-pages", "2"]),
ErrorKind::MissingRequiredArgument
);
assert_eq!(
clap_error(&[
"sets",
"curate",
"01KZ9GFKW3SY1G000000000001",
"--revision",
"0",
"--name",
"Aquapolis",
"--release-date",
"2003-01-15",
"--no-release-date",
]),
ErrorKind::ArgumentConflict
);
assert_eq!(
clap_error(&["cards", "get", "not-an-id",]),
ErrorKind::ValueValidation
);
}
#[test]
fn parses_card_ingest_and_get_commands() {
assert_eq!(
invocation(&["cards", "ingest"]).action,
Action::Ingest(Resource::Cards)
);
assert_eq!(
invocation(&["cards", "get", "01KZ9GFKW3SY1G000000000001"]).action,
Action::Get(Resource::Cards, "01KZ9GFKW3SY1G000000000001".to_owned())
);
assert_eq!(
invocation(&[
"cards",
"ingest",
"--set",
"01KZ9GFKW3SY1G000000000001",
"--offset",
"50",
"--limit",
"10",
])
.action,
Action::IngestSetCards(IngestSetCardsArgs {
pokemon_set_id: "01KZ9GFKW3SY1G000000000001".to_owned(),
offset: 50,
limit: 10,
})
);
}
#[test]
fn targeted_card_ingest_rejects_invalid_limits() {
let result = parse_test_invocation(&[
"cards",
"ingest",
"--set",
"01KZ9GFKW3SY1G000000000001",
"--limit",
"11",
]);
let Err(error) = result else {
panic!("oversized ingestion should be rejected");
};
assert!(matches!(error, CliError::Clap(error) if error.to_string().contains("1..=10")));
}
#[test]
fn parses_collection_ingest_and_list_commands() {
assert_eq!(
invocation(&["collections", "ingest"]).action,
Action::Ingest(Resource::Collections)
);
assert_eq!(
invocation(&["collections", "list", "--all"]).action,
Action::List(
Resource::Collections,
ListOptions {
all: true,
..ListOptions::default()
}
)
);
}
#[test]
fn parses_revisioned_set_curation_and_lock_commands() {
let source_id = "01KZ9GFKW3SY1G000000000001";
let set_id = "01KZ9GFKW3SY1G000000000002";
assert_eq!(
invocation(&[
"sets",
"curate",
source_id,
"--set",
set_id,
"--revision",
"7",
"--name",
"Base Set",
"--release-date",
"1999-01-09",
"--retired",
])
.action,
Action::CurateSet(CuratePokemonSetArgs {
source_id: source_id.to_owned(),
pokemon_set_id: Some(set_id.to_owned()),
expected_revision: Some(7),
name: "Base Set".to_owned(),
release_date: Some("1999-01-09".to_owned()),
lifecycle_status: SetLifecycleStatus::Retired,
})
);
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",
source_id,
"--revision",
"7",
"--name",
"Base Set",
"--no-release-date",
])
.is_err()
);
}
#[test]
fn parses_new_card_curation_and_source_review_commands() {
let source_id = "01KZ9GFKW3SY1G000000000001";
let set_id = "01KZ9GFKW3SY1G000000000002";
let card_id = "01KZ9GFKW3SY1G000000000003";
assert_eq!(
invocation(&[
"cards",
"curate",
source_id,
"--set",
set_id,
"--name",
"Lugia",
"--collector-number",
"149/147",
"--rarity",
"Secret Rare",
])
.action,
Action::CurateCard(CuratePokemonCardArgs {
source_id: source_id.to_owned(),
pokemon_card_id: None,
expected_revision: None,
pokemon_set_id: set_id.to_owned(),
name: "Lugia".to_owned(),
collector_number: "149/147".to_owned(),
rarity: Some("Secret Rare".to_owned()),
})
);
assert_eq!(
invocation(&["cards", "lock", card_id, "--revision", "1"]).action,
Action::LockCard(LockPokemonCardArgs {
id: card_id.to_owned(),
expected_revision: 1,
})
);
assert_eq!(
invocation(&["sets", "sources", "reject", source_id]).action,
Action::RejectSource(Resource::SetSources, source_id.to_owned())
);
}
#[test]
fn parses_scheduler_history_and_price_commands() {
assert_eq!(invocation(&["scheduler"]).action, Action::Scheduler);
assert_eq!(
invocation(&[
"runs",
"list",
"--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(&["logs", "list", "--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 hexadecimal_transport_round_trips_raw_candid() {
let bytes = encode_args((None::<String>, 20_u16)).expect("encode arguments");
assert_eq!(
decode_hex(format!("0x{}\n", encode_hex(&bytes)).as_bytes()).expect("decode hex"),
bytes
);
}
#[test]
fn binary_call_arguments_are_removed_after_use() {
let arguments = b"bounded candid payload";
let argument_file =
CallArgumentFile::create(arguments).expect("argument file should be created");
let path = argument_file.path.clone();
assert_eq!(
fs::read(&path).expect("argument file should be readable"),
arguments
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = fs::metadata(&path)
.expect("argument metadata should be readable")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
drop(argument_file);
assert!(!path.exists());
}
#[test]
fn decodes_a_typed_canister_result() {
let status = FeedStatus {
configured: true,
scrydex_configured: true,
next_offset: 50,
sets_next_offset: 20,
ingesting: false,
last_error_code: None,
updated_at_ns: 123,
};
let reply = encode_one(Ok::<_, FeedError>(status.clone())).expect("encode reply");
assert_eq!(
decode_result::<FeedStatus>("toko_feed_status", &reply).expect("decode result"),
status
);
}
#[test]
fn rejects_non_hexadecimal_transport_output() {
assert!(decode_hex(b"not-hex").is_err());
assert!(decode_hex(b"abc").is_err());
}
#[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
));
}
}