Skip to main content

ic_query_cli/
lib.rs

1mod cli;
2mod icrc;
3mod nns;
4mod output;
5mod progress;
6mod sns;
7mod storage;
8
9#[cfg(test)]
10mod test_support;
11
12use crate::cli::clap::{
13    parse_matches_or_usage, passthrough_args, passthrough_subcommand, string_option,
14};
15use clap::{Arg, ArgAction, Command};
16use ic_query::subnet_catalog::MAINNET_NETWORK;
17use std::ffi::OsString;
18use thiserror::Error as ThisError;
19
20const TOP_LEVEL_HELP_TEMPLATE: &str = "{name} {version}\n{about-with-newline}\n{usage-heading} {usage}\n\nCommands:\n{subcommands}\n\nOptions:\n{options}{after-help}\n";
21const VERSION_TEXT: &str = concat!("icq ", env!("CARGO_PKG_VERSION"));
22const INTERNAL_NETWORK_OPTION: &str = "--__icq-network";
23
24const fn version_text() -> &'static str {
25    VERSION_TEXT
26}
27
28///
29/// IcqCliError
30///
31/// Top-level CLI dispatch error.
32///
33
34#[derive(Debug, ThisError)]
35pub enum IcqCliError {
36    #[error("{0}")]
37    Usage(String),
38
39    #[error("nns: {0}")]
40    Nns(#[from] nns::NnsCommandError),
41
42    #[error("icrc: {0}")]
43    Icrc(#[from] icrc::IcrcCommandError),
44
45    #[error("sns: {0}")]
46    Sns(#[from] sns::SnsCommandError),
47}
48
49impl IcqCliError {
50    /// Whether stdout closed before the command finished writing its report.
51    #[must_use]
52    pub fn is_broken_pipe(&self) -> bool {
53        match self {
54            Self::Nns(nns::NnsCommandError::Io(err))
55            | Self::Icrc(icrc::IcrcCommandError::Io(err))
56            | Self::Sns(sns::SnsCommandError::Io(err)) => {
57                err.kind() == std::io::ErrorKind::BrokenPipe
58            }
59            Self::Usage(_) | Self::Nns(_) | Self::Icrc(_) | Self::Sns(_) => false,
60        }
61    }
62
63    /// Process exit code for this command error.
64    #[must_use]
65    pub const fn exit_code(&self) -> i32 {
66        match self {
67            Self::Usage(_)
68            | Self::Nns(nns::NnsCommandError::Usage(_))
69            | Self::Icrc(icrc::IcrcCommandError::Usage(_))
70            | Self::Sns(sns::SnsCommandError::Usage(_)) => 2,
71            Self::Nns(_) | Self::Icrc(_) | Self::Sns(_) => 1,
72        }
73    }
74}
75
76/// Run the CLI from process arguments.
77pub fn run_from_env() -> Result<(), IcqCliError> {
78    run(std::env::args_os().skip(1))
79}
80
81/// Run the CLI from an argument iterator.
82pub fn run<I>(args: I) -> Result<(), IcqCliError>
83where
84    I: IntoIterator<Item = OsString>,
85{
86    let Some(args) = collect_args_or_print_help(args, usage) else {
87        return Ok(());
88    };
89    if let Some((command, option)) = command_local_global_option(&args) {
90        if command == "icrc" {
91            return Err(unsupported_global_network_error(command));
92        }
93        return Err(IcqCliError::Usage(format!(
94            "{option} is a top-level option; put it before the command\n\n{}",
95            usage()
96        )));
97    }
98
99    let matches = parse_matches_or_usage(top_level_dispatch_command(), args, usage)
100        .map_err(IcqCliError::Usage)?;
101    if matches.get_flag("version") {
102        println!("{VERSION_TEXT}");
103        return Ok(());
104    }
105    let global_network = string_option(&matches, "network");
106
107    let Some((command, subcommand_matches)) = matches.subcommand() else {
108        return Err(IcqCliError::Usage(usage()));
109    };
110    let mut tail = passthrough_args(subcommand_matches);
111    apply_global_network(command, &mut tail, global_network)?;
112    let tail = tail.into_iter();
113
114    match command {
115        "icrc" => Ok(icrc::run(tail)?),
116        "nns" => Ok(nns::run(tail)?),
117        "sns" => Ok(sns::run(tail)?),
118        _ => unreachable!("top-level dispatch command only defines known commands"),
119    }
120}
121
122fn collect_args_or_print_help<I>(args: I, usage: impl FnOnce() -> String) -> Option<Vec<OsString>>
123where
124    I: IntoIterator<Item = OsString>,
125{
126    let args = args.into_iter().collect::<Vec<_>>();
127    if top_level_help_requested(&args) {
128        println!("{}", usage());
129        return None;
130    }
131    Some(args)
132}
133
134fn top_level_help_requested(args: &[OsString]) -> bool {
135    let mut index = 0;
136    while index < args.len() {
137        let Some(arg) = args[index].to_str() else {
138            return false;
139        };
140        if command_family(arg).is_some() {
141            return false;
142        }
143        if matches!(arg, "help" | "--help" | "-h") {
144            return true;
145        }
146        index += if arg == "--network" { 2 } else { 1 };
147    }
148    false
149}
150
151fn network_arg() -> Arg {
152    Arg::new("network")
153        .num_args(1)
154        .long("network")
155        .value_name("name")
156        .help("Network identity for NNS and SNS commands; currently only ic")
157}
158
159fn top_level_command() -> Command {
160    Command::new("icq")
161        .version(env!("CARGO_PKG_VERSION"))
162        .about("Internet Computer metadata query CLI")
163        .disable_help_subcommand(true)
164        .disable_version_flag(true)
165        .arg(
166            Arg::new("version")
167                .short('V')
168                .long("version")
169                .action(ArgAction::SetTrue)
170                .help("Print version"),
171        )
172        .arg(network_arg().global(true))
173        .subcommand_help_heading("Commands")
174        .help_template(TOP_LEVEL_HELP_TEMPLATE)
175        .after_help("Run `icq <command> help` for command-specific help.")
176        .subcommands(
177            COMMAND_FAMILIES
178                .iter()
179                .map(|family| Command::new(family.name).about(family.about)),
180        )
181}
182
183fn top_level_dispatch_command() -> Command {
184    let command = Command::new("icq")
185        .disable_help_flag(true)
186        .disable_help_subcommand(true)
187        .disable_version_flag(true)
188        .arg(
189            Arg::new("version")
190                .short('V')
191                .long("version")
192                .action(ArgAction::SetTrue),
193        )
194        .arg(network_arg().global(true));
195
196    COMMAND_FAMILIES.iter().fold(command, |command, family| {
197        command.subcommand(passthrough_subcommand(
198            Command::new(family.name).about(family.about),
199        ))
200    })
201}
202
203fn usage() -> String {
204    let mut command = top_level_command();
205    command.render_help().to_string()
206}
207
208fn command_local_global_option(args: &[OsString]) -> Option<(&'static str, &'static str)> {
209    let mut index = 0;
210    while index < args.len() {
211        let arg = args[index].to_str()?;
212        if let Some(family) = command_family(arg) {
213            return args[index + 1..]
214                .iter()
215                .filter_map(|arg| arg.to_str())
216                .find_map(global_option_name)
217                .map(|option| (family.name, option));
218        }
219        index += if arg == "--network" { 2 } else { 1 };
220    }
221    None
222}
223
224fn global_option_name(arg: &str) -> Option<&'static str> {
225    match arg {
226        "--network" => Some("--network"),
227        _ if arg.starts_with("--network=") => Some("--network"),
228        _ => None,
229    }
230}
231
232fn apply_global_network(
233    command: &str,
234    tail: &mut Vec<OsString>,
235    global_network: Option<String>,
236) -> Result<(), IcqCliError> {
237    let Some(global_network) = global_network else {
238        return Ok(());
239    };
240    if tail_requests_help_or_version(tail) {
241        return Ok(());
242    }
243    if !command_accepts_global_network(command, tail) {
244        return Err(unsupported_global_network_error(command));
245    }
246    if global_network != MAINNET_NETWORK {
247        return Err(unsupported_mainnet_network_error(command, &global_network));
248    }
249    if tail_has_option(tail, INTERNAL_NETWORK_OPTION) {
250        return Ok(());
251    }
252
253    tail.push(OsString::from(INTERNAL_NETWORK_OPTION));
254    tail.push(OsString::from(global_network));
255    Ok(())
256}
257
258fn unsupported_global_network_error(command: &str) -> IcqCliError {
259    let guidance = if command == "icrc" {
260        " use the command's --source-endpoint option to select the IC API endpoint"
261    } else {
262        ""
263    };
264    IcqCliError::Usage(format!(
265        "--network is not supported by `icq {command}`;{guidance}\n\n{}",
266        usage()
267    ))
268}
269
270fn unsupported_mainnet_network_error(command: &str, network: &str) -> IcqCliError {
271    IcqCliError::Usage(format!(
272        "`icq {command}` currently supports only the mainnet `{MAINNET_NETWORK}` network; received `{network}`\n\n{}",
273        usage()
274    ))
275}
276
277fn command_accepts_global_network(command: &str, tail: &[OsString]) -> bool {
278    command_family(command).is_some_and(|family| (family.accepts_global_network)(tail))
279}
280
281fn tail_has_option(tail: &[OsString], name: &str) -> bool {
282    tail.iter().any(|arg| arg.to_str() == Some(name))
283}
284
285fn tail_requests_help_or_version(tail: &[OsString]) -> bool {
286    tail.iter()
287        .filter_map(|arg| arg.to_str())
288        .any(|arg| matches!(arg, "help" | "--help" | "-h" | "--version" | "-V"))
289}
290
291#[derive(Clone, Copy, Debug)]
292struct CommandFamily {
293    name: &'static str,
294    about: &'static str,
295    accepts_global_network: fn(&[OsString]) -> bool,
296}
297
298const COMMAND_FAMILIES: &[CommandFamily] = &[
299    CommandFamily {
300        name: "icrc",
301        about: "Inspect generic ICRC ledger and account metadata",
302        accepts_global_network: icrc_accepts_global_network,
303    },
304    CommandFamily {
305        name: "nns",
306        about: "Inspect NNS metadata",
307        accepts_global_network: nns_accepts_global_network,
308    },
309    CommandFamily {
310        name: "sns",
311        about: "Inspect SNS metadata",
312        accepts_global_network: sns_accepts_global_network,
313    },
314];
315
316fn command_family(name: &str) -> Option<&'static CommandFamily> {
317    COMMAND_FAMILIES.iter().find(|family| family.name == name)
318}
319
320fn nns_accepts_global_network(tail: &[OsString]) -> bool {
321    matches!(
322        tail.first().and_then(|arg| arg.to_str()),
323        Some(
324            "data-center"
325                | "node"
326                | "node-operator"
327                | "node-provider"
328                | "proposal"
329                | "registry"
330                | "subnet"
331                | "topology"
332        )
333    )
334}
335
336const fn icrc_accepts_global_network(_tail: &[OsString]) -> bool {
337    false
338}
339
340fn sns_accepts_global_network(tail: &[OsString]) -> bool {
341    matches!(
342        tail.first().and_then(|arg| arg.to_str()),
343        Some("list" | "info" | "token" | "params" | "proposal" | "neuron")
344    )
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn usage_lists_query_families() {
353        let text = usage();
354
355        assert!(text.contains("Usage: icq [OPTIONS] [COMMAND]"));
356        assert!(text.contains("icrc"));
357        assert!(text.contains("Inspect generic ICRC ledger and account metadata"));
358        assert!(text.contains("nns"));
359        assert!(text.contains("Inspect NNS metadata"));
360        assert!(text.contains("sns"));
361        assert!(text.contains("Inspect SNS metadata"));
362        assert!(text.contains("Run `icq <command> help`"));
363    }
364
365    #[test]
366    fn top_level_usage_snapshot() {
367        let expected = format!(
368            "\
369icq {}
370Internet Computer metadata query CLI
371
372Usage: icq [OPTIONS] [COMMAND]
373
374Commands:
375  icrc  Inspect generic ICRC ledger and account metadata
376  nns   Inspect NNS metadata
377  sns   Inspect SNS metadata
378
379Options:
380  -V, --version         Print version
381      --network <name>  Network identity for NNS and SNS commands; currently only ic
382  -h, --help            Print help
383
384Run `icq <command> help` for command-specific help.
385",
386            env!("CARGO_PKG_VERSION")
387        );
388
389        assert_eq!(usage(), expected);
390    }
391
392    #[test]
393    fn command_family_help_returns_ok() {
394        for args in [
395            &["icrc", "help"][..],
396            &["icrc", "ledger", "help"],
397            &["icrc", "ledger", "token", "help"],
398            &["icrc", "account", "help"],
399            &["icrc", "account", "balance", "help"],
400            &["icrc", "account", "allowance", "help"],
401            &["icrc", "account", "transaction", "help"],
402            &["icrc", "account", "transaction", "page", "help"],
403            &["icrc", "account", "transaction", "list", "help"],
404            &["icrc", "account", "transaction", "refresh", "help"],
405            &["icrc", "account", "transaction", "cache", "help"],
406            &["icrc", "account", "transaction", "cache", "status", "help"],
407            &["icrc", "ledger", "index", "help"],
408            &["nns", "help"][..],
409            &["nns", "data-center", "help"],
410            &["nns", "data-center", "list", "help"],
411            &["nns", "data-center", "info", "help"],
412            &["nns", "data-center", "refresh", "help"],
413            &["nns", "node", "help"],
414            &["nns", "node", "list", "help"],
415            &["nns", "node", "info", "help"],
416            &["nns", "node", "refresh", "help"],
417            &["nns", "node-provider", "help"],
418            &["nns", "node-provider", "list", "help"],
419            &["nns", "node-provider", "info", "help"],
420            &["nns", "node-provider", "refresh", "help"],
421            &["nns", "node-operator", "help"],
422            &["nns", "node-operator", "list", "help"],
423            &["nns", "node-operator", "info", "help"],
424            &["nns", "node-operator", "refresh", "help"],
425            &["nns", "proposal", "help"],
426            &["nns", "proposal", "list", "help"],
427            &["nns", "proposal", "info", "help"],
428            &["nns", "registry", "help"],
429            &["nns", "registry", "version", "help"],
430            &["nns", "subnet", "help"],
431            &["nns", "subnet", "list", "help"],
432            &["nns", "subnet", "info", "help"],
433            &["nns", "subnet", "refresh", "help"],
434            &["nns", "topology", "help"],
435            &["nns", "topology", "summary", "help"],
436            &["nns", "topology", "coverage", "help"],
437            &["nns", "topology", "versions", "help"],
438            &["nns", "topology", "health", "help"],
439            &["nns", "topology", "gaps", "help"],
440            &["nns", "topology", "capacity", "help"],
441            &["nns", "topology", "regions", "help"],
442            &["nns", "topology", "providers", "help"],
443            &["nns", "topology", "refresh", "help"],
444            &["sns", "help"],
445            &["sns", "list", "help"],
446            &["sns", "info", "help"],
447            &["sns", "token", "help"],
448            &["sns", "params", "help"],
449            &["sns", "proposal", "help"],
450            &["sns", "proposal", "list", "help"],
451            &["sns", "proposal", "info", "help"],
452            &["sns", "proposal", "cache", "help"],
453            &["sns", "proposal", "cache", "list", "help"],
454            &["sns", "proposal", "cache", "status", "help"],
455            &["sns", "proposal", "refresh", "help"],
456            &["sns", "neuron", "help"],
457            &["sns", "neuron", "list", "help"],
458            &["sns", "neuron", "cache", "help"],
459            &["sns", "neuron", "cache", "list", "help"],
460            &["sns", "neuron", "cache", "status", "help"],
461            &["sns", "neuron", "refresh", "help"],
462        ] {
463            assert_run_ok(args);
464        }
465    }
466
467    #[test]
468    fn version_flags_return_ok() {
469        assert_eq!(VERSION_TEXT, concat!("icq ", env!("CARGO_PKG_VERSION")));
470        assert!(run([OsString::from("--version")]).is_ok());
471        assert!(run([OsString::from("icrc"), OsString::from("--version")]).is_ok());
472        assert!(run([OsString::from("nns"), OsString::from("--version")]).is_ok());
473        assert!(run([OsString::from("sns"), OsString::from("--version")]).is_ok());
474        assert!(
475            run([
476                OsString::from("nns"),
477                OsString::from("subnet"),
478                OsString::from("list"),
479                OsString::from("--version")
480            ])
481            .is_ok()
482        );
483
484        let mut sns_info_tail = vec![OsString::from("info"), OsString::from("1")];
485
486        apply_global_network("sns", &mut sns_info_tail, Some("ic".to_string()))
487            .expect("SNS supports global network");
488
489        assert_eq!(
490            sns_info_tail,
491            vec![
492                OsString::from("info"),
493                OsString::from("1"),
494                OsString::from(INTERNAL_NETWORK_OPTION),
495                OsString::from("ic")
496            ]
497        );
498    }
499
500    #[test]
501    fn typed_cli_errors_preserve_exit_and_broken_pipe_semantics() {
502        let usage = IcqCliError::Icrc(icrc::IcrcCommandError::Usage("bad input".to_string()));
503        assert_eq!(usage.exit_code(), 2);
504        assert!(!usage.is_broken_pipe());
505
506        let broken_pipe = IcqCliError::Icrc(icrc::IcrcCommandError::Io(std::io::Error::from(
507            std::io::ErrorKind::BrokenPipe,
508        )));
509        assert_eq!(broken_pipe.exit_code(), 1);
510        assert!(broken_pipe.is_broken_pipe());
511    }
512
513    #[test]
514    fn global_network_is_forwarded_to_networked_leaf_commands() {
515        let mut nns_tail = vec![OsString::from("data-center"), OsString::from("list")];
516
517        apply_global_network("nns", &mut nns_tail, Some("ic".to_string()))
518            .expect("NNS data-center supports global network");
519
520        assert_eq!(
521            nns_tail,
522            vec![
523                OsString::from("data-center"),
524                OsString::from("list"),
525                OsString::from(INTERNAL_NETWORK_OPTION),
526                OsString::from("ic")
527            ]
528        );
529
530        let mut sns_tail = vec![OsString::from("list")];
531
532        apply_global_network("sns", &mut sns_tail, Some("ic".to_string()))
533            .expect("SNS list supports global network");
534
535        assert_eq!(
536            sns_tail,
537            vec![
538                OsString::from("list"),
539                OsString::from(INTERNAL_NETWORK_OPTION),
540                OsString::from("ic")
541            ]
542        );
543
544        let mut nns_proposal_tail = vec![OsString::from("proposal"), OsString::from("list")];
545
546        apply_global_network("nns", &mut nns_proposal_tail, Some("ic".to_string()))
547            .expect("NNS proposal supports global network");
548
549        assert_eq!(
550            nns_proposal_tail,
551            vec![
552                OsString::from("proposal"),
553                OsString::from("list"),
554                OsString::from(INTERNAL_NETWORK_OPTION),
555                OsString::from("ic")
556            ]
557        );
558    }
559
560    #[test]
561    fn non_mainnet_network_is_rejected_before_nns_or_sns_dispatch() {
562        for command in ["nns", "sns"] {
563            let mut tail = if command == "nns" {
564                vec![OsString::from("proposal"), OsString::from("list")]
565            } else {
566                vec![OsString::from("list")]
567            };
568
569            let error = apply_global_network(command, &mut tail, Some("local".to_string()))
570                .expect_err("current NNS and SNS adapters are mainnet-only");
571
572            assert_eq!(error.exit_code(), 2);
573            assert!(error.to_string().contains("supports only the mainnet `ic`"));
574            assert!(error.to_string().contains("received `local`"));
575            assert!(!tail_has_option(&tail, INTERNAL_NETWORK_OPTION));
576        }
577
578        let mut preforwarded_tail = vec![
579            OsString::from("proposal"),
580            OsString::from("list"),
581            OsString::from(INTERNAL_NETWORK_OPTION),
582            OsString::from(MAINNET_NETWORK),
583        ];
584        let error = apply_global_network("nns", &mut preforwarded_tail, Some("local".to_string()))
585            .expect_err("an internal forwarded value must not bypass global validation");
586        assert!(error.to_string().contains("received `local`"));
587
588        for args in [
589            vec![
590                OsString::from("--network"),
591                OsString::from("local"),
592                OsString::from("nns"),
593                OsString::from("proposal"),
594                OsString::from("list"),
595            ],
596            vec![
597                OsString::from("--network"),
598                OsString::from("local"),
599                OsString::from("sns"),
600                OsString::from("list"),
601            ],
602        ] {
603            let command = args[2].to_string_lossy().into_owned();
604            let error = run(args).expect_err("non-mainnet network must fail before dispatch");
605
606            assert_eq!(error.exit_code(), 2);
607            assert!(
608                error
609                    .to_string()
610                    .starts_with(&format!("`icq {command}` currently"))
611            );
612        }
613    }
614
615    #[test]
616    fn global_network_is_rejected_when_the_family_uses_endpoint_identity() {
617        let mut icrc_tail = vec![OsString::from("ledger"), OsString::from("token")];
618
619        let error = apply_global_network("icrc", &mut icrc_tail, Some("ic".to_string()))
620            .expect_err("ICRC must reject an inapplicable global network");
621
622        assert_eq!(error.exit_code(), 2);
623        assert!(error.to_string().contains("--network is not supported"));
624        assert!(error.to_string().contains("icq icrc"));
625        assert!(error.to_string().contains("--source-endpoint"));
626        assert_eq!(
627            icrc_tail,
628            vec![OsString::from("ledger"), OsString::from("token")]
629        );
630
631        let error = run([
632            OsString::from("--network"),
633            OsString::from("ic"),
634            OsString::from("icrc"),
635            OsString::from("ledger"),
636            OsString::from("token"),
637            OsString::from("ryjl3-tyaaa-aaaaa-aaaba-cai"),
638        ])
639        .expect_err("ICRC global network must fail before dispatch");
640
641        assert_eq!(error.exit_code(), 2);
642        assert!(error.to_string().contains("--source-endpoint"));
643
644        let error = run([
645            OsString::from("icrc"),
646            OsString::from("ledger"),
647            OsString::from("token"),
648            OsString::from("ryjl3-tyaaa-aaaaa-aaaba-cai"),
649            OsString::from("--network"),
650            OsString::from("ic"),
651        ])
652        .expect_err("command-local ICRC network must use the same rejection");
653
654        assert_eq!(error.exit_code(), 2);
655        assert!(error.to_string().contains("--network is not supported"));
656        assert!(!error.to_string().contains("put it before the command"));
657
658        assert!(
659            run([
660                OsString::from("--network"),
661                OsString::from("ic"),
662                OsString::from("icrc"),
663                OsString::from("ledger"),
664                OsString::from("token"),
665                OsString::from("help"),
666            ])
667            .is_ok(),
668            "help must remain available without dispatching a query"
669        );
670    }
671
672    #[test]
673    fn sns_nested_commands_dispatch_through_clap_subcommands() {
674        assert!(
675            run([
676                OsString::from("sns"),
677                OsString::from("neuron"),
678                OsString::from("refresh"),
679                OsString::from("--help")
680            ])
681            .is_ok()
682        );
683        assert!(
684            run([
685                OsString::from("sns"),
686                OsString::from("proposal"),
687                OsString::from("cache"),
688                OsString::from("status"),
689                OsString::from("--help")
690            ])
691            .is_ok()
692        );
693    }
694
695    fn assert_run_ok(args: &[&str]) {
696        let args = args.iter().copied().map(OsString::from).collect::<Vec<_>>();
697        if let Err(err) = run(args.clone()) {
698            panic!("expected {args:?} to succeed, got {err}");
699        }
700    }
701}