Skip to main content

ic_query_cli/
lib.rs

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