CommandLineUtils.java

/*
 * Copyright ConsenSys AG.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
 * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
 * specific language governing permissions and limitations under the License.
 *
 * SPDX-License-Identifier: Apache-2.0
 */
package org.hyperledger.besu.cli.util;

import org.hyperledger.besu.cli.converter.TypeFormatter;
import org.hyperledger.besu.cli.options.OptionParser;
import org.hyperledger.besu.util.StringUtils;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

import com.google.common.base.Strings;
import org.slf4j.Logger;
import picocli.CommandLine;

/** The Command line utils. */
public class CommandLineUtils {
  /** The constant DEPENDENCY_WARNING_MSG. */
  public static final String DEPENDENCY_WARNING_MSG =
      "{} has been ignored because {} was not defined on the command line.";

  /** The constant MULTI_DEPENDENCY_WARNING_MSG. */
  public static final String MULTI_DEPENDENCY_WARNING_MSG =
      "{} ignored because none of {} was defined.";

  /** The constant DEPRECATION_WARNING_MSG. */
  public static final String DEPRECATION_WARNING_MSG = "{} has been deprecated, use {} instead.";

  /** The constant DEPRECATED_AND_USELESS_WARNING_MSG. */
  public static final String DEPRECATED_AND_USELESS_WARNING_MSG =
      "{} has been deprecated and is now useless, remove it.";

  /**
   * Check if options are passed that require an option to be true to have any effect and log a
   * warning with the list of affected options.
   *
   * <p>Note that in future version of PicoCLI some options dependency mechanism may be implemented
   * that could replace this. See https://github.com/remkop/picocli/issues/295
   *
   * @param logger the logger instance used to log the warning
   * @param commandLine the command line containing the options we want to check
   * @param mainOptionName the name of the main option to test dependency against. Only used for
   *     display.
   * @param isMainOptionCondition the condition to test the options dependencies, if true will test
   *     if not won't
   * @param dependentOptionsNames a list of option names that can't be used if condition is met.
   *     Example: if --miner-coinbase is in the list and condition is that --miner-enabled should
   *     not be false, we log a warning.
   */
  public static void checkOptionDependencies(
      final Logger logger,
      final CommandLine commandLine,
      final String mainOptionName,
      final boolean isMainOptionCondition,
      final List<String> dependentOptionsNames) {
    if (isMainOptionCondition) {
      final String affectedOptions = getAffectedOptions(commandLine, dependentOptionsNames);

      if (!affectedOptions.isEmpty()) {
        logger.warn(DEPENDENCY_WARNING_MSG, affectedOptions, mainOptionName);
      }
    }
  }

  /**
   * Check if options are passed that require an option to be true to have any effect and log a
   * warning with the list of affected options. Multiple main options may be passed to check
   * dependencies against.
   *
   * <p>Note that in future version of PicoCLI some options dependency mechanism may be implemented
   * that could replace this. See https://github.com/remkop/picocli/issues/295
   *
   * @param logger the logger instance used to log the warning
   * @param commandLine the command line containing the options we want to check display.
   * @param stringToLog the string that is going to be logged.
   * @param isMainOptionCondition the conditions to test dependent options against. If all
   *     conditions are true, dependent options will be checked.
   * @param dependentOptionsNames a list of option names that can't be used if condition is met.
   *     Example: if --min-gas-price is in the list and condition is that --miner-enabled should not
   *     be false, we log a warning.
   */
  public static void checkMultiOptionDependencies(
      final Logger logger,
      final CommandLine commandLine,
      final String stringToLog,
      final List<Boolean> isMainOptionCondition,
      final List<String> dependentOptionsNames) {
    if (isMainOptionCondition.stream().allMatch(isTrue -> isTrue)) {
      final String affectedOptions = getAffectedOptions(commandLine, dependentOptionsNames);

      if (!affectedOptions.isEmpty()) {
        logger.warn(stringToLog);
      }
    }
  }

  /**
   * Fail if option doesn't meet requirement.
   *
   * @param commandLine the command line
   * @param errorMessage the error message
   * @param requirement the requirement
   * @param dependentOptionsNames the dependent options names
   */
  public static void failIfOptionDoesntMeetRequirement(
      final CommandLine commandLine,
      final String errorMessage,
      final boolean requirement,
      final List<String> dependentOptionsNames) {
    if (!requirement) {
      final String affectedOptions = getAffectedOptions(commandLine, dependentOptionsNames);

      if (!affectedOptions.isEmpty()) {
        throw new CommandLine.ParameterException(
            commandLine, errorMessage + " [" + affectedOptions + "]");
      }
    }
  }

