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, CollectionView, FeedError,
20 FeedStatus, IngestReceipt, PokemonCardDetails, PokemonCardPage, PokemonCardView,
21 PokemonSetPage, PokemonSetView, SetIngestReceipt,
22};
23
24const DEFAULT_LIMIT: u16 = 20;
25const DEFAULT_MAX_PAGES: usize = 1_000;
26const MAX_RAW_REPLY_BYTES: usize = 16 * 1024 * 1024;
27
28const HELP: &str = r"toko-feed
29
30Operate and query a Toko Feed canister without writing Candid arguments.
31
32Usage:
33 toko-feed [GLOBAL OPTIONS] status
34 toko-feed [GLOBAL OPTIONS] collections ingest
35 toko-feed [GLOBAL OPTIONS] collections list [LIST OPTIONS]
36 toko-feed [GLOBAL OPTIONS] collections get <ULID>
37 toko-feed [GLOBAL OPTIONS] sets ingest
38 toko-feed [GLOBAL OPTIONS] sets list [LIST OPTIONS]
39 toko-feed [GLOBAL OPTIONS] sets get <ULID>
40 toko-feed [GLOBAL OPTIONS] cards ingest
41 toko-feed [GLOBAL OPTIONS] cards list [LIST OPTIONS]
42 toko-feed [GLOBAL OPTIONS] cards get <ULID>
43
44Global options:
45 --environment <NAME> ICP environment (default: local)
46 --canister <NAME|ID> Canister name or principal (default: toko-feed)
47 --identity <NAME> ICP identity used for the call (default: anonymous)
48 --identity-password-file <PATH>
49 Read an encrypted identity password from a file
50 --project-root <PATH> Override ICP project discovery
51 --icp <PATH> ICP CLI executable (default: icp)
52 --compact Print compact JSON instead of pretty JSON
53 -h, --help Print this help
54 -V, --version Print the CLI version
55
56List options:
57 --limit <1..100> Records requested per canister call (default: 20)
58 --after <ULID> Start strictly after this local ID
59 --all Follow next_after until the listing is complete
60 --max-pages <COUNT> Safety bound for --all (default: 1000)
61
62Examples:
63 toko-feed status
64 toko-feed collections ingest
65 toko-feed collections list --all
66 toko-feed sets list --limit 10
67 toko-feed sets list --limit 100 --all
68 toko-feed sets list --after 01KZ9GFKW3SY1G000000000001
69 toko-feed sets get 01KZ9GFKW3SY1G000000000001
70 toko-feed sets ingest
71 toko-feed cards ingest
72 toko-feed cards list --limit 100 --all
73";
74
75#[derive(Debug, Error)]
78pub enum CliError {
79 #[error("{0}\n\nRun with --help for usage.")]
81 Usage(String),
82 #[error("could not start icp: {0}")]
84 StartIcp(#[source] io::Error),
85 #[error("icp call failed{status}: {message}")]
87 Icp {
88 status: String,
90 message: String,
92 },
93 #[error("could not encode arguments for `{method}`: {source}")]
95 Encode {
96 method: &'static str,
98 #[source]
100 source: candid::Error,
101 },
102 #[error("could not decode the typed reply from `{method}`: {source}")]
104 Decode {
105 method: &'static str,
107 #[source]
109 source: candid::Error,
110 },
111 #[error("icp returned an invalid raw reply: {0}")]
113 RawReply(&'static str),
114 #[error("canister method `{method}` returned {error}")]
116 Canister {
117 method: &'static str,
119 error: String,
121 },
122 #[error("could not render JSON output: {0}")]
124 Json(#[from] serde_json::Error),
125 #[error("could not write output: {0}")]
127 Io(#[source] io::Error),
128 #[error("pagination cursor did not advance: {0}")]
130 PaginationStalled(String),
131 #[error("listing still had another page after the --max-pages limit of {0}")]
133 PaginationLimit(usize),
134}
135
136impl CliError {
137 #[must_use]
139 pub const fn exit_code(&self) -> i32 {
140 if matches!(self, Self::Usage(_)) { 2 } else { 1 }
141 }
142
143 #[must_use]
145 pub fn is_broken_pipe(&self) -> bool {
146 matches!(self, Self::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)
147 }
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
151struct Target {
152 environment: String,
153 canister: String,
154 identity: Option<String>,
155 identity_password_file: Option<PathBuf>,
156 project_root: Option<PathBuf>,
157 icp: PathBuf,
158 compact: bool,
159}
160
161impl Default for Target {
162 fn default() -> Self {
163 Self {
164 environment: "local".to_owned(),
165 canister: "toko-feed".to_owned(),
166 identity: Some("anonymous".to_owned()),
167 identity_password_file: None,
168 project_root: None,
169 icp: PathBuf::from("icp"),
170 compact: false,
171 }
172 }
173}
174
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176enum Resource {
177 Collections,
178 Sets,
179 Cards,
180}
181
182impl Resource {
183 const fn collection_field(self) -> &'static str {
184 match self {
185 Self::Collections => "collections",
186 Self::Sets => "sets",
187 Self::Cards => "cards",
188 }
189 }
190}
191
192#[derive(Clone, Debug, Eq, PartialEq)]
193struct ListOptions {
194 limit: u16,
195 after: Option<String>,
196 all: bool,
197 max_pages: usize,
198}
199
200impl Default for ListOptions {
201 fn default() -> Self {
202 Self {
203 limit: DEFAULT_LIMIT,
204 after: None,
205 all: false,
206 max_pages: DEFAULT_MAX_PAGES,
207 }
208 }
209}
210
211#[derive(Clone, Debug, Eq, PartialEq)]
212enum Action {
213 Status,
214 Ingest(Resource),
215 List(Resource, ListOptions),
216 Get(Resource, String),
217}
218
219#[derive(Clone, Debug, Eq, PartialEq)]
220struct Invocation {
221 target: Target,
222 action: Action,
223}
224
225enum Parsed {
226 Help,
227 Version,
228 Run(Invocation),
229}
230
231pub fn run_from_env() -> Result<(), CliError> {
239 let arguments = std::env::args().skip(1).collect::<Vec<_>>();
240 run(&arguments)
241}
242
243fn run(arguments: &[String]) -> Result<(), CliError> {
244 let invocation = match parse_arguments(arguments)? {
245 Parsed::Help => return write_text(HELP),
246 Parsed::Version => {
247 return write_text(&format!("toko-feed {}\n", env!("CARGO_PKG_VERSION")));
248 }
249 Parsed::Run(invocation) => invocation,
250 };
251
252 let output = execute(&invocation)?;
253 let rendered = if invocation.target.compact {
254 serde_json::to_string(&output)?
255 } else {
256 serde_json::to_string_pretty(&output)?
257 };
258 write_text(&format!("{rendered}\n"))
259}
260
261fn write_text(text: &str) -> Result<(), CliError> {
262 io::stdout()
263 .lock()
264 .write_all(text.as_bytes())
265 .map_err(CliError::Io)
266}
267
268fn parse_arguments(arguments: &[String]) -> Result<Parsed, CliError> {
269 let mut arguments = arguments
270 .iter()
271 .map(String::as_str)
272 .collect::<VecDeque<_>>();
273 if arguments.is_empty() {
274 return Ok(Parsed::Help);
275 }
276 if arguments
277 .iter()
278 .any(|argument| matches!(*argument, "-h" | "--help"))
279 {
280 return Ok(Parsed::Help);
281 }
282
283 let mut target = Target::default();
284 loop {
285 match arguments.front().copied() {
286 Some("-V" | "--version") => return Ok(Parsed::Version),
287 Some("--environment") => {
288 arguments.pop_front();
289 take_value(&mut arguments, "--environment")?.clone_into(&mut target.environment);
290 }
291 Some("--canister") => {
292 arguments.pop_front();
293 take_value(&mut arguments, "--canister")?.clone_into(&mut target.canister);
294 }
295 Some("--identity") => {
296 arguments.pop_front();
297 target.identity = Some(take_value(&mut arguments, "--identity")?.to_owned());
298 }
299 Some("--identity-password-file") => {
300 arguments.pop_front();
301 target.identity_password_file = Some(PathBuf::from(take_value(
302 &mut arguments,
303 "--identity-password-file",
304 )?));
305 }
306 Some("--project-root") => {
307 arguments.pop_front();
308 target.project_root =
309 Some(PathBuf::from(take_value(&mut arguments, "--project-root")?));
310 }
311 Some("--icp") => {
312 arguments.pop_front();
313 target.icp = PathBuf::from(take_value(&mut arguments, "--icp")?);
314 }
315 Some("--compact") => {
316 arguments.pop_front();
317 target.compact = true;
318 }
319 Some(option) if option.starts_with('-') => {
320 return Err(CliError::Usage(format!(
321 "unknown global option `{option}`; global options must precede the command"
322 )));
323 }
324 _ => break,
325 }
326 }
327
328 let command = arguments
329 .pop_front()
330 .ok_or_else(|| CliError::Usage("missing command".to_owned()))?;
331 let action = match command {
332 "status" => {
333 reject_extra(&arguments, "status")?;
334 Action::Status
335 }
336 "collections" => parse_resource_action(Resource::Collections, &mut arguments)?,
337 "sets" => parse_resource_action(Resource::Sets, &mut arguments)?,
338 "cards" => parse_resource_action(Resource::Cards, &mut arguments)?,
339 unknown => return Err(CliError::Usage(format!("unknown command `{unknown}`"))),
340 };
341
342 Ok(Parsed::Run(Invocation { target, action }))
343}
344
345fn parse_resource_action(
346 resource: Resource,
347 arguments: &mut VecDeque<&str>,
348) -> Result<Action, CliError> {
349 let resource_name = resource.collection_field();
350 let subcommand = arguments
351 .pop_front()
352 .ok_or_else(|| CliError::Usage(format!("missing {resource_name} subcommand")))?;
353
354 match subcommand {
355 "ingest" => {
356 reject_extra(arguments, &format!("{resource_name} ingest"))?;
357 Ok(Action::Ingest(resource))
358 }
359 "get" => {
360 let id = arguments
361 .pop_front()
362 .ok_or_else(|| CliError::Usage(format!("{resource_name} get requires a ULID")))?;
363 validate_local_id(id)?;
364 reject_extra(arguments, &format!("{resource_name} get"))?;
365 Ok(Action::Get(resource, id.to_owned()))
366 }
367 "list" => parse_list_options(resource, arguments),
368 unknown => Err(CliError::Usage(format!(
369 "unknown {resource_name} subcommand `{unknown}`"
370 ))),
371 }
372}
373
374fn parse_list_options(
375 resource: Resource,
376 arguments: &mut VecDeque<&str>,
377) -> Result<Action, CliError> {
378 let mut options = ListOptions::default();
379
380 while let Some(option) = arguments.pop_front() {
381 match option {
382 "--limit" => {
383 let value = take_value(arguments, "--limit")?;
384 options.limit = value.parse::<u16>().map_err(|_| {
385 CliError::Usage("--limit must be an integer from 1 to 100".into())
386 })?;
387 if !(1..=100).contains(&options.limit) {
388 return Err(CliError::Usage(
389 "--limit must be an integer from 1 to 100".into(),
390 ));
391 }
392 }
393 "--after" => {
394 let value = take_value(arguments, "--after")?;
395 validate_local_id(value)?;
396 options.after = Some(value.to_owned());
397 }
398 "--all" => options.all = true,
399 "--max-pages" => {
400 let value = take_value(arguments, "--max-pages")?;
401 options.max_pages = value.parse::<usize>().map_err(|_| {
402 CliError::Usage("--max-pages must be a positive integer".into())
403 })?;
404 if options.max_pages == 0 {
405 return Err(CliError::Usage(
406 "--max-pages must be a positive integer".into(),
407 ));
408 }
409 }
410 unknown => return Err(CliError::Usage(format!("unknown list option `{unknown}`"))),
411 }
412 }
413
414 if !options.all && options.max_pages != DEFAULT_MAX_PAGES {
415 return Err(CliError::Usage(
416 "--max-pages is only meaningful together with --all".into(),
417 ));
418 }
419
420 Ok(Action::List(resource, options))
421}
422
423fn take_value<'a>(arguments: &mut VecDeque<&'a str>, option: &str) -> Result<&'a str, CliError> {
424 arguments
425 .pop_front()
426 .filter(|value| !value.is_empty() && !value.starts_with('-'))
427 .ok_or_else(|| CliError::Usage(format!("{option} requires a value")))
428}
429
430fn reject_extra(arguments: &VecDeque<&str>, command: &str) -> Result<(), CliError> {
431 if let Some(extra) = arguments.front() {
432 return Err(CliError::Usage(format!(
433 "unexpected argument `{extra}` after `{command}`"
434 )));
435 }
436 Ok(())
437}
438
439fn validate_local_id(id: &str) -> Result<(), CliError> {
440 if is_valid_local_id(id) {
441 Ok(())
442 } else {
443 Err(CliError::Usage(format!(
444 "`{id}` is not a 26-character uppercase ULID"
445 )))
446 }
447}
448
449fn is_valid_local_id(id: &str) -> bool {
450 id.len() == 26
451 && id.bytes().all(|byte| {
452 matches!(
453 byte,
454 b'0'..=b'9' | b'A'..=b'H' | b'J'..=b'K' | b'M'..=b'N' | b'P'..=b'T' | b'V'..=b'Z'
455 )
456 })
457}
458
459fn execute(invocation: &Invocation) -> Result<Value, CliError> {
460 match &invocation.action {
461 Action::Status => output(call_empty::<FeedStatus>(
462 &invocation.target,
463 "toko_feed_status",
464 true,
465 )?),
466 Action::Ingest(Resource::Collections) => output(call_empty::<CollectionIngestReceipt>(
467 &invocation.target,
468 "toko_feed_ingest_collections",
469 false,
470 )?),
471 Action::Ingest(Resource::Sets) => output(call_empty::<SetIngestReceipt>(
472 &invocation.target,
473 "toko_feed_ingest_sets",
474 false,
475 )?),
476 Action::Get(Resource::Collections, id) => output(call_one::<_, Option<CollectionDetails>>(
477 &invocation.target,
478 "toko_feed_collection",
479 id.clone(),
480 true,
481 )?),
482 Action::Ingest(Resource::Cards) => output(call_empty::<IngestReceipt>(
483 &invocation.target,
484 "toko_feed_ingest",
485 false,
486 )?),
487 Action::Get(Resource::Sets, id) => output(call_one::<_, Option<PokemonSetView>>(
488 &invocation.target,
489 "toko_feed_set",
490 id.clone(),
491 true,
492 )?),
493 Action::Get(Resource::Cards, id) => output(call_one::<_, Option<PokemonCardDetails>>(
494 &invocation.target,
495 "toko_feed_card",
496 id.clone(),
497 true,
498 )?),
499 Action::List(Resource::Sets, options) if options.all => {
500 list_all_sets(&invocation.target, options)
501 }
502 Action::List(Resource::Collections, options) if options.all => {
503 list_all_collections(&invocation.target, options)
504 }
505 Action::List(Resource::Cards, options) if options.all => {
506 list_all_cards(&invocation.target, options)
507 }
508 Action::List(Resource::Sets, options) => output(fetch_set_page(
509 &invocation.target,
510 options.after.clone(),
511 options.limit,
512 )?),
513 Action::List(Resource::Collections, options) => output(fetch_collection_page(
514 &invocation.target,
515 options.after.clone(),
516 options.limit,
517 )?),
518 Action::List(Resource::Cards, options) => output(fetch_card_page(
519 &invocation.target,
520 options.after.clone(),
521 options.limit,
522 )?),
523 }
524}
525
526fn output(value: impl Serialize) -> Result<Value, CliError> {
527 serde_json::to_value(value).map_err(CliError::Json)
528}
529
530fn call_empty<T>(target: &Target, method: &'static str, query: bool) -> Result<T, CliError>
531where
532 T: CandidType + DeserializeOwned,
533{
534 let arguments = encode_args(()).map_err(|source| CliError::Encode { method, source })?;
535 call_and_decode(target, method, &arguments, query)
536}
537
538fn call_one<A, T>(
539 target: &Target,
540 method: &'static str,
541 argument: A,
542 query: bool,
543) -> Result<T, CliError>
544where
545 A: CandidType,
546 T: CandidType + DeserializeOwned,
547{
548 let arguments =
549 encode_args((argument,)).map_err(|source| CliError::Encode { method, source })?;
550 call_and_decode(target, method, &arguments, query)
551}
552
553fn fetch_set_page(
554 target: &Target,
555 after: Option<String>,
556 limit: u16,
557) -> Result<PokemonSetPage, CliError> {
558 call_page(target, "toko_feed_sets", after, limit)
559}
560
561fn fetch_collection_page(
562 target: &Target,
563 after: Option<String>,
564 limit: u16,
565) -> Result<CollectionPage, CliError> {
566 call_page(target, "toko_feed_collections", after, limit)
567}
568
569fn fetch_card_page(
570 target: &Target,
571 after: Option<String>,
572 limit: u16,
573) -> Result<PokemonCardPage, CliError> {
574 call_page(target, "toko_feed_cards", after, limit)
575}
576
577fn call_page<T>(
578 target: &Target,
579 method: &'static str,
580 after: Option<String>,
581 limit: u16,
582) -> Result<T, CliError>
583where
584 T: CandidType + DeserializeOwned,
585{
586 let arguments =
587 encode_args((after, limit)).map_err(|source| CliError::Encode { method, source })?;
588 call_and_decode(target, method, &arguments, true)
589}
590
591fn call_and_decode<T>(
592 target: &Target,
593 method: &'static str,
594 arguments: &[u8],
595 query: bool,
596) -> Result<T, CliError>
597where
598 T: CandidType + DeserializeOwned,
599{
600 let reply = call_raw(target, method, arguments, query)?;
601 decode_result(method, &reply)
602}
603
604fn decode_result<T>(method: &'static str, reply: &[u8]) -> Result<T, CliError>
605where
606 T: CandidType + DeserializeOwned,
607{
608 let result = decode_one::<Result<T, FeedError>>(reply)
609 .map_err(|source| CliError::Decode { method, source })?;
610 result.map_err(|error| CliError::Canister {
611 method,
612 error: format!("{error:?}"),
613 })
614}
615
616fn call_raw(
617 target: &Target,
618 method: &'static str,
619 arguments: &[u8],
620 query: bool,
621) -> Result<Vec<u8>, CliError> {
622 let mut command = Command::new(&target.icp);
623 if let Some(project_root) = &target.project_root {
624 command.arg("--project-root-override").arg(project_root);
625 }
626 if let Some(password_file) = &target.identity_password_file {
627 command.arg("--identity-password-file").arg(password_file);
628 }
629 command
630 .args(["canister", "call", "--environment"])
631 .arg(&target.environment)
632 .args(["--args-format", "hex", "--output", "hex"]);
633 if let Some(identity) = &target.identity {
634 command.args(["--identity", identity]);
635 }
636 if query {
637 command.arg("--query");
638 }
639 command
640 .args([&target.canister, method])
641 .arg(encode_hex(arguments));
642
643 let output = command.output().map_err(CliError::StartIcp)?;
644 if !output.status.success() {
645 let status = output
646 .status
647 .code()
648 .map_or_else(String::new, |code| format!(" (exit {code})"));
649 let message = bounded_diagnostic(&output.stderr);
650 return Err(CliError::Icp { status, message });
651 }
652 if output.stdout.len() > MAX_RAW_REPLY_BYTES * 2 + 2 {
653 return Err(CliError::RawReply("hexadecimal response exceeded 16 MiB"));
654 }
655 decode_hex(&output.stdout)
656}
657
658fn bounded_diagnostic(bytes: &[u8]) -> String {
659 const MAX_DIAGNOSTIC_BYTES: usize = 8 * 1024;
660 let visible = &bytes[..bytes.len().min(MAX_DIAGNOSTIC_BYTES)];
661 let mut message = String::from_utf8_lossy(visible).trim().to_owned();
662 if bytes.len() > MAX_DIAGNOSTIC_BYTES {
663 message.push_str("…[truncated]");
664 }
665 if message.is_empty() {
666 "no diagnostic was written to stderr".to_owned()
667 } else {
668 message
669 }
670}
671
672fn encode_hex(bytes: &[u8]) -> String {
673 const DIGITS: &[u8; 16] = b"0123456789abcdef";
674 let mut output = String::with_capacity(bytes.len() * 2);
675 for byte in bytes {
676 output.push(char::from(DIGITS[usize::from(byte >> 4)]));
677 output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
678 }
679 output
680}
681
682fn decode_hex(input: &[u8]) -> Result<Vec<u8>, CliError> {
683 let input = std::str::from_utf8(input)
684 .map_err(|_| CliError::RawReply("response was not UTF-8 hexadecimal text"))?
685 .trim();
686 let input = input.strip_prefix("0x").unwrap_or(input);
687 if input.len() % 2 != 0 {
688 return Err(CliError::RawReply(
689 "hexadecimal response had an odd number of digits",
690 ));
691 }
692
693 input
694 .as_bytes()
695 .chunks_exact(2)
696 .map(|pair| {
697 let high = hex_digit(pair[0])?;
698 let low = hex_digit(pair[1])?;
699 Ok((high << 4) | low)
700 })
701 .collect()
702}
703
704const fn hex_digit(byte: u8) -> Result<u8, CliError> {
705 match byte {
706 b'0'..=b'9' => Ok(byte - b'0'),
707 b'a'..=b'f' => Ok(byte - b'a' + 10),
708 b'A'..=b'F' => Ok(byte - b'A' + 10),
709 _ => Err(CliError::RawReply(
710 "response contained a non-hexadecimal character",
711 )),
712 }
713}
714
715fn list_all_sets(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
716 let mut after = options.after.clone();
717 let mut seen = after.iter().cloned().collect::<HashSet<_>>();
718 let mut sets = Vec::new();
719 let mut pages = 0_usize;
720
721 loop {
722 if pages == options.max_pages {
723 return Err(CliError::PaginationLimit(options.max_pages));
724 }
725 let page = fetch_set_page(target, after, options.limit)?;
726 pages += 1;
727 sets.extend(page.sets);
728 let Some(next_after) = page.next_after else {
729 return Ok(json!({
730 "sets": sets,
731 "count": sets.len(),
732 "pages": pages,
733 "next_after": null,
734 }));
735 };
736 validate_reply_cursor(&next_after, &mut seen)?;
737 after = Some(next_after);
738 }
739}
740
741fn list_all_collections(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
742 let mut after = options.after.clone();
743 let mut seen = after.iter().cloned().collect::<HashSet<_>>();
744 let mut collections = Vec::<CollectionView>::new();
745 let mut pages = 0_usize;
746
747 loop {
748 if pages == options.max_pages {
749 return Err(CliError::PaginationLimit(options.max_pages));
750 }
751 let page = fetch_collection_page(target, after, options.limit)?;
752 pages += 1;
753 collections.extend(page.collections);
754 let Some(next_after) = page.next_after else {
755 return Ok(json!({
756 "collections": collections,
757 "count": collections.len(),
758 "pages": pages,
759 "next_after": null,
760 }));
761 };
762 validate_reply_cursor(&next_after, &mut seen)?;
763 after = Some(next_after);
764 }
765}
766
767fn list_all_cards(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
768 let mut after = options.after.clone();
769 let mut seen = after.iter().cloned().collect::<HashSet<_>>();
770 let mut cards = Vec::<PokemonCardView>::new();
771 let mut pages = 0_usize;
772
773 loop {
774 if pages == options.max_pages {
775 return Err(CliError::PaginationLimit(options.max_pages));
776 }
777 let page = fetch_card_page(target, after, options.limit)?;
778 pages += 1;
779 cards.extend(page.cards);
780 let Some(next_after) = page.next_after else {
781 return Ok(json!({
782 "cards": cards,
783 "count": cards.len(),
784 "pages": pages,
785 "next_after": null,
786 }));
787 };
788 validate_reply_cursor(&next_after, &mut seen)?;
789 after = Some(next_after);
790 }
791}
792
793fn validate_reply_cursor(cursor: &str, seen: &mut HashSet<String>) -> Result<(), CliError> {
794 if !is_valid_local_id(cursor) {
795 return Err(CliError::RawReply("next_after was not a valid local ULID"));
796 }
797 if !seen.insert(cursor.to_owned()) {
798 return Err(CliError::PaginationStalled(cursor.to_owned()));
799 }
800 Ok(())
801}
802
803#[cfg(test)]
804mod tests {
805 use candid::{decode_args, encode_one};
806
807 use super::*;
808
809 fn strings(values: &[&str]) -> Vec<String> {
810 values.iter().map(ToString::to_string).collect()
811 }
812
813 fn invocation(values: &[&str]) -> Invocation {
814 match parse_arguments(&strings(values)).expect("arguments should parse") {
815 Parsed::Run(invocation) => invocation,
816 Parsed::Help | Parsed::Version => panic!("expected an invocation"),
817 }
818 }
819
820 #[test]
821 fn parses_global_and_automatic_pagination_options() {
822 let parsed = invocation(&[
823 "--environment",
824 "ic",
825 "--canister",
826 "aaaaa-aa",
827 "--identity",
828 "operator",
829 "--project-root",
830 "/srv/toko-feed",
831 "--compact",
832 "sets",
833 "list",
834 "--limit",
835 "100",
836 "--after",
837 "01KZ9GFKW3SY1G000000000001",
838 "--all",
839 "--max-pages",
840 "12",
841 ]);
842
843 assert_eq!(parsed.target.environment, "ic");
844 assert_eq!(parsed.target.canister, "aaaaa-aa");
845 assert_eq!(parsed.target.identity.as_deref(), Some("operator"));
846 assert_eq!(
847 parsed.target.project_root.as_deref(),
848 Some(std::path::Path::new("/srv/toko-feed"))
849 );
850 assert!(parsed.target.compact);
851 assert_eq!(
852 parsed.action,
853 Action::List(
854 Resource::Sets,
855 ListOptions {
856 limit: 100,
857 after: Some("01KZ9GFKW3SY1G000000000001".to_owned()),
858 all: true,
859 max_pages: 12,
860 }
861 )
862 );
863 }
864
865 #[test]
866 fn parses_card_ingest_and_get_commands() {
867 assert_eq!(
868 invocation(&["cards", "ingest"]).action,
869 Action::Ingest(Resource::Cards)
870 );
871 assert_eq!(
872 invocation(&["cards", "get", "01KZ9GFKW3SY1G000000000001"]).action,
873 Action::Get(Resource::Cards, "01KZ9GFKW3SY1G000000000001".to_owned())
874 );
875 }
876
877 #[test]
878 fn parses_collection_ingest_and_list_commands() {
879 assert_eq!(
880 invocation(&["collections", "ingest"]).action,
881 Action::Ingest(Resource::Collections)
882 );
883 assert_eq!(
884 invocation(&["collections", "list", "--all"]).action,
885 Action::List(
886 Resource::Collections,
887 ListOptions {
888 all: true,
889 ..ListOptions::default()
890 }
891 )
892 );
893 }
894
895 #[test]
896 fn rejects_invalid_bounds_and_identifiers() {
897 assert!(parse_arguments(&strings(&["sets", "list", "--limit", "0"])).is_err());
898 assert!(parse_arguments(&strings(&["sets", "list", "--max-pages", "2"])).is_err());
899 assert!(parse_arguments(&strings(&["sets", "get", "not-an-id"])).is_err());
900 }
901
902 #[test]
903 fn accepts_help_at_any_command_depth_and_version_at_the_top_level() {
904 assert!(matches!(
905 parse_arguments(&strings(&["sets", "list", "--help"])),
906 Ok(Parsed::Help)
907 ));
908 assert!(matches!(
909 parse_arguments(&strings(&["--version"])),
910 Ok(Parsed::Version)
911 ));
912 }
913
914 #[test]
915 fn encodes_typed_page_arguments_without_candid_text() {
916 let bytes = encode_args((Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100_u16))
917 .expect("encode page arguments");
918 let decoded = decode_args::<(Option<String>, u16)>(&bytes).expect("decode page arguments");
919
920 assert_eq!(
921 decoded,
922 (Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100)
923 );
924 }
925
926 #[test]
927 fn hexadecimal_transport_round_trips_raw_candid() {
928 let bytes = encode_args((None::<String>, 20_u16)).expect("encode arguments");
929 assert_eq!(
930 decode_hex(format!("0x{}\n", encode_hex(&bytes)).as_bytes()).expect("decode hex"),
931 bytes
932 );
933 }
934
935 #[test]
936 fn decodes_a_typed_canister_result() {
937 let status = FeedStatus {
938 configured: true,
939 next_offset: 50,
940 ingesting: false,
941 last_error_code: None,
942 updated_at_ns: 123,
943 };
944 let reply = encode_one(Ok::<_, FeedError>(status.clone())).expect("encode reply");
945
946 assert_eq!(
947 decode_result::<FeedStatus>("toko_feed_status", &reply).expect("decode result"),
948 status
949 );
950 }
951
952 #[test]
953 fn rejects_non_hexadecimal_transport_output() {
954 assert!(decode_hex(b"not-hex").is_err());
955 assert!(decode_hex(b"abc").is_err());
956 }
957}