1use std::{
8 collections::{HashSet, VecDeque},
9 io::{self, Write},
10 path::PathBuf,
11 process::Command,
12};
13
14use candid::{CandidType, decode_one, encode_args};
15use serde::{Serialize, de::DeserializeOwned};
16use serde_json::{Value, json};
17use thiserror::Error;
18use toko_feed::{
19 CollectionDetails, CollectionIngestReceipt, CollectionPage, CuratePokemonSetArgs, FeedError,
20 FeedStatus, IngestReceipt, IngestionRunPage, LockPokemonSetArgs, OperationalLogPage,
21 PokemonCardDetails, PokemonCardPage, PokemonSetDetails, PokemonSetPage, PokemonSetView,
22 PriceObservationPage, SchedulerStatus, SetIngestReceipt, SetLifecycleStatus, TimeCursor,
23};
24
25const DEFAULT_LIMIT: u16 = 20;
26const DEFAULT_MAX_PAGES: usize = 1_000;
27const MAX_RAW_REPLY_BYTES: usize = 16 * 1024 * 1024;
28
29const HELP: &str = r#"toko-feed
30
31Operate and query a Toko Feed canister without writing Candid arguments.
32
33Usage:
34 toko-feed [GLOBAL OPTIONS] status
35 toko-feed [GLOBAL OPTIONS] scheduler
36 toko-feed [GLOBAL OPTIONS] runs list [HISTORY OPTIONS]
37 toko-feed [GLOBAL OPTIONS] logs list [HISTORY OPTIONS]
38 toko-feed [GLOBAL OPTIONS] collections ingest
39 toko-feed [GLOBAL OPTIONS] collections list [LIST OPTIONS]
40 toko-feed [GLOBAL OPTIONS] collections get <ULID>
41 toko-feed [GLOBAL OPTIONS] sets ingest
42 toko-feed [GLOBAL OPTIONS] sets list [LIST OPTIONS]
43 toko-feed [GLOBAL OPTIONS] sets get <ULID>
44 toko-feed [GLOBAL OPTIONS] sets curate <ULID> --revision <N> --name <NAME> (--release-date <DATE> | --no-release-date) [--active | --retired]
45 toko-feed [GLOBAL OPTIONS] sets lock <ULID> --revision <N>
46 toko-feed [GLOBAL OPTIONS] cards ingest
47 toko-feed [GLOBAL OPTIONS] cards list [LIST OPTIONS]
48 toko-feed [GLOBAL OPTIONS] cards get <ULID>
49 toko-feed [GLOBAL OPTIONS] cards prices <ULID> [HISTORY OPTIONS]
50
51Global options:
52 --environment <NAME> ICP environment (default: local)
53 --canister <NAME|ID> Canister name or principal (default: toko-feed)
54 --identity <NAME> ICP identity used for the call (default: anonymous)
55 --identity-password-file <PATH>
56 Read an encrypted identity password from a file
57 --project-root <PATH> Override ICP project discovery
58 --icp <PATH> ICP CLI executable (default: icp)
59 --compact Print compact JSON instead of pretty JSON
60 -h, --help Print this help
61 -V, --version Print the CLI version
62
63List options:
64 --limit <1..100> Records requested per canister call (default: 20)
65 --after <ULID> Start strictly after this local ID
66 --all Follow next_after until the listing is complete
67 --max-pages <COUNT> Safety bound for --all (default: 1000)
68
69History options:
70 --limit <1..100> Records requested per canister call (default: 20)
71 --before-time <INTEGER> Timestamp from a returned next_before cursor
72 --before-id <ULID> ULID from the same next_before cursor
73 --all Follow next_before until the listing is complete
74 --max-pages <COUNT> Safety bound for --all (default: 1000)
75
76Examples:
77 toko-feed status
78 toko-feed scheduler
79 toko-feed runs list --limit 100
80 toko-feed logs list --all
81 toko-feed collections ingest
82 toko-feed collections list --all
83 toko-feed sets list --limit 10
84 toko-feed sets list --limit 100 --all
85 toko-feed sets list --after 01KZ9GFKW3SY1G000000000001
86 toko-feed sets get 01KZ9GFKW3SY1G000000000001
87 toko-feed --identity operator sets curate 01KZ9GFKW3SY1G000000000001 --revision 0 --name "Base Set" --release-date 1999-01-09
88 toko-feed --identity operator sets lock 01KZ9GFKW3SY1G000000000001 --revision 1
89 toko-feed sets ingest
90 toko-feed cards ingest
91 toko-feed cards list --limit 100 --all
92 toko-feed cards prices 01KZ9GFKW3SY1G000000000001 --limit 100
93"#;
94
95#[derive(Debug, Error)]
98pub enum CliError {
99 #[error("{0}\n\nRun with --help for usage.")]
101 Usage(String),
102 #[error("could not start icp: {0}")]
104 StartIcp(#[source] io::Error),
105 #[error("icp call failed{status}: {message}")]
107 Icp {
108 status: String,
110 message: String,
112 },
113 #[error("could not encode arguments for `{method}`: {source}")]
115 Encode {
116 method: &'static str,
118 #[source]
120 source: candid::Error,
121 },
122 #[error("could not decode the typed reply from `{method}`: {source}")]
124 Decode {
125 method: &'static str,
127 #[source]
129 source: candid::Error,
130 },
131 #[error("icp returned an invalid raw reply: {0}")]
133 RawReply(&'static str),
134 #[error("canister method `{method}` returned {error}")]
136 Canister {
137 method: &'static str,
139 error: String,
141 },
142 #[error("could not render JSON output: {0}")]
144 Json(#[from] serde_json::Error),
145 #[error("could not write output: {0}")]
147 Io(#[source] io::Error),
148 #[error("pagination cursor did not advance: {0}")]
150 PaginationStalled(String),
151 #[error("listing still had another page after the --max-pages limit of {0}")]
153 PaginationLimit(usize),
154}
155
156impl CliError {
157 #[must_use]
159 pub const fn exit_code(&self) -> i32 {
160 if matches!(self, Self::Usage(_)) { 2 } else { 1 }
161 }
162
163 #[must_use]
165 pub fn is_broken_pipe(&self) -> bool {
166 matches!(self, Self::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)
167 }
168}
169
170#[derive(Clone, Debug, Eq, PartialEq)]
171struct Target {
172 environment: String,
173 canister: String,
174 identity: Option<String>,
175 identity_password_file: Option<PathBuf>,
176 project_root: Option<PathBuf>,
177 icp: PathBuf,
178 compact: bool,
179}
180
181impl Default for Target {
182 fn default() -> Self {
183 Self {
184 environment: "local".to_owned(),
185 canister: "toko-feed".to_owned(),
186 identity: Some("anonymous".to_owned()),
187 identity_password_file: None,
188 project_root: None,
189 icp: PathBuf::from("icp"),
190 compact: false,
191 }
192 }
193}
194
195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
196enum Resource {
197 Collections,
198 Sets,
199 Cards,
200}
201
202impl Resource {
203 const fn collection_field(self) -> &'static str {
204 match self {
205 Self::Collections => "collections",
206 Self::Sets => "sets",
207 Self::Cards => "cards",
208 }
209 }
210}
211
212#[derive(Clone, Debug, Eq, PartialEq)]
213struct ListOptions {
214 limit: u16,
215 after: Option<String>,
216 all: bool,
217 max_pages: usize,
218}
219
220impl Default for ListOptions {
221 fn default() -> Self {
222 Self {
223 limit: DEFAULT_LIMIT,
224 after: None,
225 all: false,
226 max_pages: DEFAULT_MAX_PAGES,
227 }
228 }
229}
230
231#[derive(Clone, Debug, Eq, PartialEq)]
232struct HistoryOptions {
233 limit: u16,
234 before: Option<TimeCursor>,
235 all: bool,
236 max_pages: usize,
237}
238
239impl Default for HistoryOptions {
240 fn default() -> Self {
241 Self {
242 limit: DEFAULT_LIMIT,
243 before: None,
244 all: false,
245 max_pages: DEFAULT_MAX_PAGES,
246 }
247 }
248}
249
250#[derive(Clone, Debug, Eq, PartialEq)]
251enum Action {
252 Status,
253 Scheduler,
254 Runs(HistoryOptions),
255 Logs(HistoryOptions),
256 Ingest(Resource),
257 List(Resource, ListOptions),
258 Get(Resource, String),
259 CurateSet(CuratePokemonSetArgs),
260 LockSet(LockPokemonSetArgs),
261 Prices(String, HistoryOptions),
262}
263
264#[derive(Clone, Debug, Eq, PartialEq)]
265struct Invocation {
266 target: Target,
267 action: Action,
268}
269
270enum Parsed {
271 Help,
272 Version,
273 Run(Box<Invocation>),
274}
275
276pub fn run_from_env() -> Result<(), CliError> {
284 let arguments = std::env::args().skip(1).collect::<Vec<_>>();
285 run(&arguments)
286}
287
288fn run(arguments: &[String]) -> Result<(), CliError> {
289 let invocation = match parse_arguments(arguments)? {
290 Parsed::Help => return write_text(HELP),
291 Parsed::Version => {
292 return write_text(&format!("toko-feed {}\n", env!("CARGO_PKG_VERSION")));
293 }
294 Parsed::Run(invocation) => invocation,
295 };
296
297 let output = execute(&invocation)?;
298 let rendered = if invocation.target.compact {
299 serde_json::to_string(&output)?
300 } else {
301 serde_json::to_string_pretty(&output)?
302 };
303 write_text(&format!("{rendered}\n"))
304}
305
306fn write_text(text: &str) -> Result<(), CliError> {
307 io::stdout()
308 .lock()
309 .write_all(text.as_bytes())
310 .map_err(CliError::Io)
311}
312
313fn parse_arguments(arguments: &[String]) -> Result<Parsed, CliError> {
314 let mut arguments = arguments
315 .iter()
316 .map(String::as_str)
317 .collect::<VecDeque<_>>();
318 if arguments.is_empty() {
319 return Ok(Parsed::Help);
320 }
321 if arguments
322 .iter()
323 .any(|argument| matches!(*argument, "-h" | "--help"))
324 {
325 return Ok(Parsed::Help);
326 }
327
328 let mut target = Target::default();
329 loop {
330 match arguments.front().copied() {
331 Some("-V" | "--version") => return Ok(Parsed::Version),
332 Some("--environment") => {
333 arguments.pop_front();
334 take_value(&mut arguments, "--environment")?.clone_into(&mut target.environment);
335 }
336 Some("--canister") => {
337 arguments.pop_front();
338 take_value(&mut arguments, "--canister")?.clone_into(&mut target.canister);
339 }
340 Some("--identity") => {
341 arguments.pop_front();
342 target.identity = Some(take_value(&mut arguments, "--identity")?.to_owned());
343 }
344 Some("--identity-password-file") => {
345 arguments.pop_front();
346 target.identity_password_file = Some(PathBuf::from(take_value(
347 &mut arguments,
348 "--identity-password-file",
349 )?));
350 }
351 Some("--project-root") => {
352 arguments.pop_front();
353 target.project_root =
354 Some(PathBuf::from(take_value(&mut arguments, "--project-root")?));
355 }
356 Some("--icp") => {
357 arguments.pop_front();
358 target.icp = PathBuf::from(take_value(&mut arguments, "--icp")?);
359 }
360 Some("--compact") => {
361 arguments.pop_front();
362 target.compact = true;
363 }
364 Some(option) if option.starts_with('-') => {
365 return Err(CliError::Usage(format!(
366 "unknown global option `{option}`; global options must precede the command"
367 )));
368 }
369 _ => break,
370 }
371 }
372
373 let command = arguments
374 .pop_front()
375 .ok_or_else(|| CliError::Usage("missing command".to_owned()))?;
376 let action = match command {
377 "status" => {
378 reject_extra(&arguments, "status")?;
379 Action::Status
380 }
381 "scheduler" => {
382 reject_extra(&arguments, "scheduler")?;
383 Action::Scheduler
384 }
385 "runs" => parse_history_action("runs", &mut arguments, Action::Runs)?,
386 "logs" => parse_history_action("logs", &mut arguments, Action::Logs)?,
387 "collections" => parse_resource_action(Resource::Collections, &mut arguments)?,
388 "sets" => parse_resource_action(Resource::Sets, &mut arguments)?,
389 "cards" => parse_resource_action(Resource::Cards, &mut arguments)?,
390 unknown => return Err(CliError::Usage(format!("unknown command `{unknown}`"))),
391 };
392
393 Ok(Parsed::Run(Box::new(Invocation { target, action })))
394}
395
396fn parse_resource_action(
397 resource: Resource,
398 arguments: &mut VecDeque<&str>,
399) -> Result<Action, CliError> {
400 let resource_name = resource.collection_field();
401 let subcommand = arguments
402 .pop_front()
403 .ok_or_else(|| CliError::Usage(format!("missing {resource_name} subcommand")))?;
404
405 match subcommand {
406 "ingest" => {
407 reject_extra(arguments, &format!("{resource_name} ingest"))?;
408 Ok(Action::Ingest(resource))
409 }
410 "get" => {
411 let id = arguments
412 .pop_front()
413 .ok_or_else(|| CliError::Usage(format!("{resource_name} get requires a ULID")))?;
414 validate_local_id(id)?;
415 reject_extra(arguments, &format!("{resource_name} get"))?;
416 Ok(Action::Get(resource, id.to_owned()))
417 }
418 "prices" if resource == Resource::Cards => {
419 let id = arguments
420 .pop_front()
421 .ok_or_else(|| CliError::Usage("cards prices requires a ULID".to_owned()))?;
422 validate_local_id(id)?;
423 Ok(Action::Prices(
424 id.to_owned(),
425 parse_history_options(arguments)?,
426 ))
427 }
428 "curate" if resource == Resource::Sets => {
429 let id = arguments
430 .pop_front()
431 .ok_or_else(|| CliError::Usage("sets curate requires a ULID".to_owned()))?;
432 validate_local_id(id)?;
433 parse_set_curate(id, arguments).map(Action::CurateSet)
434 }
435 "lock" if resource == Resource::Sets => {
436 let id = arguments
437 .pop_front()
438 .ok_or_else(|| CliError::Usage("sets lock requires a ULID".to_owned()))?;
439 validate_local_id(id)?;
440 parse_set_lock(id, arguments).map(Action::LockSet)
441 }
442 "list" => parse_list_options(resource, arguments),
443 unknown => Err(CliError::Usage(format!(
444 "unknown {resource_name} subcommand `{unknown}`"
445 ))),
446 }
447}
448
449fn parse_set_curate(
450 id: &str,
451 arguments: &mut VecDeque<&str>,
452) -> Result<CuratePokemonSetArgs, CliError> {
453 let mut expected_revision = None;
454 let mut name = None;
455 let mut release_date = None;
456 let mut release_date_selected = false;
457 let mut lifecycle_status = SetLifecycleStatus::Active;
458 let mut lifecycle_selected = false;
459 while let Some(option) = arguments.pop_front() {
460 match option {
461 "--revision" => {
462 if expected_revision.is_some() {
463 return Err(CliError::Usage(
464 "--revision may be supplied once".to_owned(),
465 ));
466 }
467 expected_revision = Some(parse_revision(take_value(arguments, "--revision")?)?);
468 }
469 "--name" => {
470 if name.is_some() {
471 return Err(CliError::Usage("--name may be supplied once".to_owned()));
472 }
473 name = Some(take_value(arguments, "--name")?.to_owned());
474 }
475 "--release-date" => {
476 if release_date_selected {
477 return Err(CliError::Usage(
478 "choose exactly one of --release-date or --no-release-date".to_owned(),
479 ));
480 }
481 release_date = Some(take_value(arguments, "--release-date")?.to_owned());
482 release_date_selected = true;
483 }
484 "--no-release-date" => {
485 if release_date_selected {
486 return Err(CliError::Usage(
487 "choose exactly one of --release-date or --no-release-date".to_owned(),
488 ));
489 }
490 release_date_selected = true;
491 }
492 "--active" | "--retired" => {
493 if lifecycle_selected {
494 return Err(CliError::Usage(
495 "choose at most one of --active or --retired".to_owned(),
496 ));
497 }
498 lifecycle_status = if option == "--retired" {
499 SetLifecycleStatus::Retired
500 } else {
501 SetLifecycleStatus::Active
502 };
503 lifecycle_selected = true;
504 }
505 unknown => {
506 return Err(CliError::Usage(format!(
507 "unknown sets curate option `{unknown}`"
508 )));
509 }
510 }
511 }
512 if !release_date_selected {
513 return Err(CliError::Usage(
514 "sets curate requires --release-date or --no-release-date".to_owned(),
515 ));
516 }
517 Ok(CuratePokemonSetArgs {
518 id: id.to_owned(),
519 expected_revision: expected_revision
520 .ok_or_else(|| CliError::Usage("sets curate requires --revision".to_owned()))?,
521 name: name.ok_or_else(|| CliError::Usage("sets curate requires --name".to_owned()))?,
522 release_date,
523 lifecycle_status,
524 })
525}
526
527fn parse_set_lock(
528 id: &str,
529 arguments: &mut VecDeque<&str>,
530) -> Result<LockPokemonSetArgs, CliError> {
531 if arguments.pop_front() != Some("--revision") {
532 return Err(CliError::Usage(
533 "sets lock requires --revision <N>".to_owned(),
534 ));
535 }
536 let expected_revision = parse_revision(take_value(arguments, "--revision")?)?;
537 reject_extra(arguments, "sets lock")?;
538 Ok(LockPokemonSetArgs {
539 id: id.to_owned(),
540 expected_revision,
541 })
542}
543
544fn parse_revision(value: &str) -> Result<u64, CliError> {
545 value
546 .parse()
547 .map_err(|_| CliError::Usage("--revision must be an unsigned integer".to_owned()))
548}
549
550fn parse_history_action(
551 resource: &str,
552 arguments: &mut VecDeque<&str>,
553 build: impl FnOnce(HistoryOptions) -> Action,
554) -> Result<Action, CliError> {
555 let subcommand = arguments
556 .pop_front()
557 .ok_or_else(|| CliError::Usage(format!("missing {resource} subcommand")))?;
558 if subcommand != "list" {
559 return Err(CliError::Usage(format!(
560 "unknown {resource} subcommand `{subcommand}`"
561 )));
562 }
563 parse_history_options(arguments).map(build)
564}
565
566fn parse_history_options(arguments: &mut VecDeque<&str>) -> Result<HistoryOptions, CliError> {
567 let mut options = HistoryOptions::default();
568 let mut before_time = None;
569 let mut before_id = None;
570 while let Some(option) = arguments.pop_front() {
571 match option {
572 "--limit" => {
573 let value = take_value(arguments, "--limit")?;
574 options.limit = parse_page_limit(value)?;
575 }
576 "--before-time" => {
577 let value = take_value(arguments, "--before-time")?;
578 before_time = Some(value.parse::<u64>().map_err(|_| {
579 CliError::Usage("--before-time must be an unsigned integer".into())
580 })?);
581 }
582 "--before-id" => {
583 let value = take_value(arguments, "--before-id")?;
584 validate_local_id(value)?;
585 before_id = Some(value.to_owned());
586 }
587 "--all" => options.all = true,
588 "--max-pages" => {
589 let value = take_value(arguments, "--max-pages")?;
590 options.max_pages = parse_max_pages(value)?;
591 }
592 unknown => {
593 return Err(CliError::Usage(format!(
594 "unknown history option `{unknown}`"
595 )));
596 }
597 }
598 }
599 options.before = match (before_time, before_id) {
600 (Some(timestamp), Some(id)) => Some(TimeCursor { timestamp, id }),
601 (None, None) => None,
602 _ => {
603 return Err(CliError::Usage(
604 "--before-time and --before-id must be supplied together".into(),
605 ));
606 }
607 };
608 if !options.all && options.max_pages != DEFAULT_MAX_PAGES {
609 return Err(CliError::Usage(
610 "--max-pages is only meaningful together with --all".into(),
611 ));
612 }
613 Ok(options)
614}
615
616fn parse_list_options(
617 resource: Resource,
618 arguments: &mut VecDeque<&str>,
619) -> Result<Action, CliError> {
620 let mut options = ListOptions::default();
621
622 while let Some(option) = arguments.pop_front() {
623 match option {
624 "--limit" => {
625 let value = take_value(arguments, "--limit")?;
626 options.limit = parse_page_limit(value)?;
627 }
628 "--after" => {
629 let value = take_value(arguments, "--after")?;
630 validate_local_id(value)?;
631 options.after = Some(value.to_owned());
632 }
633 "--all" => options.all = true,
634 "--max-pages" => {
635 let value = take_value(arguments, "--max-pages")?;
636 options.max_pages = parse_max_pages(value)?;
637 }
638 unknown => return Err(CliError::Usage(format!("unknown list option `{unknown}`"))),
639 }
640 }
641
642 if !options.all && options.max_pages != DEFAULT_MAX_PAGES {
643 return Err(CliError::Usage(
644 "--max-pages is only meaningful together with --all".into(),
645 ));
646 }
647
648 Ok(Action::List(resource, options))
649}
650
651fn parse_page_limit(value: &str) -> Result<u16, CliError> {
652 value
653 .parse::<u16>()
654 .ok()
655 .filter(|limit| (1..=100).contains(limit))
656 .ok_or_else(|| CliError::Usage("--limit must be an integer from 1 to 100".into()))
657}
658
659fn parse_max_pages(value: &str) -> Result<usize, CliError> {
660 value
661 .parse::<usize>()
662 .ok()
663 .filter(|maximum| *maximum > 0)
664 .ok_or_else(|| CliError::Usage("--max-pages must be a positive integer".into()))
665}
666
667fn take_value<'a>(arguments: &mut VecDeque<&'a str>, option: &str) -> Result<&'a str, CliError> {
668 arguments
669 .pop_front()
670 .filter(|value| !value.is_empty() && !value.starts_with('-'))
671 .ok_or_else(|| CliError::Usage(format!("{option} requires a value")))
672}
673
674fn reject_extra(arguments: &VecDeque<&str>, command: &str) -> Result<(), CliError> {
675 if let Some(extra) = arguments.front() {
676 return Err(CliError::Usage(format!(
677 "unexpected argument `{extra}` after `{command}`"
678 )));
679 }
680 Ok(())
681}
682
683fn validate_local_id(id: &str) -> Result<(), CliError> {
684 if is_valid_local_id(id) {
685 Ok(())
686 } else {
687 Err(CliError::Usage(format!(
688 "`{id}` is not a 26-character uppercase ULID"
689 )))
690 }
691}
692
693fn is_valid_local_id(id: &str) -> bool {
694 id.len() == 26
695 && id.bytes().all(|byte| {
696 matches!(
697 byte,
698 b'0'..=b'9' | b'A'..=b'H' | b'J'..=b'K' | b'M'..=b'N' | b'P'..=b'T' | b'V'..=b'Z'
699 )
700 })
701}
702
703fn execute(invocation: &Invocation) -> Result<Value, CliError> {
704 match &invocation.action {
705 Action::Status => output(call_empty::<FeedStatus>(
706 &invocation.target,
707 "toko_feed_status",
708 true,
709 )?),
710 Action::Scheduler => output(call_empty::<SchedulerStatus>(
711 &invocation.target,
712 "toko_feed_scheduler",
713 true,
714 )?),
715 Action::Runs(options) if options.all => list_all_runs(&invocation.target, options),
716 Action::Logs(options) if options.all => list_all_logs(&invocation.target, options),
717 Action::Prices(card_id, options) if options.all => {
718 list_all_prices(&invocation.target, card_id, options)
719 }
720 Action::Runs(options) => output(fetch_run_page(
721 &invocation.target,
722 options.before.clone(),
723 options.limit,
724 )?),
725 Action::Logs(options) => output(fetch_log_page(
726 &invocation.target,
727 options.before.clone(),
728 options.limit,
729 )?),
730 Action::Prices(card_id, options) => output(fetch_price_page(
731 &invocation.target,
732 card_id.clone(),
733 options.before.clone(),
734 options.limit,
735 )?),
736 Action::Ingest(Resource::Collections) => output(call_empty::<CollectionIngestReceipt>(
737 &invocation.target,
738 "toko_feed_ingest_collections",
739 false,
740 )?),
741 Action::Ingest(Resource::Sets) => output(call_empty::<SetIngestReceipt>(
742 &invocation.target,
743 "toko_feed_ingest_sets",
744 false,
745 )?),
746 Action::Get(Resource::Collections, id) => output(call_one::<_, Option<CollectionDetails>>(
747 &invocation.target,
748 "toko_feed_collection",
749 id.clone(),
750 true,
751 )?),
752 Action::Ingest(Resource::Cards) => output(call_empty::<IngestReceipt>(
753 &invocation.target,
754 "toko_feed_ingest",
755 false,
756 )?),
757 Action::Get(Resource::Sets, id) => output(call_one::<_, Option<PokemonSetDetails>>(
758 &invocation.target,
759 "toko_feed_set",
760 id.clone(),
761 true,
762 )?),
763 Action::CurateSet(args) => set_mutation(&invocation.target, "toko_feed_curate_set", args),
764 Action::LockSet(args) => set_mutation(&invocation.target, "toko_feed_lock_set", args),
765 Action::Get(Resource::Cards, id) => output(call_one::<_, Option<PokemonCardDetails>>(
766 &invocation.target,
767 "toko_feed_card",
768 id.clone(),
769 true,
770 )?),
771 Action::List(Resource::Sets, options) if options.all => {
772 list_all_sets(&invocation.target, options)
773 }
774 Action::List(Resource::Collections, options) if options.all => {
775 list_all_collections(&invocation.target, options)
776 }
777 Action::List(Resource::Cards, options) if options.all => {
778 list_all_cards(&invocation.target, options)
779 }
780 Action::List(Resource::Sets, options) => output(fetch_set_page(
781 &invocation.target,
782 options.after.clone(),
783 options.limit,
784 )?),
785 Action::List(Resource::Collections, options) => output(fetch_collection_page(
786 &invocation.target,
787 options.after.clone(),
788 options.limit,
789 )?),
790 Action::List(Resource::Cards, options) => output(fetch_card_page(
791 &invocation.target,
792 options.after.clone(),
793 options.limit,
794 )?),
795 }
796}
797
798fn output(value: impl Serialize) -> Result<Value, CliError> {
799 serde_json::to_value(value).map_err(CliError::Json)
800}
801
802fn set_mutation<A>(target: &Target, method: &'static str, args: &A) -> Result<Value, CliError>
803where
804 A: CandidType + Clone,
805{
806 output(call_one::<_, PokemonSetView>(
807 target,
808 method,
809 args.clone(),
810 false,
811 )?)
812}
813
814fn call_empty<T>(target: &Target, method: &'static str, query: bool) -> Result<T, CliError>
815where
816 T: CandidType + DeserializeOwned,
817{
818 let arguments = encode_args(()).map_err(|source| CliError::Encode { method, source })?;
819 call_and_decode(target, method, &arguments, query)
820}
821
822fn call_one<A, T>(
823 target: &Target,
824 method: &'static str,
825 argument: A,
826 query: bool,
827) -> Result<T, CliError>
828where
829 A: CandidType,
830 T: CandidType + DeserializeOwned,
831{
832 let arguments =
833 encode_args((argument,)).map_err(|source| CliError::Encode { method, source })?;
834 call_and_decode(target, method, &arguments, query)
835}
836
837fn fetch_set_page(
838 target: &Target,
839 after: Option<String>,
840 limit: u16,
841) -> Result<PokemonSetPage, CliError> {
842 call_page(target, "toko_feed_sets", after, limit)
843}
844
845fn fetch_collection_page(
846 target: &Target,
847 after: Option<String>,
848 limit: u16,
849) -> Result<CollectionPage, CliError> {
850 call_page(target, "toko_feed_collections", after, limit)
851}
852
853fn fetch_card_page(
854 target: &Target,
855 after: Option<String>,
856 limit: u16,
857) -> Result<PokemonCardPage, CliError> {
858 call_page(target, "toko_feed_cards", after, limit)
859}
860
861fn fetch_run_page(
862 target: &Target,
863 before: Option<TimeCursor>,
864 limit: u16,
865) -> Result<IngestionRunPage, CliError> {
866 call_history_page(target, "toko_feed_runs", before, limit)
867}
868
869fn fetch_log_page(
870 target: &Target,
871 before: Option<TimeCursor>,
872 limit: u16,
873) -> Result<OperationalLogPage, CliError> {
874 call_history_page(target, "toko_feed_logs", before, limit)
875}
876
877fn fetch_price_page(
878 target: &Target,
879 card_id: String,
880 before: Option<TimeCursor>,
881 limit: u16,
882) -> Result<PriceObservationPage, CliError> {
883 let method = "toko_feed_card_prices";
884 let arguments = encode_args((card_id, before, limit))
885 .map_err(|source| CliError::Encode { method, source })?;
886 call_and_decode(target, method, &arguments, true)
887}
888
889fn call_history_page<T>(
890 target: &Target,
891 method: &'static str,
892 before: Option<TimeCursor>,
893 limit: u16,
894) -> Result<T, CliError>
895where
896 T: CandidType + DeserializeOwned,
897{
898 let arguments =
899 encode_args((before, limit)).map_err(|source| CliError::Encode { method, source })?;
900 call_and_decode(target, method, &arguments, true)
901}
902
903fn call_page<T>(
904 target: &Target,
905 method: &'static str,
906 after: Option<String>,
907 limit: u16,
908) -> Result<T, CliError>
909where
910 T: CandidType + DeserializeOwned,
911{
912 let arguments =
913 encode_args((after, limit)).map_err(|source| CliError::Encode { method, source })?;
914 call_and_decode(target, method, &arguments, true)
915}
916
917fn call_and_decode<T>(
918 target: &Target,
919 method: &'static str,
920 arguments: &[u8],
921 query: bool,
922) -> Result<T, CliError>
923where
924 T: CandidType + DeserializeOwned,
925{
926 let reply = call_raw(target, method, arguments, query)?;
927 decode_result(method, &reply)
928}
929
930fn decode_result<T>(method: &'static str, reply: &[u8]) -> Result<T, CliError>
931where
932 T: CandidType + DeserializeOwned,
933{
934 let result = decode_one::<Result<T, FeedError>>(reply)
935 .map_err(|source| CliError::Decode { method, source })?;
936 result.map_err(|error| CliError::Canister {
937 method,
938 error: format!("{error:?}"),
939 })
940}
941
942fn call_raw(
943 target: &Target,
944 method: &'static str,
945 arguments: &[u8],
946 query: bool,
947) -> Result<Vec<u8>, CliError> {
948 let mut command = Command::new(&target.icp);
949 if let Some(project_root) = &target.project_root {
950 command.arg("--project-root-override").arg(project_root);
951 }
952 if let Some(password_file) = &target.identity_password_file {
953 command.arg("--identity-password-file").arg(password_file);
954 }
955 command
956 .args(["canister", "call", "--environment"])
957 .arg(&target.environment)
958 .args(["--args-format", "hex", "--output", "hex"]);
959 if let Some(identity) = &target.identity {
960 command.args(["--identity", identity]);
961 }
962 if query {
963 command.arg("--query");
964 }
965 command
966 .args([&target.canister, method])
967 .arg(encode_hex(arguments));
968
969 let output = command.output().map_err(CliError::StartIcp)?;
970 if !output.status.success() {
971 let status = output
972 .status
973 .code()
974 .map_or_else(String::new, |code| format!(" (exit {code})"));
975 let message = bounded_diagnostic(&output.stderr);
976 return Err(CliError::Icp { status, message });
977 }
978 if output.stdout.len() > MAX_RAW_REPLY_BYTES * 2 + 2 {
979 return Err(CliError::RawReply("hexadecimal response exceeded 16 MiB"));
980 }
981 decode_hex(&output.stdout)
982}
983
984fn bounded_diagnostic(bytes: &[u8]) -> String {
985 const MAX_DIAGNOSTIC_BYTES: usize = 8 * 1024;
986 let visible = &bytes[..bytes.len().min(MAX_DIAGNOSTIC_BYTES)];
987 let mut message = String::from_utf8_lossy(visible).trim().to_owned();
988 if bytes.len() > MAX_DIAGNOSTIC_BYTES {
989 message.push_str("…[truncated]");
990 }
991 if message.is_empty() {
992 "no diagnostic was written to stderr".to_owned()
993 } else {
994 message
995 }
996}
997
998fn encode_hex(bytes: &[u8]) -> String {
999 const DIGITS: &[u8; 16] = b"0123456789abcdef";
1000 let mut output = String::with_capacity(bytes.len() * 2);
1001 for byte in bytes {
1002 output.push(char::from(DIGITS[usize::from(byte >> 4)]));
1003 output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
1004 }
1005 output
1006}
1007
1008fn decode_hex(input: &[u8]) -> Result<Vec<u8>, CliError> {
1009 let input = std::str::from_utf8(input)
1010 .map_err(|_| CliError::RawReply("response was not UTF-8 hexadecimal text"))?
1011 .trim();
1012 let input = input.strip_prefix("0x").unwrap_or(input);
1013 if input.len() % 2 != 0 {
1014 return Err(CliError::RawReply(
1015 "hexadecimal response had an odd number of digits",
1016 ));
1017 }
1018
1019 input
1020 .as_bytes()
1021 .chunks_exact(2)
1022 .map(|pair| {
1023 let high = hex_digit(pair[0])?;
1024 let low = hex_digit(pair[1])?;
1025 Ok((high << 4) | low)
1026 })
1027 .collect()
1028}
1029
1030const fn hex_digit(byte: u8) -> Result<u8, CliError> {
1031 match byte {
1032 b'0'..=b'9' => Ok(byte - b'0'),
1033 b'a'..=b'f' => Ok(byte - b'a' + 10),
1034 b'A'..=b'F' => Ok(byte - b'A' + 10),
1035 _ => Err(CliError::RawReply(
1036 "response contained a non-hexadecimal character",
1037 )),
1038 }
1039}
1040
1041fn list_all_runs(target: &Target, options: &HistoryOptions) -> Result<Value, CliError> {
1042 let (runs, pages) = collect_history_pages(options, |before, limit| {
1043 let page = fetch_run_page(target, before, limit)?;
1044 Ok((page.runs, page.next_before))
1045 })?;
1046 Ok(json!({
1047 "runs": runs,
1048 "count": runs.len(),
1049 "pages": pages,
1050 "next_before": null,
1051 }))
1052}
1053
1054fn list_all_logs(target: &Target, options: &HistoryOptions) -> Result<Value, CliError> {
1055 let (logs, pages) = collect_history_pages(options, |before, limit| {
1056 let page = fetch_log_page(target, before, limit)?;
1057 Ok((page.logs, page.next_before))
1058 })?;
1059 Ok(json!({
1060 "logs": logs,
1061 "count": logs.len(),
1062 "pages": pages,
1063 "next_before": null,
1064 }))
1065}
1066
1067fn list_all_prices(
1068 target: &Target,
1069 card_id: &str,
1070 options: &HistoryOptions,
1071) -> Result<Value, CliError> {
1072 let (observations, pages) = collect_history_pages(options, |before, limit| {
1073 let page = fetch_price_page(target, card_id.to_owned(), before, limit)?;
1074 Ok((page.observations, page.next_before))
1075 })?;
1076 Ok(json!({
1077 "observations": observations,
1078 "count": observations.len(),
1079 "pages": pages,
1080 "next_before": null,
1081 }))
1082}
1083
1084fn collect_history_pages<T>(
1085 options: &HistoryOptions,
1086 mut fetch: impl FnMut(Option<TimeCursor>, u16) -> Result<(Vec<T>, Option<TimeCursor>), CliError>,
1087) -> Result<(Vec<T>, usize), CliError> {
1088 let mut before = options.before.clone();
1089 let mut seen = before
1090 .as_ref()
1091 .map(time_cursor_token)
1092 .into_iter()
1093 .collect::<HashSet<_>>();
1094 let mut items = Vec::new();
1095 let mut pages = 0usize;
1096 loop {
1097 enforce_page_budget(pages, options.max_pages)?;
1098 let (page_items, next_before) = fetch(before, options.limit)?;
1099 pages += 1;
1100 items.extend(page_items);
1101 let Some(next_before) = next_before else {
1102 return Ok((items, pages));
1103 };
1104 validate_time_cursor(&next_before, &mut seen)?;
1105 before = Some(next_before);
1106 }
1107}
1108
1109const fn enforce_page_budget(pages: usize, maximum: usize) -> Result<(), CliError> {
1110 if pages == maximum {
1111 Err(CliError::PaginationLimit(maximum))
1112 } else {
1113 Ok(())
1114 }
1115}
1116
1117fn validate_time_cursor(cursor: &TimeCursor, seen: &mut HashSet<String>) -> Result<(), CliError> {
1118 if !is_valid_local_id(&cursor.id) {
1119 return Err(CliError::RawReply(
1120 "next_before contained an invalid local ULID",
1121 ));
1122 }
1123 let token = time_cursor_token(cursor);
1124 if !seen.insert(token.clone()) {
1125 return Err(CliError::PaginationStalled(token));
1126 }
1127 Ok(())
1128}
1129
1130fn time_cursor_token(cursor: &TimeCursor) -> String {
1131 format!("{}:{}", cursor.timestamp, cursor.id)
1132}
1133
1134fn list_all_sets(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
1135 let (sets, pages) = collect_keyset_pages(options, |after, limit| {
1136 let page = fetch_set_page(target, after, limit)?;
1137 Ok((page.sets, page.next_after))
1138 })?;
1139 Ok(json!({
1140 "sets": sets,
1141 "count": sets.len(),
1142 "pages": pages,
1143 "next_after": null,
1144 }))
1145}
1146
1147fn list_all_collections(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
1148 let (collections, pages) = collect_keyset_pages(options, |after, limit| {
1149 let page = fetch_collection_page(target, after, limit)?;
1150 Ok((page.collections, page.next_after))
1151 })?;
1152 Ok(json!({
1153 "collections": collections,
1154 "count": collections.len(),
1155 "pages": pages,
1156 "next_after": null,
1157 }))
1158}
1159
1160fn list_all_cards(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
1161 let (cards, pages) = collect_keyset_pages(options, |after, limit| {
1162 let page = fetch_card_page(target, after, limit)?;
1163 Ok((page.cards, page.next_after))
1164 })?;
1165 Ok(json!({
1166 "cards": cards,
1167 "count": cards.len(),
1168 "pages": pages,
1169 "next_after": null,
1170 }))
1171}
1172
1173fn collect_keyset_pages<T>(
1174 options: &ListOptions,
1175 mut fetch: impl FnMut(Option<String>, u16) -> Result<(Vec<T>, Option<String>), CliError>,
1176) -> Result<(Vec<T>, usize), CliError> {
1177 let mut after = options.after.clone();
1178 let mut seen = after.iter().cloned().collect::<HashSet<_>>();
1179 let mut items = Vec::new();
1180 let mut pages = 0usize;
1181 loop {
1182 enforce_page_budget(pages, options.max_pages)?;
1183 let (page_items, next_after) = fetch(after, options.limit)?;
1184 pages += 1;
1185 items.extend(page_items);
1186 let Some(next_after) = next_after else {
1187 return Ok((items, pages));
1188 };
1189 validate_reply_cursor(&next_after, &mut seen)?;
1190 after = Some(next_after);
1191 }
1192}
1193
1194fn validate_reply_cursor(cursor: &str, seen: &mut HashSet<String>) -> Result<(), CliError> {
1195 if !is_valid_local_id(cursor) {
1196 return Err(CliError::RawReply("next_after was not a valid local ULID"));
1197 }
1198 if !seen.insert(cursor.to_owned()) {
1199 return Err(CliError::PaginationStalled(cursor.to_owned()));
1200 }
1201 Ok(())
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206 use candid::{decode_args, encode_one};
1207
1208 use super::*;
1209
1210 fn strings(values: &[&str]) -> Vec<String> {
1211 values.iter().map(ToString::to_string).collect()
1212 }
1213
1214 fn invocation(values: &[&str]) -> Invocation {
1215 match parse_arguments(&strings(values)).expect("arguments should parse") {
1216 Parsed::Run(invocation) => *invocation,
1217 Parsed::Help | Parsed::Version => panic!("expected an invocation"),
1218 }
1219 }
1220
1221 #[test]
1222 fn parses_global_and_automatic_pagination_options() {
1223 let parsed = invocation(&[
1224 "--environment",
1225 "ic",
1226 "--canister",
1227 "aaaaa-aa",
1228 "--identity",
1229 "operator",
1230 "--project-root",
1231 "/srv/toko-feed",
1232 "--compact",
1233 "sets",
1234 "list",
1235 "--limit",
1236 "100",
1237 "--after",
1238 "01KZ9GFKW3SY1G000000000001",
1239 "--all",
1240 "--max-pages",
1241 "12",
1242 ]);
1243
1244 assert_eq!(parsed.target.environment, "ic");
1245 assert_eq!(parsed.target.canister, "aaaaa-aa");
1246 assert_eq!(parsed.target.identity.as_deref(), Some("operator"));
1247 assert_eq!(
1248 parsed.target.project_root.as_deref(),
1249 Some(std::path::Path::new("/srv/toko-feed"))
1250 );
1251 assert!(parsed.target.compact);
1252 assert_eq!(
1253 parsed.action,
1254 Action::List(
1255 Resource::Sets,
1256 ListOptions {
1257 limit: 100,
1258 after: Some("01KZ9GFKW3SY1G000000000001".to_owned()),
1259 all: true,
1260 max_pages: 12,
1261 }
1262 )
1263 );
1264 }
1265
1266 #[test]
1267 fn parses_card_ingest_and_get_commands() {
1268 assert_eq!(
1269 invocation(&["cards", "ingest"]).action,
1270 Action::Ingest(Resource::Cards)
1271 );
1272 assert_eq!(
1273 invocation(&["cards", "get", "01KZ9GFKW3SY1G000000000001"]).action,
1274 Action::Get(Resource::Cards, "01KZ9GFKW3SY1G000000000001".to_owned())
1275 );
1276 }
1277
1278 #[test]
1279 fn parses_collection_ingest_and_list_commands() {
1280 assert_eq!(
1281 invocation(&["collections", "ingest"]).action,
1282 Action::Ingest(Resource::Collections)
1283 );
1284 assert_eq!(
1285 invocation(&["collections", "list", "--all"]).action,
1286 Action::List(
1287 Resource::Collections,
1288 ListOptions {
1289 all: true,
1290 ..ListOptions::default()
1291 }
1292 )
1293 );
1294 }
1295
1296 #[test]
1297 fn parses_revisioned_set_curation_and_lock_commands() {
1298 let id = "01KZ9GFKW3SY1G000000000001";
1299 assert_eq!(
1300 invocation(&[
1301 "sets",
1302 "curate",
1303 id,
1304 "--revision",
1305 "7",
1306 "--name",
1307 "Base Set",
1308 "--release-date",
1309 "1999-01-09",
1310 "--retired",
1311 ])
1312 .action,
1313 Action::CurateSet(CuratePokemonSetArgs {
1314 id: id.to_owned(),
1315 expected_revision: 7,
1316 name: "Base Set".to_owned(),
1317 release_date: Some("1999-01-09".to_owned()),
1318 lifecycle_status: SetLifecycleStatus::Retired,
1319 })
1320 );
1321 assert_eq!(
1322 invocation(&["sets", "lock", id, "--revision", "8"]).action,
1323 Action::LockSet(LockPokemonSetArgs {
1324 id: id.to_owned(),
1325 expected_revision: 8,
1326 })
1327 );
1328 assert!(
1329 parse_arguments(&strings(&[
1330 "sets",
1331 "curate",
1332 id,
1333 "--revision",
1334 "7",
1335 "--name",
1336 "Base Set",
1337 ]))
1338 .is_err()
1339 );
1340 }
1341
1342 #[test]
1343 fn parses_scheduler_history_and_price_commands() {
1344 assert_eq!(invocation(&["scheduler"]).action, Action::Scheduler);
1345 assert_eq!(
1346 invocation(&[
1347 "runs",
1348 "list",
1349 "--before-time",
1350 "123",
1351 "--before-id",
1352 "01KZ9GFKW3SY1G000000000001",
1353 "--all",
1354 ])
1355 .action,
1356 Action::Runs(HistoryOptions {
1357 before: Some(TimeCursor {
1358 timestamp: 123,
1359 id: "01KZ9GFKW3SY1G000000000001".to_owned(),
1360 }),
1361 all: true,
1362 ..HistoryOptions::default()
1363 })
1364 );
1365 assert!(matches!(
1366 invocation(&[
1367 "cards",
1368 "prices",
1369 "01KZ9GFKW3SY1G000000000001",
1370 "--limit",
1371 "100",
1372 ])
1373 .action,
1374 Action::Prices(_, HistoryOptions { limit: 100, .. })
1375 ));
1376 assert!(parse_arguments(&strings(&["logs", "list", "--before-time", "123",])).is_err());
1377 }
1378
1379 #[test]
1380 fn rejects_invalid_bounds_and_identifiers() {
1381 assert!(parse_arguments(&strings(&["sets", "list", "--limit", "0"])).is_err());
1382 assert!(parse_arguments(&strings(&["sets", "list", "--max-pages", "2"])).is_err());
1383 assert!(parse_arguments(&strings(&["sets", "get", "not-an-id"])).is_err());
1384 }
1385
1386 #[test]
1387 fn accepts_help_at_any_command_depth_and_version_at_the_top_level() {
1388 assert!(matches!(
1389 parse_arguments(&strings(&["sets", "list", "--help"])),
1390 Ok(Parsed::Help)
1391 ));
1392 assert!(matches!(
1393 parse_arguments(&strings(&["--version"])),
1394 Ok(Parsed::Version)
1395 ));
1396 }
1397
1398 #[test]
1399 fn encodes_typed_page_arguments_without_candid_text() {
1400 let bytes = encode_args((Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100_u16))
1401 .expect("encode page arguments");
1402 let decoded = decode_args::<(Option<String>, u16)>(&bytes).expect("decode page arguments");
1403
1404 assert_eq!(
1405 decoded,
1406 (Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100)
1407 );
1408 }
1409
1410 #[test]
1411 fn hexadecimal_transport_round_trips_raw_candid() {
1412 let bytes = encode_args((None::<String>, 20_u16)).expect("encode arguments");
1413 assert_eq!(
1414 decode_hex(format!("0x{}\n", encode_hex(&bytes)).as_bytes()).expect("decode hex"),
1415 bytes
1416 );
1417 }
1418
1419 #[test]
1420 fn decodes_a_typed_canister_result() {
1421 let status = FeedStatus {
1422 configured: true,
1423 next_offset: 50,
1424 ingesting: false,
1425 last_error_code: None,
1426 updated_at_ns: 123,
1427 };
1428 let reply = encode_one(Ok::<_, FeedError>(status.clone())).expect("encode reply");
1429
1430 assert_eq!(
1431 decode_result::<FeedStatus>("toko_feed_status", &reply).expect("decode result"),
1432 status
1433 );
1434 }
1435
1436 #[test]
1437 fn rejects_non_hexadecimal_transport_output() {
1438 assert!(decode_hex(b"not-hex").is_err());
1439 assert!(decode_hex(b"abc").is_err());
1440 }
1441
1442 #[test]
1443 fn keyset_page_collection_preserves_order_and_rejects_stalled_cursors() {
1444 const CURSOR: &str = "01KZ9GFKW3SY1G000000000001";
1445 let options = ListOptions {
1446 after: None,
1447 limit: 2,
1448 all: true,
1449 max_pages: 3,
1450 };
1451 let (items, pages) = collect_keyset_pages(&options, |after, limit| {
1452 assert_eq!(limit, 2);
1453 match after.as_deref() {
1454 None => Ok((vec![1, 2], Some(CURSOR.to_owned()))),
1455 Some(CURSOR) => Ok((vec![3], None)),
1456 Some(_) => Err(CliError::RawReply("unexpected test cursor")),
1457 }
1458 })
1459 .expect("bounded pages should collect");
1460 assert_eq!(items, vec![1, 2, 3]);
1461 assert_eq!(pages, 2);
1462
1463 let stalled = ListOptions {
1464 after: Some(CURSOR.to_owned()),
1465 ..options
1466 };
1467 assert!(matches!(
1468 collect_keyset_pages::<u8>(&stalled, |_, _| Ok((vec![1], Some(CURSOR.to_owned())))),
1469 Err(CliError::PaginationStalled(cursor)) if cursor == CURSOR
1470 ));
1471 }
1472}