Skip to main content

canic_cli/
lib.rs

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