Skip to main content

ic_query_cli/
lib.rs

1mod cli;
2mod icrc;
3mod nns;
4mod output;
5mod progress;
6mod project;
7mod sns;
8
9#[cfg(test)]
10mod test_support;
11
12use crate::cli::clap::{
13    parse_matches_or_usage, passthrough_args, passthrough_subcommand, string_option,
14};
15use clap::{Arg, ArgAction, Command};
16use std::ffi::OsString;
17use thiserror::Error as ThisError;
18
19const 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";
20const VERSION_TEXT: &str = concat!("icq ", env!("CARGO_PKG_VERSION"));
21const INTERNAL_NETWORK_OPTION: &str = "--__icq-network";
22
23const fn version_text() -> &'static str {
24    VERSION_TEXT
25}
26
27///
28/// IcqCliError
29///
30/// Top-level CLI dispatch error.
31///
32
33#[derive(Debug, ThisError)]
34pub enum IcqCliError {
35    #[error("{0}")]
36    Usage(String),
37
38    #[error("nns: {0}")]
39    Nns(#[from] nns::NnsCommandError),
40
41    #[error("icrc: {0}")]
42    Icrc(#[from] icrc::IcrcCommandError),
43
44    #[error("sns: {0}")]
45    Sns(#[from] sns::SnsCommandError),
46}
47
48impl IcqCliError {
49    /// Whether stdout closed before the command finished writing its report.
50    #[must_use]
51    pub fn is_broken_pipe(&self) -> bool {
52        match self {
53            Self::Nns(nns::NnsCommandError::Io(err))
54            | Self::Icrc(icrc::IcrcCommandError::Io(err))
55            | Self::Sns(sns::SnsCommandError::Io(err)) => {
56                err.kind() == std::io::ErrorKind::BrokenPipe
57            }
58            Self::Usage(_) | Self::Nns(_) | Self::Icrc(_) | Self::Sns(_) => false,
59        }
60    }
61
62    /// Process exit code for this command error.
63    #[must_use]
64    pub const fn exit_code(&self) -> i32 {
65        match self {
66            Self::Usage(_)
67            | Self::Nns(nns::NnsCommandError::Usage(_))
68            | Self::Icrc(icrc::IcrcCommandError::Usage(_))
69            | Self::Sns(sns::SnsCommandError::Usage(_)) => 2,
70            Self::Nns(_) | Self::Icrc(_) | Self::Sns(_) => 1,
71        }
72    }
73}
74
75/// Run the CLI from process arguments.
76pub fn run_from_env() -> Result<(), IcqCliError> {
77    run(std::env::args_os().skip(1))
78}
79
80/// Run the CLI from an argument iterator.
81pub fn run<I>(args: I) -> Result<(), IcqCliError>
82where
83    I: IntoIterator<Item = OsString>,
84{
85    let Some(args) = collect_args_or_print_help(args, usage) else {
86        return Ok(());
87    };
88    if let Some((command, option)) = command_local_global_option(&args) {
89        if command == "icrc" {
90            return Err(unsupported_global_network_error(command));
91        }
92        return Err(IcqCliError::Usage(format!(
93            "{option} is a top-level option; put it before the command\n\n{}",
94            usage()
95        )));
96    }
97
98    let matches = parse_matches_or_usage(top_level_dispatch_command(), args, usage)
99        .map_err(IcqCliError::Usage)?;
100    if matches.get_flag("version") {
101        println!("{VERSION_TEXT}");
102        return Ok(());
103    }
104    let global_network = string_option(&matches, "network");
105
106    let Some((command, subcommand_matches)) = matches.subcommand() else {
107        return Err(IcqCliError::Usage(usage()));
108    };
109    let mut tail = passthrough_args(subcommand_matches);
110    apply_global_network(command, &mut tail, global_network)?;
111    let tail = tail.into_iter();
112
113    match command {
114        "icrc" => Ok(icrc::run(tail)?),
115        "nns" => Ok(nns::run(tail)?),
116        "sns" => Ok(sns::run(tail)?),
117        _ => unreachable!("top-level dispatch command only defines known commands"),
118    }
119}
120
121fn collect_args_or_print_help<I>(args: I, usage: impl FnOnce() -> String) -> Option<Vec<OsString>>
122where
123    I: IntoIterator<Item = OsString>,
124{
125    let args = args.into_iter().collect::<Vec<_>>();
126    if top_level_help_requested(&args) {
127        println!("{}", usage());
128        return None;
129    }
130    Some(args)
131}
132
133fn top_level_help_requested(args: &[OsString]) -> bool {
134    let mut index = 0;
135    while index < args.len() {
136        let Some(arg) = args[index].to_str() else {
137            return false;
138        };
139        if command_family(arg).is_some() {
140            return false;
141        }
142        if matches!(arg, "help" | "--help" | "-h") {
143            return true;
144        }
145        index += if arg == "--network" { 2 } else { 1 };
146    }
147    false
148}
149
150fn network_arg() -> Arg {
151    Arg::new("network")
152        .num_args(1)
153        .long("network")
154        .value_name("name")
155        .help("Network identity for supported NNS and SNS commands")
156}
157
158fn top_level_command() -> Command {
159    Command::new("icq")
160        .version(env!("CARGO_PKG_VERSION"))
161        .about("Internet Computer metadata query CLI")
162        .disable_help_subcommand(true)
163        .disable_version_flag(true)
164        .arg(
165            Arg::new("version")
166                .short('V')
167                .long("version")
168                .action(ArgAction::SetTrue)
169                .help("Print version"),
170        )
171        .arg(network_arg().global(true))
172        .subcommand_help_heading("Commands")
173        .help_template(TOP_LEVEL_HELP_TEMPLATE)
174        .after_help("Run `icq <command> help` for command-specific help.")
175        .subcommands(
176            COMMAND_FAMILIES
177                .iter()
178                .map(|family| Command::new(family.name).about(family.about)),
179        )
180}
181
182fn top_level_dispatch_command() -> Command {
183    let command = Command::new("icq")
184        .disable_help_flag(true)
185        .disable_help_subcommand(true)
186        .disable_version_flag(true)
187        .arg(
188            Arg::new("version")
189                .short('V')
190                .long("version")
191                .action(ArgAction::SetTrue),
192        )
193        .arg(network_arg().global(true));
194
195    COMMAND_FAMILIES.iter().fold(command, |command, family| {
196        command.subcommand(passthrough_subcommand(
197            Command::new(family.name).about(family.about),
198        ))
199    })
200}
201
202fn usage() -> String {
203    let mut command = top_level_command();
204    command.render_help().to_string()
205}
206
207fn command_local_global_option(args: &[OsString]) -> Option<(&'static str, &'static str)> {
208    let mut index = 0;
209    while index < args.len() {
210        let arg = args[index].to_str()?;
211        if let Some(family) = command_family(arg) {
212            return args[index + 1..]
213                .iter()
214                .filter_map(|arg| arg.to_str())
215                .find_map(global_option_name)
216                .map(|option| (family.name, option));
217        }
218        index += if arg == "--network" { 2 } else { 1 };
219    }
220    None
221}
222
223fn global_option_name(arg: &str) -> Option<&'static str> {
224    match arg {
225        "--network" => Some("--network"),
226        _ if arg.starts_with("--network=") => Some("--network"),
227        _ => None,
228    }
229}
230
231fn apply_global_network(
232    command: &str,
233    tail: &mut Vec<OsString>,
234    global_network: Option<String>,
235) -> Result<(), IcqCliError> {
236    let Some(global_network) = global_network else {
237        return Ok(());
238    };
239    if tail_has_option(tail, INTERNAL_NETWORK_OPTION) {
240        return Ok(());
241    }
242    if tail_requests_help_or_version(tail) {
243        return Ok(());
244    }
245    if !command_accepts_global_network(command, tail) {
246        return Err(unsupported_global_network_error(command));
247    }
248
249    tail.push(OsString::from(INTERNAL_NETWORK_OPTION));
250    tail.push(OsString::from(global_network));
251    Ok(())
252}
253
254fn unsupported_global_network_error(command: &str) -> IcqCliError {
255    let guidance = if command == "icrc" {
256        " use the command's --source-endpoint option to select the IC API endpoint"
257    } else {
258        ""
259    };
260    IcqCliError::Usage(format!(
261        "--network is not supported by `icq {command}`;{guidance}\n\n{}",
262        usage()
263    ))
264}
265
266fn command_accepts_global_network(command: &str, tail: &[OsString]) -> bool {
267    command_family(command).is_some_and(|family| (family.accepts_global_network)(tail))
268}
269
270fn tail_has_option(tail: &[OsString], name: &str) -> bool {
271    tail.iter().any(|arg| arg.to_str() == Some(name))
272}
273
274fn tail_requests_help_or_version(tail: &[OsString]) -> bool {
275    tail.iter()
276        .filter_map(|arg| arg.to_str())
277        .any(|arg| matches!(arg, "help" | "--help" | "-h" | "--version" | "-V"))
278}
279
280#[derive(Clone, Copy, Debug)]
281struct CommandFamily {
282    name: &'static str,
283    about: &'static str,
284    accepts_global_network: fn(&[OsString]) -> bool,
285}
286
287const COMMAND_FAMILIES: &[CommandFamily] = &[
288    CommandFamily {
289        name: "icrc",
290        about: "Inspect generic ICRC ledger metadata",
291        accepts_global_network: icrc_accepts_global_network,
292    },
293    CommandFamily {
294        name: "nns",
295        about: "Inspect NNS metadata",
296        accepts_global_network: nns_accepts_global_network,
297    },
298    CommandFamily {
299        name: "sns",
300        about: "Inspect SNS metadata",
301        accepts_global_network: sns_accepts_global_network,
302    },
303];
304
305fn command_family(name: &str) -> Option<&'static CommandFamily> {
306    COMMAND_FAMILIES.iter().find(|family| family.name == name)
307}
308
309fn nns_accepts_global_network(tail: &[OsString]) -> bool {
310    matches!(
311        tail.first().and_then(|arg| arg.to_str()),
312        Some(
313            "data-center"
314                | "node"
315                | "node-operator"
316                | "node-provider"
317                | "proposal"
318                | "registry"
319                | "subnet"
320                | "topology"
321        )
322    )
323}
324
325const fn icrc_accepts_global_network(_tail: &[OsString]) -> bool {
326    false
327}
328
329fn sns_accepts_global_network(tail: &[OsString]) -> bool {
330    matches!(
331        tail.first().and_then(|arg| arg.to_str()),
332        Some("list" | "info" | "token" | "params" | "proposal" | "proposals" | "neurons")
333    )
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn usage_lists_query_families() {
342        let text = usage();
343
344        assert!(text.contains("Usage: icq [OPTIONS] [COMMAND]"));
345        assert!(text.contains("icrc"));
346        assert!(text.contains("Inspect generic ICRC ledger metadata"));
347        assert!(text.contains("nns"));
348        assert!(text.contains("Inspect NNS metadata"));
349        assert!(text.contains("sns"));
350        assert!(text.contains("Inspect SNS metadata"));
351        assert!(text.contains("Run `icq <command> help`"));
352    }
353
354    #[test]
355    fn top_level_usage_snapshot() {
356        let expected = format!(
357            "\
358icq {}
359Internet Computer metadata query CLI
360
361Usage: icq [OPTIONS] [COMMAND]
362
363Commands:
364  icrc  Inspect generic ICRC ledger metadata
365  nns   Inspect NNS metadata
366  sns   Inspect SNS metadata
367
368Options:
369  -V, --version         Print version
370      --network <name>  Network identity for supported NNS and SNS commands
371  -h, --help            Print help
372
373Run `icq <command> help` for command-specific help.
374",
375            env!("CARGO_PKG_VERSION")
376        );
377
378        assert_eq!(usage(), expected);
379    }
380
381    #[test]
382    fn command_family_help_returns_ok() {
383        for args in [
384            &["icrc", "help"][..],
385            &["icrc", "token", "help"],
386            &["icrc", "balance", "help"],
387            &["icrc", "allowance", "help"],
388            &["icrc", "index", "help"],
389            &["nns", "help"][..],
390            &["nns", "data-center", "help"],
391            &["nns", "data-center", "list", "help"],
392            &["nns", "data-center", "info", "help"],
393            &["nns", "data-center", "refresh", "help"],
394            &["nns", "node", "help"],
395            &["nns", "node", "list", "help"],
396            &["nns", "node", "info", "help"],
397            &["nns", "node", "refresh", "help"],
398            &["nns", "node-provider", "help"],
399            &["nns", "node-provider", "list", "help"],
400            &["nns", "node-provider", "info", "help"],
401            &["nns", "node-provider", "refresh", "help"],
402            &["nns", "node-operator", "help"],
403            &["nns", "node-operator", "list", "help"],
404            &["nns", "node-operator", "info", "help"],
405            &["nns", "node-operator", "refresh", "help"],
406            &["nns", "proposal", "help"],
407            &["nns", "proposal", "list", "help"],
408            &["nns", "proposal", "info", "help"],
409            &["nns", "registry", "help"],
410            &["nns", "registry", "version", "help"],
411            &["nns", "subnet", "help"],
412            &["nns", "subnet", "list", "help"],
413            &["nns", "subnet", "info", "help"],
414            &["nns", "subnet", "refresh", "help"],
415            &["nns", "topology", "help"],
416            &["nns", "topology", "summary", "help"],
417            &["nns", "topology", "coverage", "help"],
418            &["nns", "topology", "versions", "help"],
419            &["nns", "topology", "health", "help"],
420            &["nns", "topology", "gaps", "help"],
421            &["nns", "topology", "capacity", "help"],
422            &["nns", "topology", "regions", "help"],
423            &["nns", "topology", "providers", "help"],
424            &["nns", "topology", "refresh", "help"],
425            &["sns", "help"],
426            &["sns", "list", "help"],
427            &["sns", "info", "help"],
428            &["sns", "token", "help"],
429            &["sns", "params", "help"],
430            &["sns", "proposal", "help"],
431            &["sns", "proposals", "help"],
432            &["sns", "neurons", "help"],
433            &["sns", "neurons", "cache", "help"],
434            &["sns", "neurons", "cache", "list", "help"],
435            &["sns", "neurons", "cache", "status", "help"],
436            &["sns", "neurons", "refresh", "help"],
437        ] {
438            assert_run_ok(args);
439        }
440    }
441
442    #[test]
443    fn version_flags_return_ok() {
444        assert_eq!(VERSION_TEXT, concat!("icq ", env!("CARGO_PKG_VERSION")));
445        assert!(run([OsString::from("--version")]).is_ok());
446        assert!(run([OsString::from("icrc"), OsString::from("--version")]).is_ok());
447        assert!(run([OsString::from("nns"), OsString::from("--version")]).is_ok());
448        assert!(run([OsString::from("sns"), OsString::from("--version")]).is_ok());
449        assert!(
450            run([
451                OsString::from("nns"),
452                OsString::from("subnet"),
453                OsString::from("list"),
454                OsString::from("--version")
455            ])
456            .is_ok()
457        );
458
459        let mut sns_info_tail = vec![OsString::from("info"), OsString::from("1")];
460
461        apply_global_network("sns", &mut sns_info_tail, Some("ic".to_string()))
462            .expect("SNS supports global network");
463
464        assert_eq!(
465            sns_info_tail,
466            vec![
467                OsString::from("info"),
468                OsString::from("1"),
469                OsString::from(INTERNAL_NETWORK_OPTION),
470                OsString::from("ic")
471            ]
472        );
473    }
474
475    #[test]
476    fn typed_cli_errors_preserve_exit_and_broken_pipe_semantics() {
477        let usage = IcqCliError::Icrc(icrc::IcrcCommandError::Usage("bad input".to_string()));
478        assert_eq!(usage.exit_code(), 2);
479        assert!(!usage.is_broken_pipe());
480
481        let broken_pipe = IcqCliError::Icrc(icrc::IcrcCommandError::Io(std::io::Error::from(
482            std::io::ErrorKind::BrokenPipe,
483        )));
484        assert_eq!(broken_pipe.exit_code(), 1);
485        assert!(broken_pipe.is_broken_pipe());
486    }
487
488    #[test]
489    fn global_network_is_forwarded_to_networked_leaf_commands() {
490        let mut nns_tail = vec![OsString::from("data-center"), OsString::from("list")];
491
492        apply_global_network("nns", &mut nns_tail, Some("ic".to_string()))
493            .expect("NNS data-center supports global network");
494
495        assert_eq!(
496            nns_tail,
497            vec![
498                OsString::from("data-center"),
499                OsString::from("list"),
500                OsString::from(INTERNAL_NETWORK_OPTION),
501                OsString::from("ic")
502            ]
503        );
504
505        let mut sns_tail = vec![OsString::from("list")];
506
507        apply_global_network("sns", &mut sns_tail, Some("ic".to_string()))
508            .expect("SNS list supports global network");
509
510        assert_eq!(
511            sns_tail,
512            vec![
513                OsString::from("list"),
514                OsString::from(INTERNAL_NETWORK_OPTION),
515                OsString::from("ic")
516            ]
517        );
518
519        let mut nns_proposal_tail = vec![OsString::from("proposal"), OsString::from("list")];
520
521        apply_global_network("nns", &mut nns_proposal_tail, Some("local".to_string()))
522            .expect("NNS proposal supports global network");
523
524        assert_eq!(
525            nns_proposal_tail,
526            vec![
527                OsString::from("proposal"),
528                OsString::from("list"),
529                OsString::from(INTERNAL_NETWORK_OPTION),
530                OsString::from("local")
531            ]
532        );
533    }
534
535    #[test]
536    fn global_network_is_rejected_when_the_family_uses_endpoint_identity() {
537        let mut icrc_tail = vec![OsString::from("token")];
538
539        let error = apply_global_network("icrc", &mut icrc_tail, Some("ic".to_string()))
540            .expect_err("ICRC must reject an inapplicable global network");
541
542        assert_eq!(error.exit_code(), 2);
543        assert!(error.to_string().contains("--network is not supported"));
544        assert!(error.to_string().contains("icq icrc"));
545        assert!(error.to_string().contains("--source-endpoint"));
546        assert_eq!(icrc_tail, vec![OsString::from("token")]);
547
548        let error = run([
549            OsString::from("--network"),
550            OsString::from("ic"),
551            OsString::from("icrc"),
552            OsString::from("token"),
553            OsString::from("ryjl3-tyaaa-aaaaa-aaaba-cai"),
554        ])
555        .expect_err("ICRC global network must fail before dispatch");
556
557        assert_eq!(error.exit_code(), 2);
558        assert!(error.to_string().contains("--source-endpoint"));
559
560        let error = run([
561            OsString::from("icrc"),
562            OsString::from("token"),
563            OsString::from("ryjl3-tyaaa-aaaaa-aaaba-cai"),
564            OsString::from("--network"),
565            OsString::from("ic"),
566        ])
567        .expect_err("command-local ICRC network must use the same rejection");
568
569        assert_eq!(error.exit_code(), 2);
570        assert!(error.to_string().contains("--network is not supported"));
571        assert!(!error.to_string().contains("put it before the command"));
572
573        assert!(
574            run([
575                OsString::from("--network"),
576                OsString::from("ic"),
577                OsString::from("icrc"),
578                OsString::from("token"),
579                OsString::from("help"),
580            ])
581            .is_ok(),
582            "help must remain available without dispatching a query"
583        );
584    }
585
586    #[test]
587    fn sns_nested_commands_dispatch_through_clap_subcommands() {
588        assert!(
589            run([
590                OsString::from("sns"),
591                OsString::from("neurons"),
592                OsString::from("refresh"),
593                OsString::from("--help")
594            ])
595            .is_ok()
596        );
597        assert!(
598            run([
599                OsString::from("sns"),
600                OsString::from("proposals"),
601                OsString::from("cache"),
602                OsString::from("status"),
603                OsString::from("--help")
604            ])
605            .is_ok()
606        );
607    }
608
609    fn assert_run_ok(args: &[&str]) {
610        let args = args.iter().copied().map(OsString::from).collect::<Vec<_>>();
611        if let Err(err) = run(args.clone()) {
612            panic!("expected {args:?} to succeed, got {err}");
613        }
614    }
615}