Skip to main content

ic_query_cli/
lib.rs

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