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#[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 #[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 #[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
100pub fn run_from_env() -> Result<(), IcqCliError> {
102 run(std::env::args_os().skip(1))
103}
104
105pub 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}