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(#[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    /// Whether stdout closed before the command finished writing its report.
34    #[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    /// Process exit code for this command error.
47    #[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
59/// Run the CLI from process arguments.
60pub fn run_from_env() -> Result<(), IcqCliError> {
61    run(std::env::args_os().skip(1))
62}
63
64/// Run the CLI from an argument iterator.
65pub 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}