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