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#[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 #[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 #[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
76pub fn run_from_env() -> Result<(), IcqCliError> {
78 run(std::env::args_os().skip(1))
79}
80
81pub 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}