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