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