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