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