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