Skip to main content

ic_query_cli/
lib.rs

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///
12/// IcqCliError
13///
14/// Top-level CLI dispatch error.
15///
16
17#[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
32/// Run the CLI from process arguments.
33pub fn run_from_env() -> Result<(), IcqCliError> {
34    run(std::env::args_os().skip(1))
35}
36
37/// Run the CLI from an argument iterator.
38pub 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(|error| format!("{error}\n{}", 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 top_level_help_requested(&args) {
121        println!("{}", usage());
122        return None;
123    }
124    Some(args)
125}
126
127fn top_level_help_requested(args: &[OsString]) -> bool {
128    let mut index = 0;
129    while index < args.len() {
130        let Some(arg) = args[index].to_str() else {
131            return false;
132        };
133        if command_family(arg).is_some() {
134            return false;
135        }
136        if matches!(arg, "help" | "--help" | "-h") {
137            return true;
138        }
139        index += if arg == "--network" { 2 } else { 1 };
140    }
141    false
142}
143
144fn network_arg() -> Arg {
145    Arg::new("network")
146        .num_args(1)
147        .long("network")
148        .value_name("name")
149        .help("ICP CLI network for networked commands")
150}
151
152fn top_level_command() -> Command {
153    Command::new("icq")
154        .version(env!("CARGO_PKG_VERSION"))
155        .about("Internet Computer metadata query CLI")
156        .disable_help_subcommand(true)
157        .disable_version_flag(true)
158        .arg(
159            Arg::new("version")
160                .short('V')
161                .long("version")
162                .action(ArgAction::SetTrue)
163                .help("Print version"),
164        )
165        .arg(network_arg().global(true))
166        .subcommand_help_heading("Commands")
167        .help_template(TOP_LEVEL_HELP_TEMPLATE)
168        .after_help("Run `icq <command> help` for command-specific help.")
169        .subcommands(
170            COMMAND_FAMILIES
171                .iter()
172                .map(|family| Command::new(family.name).about(family.about)),
173        )
174}
175
176fn top_level_dispatch_command() -> Command {
177    let command = Command::new("icq")
178        .disable_help_flag(true)
179        .disable_help_subcommand(true)
180        .disable_version_flag(true)
181        .arg(
182            Arg::new("version")
183                .short('V')
184                .long("version")
185                .action(ArgAction::SetTrue),
186        )
187        .arg(network_arg().global(true));
188
189    COMMAND_FAMILIES.iter().fold(command, |command, family| {
190        command.subcommand(passthrough_subcommand(
191            Command::new(family.name).about(family.about),
192        ))
193    })
194}
195
196fn usage() -> String {
197    let mut command = top_level_command();
198    command.render_help().to_string()
199}
200
201fn command_local_global_option(args: &[OsString]) -> Option<&'static str> {
202    let mut index = 0;
203    while index < args.len() {
204        let arg = args[index].to_str()?;
205        if command_family(arg).is_some() {
206            return args[index + 1..]
207                .iter()
208                .filter_map(|arg| arg.to_str())
209                .find_map(global_option_name);
210        }
211        index += if arg == "--network" { 2 } else { 1 };
212    }
213    None
214}
215
216fn global_option_name(arg: &str) -> Option<&'static str> {
217    match arg {
218        "--network" => Some("--network"),
219        _ if arg.starts_with("--network=") => Some("--network"),
220        _ => None,
221    }
222}
223
224fn apply_global_network(command: &str, tail: &mut Vec<OsString>, global_network: Option<String>) {
225    let Some(global_network) = global_network else {
226        return;
227    };
228    if tail_has_option(tail, INTERNAL_NETWORK_OPTION) {
229        return;
230    }
231    if !command_accepts_global_network(command, tail) {
232        return;
233    }
234
235    tail.push(OsString::from(INTERNAL_NETWORK_OPTION));
236    tail.push(OsString::from(global_network));
237}
238
239fn command_accepts_global_network(command: &str, tail: &[OsString]) -> bool {
240    command_family(command).is_some_and(|family| (family.accepts_global_network)(tail))
241}
242
243fn tail_has_option(tail: &[OsString], name: &str) -> bool {
244    tail.iter().any(|arg| arg.to_str() == Some(name))
245}
246
247#[derive(Clone, Copy, Debug)]
248struct CommandFamily {
249    name: &'static str,
250    about: &'static str,
251    accepts_global_network: fn(&[OsString]) -> bool,
252}
253
254const COMMAND_FAMILIES: &[CommandFamily] = &[
255    CommandFamily {
256        name: "icrc",
257        about: "Inspect generic ICRC ledger metadata",
258        accepts_global_network: icrc_accepts_global_network,
259    },
260    CommandFamily {
261        name: "nns",
262        about: "Inspect NNS metadata",
263        accepts_global_network: nns_accepts_global_network,
264    },
265    CommandFamily {
266        name: "sns",
267        about: "Inspect SNS metadata",
268        accepts_global_network: sns_accepts_global_network,
269    },
270];
271
272fn command_family(name: &str) -> Option<&'static CommandFamily> {
273    COMMAND_FAMILIES.iter().find(|family| family.name == name)
274}
275
276fn nns_accepts_global_network(tail: &[OsString]) -> bool {
277    matches!(
278        tail.first().and_then(|arg| arg.to_str()),
279        Some(
280            "data-center"
281                | "node"
282                | "node-operator"
283                | "node-provider"
284                | "registry"
285                | "subnet"
286                | "topology"
287        )
288    )
289}
290
291const fn icrc_accepts_global_network(_tail: &[OsString]) -> bool {
292    false
293}
294
295fn sns_accepts_global_network(tail: &[OsString]) -> bool {
296    matches!(
297        tail.first().and_then(|arg| arg.to_str()),
298        Some("list" | "info" | "token" | "params" | "proposal" | "proposals" | "neurons")
299    )
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn usage_lists_query_families() {
308        let text = usage();
309
310        assert!(text.contains("Usage: icq [OPTIONS] [COMMAND]"));
311        assert!(text.contains("icrc"));
312        assert!(text.contains("Inspect generic ICRC ledger metadata"));
313        assert!(text.contains("nns"));
314        assert!(text.contains("Inspect NNS metadata"));
315        assert!(text.contains("sns"));
316        assert!(text.contains("Inspect SNS metadata"));
317        assert!(text.contains("Run `icq <command> help`"));
318    }
319
320    #[test]
321    fn top_level_usage_snapshot() {
322        let expected = format!(
323            "\
324icq {}
325Internet Computer metadata query CLI
326
327Usage: icq [OPTIONS] [COMMAND]
328
329Commands:
330  icrc  Inspect generic ICRC ledger metadata
331  nns   Inspect NNS metadata
332  sns   Inspect SNS metadata
333
334Options:
335  -V, --version         Print version
336      --network <name>  ICP CLI network for networked commands
337  -h, --help            Print help
338
339Run `icq <command> help` for command-specific help.
340",
341            env!("CARGO_PKG_VERSION")
342        );
343
344        assert_eq!(usage(), expected);
345    }
346
347    #[test]
348    fn command_family_help_returns_ok() {
349        for args in [
350            &["icrc", "help"][..],
351            &["icrc", "token", "help"],
352            &["icrc", "balance", "help"],
353            &["icrc", "allowance", "help"],
354            &["icrc", "index", "help"],
355            &["nns", "help"][..],
356            &["nns", "data-center", "help"],
357            &["nns", "data-center", "list", "help"],
358            &["nns", "data-center", "info", "help"],
359            &["nns", "data-center", "refresh", "help"],
360            &["nns", "node", "help"],
361            &["nns", "node", "list", "help"],
362            &["nns", "node", "info", "help"],
363            &["nns", "node", "refresh", "help"],
364            &["nns", "node-provider", "help"],
365            &["nns", "node-provider", "list", "help"],
366            &["nns", "node-provider", "info", "help"],
367            &["nns", "node-provider", "refresh", "help"],
368            &["nns", "node-operator", "help"],
369            &["nns", "node-operator", "list", "help"],
370            &["nns", "node-operator", "info", "help"],
371            &["nns", "node-operator", "refresh", "help"],
372            &["nns", "proposal", "help"],
373            &["nns", "proposal", "list", "help"],
374            &["nns", "proposal", "info", "help"],
375            &["nns", "registry", "help"],
376            &["nns", "registry", "version", "help"],
377            &["nns", "subnet", "help"],
378            &["nns", "subnet", "list", "help"],
379            &["nns", "subnet", "info", "help"],
380            &["nns", "subnet", "refresh", "help"],
381            &["nns", "topology", "help"],
382            &["nns", "topology", "summary", "help"],
383            &["nns", "topology", "coverage", "help"],
384            &["nns", "topology", "versions", "help"],
385            &["nns", "topology", "health", "help"],
386            &["nns", "topology", "gaps", "help"],
387            &["nns", "topology", "capacity", "help"],
388            &["nns", "topology", "regions", "help"],
389            &["nns", "topology", "providers", "help"],
390            &["nns", "topology", "refresh", "help"],
391            &["sns", "help"],
392            &["sns", "list", "help"],
393            &["sns", "info", "help"],
394            &["sns", "token", "help"],
395            &["sns", "params", "help"],
396            &["sns", "proposal", "help"],
397            &["sns", "proposals", "help"],
398            &["sns", "neurons", "help"],
399            &["sns", "neurons", "cache", "help"],
400            &["sns", "neurons", "cache", "list", "help"],
401            &["sns", "neurons", "cache", "status", "help"],
402            &["sns", "neurons", "refresh", "help"],
403        ] {
404            assert_run_ok(args);
405        }
406    }
407
408    #[test]
409    fn version_flags_return_ok() {
410        assert_eq!(VERSION_TEXT, concat!("icq ", env!("CARGO_PKG_VERSION")));
411        assert!(run([OsString::from("--version")]).is_ok());
412        assert!(run([OsString::from("icrc"), OsString::from("--version")]).is_ok());
413        assert!(run([OsString::from("nns"), OsString::from("--version")]).is_ok());
414        assert!(run([OsString::from("sns"), OsString::from("--version")]).is_ok());
415        assert!(
416            run([
417                OsString::from("nns"),
418                OsString::from("subnet"),
419                OsString::from("list"),
420                OsString::from("--version")
421            ])
422            .is_ok()
423        );
424
425        let mut sns_info_tail = vec![OsString::from("info"), OsString::from("1")];
426
427        apply_global_network("sns", &mut sns_info_tail, Some("ic".to_string()));
428
429        assert_eq!(
430            sns_info_tail,
431            vec![
432                OsString::from("info"),
433                OsString::from("1"),
434                OsString::from(INTERNAL_NETWORK_OPTION),
435                OsString::from("ic")
436            ]
437        );
438    }
439
440    #[test]
441    fn global_network_is_forwarded_to_networked_leaf_commands() {
442        let mut nns_tail = vec![OsString::from("data-center"), OsString::from("list")];
443
444        apply_global_network("nns", &mut nns_tail, Some("ic".to_string()));
445
446        assert_eq!(
447            nns_tail,
448            vec![
449                OsString::from("data-center"),
450                OsString::from("list"),
451                OsString::from(INTERNAL_NETWORK_OPTION),
452                OsString::from("ic")
453            ]
454        );
455
456        let mut sns_tail = vec![OsString::from("list")];
457
458        apply_global_network("sns", &mut sns_tail, Some("ic".to_string()));
459
460        assert_eq!(
461            sns_tail,
462            vec![
463                OsString::from("list"),
464                OsString::from(INTERNAL_NETWORK_OPTION),
465                OsString::from("ic")
466            ]
467        );
468
469        let mut icrc_tail = vec![OsString::from("token")];
470
471        apply_global_network("icrc", &mut icrc_tail, Some("ic".to_string()));
472
473        assert_eq!(icrc_tail, vec![OsString::from("token")]);
474    }
475
476    #[test]
477    fn sns_nested_commands_dispatch_through_clap_subcommands() {
478        assert!(
479            run([
480                OsString::from("sns"),
481                OsString::from("neurons"),
482                OsString::from("refresh"),
483                OsString::from("--help")
484            ])
485            .is_ok()
486        );
487        assert!(
488            run([
489                OsString::from("sns"),
490                OsString::from("proposals"),
491                OsString::from("cache"),
492                OsString::from("status"),
493                OsString::from("--help")
494            ])
495            .is_ok()
496        );
497    }
498
499    fn assert_run_ok(args: &[&str]) {
500        let args = args.iter().copied().map(OsString::from).collect::<Vec<_>>();
501        if let Err(err) = run(args.clone()) {
502            panic!("expected {args:?} to succeed, got {err}");
503        }
504    }
505}