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, 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 matches = match parse_matches(top_level_command(), args) {
93 Ok(matches) => matches,
94 Err(error)
95 if matches!(
96 error.kind(),
97 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
98 ) =>
99 {
100 print!("{error}");
101 return Ok(());
102 }
103 Err(error) => return Err(IcqCliError::Usage(error.to_string())),
104 };
105
106 let selected_network = string_option(&matches, "network");
107 let network = selected_network.as_deref().unwrap_or(MAINNET_NETWORK);
108 let Some((command, matches)) = matches.subcommand() else {
109 return Err(IcqCliError::Usage(usage()));
110 };
111
112 match command {
113 "ic" => {
114 reject_network_for_endpoint_family(command, selected_network.as_deref())?;
115 Ok(ic::run_matches(matches)?)
116 }
117 "icrc" => {
118 reject_network_for_endpoint_family(command, selected_network.as_deref())?;
119 Ok(icrc::run_matches(matches)?)
120 }
121 "nns" => Ok(nns::run_matches(matches, network)?),
122 "sns" => Ok(sns::run_matches(
123 matches,
124 network,
125 selected_network.is_some(),
126 )?),
127 "system" => Ok(system::run_matches(matches, network)?),
128 _ => unreachable!("clap only returns declared top-level commands"),
129 }
130}
131
132fn reject_network_for_endpoint_family(
133 command: &str,
134 selected_network: Option<&str>,
135) -> Result<(), IcqCliError> {
136 if selected_network.is_none() {
137 return Ok(());
138 }
139 Err(IcqCliError::Usage(format!(
140 "--network is not supported by `icq {command}`; use the command's --source-endpoint option to select its API endpoint\n\n{}",
141 usage()
142 )))
143}
144
145fn network_arg() -> Arg {
146 Arg::new("network")
147 .num_args(1)
148 .long("network")
149 .value_name("name")
150 .value_parser([MAINNET_NETWORK])
151 .help("Network identity for NNS, SNS, and system commands; currently only ic")
152}
153
154fn top_level_command() -> Command {
155 Command::new("icq")
156 .version(env!("CARGO_PKG_VERSION"))
157 .propagate_version(true)
158 .about("Internet Computer metadata query CLI")
159 .arg(network_arg())
160 .subcommand_help_heading("Commands")
161 .help_template(TOP_LEVEL_HELP_TEMPLATE)
162 .after_help("Run `icq <command> --help` for command-specific help.")
163 .subcommand(ic::command())
164 .subcommand(icrc::command())
165 .subcommand(nns::command())
166 .subcommand(sns::command())
167 .subcommand(system::command())
168}
169
170fn usage() -> String {
171 let mut command = top_level_command();
172 command.render_help().to_string()
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn usage_lists_query_families_and_native_help_guidance() {
181 let text = usage();
182
183 assert!(text.contains("Usage: icq [OPTIONS] [COMMAND]"));
184 assert!(text.contains("ic"));
185 assert!(text.contains("Inspect official IC Dashboard data"));
186 assert!(text.contains("icrc"));
187 assert!(text.contains("Inspect generic ICRC ledgers"));
188 assert!(text.contains("nns"));
189 assert!(text.contains("Inspect NNS metadata"));
190 assert!(text.contains("sns"));
191 assert!(text.contains("Inspect SNS metadata"));
192 assert!(text.contains("system"));
193 assert!(text.contains("Inspect native IC system-canister metadata"));
194 assert!(text.contains("Run `icq <command> --help`"));
195 }
196
197 #[test]
198 fn native_help_and_propagated_version_return_without_dispatch() {
199 for args in [
200 &["--help"][..],
201 &["ic", "canister", "info", "--help"],
202 &[
203 "icrc",
204 "account",
205 "transaction",
206 "cache",
207 "status",
208 "--help",
209 ],
210 &["nns", "topology", "providers", "--help"],
211 &["sns", "proposal", "cache", "status", "--help"],
212 &["system", "cycles", "--help"],
213 &["--version"],
214 &["nns", "subnet", "list", "--version"],
215 ] {
216 assert_run_ok(args);
217 }
218 }
219
220 #[test]
221 fn every_composed_command_path_supports_native_help() {
222 fn collect_paths(
223 command: &Command,
224 prefix: &mut Vec<OsString>,
225 paths: &mut Vec<Vec<OsString>>,
226 ) {
227 for subcommand in command.get_subcommands() {
228 prefix.push(OsString::from(subcommand.get_name()));
229 paths.push(prefix.clone());
230 collect_paths(subcommand, prefix, paths);
231 prefix.pop();
232 }
233 }
234
235 let mut paths = Vec::new();
236 collect_paths(&top_level_command(), &mut Vec::new(), &mut paths);
237 assert!(!paths.is_empty());
238
239 for mut path in paths {
240 path.push(OsString::from("--help"));
241 let error = parse_matches(top_level_command(), path.clone())
242 .expect_err("native help must stop before typed dispatch");
243 assert_eq!(
244 error.kind(),
245 ErrorKind::DisplayHelp,
246 "unexpected result for {path:?}"
247 );
248 }
249 }
250
251 #[test]
252 fn every_report_leaf_exposes_the_shared_json_flag() {
253 fn assert_leaf_json(command: &Command, path: &mut Vec<String>) {
254 let subcommands = command.get_subcommands().collect::<Vec<_>>();
255 if subcommands.is_empty() {
256 assert!(
257 command
258 .get_arguments()
259 .any(|argument| argument.get_id() == "json"),
260 "missing --json on {}",
261 path.join(" ")
262 );
263 return;
264 }
265
266 for subcommand in subcommands {
267 path.push(subcommand.get_name().to_string());
268 assert_leaf_json(subcommand, path);
269 path.pop();
270 }
271 }
272
273 assert_leaf_json(&top_level_command(), &mut vec!["icq".to_string()]);
274 }
275
276 #[test]
277 fn clap_rejects_non_mainnet_and_command_local_network_options() {
278 let error = run([
279 OsString::from("--network"),
280 OsString::from("local"),
281 OsString::from("nns"),
282 OsString::from("registry"),
283 OsString::from("version"),
284 ])
285 .expect_err("non-mainnet network must fail in Clap");
286 assert_eq!(error.exit_code(), 2);
287 assert!(error.to_string().contains("invalid value 'local'"));
288
289 let error = run([
290 OsString::from("nns"),
291 OsString::from("registry"),
292 OsString::from("version"),
293 OsString::from("--network"),
294 OsString::from("ic"),
295 ])
296 .expect_err("network remains a top-level option");
297 assert_eq!(error.exit_code(), 2);
298 assert!(
299 error
300 .to_string()
301 .contains("unexpected argument '--network'")
302 );
303 }
304
305 #[test]
306 fn network_is_rejected_for_endpoint_identified_families() {
307 for args in [
308 &["--network", "ic", "ic", "canister", "count"][..],
309 &[
310 "--network",
311 "ic",
312 "icrc",
313 "ledger",
314 "token",
315 "ryjl3-tyaaa-aaaaa-aaaba-cai",
316 ],
317 ] {
318 let error = run(args.iter().map(OsString::from))
319 .expect_err("endpoint-identified families must reject --network");
320 assert_eq!(error.exit_code(), 2);
321 assert!(error.to_string().contains("--source-endpoint"));
322 }
323 }
324
325 #[test]
326 fn explicit_network_is_rejected_for_local_reward_diff() {
327 let error = run([
328 OsString::from("--network"),
329 OsString::from("ic"),
330 OsString::from("sns"),
331 OsString::from("reward"),
332 OsString::from("diff"),
333 OsString::from("before.json"),
334 OsString::from("after.json"),
335 ])
336 .expect_err("local reward diff must reject explicit network identity");
337
338 assert_eq!(error.exit_code(), 2);
339 assert!(error.to_string().contains("local-only"));
340 }
341
342 #[test]
343 fn typed_cli_errors_preserve_exit_and_broken_pipe_semantics() {
344 for usage in [
345 IcqCliError::Ic(ic::IcCommandError::Usage("bad input".to_string())),
346 IcqCliError::Icrc(icrc::IcrcCommandError::Usage("bad input".to_string())),
347 IcqCliError::System(system::SystemCommandError::Usage("bad input".to_string())),
348 ] {
349 assert_eq!(usage.exit_code(), 2);
350 assert!(!usage.is_broken_pipe());
351 }
352
353 for broken_pipe in [
354 IcqCliError::Ic(ic::IcCommandError::Io(std::io::Error::from(
355 std::io::ErrorKind::BrokenPipe,
356 ))),
357 IcqCliError::Icrc(icrc::IcrcCommandError::Io(std::io::Error::from(
358 std::io::ErrorKind::BrokenPipe,
359 ))),
360 IcqCliError::System(system::SystemCommandError::Io(std::io::Error::from(
361 std::io::ErrorKind::BrokenPipe,
362 ))),
363 ] {
364 assert_eq!(broken_pipe.exit_code(), 1);
365 assert!(broken_pipe.is_broken_pipe());
366 }
367 }
368
369 fn assert_run_ok(args: &[&str]) {
370 let args = args.iter().copied().map(OsString::from).collect::<Vec<_>>();
371 if let Err(err) = run(args.clone()) {
372 panic!("expected {args:?} to succeed, got {err}");
373 }
374 }
375}