Skip to main content

canic_cli/
lib.rs

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