  /**
   * Return all the option names declared in a class. Note this will recursively check in any inner
   * option class if present.
   *
   * @param optClass the class to look for options
   * @return a list of option names found in the class
   */
  public static List<String> getCLIOptionNames(final Class<?> optClass) {
    final List<String> cliOpts = new ArrayList<>();
    final Field[] fields = optClass.getDeclaredFields();
    for (Field field : fields) {
      field.setAccessible(true);
      Annotation ann = field.getAnnotation(CommandLine.Option.class);
      if (ann != null) {
        final var optAnn = CommandLine.Option.class.cast(ann);
        cliOpts.add(optAnn.names()[0]);
      } else {
        ann = field.getAnnotation(CommandLine.ArgGroup.class);
        if (ann != null) {
          cliOpts.addAll(getCLIOptionNames(field.getType()));
        }
      }
    }
    return cliOpts;
  }

  /**
   * Converts the runtime options into their CLI representation. Options with a value equals to its
   * default are not included in the result since redundant. Note this will recursively check in any
   * inner option class if present.
   *
   * @param currOptions the actual runtime options
   * @param defaults the default option values
   * @return a list of CLI arguments
   */
  public static List<String> getCLIOptions(final Object currOptions, final Object defaults) {
    final List<String> cliOpts = new ArrayList<>();
    final Field[] fields = currOptions.getClass().getDeclaredFields();
    for (Field field : fields) {
      field.setAccessible(true);
      Annotation ann = field.getAnnotation(CommandLine.Option.class);
      if (ann != null) {
        try {
          var optVal = field.get(currOptions);
          if (!Objects.equals(optVal, field.get(defaults))) {
            var optAnn = CommandLine.Option.class.cast(ann);
            final var optConverter = optAnn.converter();
            cliOpts.add(optAnn.names()[0] + "=" + formatValue(optConverter, optVal));
          }
        } catch (IllegalAccessException e) {
          throw new RuntimeException(e);
        }
      } else {
        ann = field.getAnnotation(CommandLine.ArgGroup.class);
        if (ann != null) {
          try {
            cliOpts.addAll(getCLIOptions(field.get(currOptions), field.get(defaults)));
          } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
          }
        }
      }
    }
    return cliOpts;
  }

  /**
   * There are different ways to format an option value back to its CLI form, the first is to use a
   * {@link TypeFormatter} if present, otherwise the formatting it is delegated to {@link
   * OptionParser#format(Object)}
   *
   * @param optConverter the list of converter types for the option
   * @param optVal the value of the options
   * @return a string with the CLI form of the value
   */
  @SuppressWarnings("unchecked")
  private static String formatValue(
      final Class<? extends CommandLine.ITypeConverter<?>>[] optConverter, final Object optVal) {
    return Arrays.stream(optConverter)
        .filter(c -> Arrays.stream(c.getInterfaces()).anyMatch(i -> i.equals(TypeFormatter.class)))
        .findFirst()
        .map(
            ctf -> {
              try {
                return (TypeFormatter) ctf.getDeclaredConstructor().newInstance();
              } catch (InstantiationException
                  | IllegalAccessException
                  | InvocationTargetException
                  | NoSuchMethodException e) {
                throw new RuntimeException(e);
              }
            })
        .map(tf -> tf.format(optVal))
        .orElseGet(() -> OptionParser.format(optVal));
  }

  private static String getAffectedOptions(
      final CommandLine commandLine, final List<String> dependentOptionsNames) {
    return commandLine.getCommandSpec().options().stream()
        .filter(option -> Arrays.stream(option.names()).anyMatch(dependentOptionsNames::contains))
        .filter(CommandLineUtils::isOptionSet)
        .map(option -> option.names()[0])
        .collect(
            Collectors.collectingAndThen(
                Collectors.toList(), StringUtils.joiningWithLastDelimiter(", ", " and ")));
  }

  private static boolean isOptionSet(final CommandLine.Model.OptionSpec option) {
    final CommandLine commandLine = option.command().commandLine();
    try {
      return !option.stringValues().isEmpty()
          || !Strings.isNullOrEmpty(commandLine.getDefaultValueProvider().defaultValue(option));
    } catch (final Exception e) {
      return false;
    }
  }

  /**
   * Is the option with that name set on the command line?
   *
   * @param commandLine the command line
   * @param optionName the option name to check
   * @return true if set
   */
  public static boolean isOptionSet(final CommandLine commandLine, final String optionName) {
    return commandLine.getCommandSpec().options().stream()
        .filter(optionSpec -> Arrays.stream(optionSpec.names()).anyMatch(optionName::equals))
        .anyMatch(CommandLineUtils::isOptionSet);
  }
}