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