Skip to main content

ic_query_cli/
lib.rs

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