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