Skip to main content

canic_cli/
lib.rs

1mod args;
2mod backup;
3mod build;
4mod endpoints;
5mod fleets;
6mod install;
7mod list;
8mod manifest;
9mod medic;
10mod output;
11mod replica;
12mod restore;
13mod scaffold;
14mod snapshot;
15mod status;
16#[cfg(test)]
17mod test_support;
18
19use crate::args::{
20    INTERNAL_ICP_OPTION, INTERNAL_NETWORK_OPTION, first_arg_is_help, icp_arg, network_arg,
21    parse_matches,
22};
23use clap::{Arg, ArgAction, Command};
24use std::ffi::OsString;
25use thiserror::Error as ThisError;
26
27const VERSION_TEXT: &str = concat!("canic ", env!("CARGO_PKG_VERSION"));
28const TOP_LEVEL_HELP_TEMPLATE: &str = "{name} {version}\n{about-with-newline}\n{usage-heading} {usage}\n\n{before-help}Options:\n{options}{after-help}\n";
29const COLOR_RESET: &str = "\x1b[0m";
30const COLOR_HEADING: &str = "\x1b[1m";
31const COLOR_GROUP: &str = "\x1b[38;5;245m";
32const COLOR_COMMAND: &str = "\x1b[38;5;109m";
33const COLOR_TIP: &str = "\x1b[38;5;245m";
34const DISPATCH_ARGS: &str = "args";
35
36///
37/// CommandScope
38///
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41enum CommandScope {
42    Global,
43    FleetContext,
44    WorkspaceFiles,
45}
46
47impl CommandScope {
48    const fn heading(self) -> &'static str {
49        match self {
50            Self::Global => "Global commands",
51            Self::FleetContext => "Fleet commands",
52            Self::WorkspaceFiles => "Workspace and file commands",
53        }
54    }
55}
56
57///
58/// CommandSpec
59///
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62struct CommandSpec {
63    name: &'static str,
64    about: &'static str,
65    scope: CommandScope,
66}
67
68const COMMAND_SPECS: &[CommandSpec] = &[
69    CommandSpec {
70        name: "status",
71        about: "Show quick Canic project status",
72        scope: CommandScope::Global,
73    },
74    CommandSpec {
75        name: "fleet",
76        about: "Manage Canic fleets",
77        scope: CommandScope::Global,
78    },
79    CommandSpec {
80        name: "replica",
81        about: "Manage the local ICP replica",
82        scope: CommandScope::Global,
83    },
84    CommandSpec {
85        name: "install",
86        about: "Install and bootstrap a Canic fleet",
87        scope: CommandScope::FleetContext,
88    },
89    CommandSpec {
90        name: "config",
91        about: "Inspect selected fleet config",
92        scope: CommandScope::FleetContext,
93    },
94    CommandSpec {
95        name: "list",
96        about: "List deployed fleet canisters",
97        scope: CommandScope::FleetContext,
98    },
99    CommandSpec {
100        name: "endpoints",
101        about: "List canister Candid endpoints",
102        scope: CommandScope::FleetContext,
103    },
104    CommandSpec {
105        name: "medic",
106        about: "Diagnose local Canic fleet setup",
107        scope: CommandScope::FleetContext,
108    },
109    CommandSpec {
110        name: "snapshot",
111        about: "Capture and download canister snapshots",
112        scope: CommandScope::FleetContext,
113    },
114    CommandSpec {
115        name: "build",
116        about: "Build one Canic canister artifact",
117        scope: CommandScope::WorkspaceFiles,
118    },
119    CommandSpec {
120        name: "backup",
121        about: "Verify backup directories and journal status",
122        scope: CommandScope::WorkspaceFiles,
123    },
124    CommandSpec {
125        name: "manifest",
126        about: "Validate fleet backup manifests",
127        scope: CommandScope::WorkspaceFiles,
128    },
129    CommandSpec {
130        name: "restore",
131        about: "Plan or run snapshot restores",
132        scope: CommandScope::WorkspaceFiles,
133    },
134];
135
136///
137/// CliError
138///
139
140#[derive(Debug, ThisError)]
141pub enum CliError {
142    #[error("{0}")]
143    Usage(String),
144
145    #[error("backup: {0}")]
146    Backup(String),
147
148    #[error("build: {0}")]
149    Build(String),
150
151    #[error("config: {0}")]
152    Config(String),
153
154    #[error("endpoints: {0}")]
155    Endpoints(String),
156
157    #[error("install: {0}")]
158    Install(String),
159
160    #[error("fleet: {0}")]
161    Fleets(String),
162
163    #[error("list: {0}")]
164    List(String),
165
166    #[error("manifest: {0}")]
167    Manifest(String),
168
169    #[error("medic: {0}")]
170    Medic(String),
171
172    #[error("snapshot: {0}")]
173    Snapshot(String),
174
175    #[error("restore: {0}")]
176    Restore(String),
177
178    #[error("replica: {0}")]
179    Replica(String),
180
181    #[error("status: {0}")]
182    Status(String),
183}
184
185impl From<backup::BackupCommandError> for CliError {
186    fn from(err: backup::BackupCommandError) -> Self {
187        Self::Backup(err.to_string())
188    }
189}
190
191impl From<build::BuildCommandError> for CliError {
192    fn from(err: build::BuildCommandError) -> Self {
193        Self::Build(err.to_string())
194    }
195}
196
197impl From<endpoints::EndpointsCommandError> for CliError {
198    fn from(err: endpoints::EndpointsCommandError) -> Self {
199        Self::Endpoints(err.to_string())
200    }
201}
202
203impl From<install::InstallCommandError> for CliError {
204    fn from(err: install::InstallCommandError) -> Self {
205        Self::Install(err.to_string())
206    }
207}
208
209impl From<fleets::FleetCommandError> for CliError {
210    fn from(err: fleets::FleetCommandError) -> Self {
211        Self::Fleets(err.to_string())
212    }
213}
214
215impl From<list::ListCommandError> for CliError {
216    fn from(err: list::ListCommandError) -> Self {
217        Self::List(err.to_string())
218    }
219}
220
221impl From<manifest::ManifestCommandError> for CliError {
222    fn from(err: manifest::ManifestCommandError) -> Self {
223        Self::Manifest(err.to_string())
224    }
225}
226
227impl From<medic::MedicCommandError> for CliError {
228    fn from(err: medic::MedicCommandError) -> Self {
229        Self::Medic(err.to_string())
230    }
231}
232
233impl From<snapshot::SnapshotCommandError> for CliError {
234    fn from(err: snapshot::SnapshotCommandError) -> Self {
235        Self::Snapshot(err.to_string())
236    }
237}
238
239impl From<restore::RestoreCommandError> for CliError {
240    fn from(err: restore::RestoreCommandError) -> Self {
241        Self::Restore(err.to_string())
242    }
243}
244
245impl From<replica::ReplicaCommandError> for CliError {
246    fn from(err: replica::ReplicaCommandError) -> Self {
247        Self::Replica(err.to_string())
248    }
249}
250
251impl From<status::StatusCommandError> for CliError {
252    fn from(err: status::StatusCommandError) -> Self {
253        Self::Status(err.to_string())
254    }
255}
256
257/// Run the CLI from process arguments.
258pub fn run_from_env() -> Result<(), CliError> {
259    run(std::env::args_os().skip(1))
260}
261
262/// Run the CLI from an argument iterator.
263pub fn run<I>(args: I) -> Result<(), CliError>
264where
265    I: IntoIterator<Item = OsString>,
266{
267    let args = args.into_iter().collect::<Vec<_>>();
268    if first_arg_is_help(&args) {
269        println!("{}", usage());
270        return Ok(());
271    }
272    if let Some(option) = command_local_global_option(&args) {
273        return Err(CliError::Usage(format!(
274            "{option} is a top-level option; put it before the command\n\n{}",
275            usage()
276        )));
277    }
278
279    let matches =
280        parse_matches(top_level_dispatch_command(), args).map_err(|_| CliError::Usage(usage()))?;
281    if matches.get_flag("version") {
282        println!("{}", version_text());
283        return Ok(());
284    }
285    let global_icp = matches.get_one::<String>("icp").cloned();
286    let global_network = matches.get_one::<String>("network").cloned();
287
288    let Some((command, subcommand_matches)) = matches.subcommand() else {
289        return Err(CliError::Usage(usage()));
290    };
291    let mut tail = subcommand_matches
292        .get_many::<OsString>(DISPATCH_ARGS)
293        .map(|values| values.cloned().collect::<Vec<_>>())
294        .unwrap_or_default();
295    apply_global_icp(command, &mut tail, global_icp);
296    apply_global_network(command, &mut tail, global_network);
297    let tail = tail.into_iter();
298
299    match command {
300        "backup" => backup::run(tail).map_err(CliError::from),
301        "build" => build::run(tail).map_err(CliError::from),
302        "config" => list::run_config(tail).map_err(|err| CliError::Config(err.to_string())),
303        "endpoints" => endpoints::run(tail).map_err(CliError::from),
304        "fleet" => fleets::run(tail).map_err(CliError::from),
305        "install" => install::run(tail).map_err(CliError::from),
306        "list" => list::run(tail).map_err(CliError::from),
307        "manifest" => manifest::run(tail).map_err(CliError::from),
308        "medic" => medic::run(tail).map_err(CliError::from),
309        "replica" => replica::run(tail).map_err(CliError::from),
310        "snapshot" => snapshot::run(tail).map_err(CliError::from),
311        "status" => status::run(tail).map_err(CliError::from),
312        "restore" => restore::run(tail).map_err(CliError::from),
313        _ => unreachable!("top-level dispatch command only defines known commands"),
314    }
315}
316
317#[must_use]
318pub fn top_level_command() -> Command {
319    let command = Command::new("canic")
320        .version(env!("CARGO_PKG_VERSION"))
321        .about("Operator CLI for Canic install, backup, and restore workflows")
322        .disable_version_flag(true)
323        .arg(
324            Arg::new("version")
325                .short('V')
326                .long("version")
327                .action(ArgAction::SetTrue)
328                .help("Print version"),
329        )
330        .arg(icp_arg().global(true))
331        .arg(network_arg().global(true))
332        .subcommand_help_heading("Commands")
333        .help_template(TOP_LEVEL_HELP_TEMPLATE)
334        .before_help(grouped_command_section(COMMAND_SPECS).join("\n"))
335        .after_help("Run `canic <command> help` for command-specific help.");
336
337    COMMAND_SPECS.iter().fold(command, |command, spec| {
338        command.subcommand(Command::new(spec.name).about(spec.about))
339    })
340}
341
342fn top_level_dispatch_command() -> Command {
343    let command = Command::new("canic")
344        .disable_help_flag(true)
345        .disable_version_flag(true)
346        .arg(
347            Arg::new("version")
348                .short('V')
349                .long("version")
350                .action(ArgAction::SetTrue),
351        );
352    let command = command
353        .arg(icp_arg().global(true))
354        .arg(network_arg().global(true));
355
356    COMMAND_SPECS.iter().fold(command, |command, spec| {
357        command.subcommand(
358            Command::new(spec.name).arg(
359                Arg::new(DISPATCH_ARGS)
360                    .num_args(0..)
361                    .allow_hyphen_values(true)
362                    .trailing_var_arg(true)
363                    .value_parser(clap::value_parser!(OsString)),
364            ),
365        )
366    })
367}
368
369fn command_local_global_option(args: &[OsString]) -> Option<&'static str> {
370    let mut index = 0;
371    while index < args.len() {
372        let arg = args[index].to_str()?;
373        if COMMAND_SPECS.iter().any(|spec| spec.name == arg) {
374            return args[index + 1..]
375                .iter()
376                .filter_map(|arg| arg.to_str())
377                .find_map(global_option_name);
378        }
379        index += if matches!(arg, "--icp" | "--network") {
380            2
381        } else {
382            1
383        };
384    }
385    None
386}
387
388fn global_option_name(arg: &str) -> Option<&'static str> {
389    match arg {
390        "--icp" => Some("--icp"),
391        "--network" => Some("--network"),
392        _ if arg.starts_with("--icp=") => Some("--icp"),
393        _ if arg.starts_with("--network=") => Some("--network"),
394        _ => None,
395    }
396}
397
398fn apply_global_icp(command: &str, tail: &mut Vec<OsString>, global_icp: Option<String>) {
399    let Some(global_icp) = global_icp else {
400        return;
401    };
402    if tail_has_option(tail, INTERNAL_ICP_OPTION) {
403        return;
404    }
405    if !command_accepts_global_icp(command, tail) {
406        return;
407    }
408
409    tail.push(OsString::from(INTERNAL_ICP_OPTION));
410    tail.push(OsString::from(global_icp));
411}
412
413fn apply_global_network(command: &str, tail: &mut Vec<OsString>, global_network: Option<String>) {
414    let Some(global_network) = global_network else {
415        return;
416    };
417    if tail_has_option(tail, INTERNAL_NETWORK_OPTION) {
418        return;
419    }
420    if !command_accepts_global_network(command, tail) {
421        return;
422    }
423
424    tail.push(OsString::from(INTERNAL_NETWORK_OPTION));
425    tail.push(OsString::from(global_network));
426}
427
428fn command_accepts_global_icp(command: &str, tail: &[OsString]) -> bool {
429    match command {
430        "endpoints" | "list" | "medic" | "status" => true,
431        "replica" => matches!(
432            tail.first().and_then(|arg| arg.to_str()),
433            Some("start" | "status" | "stop")
434        ),
435        "snapshot" => tail.first().and_then(|arg| arg.to_str()) == Some("download"),
436        "restore" => tail.first().and_then(|arg| arg.to_str()) == Some("run"),
437        _ => false,
438    }
439}
440
441fn command_accepts_global_network(command: &str, tail: &[OsString]) -> bool {
442    match command {
443        "endpoints" | "install" | "list" | "medic" | "status" => true,
444        "fleet" => tail.first().and_then(|arg| arg.to_str()) == Some("list"),
445        "snapshot" => tail.first().and_then(|arg| arg.to_str()) == Some("download"),
446        "restore" => tail.first().and_then(|arg| arg.to_str()) == Some("run"),
447        _ => false,
448    }
449}
450
451fn tail_has_option(tail: &[OsString], name: &str) -> bool {
452    tail.iter().any(|arg| arg.to_str() == Some(name))
453}
454
455#[must_use]
456pub const fn version_text() -> &'static str {
457    VERSION_TEXT
458}
459
460fn usage() -> String {
461    let mut lines = vec![
462        color(
463            COLOR_HEADING,
464            &format!("Canic Operator CLI v{}", env!("CARGO_PKG_VERSION")),
465        ),
466        String::new(),
467        "Usage: canic [OPTIONS] <COMMAND>".to_string(),
468        String::new(),
469        color(COLOR_HEADING, "Commands:"),
470    ];
471    lines.extend(grouped_command_section(COMMAND_SPECS));
472    lines.extend([
473        String::new(),
474        color(COLOR_HEADING, "Options:"),
475        "      --icp <path>      Path to the icp executable for ICP-backed commands".to_string(),
476        "      --network <name>  ICP CLI network for networked commands".to_string(),
477        "  -V, --version  Print version".to_string(),
478        "  -h, --help     Print help".to_string(),
479        String::new(),
480        format!(
481            "{}Tip:{} Run {} for command-specific help.",
482            COLOR_TIP,
483            COLOR_RESET,
484            color(COLOR_COMMAND, "`canic <command> help`")
485        ),
486    ]);
487    lines.join("\n")
488}
489
490fn grouped_command_section(specs: &[CommandSpec]) -> Vec<String> {
491    let mut lines = Vec::new();
492    let scopes = [
493        CommandScope::Global,
494        CommandScope::FleetContext,
495        CommandScope::WorkspaceFiles,
496    ];
497    for (index, scope) in scopes.into_iter().enumerate() {
498        lines.push(format!("  {}", color(COLOR_GROUP, scope.heading())));
499        for spec in specs.iter().filter(|spec| spec.scope == scope) {
500            let command = format!("{:<12}", spec.name);
501            lines.push(format!(
502                "    {} {}",
503                color(COLOR_COMMAND, &command),
504                spec.about
505            ));
506        }
507        if index + 1 < scopes.len() {
508            lines.push(String::new());
509        }
510    }
511    lines
512}
513
514fn color(code: &str, text: &str) -> String {
515    format!("{code}{text}{COLOR_RESET}")
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    // Ensure top-level help stays compact as command surfaces grow.
523    #[test]
524    fn usage_lists_command_families() {
525        let text = usage();
526        let plain = strip_ansi(&text);
527
528        assert!(plain.contains(&format!(
529            "Canic Operator CLI v{}",
530            env!("CARGO_PKG_VERSION")
531        )));
532        assert!(plain.contains("Usage: canic [OPTIONS] <COMMAND>"));
533        assert!(plain.contains("\nCommands:\n"));
534        assert!(plain.contains("Global commands"));
535        assert!(plain.contains("Fleet commands"));
536        assert!(plain.contains("Workspace and file commands"));
537        assert!(plain.find("    status") < plain.find("    fleet"));
538        assert!(plain.find("    fleet") < plain.find("    replica"));
539        assert!(plain.find("    replica") < plain.find("    install"));
540        assert!(plain.find("    install") < plain.find("    config"));
541        assert!(plain.find("    config") < plain.find("    list"));
542        assert!(plain.find("    list") < plain.find("    endpoints"));
543        assert!(plain.contains("Options:"));
544        assert!(plain.contains("--icp <path>"));
545        assert!(plain.contains("--network <name>"));
546        assert!(!plain.contains("    scaffold"));
547        assert!(plain.contains("config"));
548        assert!(plain.contains("list"));
549        assert!(plain.contains("endpoints"));
550        assert!(plain.contains("build"));
551        assert!(!plain.contains("    network"));
552        assert!(!plain.contains("    defaults"));
553        assert!(plain.contains("    status"));
554        assert!(plain.contains("fleet"));
555        assert!(plain.contains("replica"));
556        assert!(plain.contains("install"));
557        assert!(plain.contains("snapshot"));
558        assert!(plain.contains("backup"));
559        assert!(plain.contains("manifest"));
560        assert!(plain.contains("medic"));
561        assert!(plain.contains("restore"));
562        assert!(plain.contains("Tip: Run `canic <command> help`"));
563        assert!(text.contains(COLOR_HEADING));
564        assert!(text.contains(COLOR_GROUP));
565        assert!(text.contains(COLOR_COMMAND));
566    }
567
568    // Ensure command-family help paths return successfully instead of erroring.
569    #[test]
570    fn command_family_help_returns_ok() {
571        for args in [
572            &["backup", "help"][..],
573            &["backup", "list", "help"],
574            &["backup", "status", "help"],
575            &["backup", "verify", "help"],
576            &["build", "help"],
577            &["config", "help"],
578            &["endpoints", "help"],
579            &["install", "help"],
580            &["fleet"],
581            &["fleet", "help"],
582            &["fleet", "create", "help"],
583            &["fleet", "list", "help"],
584            &["fleet", "delete", "help"],
585            &["replica"],
586            &["replica", "help"],
587            &["replica", "start", "help"],
588            &["replica", "status", "help"],
589            &["replica", "stop", "help"],
590            &["list", "help"],
591            &["restore", "help"],
592            &["restore", "plan", "help"],
593            &["restore", "apply", "help"],
594            &["restore", "run", "help"],
595            &["manifest", "help"],
596            &["manifest", "validate", "help"],
597            &["medic", "help"],
598            &["snapshot", "help"],
599            &["snapshot", "download", "help"],
600            &["status", "help"],
601        ] {
602            assert_run_ok(args);
603        }
604    }
605
606    // Ensure version flags are accepted at the top level and command-family level.
607    #[test]
608    fn version_flags_return_ok() {
609        assert_eq!(version_text(), concat!("canic ", env!("CARGO_PKG_VERSION")));
610        assert!(run([OsString::from("--version")]).is_ok());
611        assert!(
612            run([
613                OsString::from("backup"),
614                OsString::from("list"),
615                OsString::from("--dir"),
616                OsString::from("version")
617            ])
618            .is_ok()
619        );
620        assert!(run([OsString::from("backup"), OsString::from("--version")]).is_ok());
621        assert!(
622            run([
623                OsString::from("backup"),
624                OsString::from("list"),
625                OsString::from("--version")
626            ])
627            .is_ok()
628        );
629        assert!(run([OsString::from("build"), OsString::from("--version")]).is_ok());
630        assert!(run([OsString::from("config"), OsString::from("--version")]).is_ok());
631        assert!(run([OsString::from("endpoints"), OsString::from("--version")]).is_ok());
632        assert!(run([OsString::from("install"), OsString::from("--version")]).is_ok());
633        assert!(run([OsString::from("fleet"), OsString::from("--version")]).is_ok());
634        assert!(run([OsString::from("replica"), OsString::from("--version")]).is_ok());
635        assert!(run([OsString::from("status"), OsString::from("--version")]).is_ok());
636        assert!(
637            run([
638                OsString::from("fleet"),
639                OsString::from("create"),
640                OsString::from("--version")
641            ])
642            .is_ok()
643        );
644        assert!(
645            run([
646                OsString::from("replica"),
647                OsString::from("start"),
648                OsString::from("--version")
649            ])
650            .is_ok()
651        );
652        assert!(run([OsString::from("list"), OsString::from("--version")]).is_ok());
653        assert!(run([OsString::from("restore"), OsString::from("--version")]).is_ok());
654        assert!(run([OsString::from("manifest"), OsString::from("--version")]).is_ok());
655        assert!(run([OsString::from("medic"), OsString::from("--version")]).is_ok());
656        assert!(run([OsString::from("snapshot"), OsString::from("--version")]).is_ok());
657        assert!(
658            run([
659                OsString::from("snapshot"),
660                OsString::from("download"),
661                OsString::from("--version")
662            ])
663            .is_ok()
664        );
665    }
666
667    #[test]
668    fn global_icp_is_forwarded_to_commands_that_use_icp() {
669        let mut tail = vec![OsString::from("test")];
670
671        apply_global_icp("medic", &mut tail, Some("/tmp/icp".to_string()));
672
673        assert_eq!(
674            tail,
675            vec![
676                OsString::from("test"),
677                OsString::from(INTERNAL_ICP_OPTION),
678                OsString::from("/tmp/icp")
679            ]
680        );
681    }
682
683    #[test]
684    fn global_icp_does_not_override_internal_forwarded_icp() {
685        let mut tail = vec![
686            OsString::from("test"),
687            OsString::from(INTERNAL_ICP_OPTION),
688            OsString::from("/bin/icp"),
689        ];
690
691        apply_global_icp("medic", &mut tail, Some("/tmp/icp".to_string()));
692
693        assert_eq!(
694            tail,
695            vec![
696                OsString::from("test"),
697                OsString::from(INTERNAL_ICP_OPTION),
698                OsString::from("/bin/icp")
699            ]
700        );
701    }
702
703    #[test]
704    fn global_icp_is_forwarded_only_to_restore_run() {
705        let mut plan_tail = vec![OsString::from("plan")];
706        let mut run_tail = vec![OsString::from("run")];
707
708        apply_global_icp("restore", &mut plan_tail, Some("/tmp/icp".to_string()));
709        apply_global_icp("restore", &mut run_tail, Some("/tmp/icp".to_string()));
710
711        assert_eq!(plan_tail, vec![OsString::from("plan")]);
712        assert_eq!(
713            run_tail,
714            vec![
715                OsString::from("run"),
716                OsString::from(INTERNAL_ICP_OPTION),
717                OsString::from("/tmp/icp")
718            ]
719        );
720    }
721
722    #[test]
723    fn global_icp_is_forwarded_only_to_replica_leaf_commands() {
724        let mut family_tail = Vec::new();
725        let mut start_tail = vec![OsString::from("start")];
726
727        apply_global_icp("replica", &mut family_tail, Some("/tmp/icp".to_string()));
728        apply_global_icp("replica", &mut start_tail, Some("/tmp/icp".to_string()));
729
730        assert!(family_tail.is_empty());
731        assert_eq!(
732            start_tail,
733            vec![
734                OsString::from("start"),
735                OsString::from(INTERNAL_ICP_OPTION),
736                OsString::from("/tmp/icp")
737            ]
738        );
739    }
740
741    #[test]
742    fn global_network_is_forwarded_to_commands_that_use_network() {
743        let mut tail = vec![OsString::from("test")];
744
745        apply_global_network("install", &mut tail, Some("ic".to_string()));
746
747        assert_eq!(
748            tail,
749            vec![
750                OsString::from("test"),
751                OsString::from(INTERNAL_NETWORK_OPTION),
752                OsString::from("ic")
753            ]
754        );
755    }
756
757    #[test]
758    fn global_network_does_not_override_internal_forwarded_network() {
759        let mut tail = vec![
760            OsString::from("test"),
761            OsString::from(INTERNAL_NETWORK_OPTION),
762            OsString::from("local"),
763        ];
764
765        apply_global_network("install", &mut tail, Some("ic".to_string()));
766
767        assert_eq!(
768            tail,
769            vec![
770                OsString::from("test"),
771                OsString::from(INTERNAL_NETWORK_OPTION),
772                OsString::from("local")
773            ]
774        );
775    }
776
777    #[test]
778    fn global_network_is_forwarded_only_to_restore_run() {
779        let mut plan_tail = vec![OsString::from("plan")];
780        let mut run_tail = vec![OsString::from("run")];
781
782        apply_global_network("restore", &mut plan_tail, Some("ic".to_string()));
783        apply_global_network("restore", &mut run_tail, Some("ic".to_string()));
784
785        assert_eq!(plan_tail, vec![OsString::from("plan")]);
786        assert_eq!(
787            run_tail,
788            vec![
789                OsString::from("run"),
790                OsString::from(INTERNAL_NETWORK_OPTION),
791                OsString::from("ic")
792            ]
793        );
794    }
795
796    #[test]
797    fn global_network_is_forwarded_only_to_fleet_list() {
798        let mut create_tail = vec![OsString::from("create")];
799        let mut list_tail = vec![OsString::from("list")];
800
801        apply_global_network("fleet", &mut create_tail, Some("local".to_string()));
802        apply_global_network("fleet", &mut list_tail, Some("local".to_string()));
803
804        assert_eq!(create_tail, vec![OsString::from("create")]);
805        assert_eq!(
806            list_tail,
807            vec![
808                OsString::from("list"),
809                OsString::from(INTERNAL_NETWORK_OPTION),
810                OsString::from("local")
811            ]
812        );
813    }
814
815    #[test]
816    fn command_local_global_options_are_hard_rejected() {
817        assert!(matches!(
818            run([
819                OsString::from("status"),
820                OsString::from("--network"),
821                OsString::from("local")
822            ]),
823            Err(CliError::Usage(_))
824        ));
825        assert!(matches!(
826            run([
827                OsString::from("medic"),
828                OsString::from("test"),
829                OsString::from("--icp"),
830                OsString::from("icp")
831            ]),
832            Err(CliError::Usage(_))
833        ));
834    }
835
836    // Remove ANSI color sequences so tests can assert help structure.
837    fn strip_ansi(text: &str) -> String {
838        let mut plain = String::new();
839        let mut chars = text.chars().peekable();
840        while let Some(ch) = chars.next() {
841            if ch == '\x1b' && chars.peek() == Some(&'[') {
842                chars.next();
843                for ch in chars.by_ref() {
844                    if ch == 'm' {
845                        break;
846                    }
847                }
848                continue;
849            }
850            plain.push(ch);
851        }
852        plain
853    }
854
855    // Assert that a CLI argv slice returns successfully.
856    fn assert_run_ok(raw_args: &[&str]) {
857        let args = raw_args.iter().map(OsString::from).collect::<Vec<_>>();
858        assert!(
859            run(args).is_ok(),
860            "expected successful run for {raw_args:?}"
861        );
862    }
863}