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
320const fn nns_accepts_global_network(_tail: &[OsString]) -> bool {
321    true
322}
323
324const fn icrc_accepts_global_network(_tail: &[OsString]) -> bool {
325    false
326}
327
328const fn sns_accepts_global_network(_tail: &[OsString]) -> bool {
329    true
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn usage_lists_query_families() {
338        let text = usage();
339
340        assert!(text.contains("Usage: icq [OPTIONS] [COMMAND]"));
341        assert!(text.contains("icrc"));
342        assert!(text.contains("Inspect generic ICRC ledger and account metadata"));
343        assert!(text.contains("nns"));
344        assert!(text.contains("Inspect NNS metadata"));
345        assert!(text.contains("sns"));
346        assert!(text.contains("Inspect SNS metadata"));
347        assert!(text.contains("Run `icq <command> help`"));
348    }
349
350    #[test]
351    fn top_level_usage_snapshot() {
352        let expected = format!(
353            "\
354icq {}
355Internet Computer metadata query CLI
356
357Usage: icq [OPTIONS] [COMMAND]
358
359Commands:
360  icrc  Inspect generic ICRC ledger and account metadata
361  nns   Inspect NNS metadata
362  sns   Inspect SNS metadata
363
364Options:
365  -V, --version         Print version
366      --network <name>  Network identity for NNS and SNS commands; currently only ic
367  -h, --help            Print help
368
369Run `icq <command> help` for command-specific help.
370",
371            env!("CARGO_PKG_VERSION")
372        );
373
374        assert_eq!(usage(), expected);
375    }
376
377    #[test]
378    fn command_family_help_returns_ok() {
379        for args in [
380            &["icrc", "help"][..],
381            &["icrc", "ledger", "help"],
382            &["icrc", "ledger", "token", "help"],
383            &["icrc", "account", "help"],
384            &["icrc", "account", "balance", "help"],
385            &["icrc", "account", "allowance", "help"],
386            &["icrc", "account", "transaction", "help"],
387            &["icrc", "account", "transaction", "page", "help"],
388            &["icrc", "account", "transaction", "list", "help"],
389            &["icrc", "account", "transaction", "refresh", "help"],
390            &["icrc", "account", "transaction", "cache", "help"],
391            &["icrc", "account", "transaction", "cache", "status", "help"],
392            &["icrc", "ledger", "index", "help"],
393            &["nns", "help"][..],
394            &["nns", "data-center", "help"],
395            &["nns", "data-center", "list", "help"],
396            &["nns", "data-center", "info", "help"],
397            &["nns", "data-center", "refresh", "help"],
398            &["nns", "node", "help"],
399            &["nns", "node", "list", "help"],
400            &["nns", "node", "info", "help"],
401            &["nns", "node", "refresh", "help"],
402            &["nns", "node-provider", "help"],
403            &["nns", "node-provider", "list", "help"],
404            &["nns", "node-provider", "info", "help"],
405            &["nns", "node-provider", "refresh", "help"],
406            &["nns", "node-operator", "help"],
407            &["nns", "node-operator", "list", "help"],
408            &["nns", "node-operator", "info", "help"],
409            &["nns", "node-operator", "refresh", "help"],
410            &["nns", "proposal", "help"],
411            &["nns", "proposal", "list", "help"],
412            &["nns", "proposal", "info", "help"],
413            &["nns", "registry", "help"],
414            &["nns", "registry", "version", "help"],
415            &["nns", "subnet", "help"],
416            &["nns", "subnet", "list", "help"],
417            &["nns", "subnet", "info", "help"],
418            &["nns", "subnet", "refresh", "help"],
419            &["nns", "topology", "help"],
420            &["nns", "topology", "summary", "help"],
421            &["nns", "topology", "coverage", "help"],
422            &["nns", "topology", "versions", "help"],
423            &["nns", "topology", "health", "help"],
424            &["nns", "topology", "gaps", "help"],
425            &["nns", "topology", "capacity", "help"],
426            &["nns", "topology", "regions", "help"],
427            &["nns", "topology", "providers", "help"],
428            &["nns", "topology", "refresh", "help"],
429            &["sns", "help"],
430            &["sns", "list", "help"],
431            &["sns", "info", "help"],
432            &["sns", "token", "help"],
433            &["sns", "params", "help"],
434            &["sns", "proposal", "help"],
435            &["sns", "proposal", "list", "help"],
436            &["sns", "proposal", "info", "help"],
437            &["sns", "proposal", "cache", "help"],
438            &["sns", "proposal", "cache", "list", "help"],
439            &["sns", "proposal", "cache", "status", "help"],
440            &["sns", "proposal", "refresh", "help"],
441            &["sns", "neuron", "help"],
442            &["sns", "neuron", "list", "help"],
443            &["sns", "neuron", "cache", "help"],
444            &["sns", "neuron", "cache", "list", "help"],
445            &["sns", "neuron", "cache", "status", "help"],
446            &["sns", "neuron", "refresh", "help"],
447        ] {
448            assert_run_ok(args);
449        }
450    }
451
452    #[test]
453    fn version_flags_return_ok() {
454        assert_eq!(VERSION_TEXT, concat!("icq ", env!("CARGO_PKG_VERSION")));
455        assert!(run([OsString::from("--version")]).is_ok());
456        assert!(run([OsString::from("icrc"), OsString::from("--version")]).is_ok());
457        assert!(run([OsString::from("nns"), OsString::from("--version")]).is_ok());
458        assert!(run([OsString::from("sns"), OsString::from("--version")]).is_ok());
459        assert!(
460            run([
461                OsString::from("nns"),
462                OsString::from("subnet"),
463                OsString::from("list"),
464                OsString::from("--version")
465            ])
466            .is_ok()
467        );
468
469        let mut sns_info_tail = vec![OsString::from("info"), OsString::from("1")];
470
471        apply_global_network("sns", &mut sns_info_tail, Some("ic".to_string()))
472            .expect("SNS supports global network");
473
474        assert_eq!(
475            sns_info_tail,
476            vec![
477                OsString::from("info"),
478                OsString::from("1"),
479                OsString::from(INTERNAL_NETWORK_OPTION),
480                OsString::from("ic")
481            ]
482        );
483    }
484
485    #[test]
486    fn typed_cli_errors_preserve_exit_and_broken_pipe_semantics() {
487        let usage = IcqCliError::Icrc(icrc::IcrcCommandError::Usage("bad input".to_string()));
488        assert_eq!(usage.exit_code(), 2);
489        assert!(!usage.is_broken_pipe());
490
491        let broken_pipe = IcqCliError::Icrc(icrc::IcrcCommandError::Io(std::io::Error::from(
492            std::io::ErrorKind::BrokenPipe,
493        )));
494        assert_eq!(broken_pipe.exit_code(), 1);
495        assert!(broken_pipe.is_broken_pipe());
496    }
497
498    #[test]
499    fn global_network_is_forwarded_to_networked_leaf_commands() {
500        for (command, leaf) in [
501            ("nns", "data-center"),
502            ("nns", "governance"),
503            ("nns", "neuron"),
504            ("nns", "node"),
505            ("nns", "node-operator"),
506            ("nns", "node-provider"),
507            ("nns", "proposal"),
508            ("nns", "registry"),
509            ("nns", "subnet"),
510            ("nns", "topology"),
511            ("sns", "canister"),
512            ("sns", "info"),
513            ("sns", "list"),
514            ("sns", "neuron"),
515            ("sns", "params"),
516            ("sns", "proposal"),
517            ("sns", "token"),
518        ] {
519            let mut tail = vec![OsString::from(leaf), OsString::from("list")];
520
521            apply_global_network(command, &mut tail, Some("ic".to_string()))
522                .expect("NNS and SNS families support the global network");
523
524            assert_eq!(
525                tail,
526                vec![
527                    OsString::from(leaf),
528                    OsString::from("list"),
529                    OsString::from(INTERNAL_NETWORK_OPTION),
530                    OsString::from("ic")
531                ]
532            );
533        }
534    }
535
536    #[test]
537    fn non_mainnet_network_is_rejected_before_nns_or_sns_dispatch() {
538        for command in ["nns", "sns"] {
539            let mut tail = if command == "nns" {
540                vec![OsString::from("proposal"), OsString::from("list")]
541            } else {
542                vec![OsString::from("list")]
543            };
544
545            let error = apply_global_network(command, &mut tail, Some("local".to_string()))
546                .expect_err("current NNS and SNS adapters are mainnet-only");
547
548            assert_eq!(error.exit_code(), 2);
549            assert!(error.to_string().contains("supports only the mainnet `ic`"));
550            assert!(error.to_string().contains("received `local`"));
551            assert!(!tail_has_option(&tail, INTERNAL_NETWORK_OPTION));
552        }
553
554        let mut preforwarded_tail = vec![
555            OsString::from("proposal"),
556            OsString::from("list"),
557            OsString::from(INTERNAL_NETWORK_OPTION),
558            OsString::from(MAINNET_NETWORK),
559        ];
560        let error = apply_global_network("nns", &mut preforwarded_tail, Some("local".to_string()))
561            .expect_err("an internal forwarded value must not bypass global validation");
562        assert!(error.to_string().contains("received `local`"));
563
564        for args in [
565            vec![
566                OsString::from("--network"),
567                OsString::from("local"),
568                OsString::from("nns"),
569                OsString::from("proposal"),
570                OsString::from("list"),
571            ],
572            vec![
573                OsString::from("--network"),
574                OsString::from("local"),
575                OsString::from("nns"),
576                OsString::from("governance"),
577                OsString::from("economics"),
578            ],
579            vec![
580                OsString::from("--network"),
581                OsString::from("local"),
582                OsString::from("nns"),
583                OsString::from("neuron"),
584                OsString::from("list"),
585            ],
586            vec![
587                OsString::from("--network"),
588                OsString::from("local"),
589                OsString::from("sns"),
590                OsString::from("list"),
591            ],
592            vec![
593                OsString::from("--network"),
594                OsString::from("local"),
595                OsString::from("sns"),
596                OsString::from("canister"),
597                OsString::from("list"),
598                OsString::from("1"),
599            ],
600        ] {
601            let command = args[2].to_string_lossy().into_owned();
602            let error = run(args).expect_err("non-mainnet network must fail before dispatch");
603
604            assert_eq!(error.exit_code(), 2);
605            assert!(
606                error
607                    .to_string()
608                    .starts_with(&format!("`icq {command}` currently"))
609            );
610        }
611    }
612
613    #[test]
614    fn global_network_is_rejected_when_the_family_uses_endpoint_identity() {
615        let mut icrc_tail = vec![OsString::from("ledger"), OsString::from("token")];
616
617        let error = apply_global_network("icrc", &mut icrc_tail, Some("ic".to_string()))
618            .expect_err("ICRC must reject an inapplicable global network");
619
620        assert_eq!(error.exit_code(), 2);
621        assert!(error.to_string().contains("--network is not supported"));
622        assert!(error.to_string().contains("icq icrc"));
623        assert!(error.to_string().contains("--source-endpoint"));
624        assert_eq!(
625            icrc_tail,
626            vec![OsString::from("ledger"), OsString::from("token")]
627        );
628
629        let error = run([
630            OsString::from("--network"),
631            OsString::from("ic"),
632            OsString::from("icrc"),
633            OsString::from("ledger"),
634            OsString::from("token"),
635            OsString::from("ryjl3-tyaaa-aaaaa-aaaba-cai"),
636        ])
637        .expect_err("ICRC global network must fail before dispatch");
638
639        assert_eq!(error.exit_code(), 2);
640        assert!(error.to_string().contains("--source-endpoint"));
641
642        let error = run([
643            OsString::from("icrc"),
644            OsString::from("ledger"),
645            OsString::from("token"),
646            OsString::from("ryjl3-tyaaa-aaaaa-aaaba-cai"),
647            OsString::from("--network"),
648            OsString::from("ic"),
649        ])
650        .expect_err("command-local ICRC network must use the same rejection");
651
652        assert_eq!(error.exit_code(), 2);
653        assert!(error.to_string().contains("--network is not supported"));
654        assert!(!error.to_string().contains("put it before the command"));
655
656        assert!(
657            run([
658                OsString::from("--network"),
659                OsString::from("ic"),
660                OsString::from("icrc"),
661                OsString::from("ledger"),
662                OsString::from("token"),
663                OsString::from("help"),
664            ])
665            .is_ok(),
666            "help must remain available without dispatching a query"
667        );
668    }
669
670    #[test]
671    fn malformed_source_endpoint_returns_typed_error_without_network_io() {
672        let error = run([
673            OsString::from("icrc"),
674            OsString::from("ledger"),
675            OsString::from("token"),
676            OsString::from("ryjl3-tyaaa-aaaaa-aaaba-cai"),
677            OsString::from("--source-endpoint"),
678            OsString::from(":::"),
679        ])
680        .expect_err("malformed endpoint must return an error");
681
682        assert_eq!(error.exit_code(), 1);
683        assert!(error.to_string().contains("failed to build IC agent"));
684        assert!(error.to_string().contains(":::"));
685    }
686
687    #[test]
688    fn sns_nested_commands_dispatch_through_clap_subcommands() {
689        assert!(
690            run([
691                OsString::from("sns"),
692                OsString::from("neuron"),
693                OsString::from("refresh"),
694                OsString::from("--help")
695            ])
696            .is_ok()
697        );
698        assert!(
699            run([
700                OsString::from("sns"),
701                OsString::from("proposal"),
702                OsString::from("cache"),
703                OsString::from("status"),
704                OsString::from("--help")
705            ])
706            .is_ok()
707        );
708    }
709
710    fn assert_run_ok(args: &[&str]) {
711        let args = args.iter().copied().map(OsString::from).collect::<Vec<_>>();
712        if let Err(err) = run(args.clone()) {
713            panic!("expected {args:?} to succeed, got {err}");
714        }
715    }
716}