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