Skip to main content

ic_query_cli/
lib.rs

1mod cli;
2mod ic;
3mod icrc;
4mod nns;
5mod output;
6mod progress;
7mod sns;
8mod storage;
9mod system;
10
11use crate::cli::clap::{parse_matches, prepare_command_tree, string_option};
12use clap::{Arg, Command, error::ErrorKind};
13use ic_query::subnet_catalog::MAINNET_NETWORK;
14use std::ffi::OsString;
15use thiserror::Error as ThisError;
16
17const 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";
18
19///
20/// IcqCliError
21///
22/// Top-level CLI dispatch error.
23///
24
25#[derive(Debug, ThisError)]
26pub enum IcqCliError {
27    #[error("{0}")]
28    Usage(String),
29
30    #[error("nns: {0}")]
31    Nns(#[from] nns::NnsCommandError),
32
33    #[error("icrc: {0}")]
34    Icrc(#[from] icrc::IcrcCommandError),
35
36    #[error("ic: {0}")]
37    Ic(#[from] ic::IcCommandError),
38
39    #[error("sns: {0}")]
40    Sns(#[from] sns::SnsCommandError),
41
42    #[error("system: {0}")]
43    System(#[from] system::SystemCommandError),
44}
45
46impl IcqCliError {
47    /// Whether stdout closed before the command finished writing its report.
48    #[must_use]
49    pub fn is_broken_pipe(&self) -> bool {
50        match self {
51            Self::Ic(ic::IcCommandError::Io(err))
52            | Self::Nns(nns::NnsCommandError::Io(err))
53            | Self::Icrc(icrc::IcrcCommandError::Io(err))
54            | Self::Sns(sns::SnsCommandError::Io(err))
55            | Self::System(system::SystemCommandError::Io(err)) => {
56                err.kind() == std::io::ErrorKind::BrokenPipe
57            }
58            Self::Usage(_)
59            | Self::Nns(_)
60            | Self::Icrc(_)
61            | Self::Ic(_)
62            | Self::Sns(_)
63            | Self::System(_) => false,
64        }
65    }
66
67    /// Process exit code for this command error.
68    #[must_use]
69    pub const fn exit_code(&self) -> i32 {
70        match self {
71            Self::Usage(_)
72            | Self::Ic(ic::IcCommandError::Usage(_))
73            | Self::Nns(nns::NnsCommandError::Usage(_))
74            | Self::Icrc(icrc::IcrcCommandError::Usage(_))
75            | Self::Sns(sns::SnsCommandError::Usage(_))
76            | Self::System(system::SystemCommandError::Usage(_)) => 2,
77            Self::Nns(_) | Self::Icrc(_) | Self::Ic(_) | Self::Sns(_) | Self::System(_) => 1,
78        }
79    }
80}
81
82/// Run the CLI from process arguments.
83pub fn run_from_env() -> Result<(), IcqCliError> {
84    run(std::env::args_os().skip(1))
85}
86
87/// Run the CLI from an argument iterator.
88pub fn run<I>(args: I) -> Result<(), IcqCliError>
89where
90    I: IntoIterator<Item = OsString>,
91{
92    let command = cli_command();
93    let matches = match parse_matches(command.clone(), args) {
94        Ok(matches) => matches,
95        Err(error)
96            if matches!(
97                error.kind(),
98                ErrorKind::DisplayHelp
99                    | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
100                    | ErrorKind::DisplayVersion
101            ) =>
102        {
103            print!("{error}");
104            return Ok(());
105        }
106        Err(error) => return Err(IcqCliError::Usage(error.to_string())),
107    };
108
109    if let Some(help) = selected_namespace_help(command, &matches) {
110        print!("{help}");
111        return Ok(());
112    }
113
114    let selected_network = string_option(&matches, "network");
115    let network = selected_network.as_deref().unwrap_or(MAINNET_NETWORK);
116    let Some((command, matches)) = matches.subcommand() else {
117        return Err(IcqCliError::Usage(usage()));
118    };
119
120    match command {
121        "ic" => {
122            reject_network_for_endpoint_family(command, selected_network.as_deref())?;
123            Ok(ic::run_matches(matches)?)
124        }
125        "icrc" => {
126            reject_network_for_endpoint_family(command, selected_network.as_deref())?;
127            Ok(icrc::run_matches(matches)?)
128        }
129        "nns" => Ok(nns::run_matches(matches, network)?),
130        "sns" => Ok(sns::run_matches(
131            matches,
132            network,
133            selected_network.is_some(),
134        )?),
135        "system" => Ok(system::run_matches(matches, network)?),
136        _ => unreachable!("clap only returns declared top-level commands"),
137    }
138}
139
140fn reject_network_for_endpoint_family(
141    command: &str,
142    selected_network: Option<&str>,
143) -> Result<(), IcqCliError> {
144    if selected_network.is_none() {
145        return Ok(());
146    }
147    Err(IcqCliError::Usage(format!(
148        "--network is not supported by `icq {command}`; use the command's --source-endpoint option to select its API endpoint\n\n{}",
149        usage()
150    )))
151}
152
153fn network_arg() -> Arg {
154    Arg::new("network")
155        .num_args(1)
156        .long("network")
157        .value_name("name")
158        .value_parser([MAINNET_NETWORK])
159        .help("Network identity for NNS, SNS, and system commands; currently only ic")
160}
161
162fn top_level_command() -> Command {
163    Command::new("icq")
164        .version(env!("CARGO_PKG_VERSION"))
165        .propagate_version(true)
166        .about("Internet Computer metadata query CLI")
167        .arg(network_arg())
168        .subcommand_help_heading("Commands")
169        .help_template(TOP_LEVEL_HELP_TEMPLATE)
170        .after_help("Run `icq <command> --help` for command-specific help.")
171        .subcommand(ic::command())
172        .subcommand(icrc::command())
173        .subcommand(nns::command())
174        .subcommand(sns::command())
175        .subcommand(system::command())
176}
177
178fn cli_command() -> Command {
179    prepare_command_tree(top_level_command())
180}
181
182fn selected_namespace_help(mut command: Command, matches: &clap::ArgMatches) -> Option<String> {
183    let mut selected_command = &mut command;
184    let mut selected_matches = matches;
185    while let Some((name, subcommand_matches)) = selected_matches.subcommand() {
186        selected_command = selected_command.find_subcommand_mut(name)?;
187        selected_matches = subcommand_matches;
188    }
189
190    let has_operational_subcommands = selected_command
191        .get_subcommands()
192        .any(|subcommand| subcommand.get_name() != "help");
193    has_operational_subcommands.then(|| selected_command.render_help().to_string())
194}
195
196fn usage() -> String {
197    let mut command = cli_command();
198    command.render_help().to_string()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn usage_lists_query_families_and_native_help_guidance() {
207        let text = usage();
208
209        assert!(text.contains("Usage: icq [OPTIONS] [COMMAND]"));
210        assert!(text.contains("ic"));
211        assert!(text.contains("Inspect official IC Dashboard data"));
212        assert!(text.contains("icrc"));
213        assert!(text.contains("Inspect generic ICRC ledgers"));
214        assert!(text.contains("nns"));
215        assert!(text.contains("Inspect NNS metadata"));
216        assert!(text.contains("sns"));
217        assert!(text.contains("Inspect SNS metadata"));
218        assert!(text.contains("system"));
219        assert!(text.contains("Inspect native IC system-canister metadata"));
220        assert!(text.contains("Run `icq <command> --help`"));
221    }
222
223    #[test]
224    fn every_subcommand_uses_alphabetical_help_order() {
225        fn assert_equal_display_order(command: &Command, path: &mut Vec<String>) {
226            for subcommand in command.get_subcommands() {
227                path.push(subcommand.get_name().to_string());
228                assert_eq!(
229                    subcommand.get_display_order(),
230                    0,
231                    "non-alphabetical display rank for {}",
232                    path.join(" ")
233                );
234                assert_equal_display_order(subcommand, path);
235                path.pop();
236            }
237        }
238
239        assert_equal_display_order(&cli_command(), &mut vec!["icq".to_string()]);
240    }
241
242    #[test]
243    fn every_command_namespace_defaults_to_local_help() {
244        fn assert_namespace_policy(command: &Command, path: &mut Vec<String>) {
245            let has_operational_subcommands = command
246                .get_subcommands()
247                .any(|subcommand| subcommand.get_name() != "help");
248            if has_operational_subcommands {
249                assert!(
250                    command.is_arg_required_else_help_set(),
251                    "missing default help policy for {}",
252                    path.join(" ")
253                );
254                assert!(
255                    !command.is_subcommand_required_set(),
256                    "terse missing-subcommand policy remains on {}",
257                    path.join(" ")
258                );
259            }
260
261            for subcommand in command
262                .get_subcommands()
263                .filter(|subcommand| subcommand.get_name() != "help")
264            {
265                path.push(subcommand.get_name().to_string());
266                assert_namespace_policy(subcommand, path);
267                path.pop();
268            }
269        }
270
271        assert_namespace_policy(&cli_command(), &mut vec!["icq".to_string()]);
272    }
273
274    #[test]
275    fn native_help_and_propagated_version_return_without_dispatch() {
276        for args in [
277            &["--help"][..],
278            &["ic", "canister", "info", "--help"],
279            &[
280                "icrc",
281                "account",
282                "transaction",
283                "cache",
284                "status",
285                "--help",
286            ],
287            &["nns", "topology", "providers", "--help"],
288            &["sns", "proposal", "cache", "status", "--help"],
289            &["system", "cycles", "--help"],
290            &["--version"],
291            &["nns", "subnet", "list", "--version"],
292        ] {
293            assert_run_ok(args);
294        }
295    }
296
297    #[test]
298    fn every_composed_command_path_supports_native_help() {
299        fn collect_paths(
300            command: &Command,
301            prefix: &mut Vec<OsString>,
302            paths: &mut Vec<Vec<OsString>>,
303        ) {
304            for subcommand in command.get_subcommands() {
305                prefix.push(OsString::from(subcommand.get_name()));
306                paths.push(prefix.clone());
307                collect_paths(subcommand, prefix, paths);
308                prefix.pop();
309            }
310        }
311
312        let mut paths = Vec::new();
313        collect_paths(&top_level_command(), &mut Vec::new(), &mut paths);
314        assert!(!paths.is_empty());
315
316        for mut path in paths {
317            path.push(OsString::from("--help"));
318            let error = parse_matches(top_level_command(), path.clone())
319                .expect_err("native help must stop before typed dispatch");
320            assert_eq!(
321                error.kind(),
322                ErrorKind::DisplayHelp,
323                "unexpected result for {path:?}"
324            );
325        }
326    }
327
328    #[test]
329    fn every_report_leaf_exposes_the_shared_json_flag() {
330        fn assert_leaf_json(command: &Command, path: &mut Vec<String>) {
331            let subcommands = command.get_subcommands().collect::<Vec<_>>();
332            if subcommands.is_empty() {
333                assert!(
334                    command
335                        .get_arguments()
336                        .any(|argument| argument.get_id() == "json"),
337                    "missing --json on {}",
338                    path.join(" ")
339                );
340                return;
341            }
342
343            for subcommand in subcommands {
344                path.push(subcommand.get_name().to_string());
345                assert_leaf_json(subcommand, path);
346                path.pop();
347            }
348        }
349
350        assert_leaf_json(&top_level_command(), &mut vec!["icq".to_string()]);
351    }
352
353    #[test]
354    fn clap_rejects_non_mainnet_and_command_local_network_options() {
355        let error = run([
356            OsString::from("--network"),
357            OsString::from("local"),
358            OsString::from("nns"),
359            OsString::from("registry"),
360            OsString::from("version"),
361        ])
362        .expect_err("non-mainnet network must fail in Clap");
363        assert_eq!(error.exit_code(), 2);
364        assert!(error.to_string().contains("invalid value 'local'"));
365
366        let error = run([
367            OsString::from("nns"),
368            OsString::from("registry"),
369            OsString::from("version"),
370            OsString::from("--network"),
371            OsString::from("ic"),
372        ])
373        .expect_err("network remains a top-level option");
374        assert_eq!(error.exit_code(), 2);
375        assert!(
376            error
377                .to_string()
378                .contains("unexpected argument '--network'")
379        );
380    }
381
382    #[test]
383    fn network_is_rejected_for_endpoint_identified_families() {
384        for args in [
385            &["--network", "ic", "ic", "canister", "count"][..],
386            &[
387                "--network",
388                "ic",
389                "icrc",
390                "ledger",
391                "token",
392                "ryjl3-tyaaa-aaaaa-aaaba-cai",
393            ],
394        ] {
395            let error = run(args.iter().map(OsString::from))
396                .expect_err("endpoint-identified families must reject --network");
397            assert_eq!(error.exit_code(), 2);
398            assert!(error.to_string().contains("--source-endpoint"));
399        }
400    }
401
402    #[test]
403    fn explicit_network_is_rejected_for_local_reward_diff() {
404        let error = run([
405            OsString::from("--network"),
406            OsString::from("ic"),
407            OsString::from("sns"),
408            OsString::from("reward"),
409            OsString::from("diff"),
410            OsString::from("before.json"),
411            OsString::from("after.json"),
412        ])
413        .expect_err("local reward diff must reject explicit network identity");
414
415        assert_eq!(error.exit_code(), 2);
416        assert!(error.to_string().contains("local-only"));
417    }
418
419    #[test]
420    fn targeted_sns_leaves_require_their_identifiers() {
421        for args in [
422            &["sns", "neuron", "list"][..],
423            &["sns", "proposal", "refresh"][..],
424            &["sns", "reward", "checkpoint"][..],
425        ] {
426            let error = run(args.iter().map(OsString::from))
427                .expect_err("targeted SNS operation must require an SNS selector");
428            assert_eq!(error.exit_code(), 2);
429            assert!(error.to_string().contains("<id|root-principal>"));
430        }
431    }
432
433    #[test]
434    fn typed_cli_errors_preserve_exit_and_broken_pipe_semantics() {
435        for usage in [
436            IcqCliError::Ic(ic::IcCommandError::Usage("bad input".to_string())),
437            IcqCliError::Icrc(icrc::IcrcCommandError::Usage("bad input".to_string())),
438            IcqCliError::System(system::SystemCommandError::Usage("bad input".to_string())),
439        ] {
440            assert_eq!(usage.exit_code(), 2);
441            assert!(!usage.is_broken_pipe());
442        }
443
444        for broken_pipe in [
445            IcqCliError::Ic(ic::IcCommandError::Io(std::io::Error::from(
446                std::io::ErrorKind::BrokenPipe,
447            ))),
448            IcqCliError::Icrc(icrc::IcrcCommandError::Io(std::io::Error::from(
449                std::io::ErrorKind::BrokenPipe,
450            ))),
451            IcqCliError::System(system::SystemCommandError::Io(std::io::Error::from(
452                std::io::ErrorKind::BrokenPipe,
453            ))),
454        ] {
455            assert_eq!(broken_pipe.exit_code(), 1);
456            assert!(broken_pipe.is_broken_pipe());
457        }
458    }
459
460    fn assert_run_ok(args: &[&str]) {
461        let args = args.iter().copied().map(OsString::from).collect::<Vec<_>>();
462        if let Err(err) = run(args.clone()) {
463            panic!("expected {args:?} to succeed, got {err}");
464        }
465    }
466}