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
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}