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