use std::{
collections::{HashSet, VecDeque},
io::{self, Write},
path::PathBuf,
process::Command,
};
use candid::{CandidType, decode_one, encode_args};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::{Value, json};
use thiserror::Error;
use toko_feed::{
CollectionDetails, CollectionIngestReceipt, CollectionPage, CollectionView, FeedError,
FeedStatus, IngestReceipt, PokemonCardDetails, PokemonCardPage, PokemonCardView,
PokemonSetPage, PokemonSetView, SetIngestReceipt,
};
const DEFAULT_LIMIT: u16 = 20;
const DEFAULT_MAX_PAGES: usize = 1_000;
const MAX_RAW_REPLY_BYTES: usize = 16 * 1024 * 1024;
const HELP: &str = r"toko-feed
Operate and query a Toko Feed canister without writing Candid arguments.
Usage:
toko-feed [GLOBAL OPTIONS] status
toko-feed [GLOBAL OPTIONS] collections ingest
toko-feed [GLOBAL OPTIONS] collections list [LIST OPTIONS]
toko-feed [GLOBAL OPTIONS] collections get <ULID>
toko-feed [GLOBAL OPTIONS] sets ingest
toko-feed [GLOBAL OPTIONS] sets list [LIST OPTIONS]
toko-feed [GLOBAL OPTIONS] sets get <ULID>
toko-feed [GLOBAL OPTIONS] cards ingest
toko-feed [GLOBAL OPTIONS] cards list [LIST OPTIONS]
toko-feed [GLOBAL OPTIONS] cards get <ULID>
Global options:
--environment <NAME> ICP environment (default: local)
--canister <NAME|ID> Canister name or principal (default: toko-feed)
--identity <NAME> ICP identity used for the call (default: anonymous)
--identity-password-file <PATH>
Read an encrypted identity password from a file
--project-root <PATH> Override ICP project discovery
--icp <PATH> ICP CLI executable (default: icp)
--compact Print compact JSON instead of pretty JSON
-h, --help Print this help
-V, --version Print the CLI version
List options:
--limit <1..100> Records requested per canister call (default: 20)
--after <ULID> Start strictly after this local ID
--all Follow next_after until the listing is complete
--max-pages <COUNT> Safety bound for --all (default: 1000)
Examples:
toko-feed status
toko-feed collections ingest
toko-feed collections list --all
toko-feed sets list --limit 10
toko-feed sets list --limit 100 --all
toko-feed sets list --after 01KZ9GFKW3SY1G000000000001
toko-feed sets get 01KZ9GFKW3SY1G000000000001
toko-feed sets ingest
toko-feed cards ingest
toko-feed cards list --limit 100 --all
";
#[derive(Debug, Error)]
pub enum CliError {
#[error("{0}\n\nRun with --help for usage.")]
Usage(String),
#[error("could not start icp: {0}")]
StartIcp(#[source] io::Error),
#[error("icp call failed{status}: {message}")]
Icp {
status: String,
message: String,
},
#[error("could not encode arguments for `{method}`: {source}")]
Encode {
method: &'static str,
#[source]
source: candid::Error,
},
#[error("could not decode the typed reply from `{method}`: {source}")]
Decode {
method: &'static str,
#[source]
source: candid::Error,
},
#[error("icp returned an invalid raw reply: {0}")]
RawReply(&'static str),
#[error("canister method `{method}` returned {error}")]
Canister {
method: &'static str,
error: String,
},
#[error("could not render JSON output: {0}")]
Json(#[from] serde_json::Error),
#[error("could not write output: {0}")]
Io(#[source] io::Error),
#[error("pagination cursor did not advance: {0}")]
PaginationStalled(String),
#[error("listing still had another page after the --max-pages limit of {0}")]
PaginationLimit(usize),
}
impl CliError {
#[must_use]
pub const fn exit_code(&self) -> i32 {
if matches!(self, Self::Usage(_)) { 2 } else { 1 }
}
#[must_use]
pub fn is_broken_pipe(&self) -> bool {
matches!(self, Self::Io(error) if error.kind() == io::ErrorKind::BrokenPipe)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Target {
environment: String,
canister: String,
identity: Option<String>,
identity_password_file: Option<PathBuf>,
project_root: Option<PathBuf>,
icp: PathBuf,
compact: bool,
}
impl Default for Target {
fn default() -> Self {
Self {
environment: "local".to_owned(),
canister: "toko-feed".to_owned(),
identity: Some("anonymous".to_owned()),
identity_password_file: None,
project_root: None,
icp: PathBuf::from("icp"),
compact: false,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Resource {
Collections,
Sets,
Cards,
}
impl Resource {
const fn collection_field(self) -> &'static str {
match self {
Self::Collections => "collections",
Self::Sets => "sets",
Self::Cards => "cards",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ListOptions {
limit: u16,
after: Option<String>,
all: bool,
max_pages: usize,
}
impl Default for ListOptions {
fn default() -> Self {
Self {
limit: DEFAULT_LIMIT,
after: None,
all: false,
max_pages: DEFAULT_MAX_PAGES,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Action {
Status,
Ingest(Resource),
List(Resource, ListOptions),
Get(Resource, String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Invocation {
target: Target,
action: Action,
}
enum Parsed {
Help,
Version,
Run(Invocation),
}
pub fn run_from_env() -> Result<(), CliError> {
let arguments = std::env::args().skip(1).collect::<Vec<_>>();
run(&arguments)
}
fn run(arguments: &[String]) -> Result<(), CliError> {
let invocation = match parse_arguments(arguments)? {
Parsed::Help => return write_text(HELP),
Parsed::Version => {
return write_text(&format!("toko-feed {}\n", env!("CARGO_PKG_VERSION")));
}
Parsed::Run(invocation) => invocation,
};
let output = execute(&invocation)?;
let rendered = if invocation.target.compact {
serde_json::to_string(&output)?
} else {
serde_json::to_string_pretty(&output)?
};
write_text(&format!("{rendered}\n"))
}
fn write_text(text: &str) -> Result<(), CliError> {
io::stdout()
.lock()
.write_all(text.as_bytes())
.map_err(CliError::Io)
}
fn parse_arguments(arguments: &[String]) -> Result<Parsed, CliError> {
let mut arguments = arguments
.iter()
.map(String::as_str)
.collect::<VecDeque<_>>();
if arguments.is_empty() {
return Ok(Parsed::Help);
}
if arguments
.iter()
.any(|argument| matches!(*argument, "-h" | "--help"))
{
return Ok(Parsed::Help);
}
let mut target = Target::default();
loop {
match arguments.front().copied() {
Some("-V" | "--version") => return Ok(Parsed::Version),
Some("--environment") => {
arguments.pop_front();
take_value(&mut arguments, "--environment")?.clone_into(&mut target.environment);
}
Some("--canister") => {
arguments.pop_front();
take_value(&mut arguments, "--canister")?.clone_into(&mut target.canister);
}
Some("--identity") => {
arguments.pop_front();
target.identity = Some(take_value(&mut arguments, "--identity")?.to_owned());
}
Some("--identity-password-file") => {
arguments.pop_front();
target.identity_password_file = Some(PathBuf::from(take_value(
&mut arguments,
"--identity-password-file",
)?));
}
Some("--project-root") => {
arguments.pop_front();
target.project_root =
Some(PathBuf::from(take_value(&mut arguments, "--project-root")?));
}
Some("--icp") => {
arguments.pop_front();
target.icp = PathBuf::from(take_value(&mut arguments, "--icp")?);
}
Some("--compact") => {
arguments.pop_front();
target.compact = true;
}
Some(option) if option.starts_with('-') => {
return Err(CliError::Usage(format!(
"unknown global option `{option}`; global options must precede the command"
)));
}
_ => break,
}
}
let command = arguments
.pop_front()
.ok_or_else(|| CliError::Usage("missing command".to_owned()))?;
let action = match command {
"status" => {
reject_extra(&arguments, "status")?;
Action::Status
}
"collections" => parse_resource_action(Resource::Collections, &mut arguments)?,
"sets" => parse_resource_action(Resource::Sets, &mut arguments)?,
"cards" => parse_resource_action(Resource::Cards, &mut arguments)?,
unknown => return Err(CliError::Usage(format!("unknown command `{unknown}`"))),
};
Ok(Parsed::Run(Invocation { target, action }))
}
fn parse_resource_action(
resource: Resource,
arguments: &mut VecDeque<&str>,
) -> Result<Action, CliError> {
let resource_name = resource.collection_field();
let subcommand = arguments
.pop_front()
.ok_or_else(|| CliError::Usage(format!("missing {resource_name} subcommand")))?;
match subcommand {
"ingest" => {
reject_extra(arguments, &format!("{resource_name} ingest"))?;
Ok(Action::Ingest(resource))
}
"get" => {
let id = arguments
.pop_front()
.ok_or_else(|| CliError::Usage(format!("{resource_name} get requires a ULID")))?;
validate_local_id(id)?;
reject_extra(arguments, &format!("{resource_name} get"))?;
Ok(Action::Get(resource, id.to_owned()))
}
"list" => parse_list_options(resource, arguments),
unknown => Err(CliError::Usage(format!(
"unknown {resource_name} subcommand `{unknown}`"
))),
}
}
fn parse_list_options(
resource: Resource,
arguments: &mut VecDeque<&str>,
) -> Result<Action, CliError> {
let mut options = ListOptions::default();
while let Some(option) = arguments.pop_front() {
match option {
"--limit" => {
let value = take_value(arguments, "--limit")?;
options.limit = value.parse::<u16>().map_err(|_| {
CliError::Usage("--limit must be an integer from 1 to 100".into())
})?;
if !(1..=100).contains(&options.limit) {
return Err(CliError::Usage(
"--limit must be an integer from 1 to 100".into(),
));
}
}
"--after" => {
let value = take_value(arguments, "--after")?;
validate_local_id(value)?;
options.after = Some(value.to_owned());
}
"--all" => options.all = true,
"--max-pages" => {
let value = take_value(arguments, "--max-pages")?;
options.max_pages = value.parse::<usize>().map_err(|_| {
CliError::Usage("--max-pages must be a positive integer".into())
})?;
if options.max_pages == 0 {
return Err(CliError::Usage(
"--max-pages must be a positive integer".into(),
));
}
}
unknown => return Err(CliError::Usage(format!("unknown list option `{unknown}`"))),
}
}
if !options.all && options.max_pages != DEFAULT_MAX_PAGES {
return Err(CliError::Usage(
"--max-pages is only meaningful together with --all".into(),
));
}
Ok(Action::List(resource, options))
}
fn take_value<'a>(arguments: &mut VecDeque<&'a str>, option: &str) -> Result<&'a str, CliError> {
arguments
.pop_front()
.filter(|value| !value.is_empty() && !value.starts_with('-'))
.ok_or_else(|| CliError::Usage(format!("{option} requires a value")))
}
fn reject_extra(arguments: &VecDeque<&str>, command: &str) -> Result<(), CliError> {
if let Some(extra) = arguments.front() {
return Err(CliError::Usage(format!(
"unexpected argument `{extra}` after `{command}`"
)));
}
Ok(())
}
fn validate_local_id(id: &str) -> Result<(), CliError> {
if is_valid_local_id(id) {
Ok(())
} else {
Err(CliError::Usage(format!(
"`{id}` is not a 26-character uppercase ULID"
)))
}
}
fn is_valid_local_id(id: &str) -> bool {
id.len() == 26
&& id.bytes().all(|byte| {
matches!(
byte,
b'0'..=b'9' | b'A'..=b'H' | b'J'..=b'K' | b'M'..=b'N' | b'P'..=b'T' | b'V'..=b'Z'
)
})
}
fn execute(invocation: &Invocation) -> Result<Value, CliError> {
match &invocation.action {
Action::Status => output(call_empty::<FeedStatus>(
&invocation.target,
"toko_feed_status",
true,
)?),
Action::Ingest(Resource::Collections) => output(call_empty::<CollectionIngestReceipt>(
&invocation.target,
"toko_feed_ingest_collections",
false,
)?),
Action::Ingest(Resource::Sets) => output(call_empty::<SetIngestReceipt>(
&invocation.target,
"toko_feed_ingest_sets",
false,
)?),
Action::Get(Resource::Collections, id) => output(call_one::<_, Option<CollectionDetails>>(
&invocation.target,
"toko_feed_collection",
id.clone(),
true,
)?),
Action::Ingest(Resource::Cards) => output(call_empty::<IngestReceipt>(
&invocation.target,
"toko_feed_ingest",
false,
)?),
Action::Get(Resource::Sets, id) => output(call_one::<_, Option<PokemonSetView>>(
&invocation.target,
"toko_feed_set",
id.clone(),
true,
)?),
Action::Get(Resource::Cards, id) => output(call_one::<_, Option<PokemonCardDetails>>(
&invocation.target,
"toko_feed_card",
id.clone(),
true,
)?),
Action::List(Resource::Sets, options) if options.all => {
list_all_sets(&invocation.target, options)
}
Action::List(Resource::Collections, options) if options.all => {
list_all_collections(&invocation.target, options)
}
Action::List(Resource::Cards, options) if options.all => {
list_all_cards(&invocation.target, options)
}
Action::List(Resource::Sets, options) => output(fetch_set_page(
&invocation.target,
options.after.clone(),
options.limit,
)?),
Action::List(Resource::Collections, options) => output(fetch_collection_page(
&invocation.target,
options.after.clone(),
options.limit,
)?),
Action::List(Resource::Cards, options) => output(fetch_card_page(
&invocation.target,
options.after.clone(),
options.limit,
)?),
}
}
fn output(value: impl Serialize) -> Result<Value, CliError> {
serde_json::to_value(value).map_err(CliError::Json)
}
fn call_empty<T>(target: &Target, method: &'static str, query: bool) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let arguments = encode_args(()).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, query)
}
fn call_one<A, T>(
target: &Target,
method: &'static str,
argument: A,
query: bool,
) -> Result<T, CliError>
where
A: CandidType,
T: CandidType + DeserializeOwned,
{
let arguments =
encode_args((argument,)).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, query)
}
fn fetch_set_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<PokemonSetPage, CliError> {
call_page(target, "toko_feed_sets", after, limit)
}
fn fetch_collection_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<CollectionPage, CliError> {
call_page(target, "toko_feed_collections", after, limit)
}
fn fetch_card_page(
target: &Target,
after: Option<String>,
limit: u16,
) -> Result<PokemonCardPage, CliError> {
call_page(target, "toko_feed_cards", after, limit)
}
fn call_page<T>(
target: &Target,
method: &'static str,
after: Option<String>,
limit: u16,
) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let arguments =
encode_args((after, limit)).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, true)
}
fn call_and_decode<T>(
target: &Target,
method: &'static str,
arguments: &[u8],
query: bool,
) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let reply = call_raw(target, method, arguments, query)?;
decode_result(method, &reply)
}
fn decode_result<T>(method: &'static str, reply: &[u8]) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let result = decode_one::<Result<T, FeedError>>(reply)
.map_err(|source| CliError::Decode { method, source })?;
result.map_err(|error| CliError::Canister {
method,
error: format!("{error:?}"),
})
}
fn call_raw(
target: &Target,
method: &'static str,
arguments: &[u8],
query: bool,
) -> Result<Vec<u8>, CliError> {
let mut command = Command::new(&target.icp);
if let Some(project_root) = &target.project_root {
command.arg("--project-root-override").arg(project_root);
}
if let Some(password_file) = &target.identity_password_file {
command.arg("--identity-password-file").arg(password_file);
}
command
.args(["canister", "call", "--environment"])
.arg(&target.environment)
.args(["--args-format", "hex", "--output", "hex"]);
if let Some(identity) = &target.identity {
command.args(["--identity", identity]);
}
if query {
command.arg("--query");
}
command
.args([&target.canister, method])
.arg(encode_hex(arguments));
let output = command.output().map_err(CliError::StartIcp)?;
if !output.status.success() {
let status = output
.status
.code()
.map_or_else(String::new, |code| format!(" (exit {code})"));
let message = bounded_diagnostic(&output.stderr);
return Err(CliError::Icp { status, message });
}
if output.stdout.len() > MAX_RAW_REPLY_BYTES * 2 + 2 {
return Err(CliError::RawReply("hexadecimal response exceeded 16 MiB"));
}
decode_hex(&output.stdout)
}
fn bounded_diagnostic(bytes: &[u8]) -> String {
const MAX_DIAGNOSTIC_BYTES: usize = 8 * 1024;
let visible = &bytes[..bytes.len().min(MAX_DIAGNOSTIC_BYTES)];
let mut message = String::from_utf8_lossy(visible).trim().to_owned();
if bytes.len() > MAX_DIAGNOSTIC_BYTES {
message.push_str("…[truncated]");
}
if message.is_empty() {
"no diagnostic was written to stderr".to_owned()
} else {
message
}
}
fn encode_hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from(DIGITS[usize::from(byte >> 4)]));
output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
}
output
}
fn decode_hex(input: &[u8]) -> Result<Vec<u8>, CliError> {
let input = std::str::from_utf8(input)
.map_err(|_| CliError::RawReply("response was not UTF-8 hexadecimal text"))?
.trim();
let input = input.strip_prefix("0x").unwrap_or(input);
if input.len() % 2 != 0 {
return Err(CliError::RawReply(
"hexadecimal response had an odd number of digits",
));
}
input
.as_bytes()
.chunks_exact(2)
.map(|pair| {
let high = hex_digit(pair[0])?;
let low = hex_digit(pair[1])?;
Ok((high << 4) | low)
})
.collect()
}
const fn hex_digit(byte: u8) -> Result<u8, CliError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err(CliError::RawReply(
"response contained a non-hexadecimal character",
)),
}
}
fn list_all_sets(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let mut after = options.after.clone();
let mut seen = after.iter().cloned().collect::<HashSet<_>>();
let mut sets = Vec::new();
let mut pages = 0_usize;
loop {
if pages == options.max_pages {
return Err(CliError::PaginationLimit(options.max_pages));
}
let page = fetch_set_page(target, after, options.limit)?;
pages += 1;
sets.extend(page.sets);
let Some(next_after) = page.next_after else {
return Ok(json!({
"sets": sets,
"count": sets.len(),
"pages": pages,
"next_after": null,
}));
};
validate_reply_cursor(&next_after, &mut seen)?;
after = Some(next_after);
}
}
fn list_all_collections(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let mut after = options.after.clone();
let mut seen = after.iter().cloned().collect::<HashSet<_>>();
let mut collections = Vec::<CollectionView>::new();
let mut pages = 0_usize;
loop {
if pages == options.max_pages {
return Err(CliError::PaginationLimit(options.max_pages));
}
let page = fetch_collection_page(target, after, options.limit)?;
pages += 1;
collections.extend(page.collections);
let Some(next_after) = page.next_after else {
return Ok(json!({
"collections": collections,
"count": collections.len(),
"pages": pages,
"next_after": null,
}));
};
validate_reply_cursor(&next_after, &mut seen)?;
after = Some(next_after);
}
}
fn list_all_cards(target: &Target, options: &ListOptions) -> Result<Value, CliError> {
let mut after = options.after.clone();
let mut seen = after.iter().cloned().collect::<HashSet<_>>();
let mut cards = Vec::<PokemonCardView>::new();
let mut pages = 0_usize;
loop {
if pages == options.max_pages {
return Err(CliError::PaginationLimit(options.max_pages));
}
let page = fetch_card_page(target, after, options.limit)?;
pages += 1;
cards.extend(page.cards);
let Some(next_after) = page.next_after else {
return Ok(json!({
"cards": cards,
"count": cards.len(),
"pages": pages,
"next_after": null,
}));
};
validate_reply_cursor(&next_after, &mut seen)?;
after = Some(next_after);
}
}
fn validate_reply_cursor(cursor: &str, seen: &mut HashSet<String>) -> Result<(), CliError> {
if !is_valid_local_id(cursor) {
return Err(CliError::RawReply("next_after was not a valid local ULID"));
}
if !seen.insert(cursor.to_owned()) {
return Err(CliError::PaginationStalled(cursor.to_owned()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use candid::{decode_args, encode_one};
use super::*;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(ToString::to_string).collect()
}
fn invocation(values: &[&str]) -> Invocation {
match parse_arguments(&strings(values)).expect("arguments should parse") {
Parsed::Run(invocation) => invocation,
Parsed::Help | Parsed::Version => panic!("expected an invocation"),
}
}
#[test]
fn parses_global_and_automatic_pagination_options() {
let parsed = invocation(&[
"--environment",
"ic",
"--canister",
"aaaaa-aa",
"--identity",
"operator",
"--project-root",
"/srv/toko-feed",
"--compact",
"sets",
"list",
"--limit",
"100",
"--after",
"01KZ9GFKW3SY1G000000000001",
"--all",
"--max-pages",
"12",
]);
assert_eq!(parsed.target.environment, "ic");
assert_eq!(parsed.target.canister, "aaaaa-aa");
assert_eq!(parsed.target.identity.as_deref(), Some("operator"));
assert_eq!(
parsed.target.project_root.as_deref(),
Some(std::path::Path::new("/srv/toko-feed"))
);
assert!(parsed.target.compact);
assert_eq!(
parsed.action,
Action::List(
Resource::Sets,
ListOptions {
limit: 100,
after: Some("01KZ9GFKW3SY1G000000000001".to_owned()),
all: true,
max_pages: 12,
}
)
);
}
#[test]
fn parses_card_ingest_and_get_commands() {
assert_eq!(
invocation(&["cards", "ingest"]).action,
Action::Ingest(Resource::Cards)
);
assert_eq!(
invocation(&["cards", "get", "01KZ9GFKW3SY1G000000000001"]).action,
Action::Get(Resource::Cards, "01KZ9GFKW3SY1G000000000001".to_owned())
);
}
#[test]
fn parses_collection_ingest_and_list_commands() {
assert_eq!(
invocation(&["collections", "ingest"]).action,
Action::Ingest(Resource::Collections)
);
assert_eq!(
invocation(&["collections", "list", "--all"]).action,
Action::List(
Resource::Collections,
ListOptions {
all: true,
..ListOptions::default()
}
)
);
}
#[test]
fn rejects_invalid_bounds_and_identifiers() {
assert!(parse_arguments(&strings(&["sets", "list", "--limit", "0"])).is_err());
assert!(parse_arguments(&strings(&["sets", "list", "--max-pages", "2"])).is_err());
assert!(parse_arguments(&strings(&["sets", "get", "not-an-id"])).is_err());
}
#[test]
fn accepts_help_at_any_command_depth_and_version_at_the_top_level() {
assert!(matches!(
parse_arguments(&strings(&["sets", "list", "--help"])),
Ok(Parsed::Help)
));
assert!(matches!(
parse_arguments(&strings(&["--version"])),
Ok(Parsed::Version)
));
}
#[test]
fn encodes_typed_page_arguments_without_candid_text() {
let bytes = encode_args((Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100_u16))
.expect("encode page arguments");
let decoded = decode_args::<(Option<String>, u16)>(&bytes).expect("decode page arguments");
assert_eq!(
decoded,
(Some("01KZ9GFKW3SY1G000000000001".to_owned()), 100)
);
}
#[test]
fn hexadecimal_transport_round_trips_raw_candid() {
let bytes = encode_args((None::<String>, 20_u16)).expect("encode arguments");
assert_eq!(
decode_hex(format!("0x{}\n", encode_hex(&bytes)).as_bytes()).expect("decode hex"),
bytes
);
}
#[test]
fn decodes_a_typed_canister_result() {
let status = FeedStatus {
configured: true,
next_offset: 50,
ingesting: false,
last_error_code: None,
updated_at_ns: 123,
};
let reply = encode_one(Ok::<_, FeedError>(status.clone())).expect("encode reply");
assert_eq!(
decode_result::<FeedStatus>("toko_feed_status", &reply).expect("decode result"),
status
);
}
#[test]
fn rejects_non_hexadecimal_transport_output() {
assert!(decode_hex(b"not-hex").is_err());
assert!(decode_hex(b"abc").is_err());
}
}