1mod cli;
2mod ic;
3mod icrc;
4mod nns;
5mod output;
6mod progress;
7mod sns;
8mod storage;
9mod system;
10
11#[cfg(test)]
12mod test_support;
13
14use crate::cli::clap::{parse_matches, string_option};
15use clap::{Arg, Command, error::ErrorKind};
16use ic_query::subnet_catalog::MAINNET_NETWORK;
17use std::ffi::OsString;
18use thiserror::Error as ThisError;
19
20const 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";
21
22#[derive(Debug, ThisError)]
29pub enum IcqCliError {
30 #[error("{0}")]
31 Usage(String),
32
33 #[error("nns: {0}")]
34 Nns(#[from] nns::NnsCommandError),
35
36 #[error("icrc: {0}")]
37 Icrc(#[from] icrc::IcrcCommandError),
38
39 #[error("ic: {0}")]
40 Ic(#[from] ic::IcCommandError),
41
42 #[error("sns: {0}")]
43 Sns(#[from] sns::SnsCommandError),
44
45 #[error("system: {0}")]
46 System(#[from] system::SystemCommandError),
47}
48
49impl IcqCliError {
50 #[must_use]
52 pub fn is_broken_pipe(&self) -> bool {
53 match self {
54 Self::Ic(ic::IcCommandError::Io(err))
55 | Self::Nns(nns::NnsCommandError::Io(err))
56 | Self::Icrc(icrc::IcrcCommandError::Io(err))
57 | Self::Sns(sns::SnsCommandError::Io(err))
58 | Self::System(system::SystemCommandError::Io(err)) => {
59 err.kind() == std::io::ErrorKind::BrokenPipe
60 }
61 Self::Usage(_)
62 | Self::Nns(_)
63 | Self::Icrc(_)
64 | Self::Ic(_)
65 | Self::Sns(_)
66 | Self::System(_) => false,
67 }
68 }
69
70 #[must_use]
72 pub const fn exit_code(&self) -> i32 {
73 match self {
74 Self::Usage(_)
75 | Self::Ic(ic::IcCommandError::Usage(_))
76 | Self::Nns(nns::NnsCommandError::Usage(_))
77 | Self::Icrc(icrc::IcrcCommandError::Usage(_))
78 | Self::Sns(sns::SnsCommandError::Usage(_))
79 | Self::System(system::SystemCommandError::Usage(_)) => 2,
80 Self::Nns(_) | Self::Icrc(_) | Self::Ic(_) | Self::Sns(_) | Self::System(_) => 1,
81 }
82 }
83}
84
85pub fn run_from_env() -> Result<(), IcqCliError> {
87 run(std::env::args_os().skip(1))
88}
89
90pub fn run<I>(args: I) -> Result<(), IcqCliError>
92where
93 I: IntoIterator<Item = OsString>,
94{
95 let matches = match parse_matches(top_level_command(), args) {
96 Ok(matches) => matches,
97 Err(error)
98 if matches!(
99 error.kind(),
100 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
101 ) =>
102 {
103 print!("{error}");
104 return Ok(());
105 }
106 Err(error) => return Err(IcqCliError::Usage(error.to_string())),
107 };
108
109 let selected_network = string_option(&matches, "network");
110 let network = selected_network.as_deref().unwrap_or(MAINNET_NETWORK);
111 let Some((command, matches)) = matches.subcommand() else {
112 return Err(IcqCliError::Usage(usage()));
113 };
114
115 match command {
116 "ic" => {
117 reject_network_for_endpoint_family(command, selected_network.as_deref())?;
118 Ok(ic::run_matches(matches)?)
119 }
120 "icrc" => {
121 reject_network_for_endpoint_family(command, selected_network.as_deref())?;
122 Ok(icrc::run_matches(matches)?)
123 }
124 "nns" => Ok(nns::run_matches(matches, network)?),
125 "sns" => Ok(sns::run_matches(matches, network)?),
126 "system" => Ok(system::run_matches(matches, network)?),
127 _ => unreachable!("clap only returns declared top-level commands"),
128 }
129}
130
131fn reject_network_for_endpoint_family(
132 command: &str,
133 selected_network: Option<&str>,
134) -> Result<(), IcqCliError> {
135 if selected_network.is_none() {
136 return Ok(());
137 }
138 Err(IcqCliError::Usage(format!(
139 "--network is not supported by `icq {command}`; use the command's --source-endpoint option to select its API endpoint\n\n{}",
140 usage()
141 )))
142}
143
144fn network_arg() -> Arg {
145 Arg::new("network")
146 .num_args(1)
147 .long("network")
148 .value_name("name")
149 .value_parser([MAINNET_NETWORK])
150 .help("Network identity for NNS, SNS, and system commands; currently only ic")
151}
152
153fn top_level_command() -> Command {
154 Command::new("icq")
155 .version(env!("CARGO_PKG_VERSION"))
156 .propagate_version(true)
157 .about("Internet Computer metadata query CLI")
158 .arg(network_arg())
159 .subcommand_help_heading("Commands")
160 .help_template(TOP_LEVEL_HELP_TEMPLATE)
161 .after_help("Run `icq <command> --help` for command-specific help.")
162 .subcommand(ic::command())
163 .subcommand(icrc::command())
164 .subcommand(nns::command())
165 .subcommand(sns::command())
166 .subcommand(system::command())
167}
168
169fn usage() -> String {
170 let mut command = top_level_command();
171 command.render_help().to_string()
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn usage_lists_query_families_and_native_help_guidance() {
180 let text = usage();
181
182 assert!(text.contains("Usage: icq [OPTIONS] [COMMAND]"));
183 assert!(text.contains("ic"));
184 assert!(text.contains("Inspect official IC Dashboard data"));
185 assert!(text.contains("icrc"));
186 assert!(text.contains("Inspect generic ICRC ledgers"));
187 assert!(text.contains("nns"));
188 assert!(text.contains("Inspect NNS metadata"));
189 assert!(text.contains("sns"));
190 assert!(text.contains("Inspect SNS metadata"));
191 assert!(text.contains("system"));
192 assert!(text.contains("Inspect native IC system-canister metadata"));
193 assert!(text.contains("Run `icq <command> --help`"));
194 }
195
196 #[test]
197 fn native_help_and_propagated_version_return_without_dispatch() {
198 for args in [
199 &["--help"][..],
200 &["ic", "canister", "info", "--help"],
201 &[
202 "icrc",
203 "account",
204 "transaction",
205 "cache",
206 "status",
207 "--help",
208 ],
209 &["nns", "topology", "providers", "--help"],
210 &["sns", "proposal", "cache", "status", "--help"],
211 &["system", "cycles", "--help"],
212 &["--version"],
213 &["nns", "subnet", "list", "--version"],
214 ] {
215 assert_run_ok(args);
216 }
217 }
218
219 #[test]
220 fn every_composed_command_path_supports_native_help() {
221 fn collect_paths(
222 command: &Command,
223 prefix: &mut Vec<OsString>,
224 paths: &mut Vec<Vec<OsString>>,
225 ) {
226 for subcommand in command.get_subcommands() {
227 prefix.push(OsString::from(subcommand.get_name()));
228 paths.push(prefix.clone());
229 collect_paths(subcommand, prefix, paths);
230 prefix.pop();
231 }
232 }
233
234 let mut paths = Vec::new();
235 collect_paths(&top_level_command(), &mut Vec::new(), &mut paths);
236 assert!(!paths.is_empty());
237
238 for mut path in paths {
239 path.push(OsString::from("--help"));
240 let error = parse_matches(top_level_command(), path.clone())
241 .expect_err("native help must stop before typed dispatch");
242 assert_eq!(
243 error.kind(),
244 ErrorKind::DisplayHelp,
245 "unexpected result for {path:?}"
246 );
247 }
248 }
249
250 #[test]
251 fn every_report_leaf_exposes_the_shared_json_flag() {
252 fn assert_leaf_json(command: &Command, path: &mut Vec<String>) {
253 let subcommands = command.get_subcommands().collect::<Vec<_>>();
254 if subcommands.is_empty() {
255 assert!(
256 command
257 .get_arguments()
258 .any(|argument| argument.get_id() == "json"),
259 "missing --json on {}",
260 path.join(" ")
261 );
262 return;
263 }
264
265 for subcommand in subcommands {
266 path.push(subcommand.get_name().to_string());
267 assert_leaf_json(subcommand, path);
268 path.pop();
269 }
270 }
271
272 assert_leaf_json(&top_level_command(), &mut vec!["icq".to_string()]);
273 }
274
275 #[test]
276 fn clap_rejects_non_mainnet_and_command_local_network_options() {
277 let error = run([
278 OsString::from("--network"),
279 OsString::from("local"),
280 OsString::from("nns"),
281 OsString::from("registry"),
282 OsString::from("version"),
283 ])
284 .expect_err("non-mainnet network must fail in Clap");
285 assert_eq!(error.exit_code(), 2);
286 assert!(error.to_string().contains("invalid value 'local'"));
287
288 let error = run([
289 OsString::from("nns"),
290 OsString::from("registry"),
291 OsString::from("version"),
292 OsString::from("--network"),
293 OsString::from("ic"),
294 ])
295 .expect_err("network remains a top-level option");
296 assert_eq!(error.exit_code(), 2);
297 assert!(
298 error
299 .to_string()
300 .contains("unexpected argument '--network'")
301 );
302 }
303
304 #[test]
305 fn network_is_rejected_for_endpoint_identified_families() {
306 for args in [
307 &["--network", "ic", "ic", "canister", "count"][..],
308 &[
309 "--network",
310 "ic",
311 "icrc",
312 "ledger",
313 "token",
314 "ryjl3-tyaaa-aaaaa-aaaba-cai",
315 ],
316 ] {
317 let error = run(args.iter().map(OsString::from))
318 .expect_err("endpoint-identified families must reject --network");
319 assert_eq!(error.exit_code(), 2);
320 assert!(error.to_string().contains("--source-endpoint"));
321 }
322 }
323
324 #[test]
325 fn typed_cli_errors_preserve_exit_and_broken_pipe_semantics() {
326 for usage in [
327 IcqCliError::Ic(ic::IcCommandError::Usage("bad input".to_string())),
328 IcqCliError::Icrc(icrc::IcrcCommandError::Usage("bad input".to_string())),
329 IcqCliError::System(system::SystemCommandError::Usage("bad input".to_string())),
330 ] {
331 assert_eq!(usage.exit_code(), 2);
332 assert!(!usage.is_broken_pipe());
333 }
334
335 for broken_pipe in [
336 IcqCliError::Ic(ic::IcCommandError::Io(std::io::Error::from(
337 std::io::ErrorKind::BrokenPipe,
338 ))),
339 IcqCliError::Icrc(icrc::IcrcCommandError::Io(std::io::Error::from(
340 std::io::ErrorKind::BrokenPipe,
341 ))),
342 IcqCliError::System(system::SystemCommandError::Io(std::io::Error::from(
343 std::io::ErrorKind::BrokenPipe,
344 ))),
345 ] {
346 assert_eq!(broken_pipe.exit_code(), 1);
347 assert!(broken_pipe.is_broken_pipe());
348 }
349 }
350
351 fn assert_run_ok(args: &[&str]) {
352 let args = args.iter().copied().map(OsString::from).collect::<Vec<_>>();
353 if let Err(err) = run(args.clone()) {
354 panic!("expected {args:?} to succeed, got {err}");
355 }
356 }
357}