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, 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 matches = match parse_matches(top_level_command(), args) {
93        Ok(matches) => matches,
94        Err(error)
95            if matches!(
96                error.kind(),
97                ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
98            ) =>
99        {
100            print!("{error}");
101            return Ok(());
102        }
103        Err(error) => return Err(IcqCliError::Usage(error.to_string())),
104    };
105
106    let selected_network = string_option(&matches, "network");
107    let network = selected_network.as_deref().unwrap_or(MAINNET_NETWORK);
108    let Some((command, matches)) = matches.subcommand() else {
109        return Err(IcqCliError::Usage(usage()));
110    };
111
112    match command {
113        "ic" => {
114            reject_network_for_endpoint_family(command, selected_network.as_deref())?;
115            Ok(ic::run_matches(matches)?)
116        }
117        "icrc" => {
118            reject_network_for_endpoint_family(command, selected_network.as_deref())?;
119            Ok(icrc::run_matches(matches)?)
120        }
121        "nns" => Ok(nns::run_matches(matches, network)?),
122        "sns" => Ok(sns::run_matches(
123            matches,
124            network,
125            selected_network.is_some(),
126        )?),
127        "system" => Ok(system::run_matches(matches, network)?),
128        _ => unreachable!("clap only returns declared top-level commands"),
129    }
130}
131
132fn reject_network_for_endpoint_family(
133    command: &str,
134    selected_network: Option<&str>,
135) -> Result<(), IcqCliError> {
136    if selected_network.is_none() {
137        return Ok(());
138    }
139    Err(IcqCliError::Usage(format!(
140        "--network is not supported by `icq {command}`; use the command's --source-endpoint option to select its API endpoint\n\n{}",
141        usage()
142    )))
143}
144
145fn network_arg() -> Arg {
146    Arg::new("network")
147        .num_args(1)
148        .long("network")
149        .value_name("name")
150        .value_parser([MAINNET_NETWORK])
151        .help("Network identity for NNS, SNS, and system commands; currently only ic")
152}
153
154fn top_level_command() -> Command {
155    Command::new("icq")
156        .version(env!("CARGO_PKG_VERSION"))
157        .propagate_version(true)
158        .about("Internet Computer metadata query CLI")
159        .arg(network_arg())
160        .subcommand_help_heading("Commands")
161        .help_template(TOP_LEVEL_HELP_TEMPLATE)
162        .after_help("Run `icq <command> --help` for command-specific help.")
163        .subcommand(ic::command())
164        .subcommand(icrc::command())
165        .subcommand(nns::command())
166        .subcommand(sns::command())
167        .subcommand(system::command())
168}
169
170fn usage() -> String {
171    let mut command = top_level_command();
172    command.render_help().to_string()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn usage_lists_query_families_and_native_help_guidance() {
181        let text = usage();
182
183        assert!(text.contains("Usage: icq [OPTIONS] [COMMAND]"));
184        assert!(text.contains("ic"));
185        assert!(text.contains("Inspect official IC Dashboard data"));
186        assert!(text.contains("icrc"));
187        assert!(text.contains("Inspect generic ICRC ledgers"));
188        assert!(text.contains("nns"));
189        assert!(text.contains("Inspect NNS metadata"));
190        assert!(text.contains("sns"));
191        assert!(text.contains("Inspect SNS metadata"));
192        assert!(text.contains("system"));
193        assert!(text.contains("Inspect native IC system-canister metadata"));
194        assert!(text.contains("Run `icq <command> --help`"));
195    }
196
197    #[test]
198    fn native_help_and_propagated_version_return_without_dispatch() {
199        for args in [
200            &["--help"][..],
201            &["ic", "canister", "info", "--help"],
202            &[
203                "icrc",
204                "account",
205                "transaction",
206                "cache",
207                "status",
208                "--help",
209            ],
210            &["nns", "topology", "providers", "--help"],
211            &["sns", "proposal", "cache", "status", "--help"],
212            &["system", "cycles", "--help"],
213            &["--version"],
214            &["nns", "subnet", "list", "--version"],
215        ] {
216            assert_run_ok(args);
217        }
218    }
219
220    #[test]
221    fn every_composed_command_path_supports_native_help() {
222        fn collect_paths(
223            command: &Command,
224            prefix: &mut Vec<OsString>,
225            paths: &mut Vec<Vec<OsString>>,
226        ) {
227            for subcommand in command.get_subcommands() {
228                prefix.push(OsString::from(subcommand.get_name()));
229                paths.push(prefix.clone());
230                collect_paths(subcommand, prefix, paths);
231                prefix.pop();
232            }
233        }
234
235        let mut paths = Vec::new();
236        collect_paths(&top_level_command(), &mut Vec::new(), &mut paths);
237        assert!(!paths.is_empty());
238
239        for mut path in paths {
240            path.push(OsString::from("--help"));
241            let error = parse_matches(top_level_command(), path.clone())
242                .expect_err("native help must stop before typed dispatch");
243            assert_eq!(
244                error.kind(),
245                ErrorKind::DisplayHelp,
246                "unexpected result for {path:?}"
247            );
248        }
249    }
250
251    #[test]
252    fn every_report_leaf_exposes_the_shared_json_flag() {
253        fn assert_leaf_json(command: &Command, path: &mut Vec<String>) {
254            let subcommands = command.get_subcommands().collect::<Vec<_>>();
255            if subcommands.is_empty() {
256                assert!(
257                    command
258                        .get_arguments()
259                        .any(|argument| argument.get_id() == "json"),
260                    "missing --json on {}",
261                    path.join(" ")
262                );
263                return;
264            }
265
266            for subcommand in subcommands {
267                path.push(subcommand.get_name().to_string());
268                assert_leaf_json(subcommand, path);
269                path.pop();
270            }
271        }
272
273        assert_leaf_json(&top_level_command(), &mut vec!["icq".to_string()]);
274    }
275
276    #[test]
277    fn clap_rejects_non_mainnet_and_command_local_network_options() {
278        let error = run([
279            OsString::from("--network"),
280            OsString::from("local"),
281            OsString::from("nns"),
282            OsString::from("registry"),
283            OsString::from("version"),
284        ])
285        .expect_err("non-mainnet network must fail in Clap");
286        assert_eq!(error.exit_code(), 2);
287        assert!(error.to_string().contains("invalid value 'local'"));
288
289        let error = run([
290            OsString::from("nns"),
291            OsString::from("registry"),
292            OsString::from("version"),
293            OsString::from("--network"),
294            OsString::from("ic"),
295        ])
296        .expect_err("network remains a top-level option");
297        assert_eq!(error.exit_code(), 2);
298        assert!(
299            error
300                .to_string()
301                .contains("unexpected argument '--network'")
302        );
303    }
304
305    #[test]
306    fn network_is_rejected_for_endpoint_identified_families() {
307        for args in [
308            &["--network", "ic", "ic", "canister", "count"][..],
309            &[
310                "--network",
311                "ic",
312                "icrc",
313                "ledger",
314                "token",
315                "ryjl3-tyaaa-aaaaa-aaaba-cai",
316            ],
317        ] {
318            let error = run(args.iter().map(OsString::from))
319                .expect_err("endpoint-identified families must reject --network");
320            assert_eq!(error.exit_code(), 2);
321            assert!(error.to_string().contains("--source-endpoint"));
322        }
323    }
324
325    #[test]
326    fn explicit_network_is_rejected_for_local_reward_diff() {
327        let error = run([
328            OsString::from("--network"),
329            OsString::from("ic"),
330            OsString::from("sns"),
331            OsString::from("reward"),
332            OsString::from("diff"),
333            OsString::from("before.json"),
334            OsString::from("after.json"),
335        ])
336        .expect_err("local reward diff must reject explicit network identity");
337
338        assert_eq!(error.exit_code(), 2);
339        assert!(error.to_string().contains("local-only"));
340    }
341
342    #[test]
343    fn typed_cli_errors_preserve_exit_and_broken_pipe_semantics() {
344        for usage in [
345            IcqCliError::Ic(ic::IcCommandError::Usage("bad input".to_string())),
346            IcqCliError::Icrc(icrc::IcrcCommandError::Usage("bad input".to_string())),
347            IcqCliError::System(system::SystemCommandError::Usage("bad input".to_string())),
348        ] {
349            assert_eq!(usage.exit_code(), 2);
350            assert!(!usage.is_broken_pipe());
351        }
352
353        for broken_pipe in [
354            IcqCliError::Ic(ic::IcCommandError::Io(std::io::Error::from(
355                std::io::ErrorKind::BrokenPipe,
356            ))),
357            IcqCliError::Icrc(icrc::IcrcCommandError::Io(std::io::Error::from(
358                std::io::ErrorKind::BrokenPipe,
359            ))),
360            IcqCliError::System(system::SystemCommandError::Io(std::io::Error::from(
361                std::io::ErrorKind::BrokenPipe,
362            ))),
363        ] {
364            assert_eq!(broken_pipe.exit_code(), 1);
365            assert!(broken_pipe.is_broken_pipe());
366        }
367    }
368
369    fn assert_run_ok(args: &[&str]) {
370        let args = args.iter().copied().map(OsString::from).collect::<Vec<_>>();
371        if let Err(err) = run(args.clone()) {
372            panic!("expected {args:?} to succeed, got {err}");
373        }
374    }
375}