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