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