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, 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/// Failure reported by CLI parsing, the ICP process boundary, or the typed
76/// canister protocol.
77#[derive(Debug, Error)]
78pub enum CliError {
79    /// Command-line arguments did not describe a valid operation.
80    #[error("{0}\n\nRun with --help for usage.")]
81    Usage(String),
82    /// The ICP CLI process could not be started.
83    #[error("could not start icp: {0}")]
84    StartIcp(#[source] io::Error),
85    /// The ICP CLI process reported a failed call.
86    #[error("icp call failed{status}: {message}")]
87    Icp {
88        /// Numeric process exit status, when the platform supplied one.
89        status: String,
90        /// Bounded diagnostic written by the ICP CLI.
91        message: String,
92    },
93    /// Typed Candid argument encoding failed before the process was started.
94    #[error("could not encode arguments for `{method}`: {source}")]
95    Encode {
96        /// Canister method being prepared.
97        method: &'static str,
98        /// Candid codec failure.
99        #[source]
100        source: candid::Error,
101    },
102    /// The canister reply was not valid for the shared interface type.
103    #[error("could not decode the typed reply from `{method}`: {source}")]
104    Decode {
105        /// Canister method whose reply was being decoded.
106        method: &'static str,
107        /// Candid codec failure.
108        #[source]
109        source: candid::Error,
110    },
111    /// The ICP CLI returned something other than one bounded hexadecimal reply.
112    #[error("icp returned an invalid raw reply: {0}")]
113    RawReply(&'static str),
114    /// A typed application error was returned by the canister.
115    #[error("canister method `{method}` returned {error}")]
116    Canister {
117        /// Called canister method.
118        method: &'static str,
119        /// Credential-free bounded application error.
120        error: String,
121    },
122    /// JSON rendering failed.
123    #[error("could not render JSON output: {0}")]
124    Json(#[from] serde_json::Error),
125    /// Writing the final report failed.
126    #[error("could not write output: {0}")]
127    Io(#[source] io::Error),
128    /// A canister returned a continuation that had already been observed.
129    #[error("pagination cursor did not advance: {0}")]
130    PaginationStalled(String),
131    /// Automatic pagination reached its operator-provided safety bound.
132    #[error("listing still had another page after the --max-pages limit of {0}")]
133    PaginationLimit(usize),
134}
135
136impl CliError {
137    /// Shell exit status for this failure category.
138    #[must_use]
139    pub const fn exit_code(&self) -> i32 {
140        if matches!(self, Self::Usage(_)) { 2 } else { 1 }
141    }
142
143    /// Whether a downstream pipeline closed standard output intentionally.
144    #[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
231/// Run the CLI from the current process arguments.
232///
233/// # Errors
234///
235/// Returns a bounded [`CliError`] when arguments are invalid, the `icp`
236/// process fails, the canister rejects the request, or output cannot be
237/// rendered.
238pub 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}