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