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