ic-query-cli 0.22.6

Command-line wrapper for read-only Internet Computer metadata queries
Documentation
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";

///
/// IcqCliError
///
/// Top-level CLI dispatch error.
///

#[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 {
    /// Whether stdout closed before the command finished writing its report.
    #[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,
        }
    }

    /// Process exit code for this command error.
    #[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,
        }
    }
}

/// Run the CLI from process arguments.
pub fn run_from_env() -> Result<(), IcqCliError> {
    run(std::env::args_os().skip(1))
}

/// Run the CLI from an argument iterator.
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}");
        }
    }
}