Skip to main content

ic_query_cli/
lib.rs

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