mod cli;
mod ic;
mod icrc;
mod nns;
mod output;
mod progress;
mod sns;
mod storage;
mod system;
#[cfg(test)]
mod test_support;
use crate::cli::clap::{parse_matches, string_option};
use clap::{Arg, Command, error::ErrorKind};
use ic_query::subnet_catalog::MAINNET_NETWORK;
use std::ffi::OsString;
use thiserror::Error as ThisError;
const 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";
#[derive(Debug, ThisError)]
pub enum IcqCliError {
#[error("{0}")]
Usage(String),
#[error("nns: {0}")]
Nns(#[from] nns::NnsCommandError),
#[error("icrc: {0}")]
Icrc(#[from] icrc::IcrcCommandError),
#[error("ic: {0}")]
Ic(#[from] ic::IcCommandError),
#[error("sns: {0}")]
Sns(#[from] sns::SnsCommandError),
#[error("system: {0}")]
System(#[from] system::SystemCommandError),
}
impl IcqCliError {
#[must_use]
pub fn is_broken_pipe(&self) -> bool {
match self {
Self::Ic(ic::IcCommandError::Io(err))
| Self::Nns(nns::NnsCommandError::Io(err))
| Self::Icrc(icrc::IcrcCommandError::Io(err))
| Self::Sns(sns::SnsCommandError::Io(err))
| Self::System(system::SystemCommandError::Io(err)) => {
err.kind() == std::io::ErrorKind::BrokenPipe
}
Self::Usage(_)
| Self::Nns(_)
| Self::Icrc(_)
| Self::Ic(_)
| Self::Sns(_)
| Self::System(_) => false,
}
}
#[must_use]
pub const fn exit_code(&self) -> i32 {
match self {
Self::Usage(_)
| Self::Ic(ic::IcCommandError::Usage(_))
| Self::Nns(nns::NnsCommandError::Usage(_))
| Self::Icrc(icrc::IcrcCommandError::Usage(_))
| Self::Sns(sns::SnsCommandError::Usage(_))
| Self::System(system::SystemCommandError::Usage(_)) => 2,
Self::Nns(_) | Self::Icrc(_) | Self::Ic(_) | Self::Sns(_) | Self::System(_) => 1,
}
}
}
pub fn run_from_env() -> Result<(), IcqCliError> {
run(std::env::args_os().skip(1))
}
pub fn run<I>(args: I) -> Result<(), IcqCliError>
where
I: IntoIterator<Item = OsString>,
{
let matches = match parse_matches(top_level_command(), args) {
Ok(matches) => matches,
Err(error)
if matches!(
error.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) =>
{
print!("{error}");
return Ok(());
}
Err(error) => return Err(IcqCliError::Usage(error.to_string())),
};
let selected_network = string_option(&matches, "network");
let network = selected_network.as_deref().unwrap_or(MAINNET_NETWORK);
let Some((command, matches)) = matches.subcommand() else {
return Err(IcqCliError::Usage(usage()));
};
match command {
"ic" => {
reject_network_for_endpoint_family(command, selected_network.as_deref())?;
Ok(ic::run_matches(matches)?)
}
"icrc" => {
reject_network_for_endpoint_family(command, selected_network.as_deref())?;
Ok(icrc::run_matches(matches)?)
}
"nns" => Ok(nns::run_matches(matches, network)?),
"sns" => Ok(sns::run_matches(matches, network)?),
"system" => Ok(system::run_matches(matches, network)?),
_ => unreachable!("clap only returns declared top-level commands"),
}
}
fn reject_network_for_endpoint_family(
command: &str,
selected_network: Option<&str>,
) -> Result<(), IcqCliError> {
if selected_network.is_none() {
return Ok(());
}
Err(IcqCliError::Usage(format!(
"--network is not supported by `icq {command}`; use the command's --source-endpoint option to select its API endpoint\n\n{}",
usage()
)))
}
fn network_arg() -> Arg {
Arg::new("network")
.num_args(1)
.long("network")
.value_name("name")
.value_parser([MAINNET_NETWORK])
.help("Network identity for NNS, SNS, and system commands; currently only ic")
}
fn top_level_command() -> Command {
Command::new("icq")
.version(env!("CARGO_PKG_VERSION"))
.propagate_version(true)
.about("Internet Computer metadata query CLI")
.arg(network_arg())
.subcommand_help_heading("Commands")
.help_template(TOP_LEVEL_HELP_TEMPLATE)
.after_help("Run `icq <command> --help` for command-specific help.")
.subcommand(ic::command())
.subcommand(icrc::command())
.subcommand(nns::command())
.subcommand(sns::command())
.subcommand(system::command())
}
fn usage() -> String {
let mut command = top_level_command();
command.render_help().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usage_lists_query_families_and_native_help_guidance() {
let text = usage();
assert!(text.contains("Usage: icq [OPTIONS] [COMMAND]"));
assert!(text.contains("ic"));
assert!(text.contains("Inspect official IC Dashboard data"));
assert!(text.contains("icrc"));
assert!(text.contains("Inspect generic ICRC ledgers"));
assert!(text.contains("nns"));
assert!(text.contains("Inspect NNS metadata"));
assert!(text.contains("sns"));
assert!(text.contains("Inspect SNS metadata"));
assert!(text.contains("system"));
assert!(text.contains("Inspect native IC system-canister metadata"));
assert!(text.contains("Run `icq <command> --help`"));
}
#[test]
fn native_help_and_propagated_version_return_without_dispatch() {
for args in [
&["--help"][..],
&["ic", "canister", "info", "--help"],
&[
"icrc",
"account",
"transaction",
"cache",
"status",
"--help",
],
&["nns", "topology", "providers", "--help"],
&["sns", "proposal", "cache", "status", "--help"],
&["system", "cycles", "--help"],
&["--version"],
&["nns", "subnet", "list", "--version"],
] {
assert_run_ok(args);
}
}
#[test]
fn every_composed_command_path_supports_native_help() {
fn collect_paths(
command: &Command,
prefix: &mut Vec<OsString>,
paths: &mut Vec<Vec<OsString>>,
) {
for subcommand in command.get_subcommands() {
prefix.push(OsString::from(subcommand.get_name()));
paths.push(prefix.clone());
collect_paths(subcommand, prefix, paths);
prefix.pop();
}
}
let mut paths = Vec::new();
collect_paths(&top_level_command(), &mut Vec::new(), &mut paths);
assert!(!paths.is_empty());
for mut path in paths {
path.push(OsString::from("--help"));
let error = parse_matches(top_level_command(), path.clone())
.expect_err("native help must stop before typed dispatch");
assert_eq!(
error.kind(),
ErrorKind::DisplayHelp,
"unexpected result for {path:?}"
);
}
}
#[test]
fn every_report_leaf_exposes_the_shared_json_flag() {
fn assert_leaf_json(command: &Command, path: &mut Vec<String>) {
let subcommands = command.get_subcommands().collect::<Vec<_>>();
if subcommands.is_empty() {
assert!(
command
.get_arguments()
.any(|argument| argument.get_id() == "json"),
"missing --json on {}",
path.join(" ")
);
return;
}
for subcommand in subcommands {
path.push(subcommand.get_name().to_string());
assert_leaf_json(subcommand, path);
path.pop();
}
}
assert_leaf_json(&top_level_command(), &mut vec!["icq".to_string()]);
}
#[test]
fn clap_rejects_non_mainnet_and_command_local_network_options() {
let error = run([
OsString::from("--network"),
OsString::from("local"),
OsString::from("nns"),
OsString::from("registry"),
OsString::from("version"),
])
.expect_err("non-mainnet network must fail in Clap");
assert_eq!(error.exit_code(), 2);
assert!(error.to_string().contains("invalid value 'local'"));
let error = run([
OsString::from("nns"),
OsString::from("registry"),
OsString::from("version"),
OsString::from("--network"),
OsString::from("ic"),
])
.expect_err("network remains a top-level option");
assert_eq!(error.exit_code(), 2);
assert!(
error
.to_string()
.contains("unexpected argument '--network'")
);
}
#[test]
fn network_is_rejected_for_endpoint_identified_families() {
for args in [
&["--network", "ic", "ic", "canister", "count"][..],
&[
"--network",
"ic",
"icrc",
"ledger",
"token",
"ryjl3-tyaaa-aaaaa-aaaba-cai",
],
] {
let error = run(args.iter().map(OsString::from))
.expect_err("endpoint-identified families must reject --network");
assert_eq!(error.exit_code(), 2);
assert!(error.to_string().contains("--source-endpoint"));
}
}
#[test]
fn typed_cli_errors_preserve_exit_and_broken_pipe_semantics() {
for usage in [
IcqCliError::Ic(ic::IcCommandError::Usage("bad input".to_string())),
IcqCliError::Icrc(icrc::IcrcCommandError::Usage("bad input".to_string())),
IcqCliError::System(system::SystemCommandError::Usage("bad input".to_string())),
] {
assert_eq!(usage.exit_code(), 2);
assert!(!usage.is_broken_pipe());
}
for broken_pipe in [
IcqCliError::Ic(ic::IcCommandError::Io(std::io::Error::from(
std::io::ErrorKind::BrokenPipe,
))),
IcqCliError::Icrc(icrc::IcrcCommandError::Io(std::io::Error::from(
std::io::ErrorKind::BrokenPipe,
))),
IcqCliError::System(system::SystemCommandError::Io(std::io::Error::from(
std::io::ErrorKind::BrokenPipe,
))),
] {
assert_eq!(broken_pipe.exit_code(), 1);
assert!(broken_pipe.is_broken_pipe());
}
}
fn assert_run_ok(args: &[&str]) {
let args = args.iter().copied().map(OsString::from).collect::<Vec<_>>();
if let Err(err) = run(args.clone()) {
panic!("expected {args:?} to succeed, got {err}");
}
}
}