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