Skip to main content

ic_query_cli/
lib.rs

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