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
7use 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/// Failure reported by CLI parsing, the ICP process boundary, or the typed
96/// canister protocol.
97#[derive(Debug, Error)]
98pub enum CliError {
99    /// Command-line arguments did not describe a valid operation.
100    #[error("{0}\n\nRun with --help for usage.")]
101    Usage(String),
102    /// The ICP CLI process could not be started.
103    #[error("could not start icp: {0}")]
104    StartIcp(#[source] io::Error),
105    /// The ICP CLI process reported a failed call.
106    #[error("icp call failed{status}: {message}")]
107    Icp {
108        /// Numeric process exit status, when the platform supplied one.
109        status: String,
110        /// Bounded diagnostic written by the ICP CLI.
111        message: String,
112    },
113    /// Typed Candid argument encoding failed before the process was started.
114    #[error("could not encode arguments for `{method}`: {source}")]
115    Encode {
116        /// Canister method being prepared.
117        method: &'static str,
118        /// Candid codec failure.
119        #[source]
120        source: candid::Error,
121    },
122    /// The canister reply was not valid for the shared interface type.
123    #[error("could not decode the typed reply from `{method}`: {source}")]
124    Decode {
125        /// Canister method whose reply was being decoded.
126        method: &'static str,
127        /// Candid codec failure.
128        #[source]
129        source: candid::Error,
130    },
131    /// The ICP CLI returned something other than one bounded hexadecimal reply.
132    #[error("icp returned an invalid raw reply: {0}")]
133    RawReply(&'static str),
134    /// A typed application error was returned by the canister.
135    #[error("canister method `{method}` returned {error}")]
136    Canister {
137        /// Called canister method.
138        method: &'static str,
139        /// Credential-free bounded application error.
140        error: String,
141    },
142    /// JSON rendering failed.
143    #[error("could not render JSON output: {0}")]
144    Json(#[from] serde_json::Error),
145    /// Writing the final report failed.
146    #[error("could not write output: {0}")]
147    Io(#[source] io::Error),
148    /// 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 const fn exit_code(&self) -> i32 {
160        if matches!(self, Self::Usage(_)) { 2 } else { 1 }
161    }
162
163    /// Whether a downstream pipeline closed standard output intentionally.
164    #[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
276/// Run the CLI from the current process arguments.
277///
278/// # Errors
279///
280/// Returns a bounded [`CliError`] when arguments are invalid, the `icp`
281/// process fails, the canister rejects the request, or output cannot be
282/// rendered.
283pub 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}