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#[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#[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#[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
258pub fn run_from_env() -> Result<(), CliError> {
260 run(std::env::args_os().skip(1))
261}
262
263pub 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 #[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 #[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 #[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 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 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}