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