Skip to main content

toko_feed_cli/
lib.rs

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