MetaschemaCommands.java

/*
 * SPDX-FileCopyrightText: none
 * SPDX-License-Identifier: CC0-1.0
 */

package gov.nist.secauto.metaschema.cli.commands;

import gov.nist.secauto.metaschema.cli.commands.metapath.MetapathCommand;
import gov.nist.secauto.metaschema.cli.processor.ExitCode;
import gov.nist.secauto.metaschema.cli.processor.OptionUtils;
import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
import gov.nist.secauto.metaschema.core.metapath.MetapathException;
import gov.nist.secauto.metaschema.core.model.IConstraintLoader;
import gov.nist.secauto.metaschema.core.model.IModule;
import gov.nist.secauto.metaschema.core.model.MetaschemaException;
import gov.nist.secauto.metaschema.core.model.constraint.IConstraintSet;
import gov.nist.secauto.metaschema.core.util.CollectionUtil;
import gov.nist.secauto.metaschema.core.util.CustomCollectors;
import gov.nist.secauto.metaschema.core.util.DeleteOnShutdown;
import gov.nist.secauto.metaschema.core.util.ObjectUtils;
import gov.nist.secauto.metaschema.core.util.UriUtils;
import gov.nist.secauto.metaschema.databind.IBindingContext;
import gov.nist.secauto.metaschema.databind.io.Format;
import gov.nist.secauto.metaschema.databind.io.IBoundLoader;
import gov.nist.secauto.metaschema.databind.model.metaschema.IBindingModuleLoader;
import gov.nist.secauto.metaschema.schemagen.ISchemaGenerator.SchemaFormat;

import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.Option;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import edu.umd.cs.findbugs.annotations.NonNull;

/**
 * This class provides a variety of utility methods for processing
 * Metaschema-related commands.
 * <p>
 * These methods handle the errors produced using the
 * {@link CommandExecutionException}, which will return an exceptional result to
 * the command line interface (CLI) processor. This approach keeps the command
 * implementations fairly clean and simple.
 */
@SuppressWarnings("PMD.GodClass")
public final class MetaschemaCommands {
  /**
   * A list of the Metaschema-related command pathways, for reuse in this and
   * other CLI applications.
   */
  @NonNull
  public static final List<ICommand> COMMANDS = ObjectUtils.notNull(List.of(
      new ValidateModuleCommand(),
      new GenerateSchemaCommand(),
      new GenerateDiagramCommand(),
      new ValidateContentUsingModuleCommand(),
      new ConvertContentUsingModuleCommand(),
      new MetapathCommand()));

  /**
   * Used by commands to declare a required Metaschema module for processing.
   *
   * @since 2.0.0
   */
  @NonNull
  public static final Option METASCHEMA_REQUIRED_OPTION = ObjectUtils.notNull(
      Option.builder("m")
          .hasArg()
          .argName("FILE_OR_URL")
          .required()
          .desc("metaschema resource")
          .numberOfArgs(1)
          .build());
  /**
   * Used by commands to declare an optional Metaschema module for processing.
   *
   * @since 2.0.0
   */
  @NonNull
  public static final Option METASCHEMA_OPTIONAL_OPTION = ObjectUtils.notNull(
      Option.builder("m")
          .hasArg()
          .argName("FILE_OR_URL")
          .desc("metaschema resource")
          .numberOfArgs(1)
          .build());
  /**
   * Used by commands to protect existing files from being overwritten, unless
   * this option is provided.
   */
  @NonNull
  public static final Option OVERWRITE_OPTION = ObjectUtils.notNull(
      Option.builder()
          .longOpt("overwrite")
          .desc("overwrite the destination if it exists")
          .build());
  /**
   * Used by commands to identify the target format for a content conversion
   * operation.
   *
   * @since 2.0.0
   */
  @NonNull
  public static final Option TO_OPTION = ObjectUtils.notNull(
      Option.builder()
          .longOpt("to")
          .required()
          .hasArg().argName("FORMAT")
          .desc("convert to format: " + Arrays.stream(Format.values())
              .map(Enum::name)
              .collect(CustomCollectors.joiningWithOxfordComma("or")))
          .numberOfArgs(1)
          .build());
  /**
   * Used by commands to identify the source format for a content-related
   * operation.
   *
   * @since 2.0.0
   */
  @NonNull
  public static final Option AS_FORMAT_OPTION = ObjectUtils.notNull(
      Option.builder()
          .longOpt("as")
          .hasArg()
          .argName("FORMAT")
          .desc("source format: " + Arrays.stream(Format.values())
              .map(Enum::name)
              .collect(CustomCollectors.joiningWithOxfordComma("or")))
          .numberOfArgs(1)
          .build());
  /**
   * Used by commands that produce schemas to identify the schema format to
   * produce.
   *
   * @since 2.0.0
   */
  @NonNull
  public static final Option AS_SCHEMA_FORMAT_OPTION = ObjectUtils.notNull(
      Option.builder()
          .longOpt("as")
          .required()
          .hasArg()
          .argName("FORMAT")
          .desc("schema format: " + Arrays.stream(SchemaFormat.values())
              .map(Enum::name)
              .collect(CustomCollectors.joiningWithOxfordComma("or")))
          .numberOfArgs(1)
          .build());

  /**
   * Get the provided source path or URI string as an absolute {@link URI} for the
   * resource.
   *
   * @param pathOrUri
   *          the resource
   * @param currentWorkingDirectory
   *          the current working directory the URI will be resolved against to
   *          ensure it is absolute
   * @return the absolute URI for the resource
   * @throws CommandExecutionException
   *           if the resulting URI is not a well-formed URI
   * @since 2.0.0
   */
  @NonNull
  public static URI handleSource(
      @NonNull String pathOrUri,
      @NonNull URI currentWorkingDirectory) throws CommandExecutionException {
    try {
      return getResourceUri(pathOrUri, currentWorkingDirectory);
    } catch (URISyntaxException ex) {
      throw new CommandExecutionException(
          ExitCode.INVALID_ARGUMENTS,
          String.format(
              "Cannot load source '%s' as it is not a valid file or URI.",
              pathOrUri),
          ex);
    }
  }

  /**
   * Get the provided destination path as an absolute {@link Path} for the
   * resource.
   * <p>
   * This method checks if the path exists and if so, if the overwrite option is
   * set. The method also ensures that the parent directory is created, if it
   * doesn't already exist.
   *
   * @param path
   *          the resource
   * @param commandLine
   *          the provided command line argument information
   * @return the absolute URI for the resource
   * @throws CommandExecutionException
   *           if the path exists and cannot be overwritten or is not writable
   * @since 2.0.0
   */
  public static Path handleDestination(
      @NonNull String path,
      @NonNull CommandLine commandLine) throws CommandExecutionException {
    Path retval = Paths.get(path).toAbsolutePath();

    if (Files.exists(retval)) {
      if (!commandLine.hasOption(OVERWRITE_OPTION)) {
        throw new CommandExecutionException(
            ExitCode.INVALID_ARGUMENTS,
            String.format("The provided destination '%s' already exists and the '%s' option was not provided.",
                retval,
                OptionUtils.toArgument(OVERWRITE_OPTION)));
      }
      if (!Files.isWritable(retval)) {
        throw new CommandExecutionException(
            ExitCode.IO_ERROR,
            String.format(
                "The provided destination '%s' is not writable.", retval));
      }
    } else {
      Path parent = retval.getParent();
      if (parent != null) {
        try {
          Files.createDirectories(parent);
        } catch (IOException ex) {
          throw new CommandExecutionException(
              ExitCode.INVALID_TARGET,
              ex);
        }
      }
    }
    return retval;
  }

  /**
   * Parse the command line options to get the selected format.
   *
   * @param commandLine
   *          the provided command line argument information
   * @param option
   *          the option specifying the format, which must be present on the
   *          command line
   * @return the format
   * @throws CommandExecutionException
   *           if the format option was not provided or was an invalid choice
   * @since 2.0.0
   */
  @SuppressWarnings("PMD.PreserveStackTrace")
  @NonNull
  public static Format getFormat(
      @NonNull CommandLine commandLine,
      @NonNull Option option) throws CommandExecutionException {
    // use the option
    String toFormatText = commandLine.getOptionValue(option);
    if (toFormatText == null) {
      throw new CommandExecutionException(
          ExitCode.INVALID_ARGUMENTS,
          String.format("The '%s' argument was not provided.",
              option.hasLongOpt()
                  ? "--" + option.getLongOpt()
                  : "-" + option.getOpt()));
    }
    try {
      return Format.valueOf(toFormatText.toUpperCase(Locale.ROOT));
    } catch (IllegalArgumentException ex) {
      throw new CommandExecutionException(
          ExitCode.INVALID_ARGUMENTS,
          String.format("Invalid '%s' argument. The format must be one of: %s.",
              option.hasLongOpt()
                  ? "--" + option.getLongOpt()
                  : "-" + option.getOpt(),
              Arrays.stream(Format.values())
                  .map(Enum::name)
                  .collect(CustomCollectors.joiningWithOxfordComma("or"))));
    }
  }

  /**
   * Parse the command line options to get the selected schema format.
   *
   * @param commandLine
   *          the provided command line argument information
   * @param option
   *          the option specifying the format, which must be present on the
   *          command line
   * @return the format
   * @throws CommandExecutionException
   *           if the format option was not provided or was an invalid choice
   * @since 2.0.0
   */
  @SuppressWarnings("PMD.PreserveStackTrace")
  @NonNull
  public static SchemaFormat getSchemaFormat(
      @NonNull CommandLine commandLine,
      @NonNull Option option) throws CommandExecutionException {
    // use the option
    String toFormatText = commandLine.getOptionValue(option);
    if (toFormatText == null) {
      throw new CommandExecutionException(
          ExitCode.INVALID_ARGUMENTS,
          String.format("Option '%s' not provided.",
              option.hasLongOpt()
                  ? "--" + option.getLongOpt()
                  : "-" + option.getOpt()));
    }
    try {
      return SchemaFormat.valueOf(toFormatText.toUpperCase(Locale.ROOT));
    } catch (IllegalArgumentException ex) {
      throw new CommandExecutionException(
          ExitCode.INVALID_ARGUMENTS,
          String.format("Invalid '%s' argument. The schema format must be one of: %s.",
              option.hasLongOpt()
                  ? "--" + option.getLongOpt()
                  : "-" + option.getOpt(),
              Arrays.stream(SchemaFormat.values())
                  .map(Enum::name)
                  .collect(CustomCollectors.joiningWithOxfordComma("or"))),
          ex);
    }
  }

  /**
   * Detect the source format for content identified using the provided option.
   * <p>
   * This method will first check if the source format is explicitly declared on
   * the command line. If so, this format will be returned.
   * <p>
   * If not, then the content will be analyzed to determine the format.
   *
   * @param commandLine
   *          the provided command line argument information
   * @param option
   *          the option specifying the format, which must be present on the
   *          command line
   * @param loader
   *          the content loader to use to load the content instance
   * @param resource
   *          the resource to load
   * @return the identified content format
   * @throws CommandExecutionException
   *           if an error occurred while determining the source format
   * @since 2.0.0
   */
  @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.OnlyOneReturn" })
  @NonNull
  public static Format determineSourceFormat(
      @NonNull CommandLine commandLine,
      @NonNull Option option,
      @NonNull IBoundLoader loader,
      @NonNull URI resource) throws CommandExecutionException {
    if (commandLine.hasOption(option)) {
      // use the option
      return getFormat(commandLine, option);
    }

    // attempt to determine the format
    try {
      return loader.detectFormat(resource);
    } catch (FileNotFoundException ex) {
      // this case was already checked for
      throw new CommandExecutionException(
          ExitCode.IO_ERROR,
          String.format("The provided source '%s' does not exist.", resource),
          ex);
    } catch (IOException ex) {
      throw new CommandExecutionException(
          ExitCode.IO_ERROR,
          String.format("Unable to determine source format. Use '%s' to specify the format. %s",
              option.hasLongOpt()
                  ? "--" + option.getLongOpt()
                  : "-" + option.getOpt(),
              ex.getLocalizedMessage()),
          ex);
    }
  }

  /**
   * Load a Metaschema module based on the provided command line option.
   *
   * @param commandLine
   *          the provided command line argument information
   * @param option
   *          the option specifying the module to load, which must be present on
   *          the command line
   * @param currentWorkingDirectory
   *          the URI of the current working directory
   * @param bindingContext
   *          the context used to access Metaschema module information based on
   *          Java class bindings
   * @return the loaded module
   * @throws CommandExecutionException
   *           if an error occurred while loading the module
   * @since 2.0.0
   */
  @NonNull
  public static IModule loadModule(
      @NonNull CommandLine commandLine,
      @NonNull Option option,
      @NonNull URI currentWorkingDirectory,
      @NonNull IBindingContext bindingContext) throws CommandExecutionException {
    String moduleName = commandLine.getOptionValue(option);
    if (moduleName == null) {
      throw new CommandExecutionException(
          ExitCode.INVALID_ARGUMENTS,
          String.format("Unable to determine the module to load. Use '%s' to specify the module.",
              option.hasLongOpt()
                  ? "--" + option.getLongOpt()
                  : "-" + option.getOpt()));
    }

    URI moduleUri;
    try {
      moduleUri = UriUtils.toUri(moduleName, currentWorkingDirectory);
    } catch (URISyntaxException ex) {
      throw new CommandExecutionException(
          ExitCode.INVALID_ARGUMENTS,
          String.format("Cannot load module as '%s' is not a valid file or URL. %s",
              ex.getInput(),
              ex.getLocalizedMessage()),
          ex);
    }
    return loadModule(moduleUri, bindingContext);
  }

  /**
   * Load a Metaschema module from the provided relative resource path.
   * <p>
   * This method will resolve the provided resource against the current working
   * directory to create an absolute URI.
   *
   * @param moduleResource
   *          the relative path to the module resource to load
   * @param currentWorkingDirectory
   *          the URI of the current working directory
   * @param bindingContext
   *          the context used to access Metaschema module information based on
   *          Java class bindings
   * @return the loaded module
   * @throws CommandExecutionException
   *           if an error occurred while loading the module
   * @since 2.0.0
   */
  @NonNull
  public static IModule loadModule(
      @NonNull String moduleResource,
      @NonNull URI currentWorkingDirectory,
      @NonNull IBindingContext bindingContext) throws CommandExecutionException {
    try {
      URI moduleUri = getResourceUri(
          moduleResource,
          currentWorkingDirectory);
      return loadModule(moduleUri, bindingContext);
    } catch (URISyntaxException ex) {
      throw new CommandExecutionException(
          ExitCode.INVALID_ARGUMENTS,
          String.format("Cannot load module as '%s' is not a valid file or URL. %s",
              ex.getInput(),
              ex.getLocalizedMessage()),
          ex);
    }
  }

  /**
   * Load a Metaschema module from the provided resource path.
   *
   * @param moduleResource
   *          the absolute path to the module resource to load
   * @param bindingContext
   *          the context used to access Metaschema module information based on
   *          Java class bindings
   * @return the loaded module
   * @throws CommandExecutionException
   *           if an error occurred while loading the module
   * @since 2.0.0
   */
  @NonNull
  public static IModule loadModule(
      @NonNull URI moduleResource,
      @NonNull IBindingContext bindingContext) throws CommandExecutionException {
    // TODO: ensure the resource URI is absolute
    try {
      IBindingModuleLoader loader = bindingContext.newModuleLoader();
      loader.allowEntityResolution();
      return loader.load(moduleResource);
    } catch (IOException | MetaschemaException ex) {
      throw new CommandExecutionException(ExitCode.PROCESSING_ERROR, ex);
    }
  }

  /**
   * For a given resource location, resolve the location into an absolute URI.
   *
   * @param location
   *          the resource location
   * @param currentWorkingDirectory
   *          the URI of the current working directory
   * @return the resolved URI
   * @throws URISyntaxException
   *           if the location is not a valid URI
   */
  @NonNull
  public static URI getResourceUri(
      @NonNull String location,
      @NonNull URI currentWorkingDirectory) throws URISyntaxException {
    return UriUtils.toUri(location, currentWorkingDirectory);
  }

  /**
   * Load a set of external Metaschema module constraints based on the provided
   * command line option.
   *
   * @param commandLine
   *          the provided command line argument information
   * @param option
   *          the option specifying the constraints to load, which must be present
   *          on the command line
   * @param currentWorkingDirectory
   *          the URI of the current working directory
   * @return the set of loaded constraints
   * @throws CommandExecutionException
   *           if an error occurred while loading the module
   * @since 2.0.0
   */
  @NonNull
  public static Set<IConstraintSet> loadConstraintSets(
      @NonNull CommandLine commandLine,
      @NonNull Option option,
      @NonNull URI currentWorkingDirectory) throws CommandExecutionException {
    Set<IConstraintSet> constraintSets;
    if (commandLine.hasOption(option)) {
      IConstraintLoader constraintLoader = IBindingContext.getConstraintLoader();
      constraintSets = new LinkedHashSet<>();
      String[] args = commandLine.getOptionValues(option);
      for (String arg : args) {
        assert arg != null;
        try {
          URI constraintUri = ObjectUtils.requireNonNull(UriUtils.toUri(arg, currentWorkingDirectory));
          constraintSets.addAll(constraintLoader.load(constraintUri));
        } catch (URISyntaxException | IOException | MetaschemaException | MetapathException ex) {
          throw new CommandExecutionException(
              ExitCode.IO_ERROR,
              String.format("Unable to process constraint set '%s'. %s",
                  arg,
                  ex.getLocalizedMessage()),
              ex);
        }
      }
    } else {
      constraintSets = CollectionUtil.emptySet();
    }
    return constraintSets;
  }

  /**
   * Create a temporary directory for ephemeral files that will be deleted on
   * shutdown.
   *
   * @return the temp directory path
   * @throws IOException
   *           if an error occurred while creating the temporary directory
   */
  @NonNull
  public static Path newTempDir() throws IOException {
    Path retval = Files.createTempDirectory("metaschema-cli-");
    DeleteOnShutdown.register(retval);
    return ObjectUtils.notNull(retval);
  }

  /**
   * Create a new {@link IBindingContext} that is configured for dynamic
   * compilation.
   *
   * @return the binding context
   * @throws CommandExecutionException
   *           if an error occurred while creating the binding context
   * @since 2.0.0
   */
  @NonNull
  public static IBindingContext newBindingContextWithDynamicCompilation() throws CommandExecutionException {
    return newBindingContextWithDynamicCompilation(CollectionUtil.emptySet());
  }

  /**
   * Create a new {@link IBindingContext} that is configured for dynamic
   * compilation and to use the provided constraints.
   *
   * @param constraintSets
   *          the Metaschema module constraints to dynamicly bind to loaded
   *          modules
   * @return the binding context
   * @throws CommandExecutionException
   *           if an error occurred while creating the binding context
   * @since 2.0.0
   */
  @NonNull
  public static IBindingContext newBindingContextWithDynamicCompilation(@NonNull Set<IConstraintSet> constraintSets)
      throws CommandExecutionException {
    try {
      Path tempDir = newTempDir();
      return IBindingContext.builder()
          .compilePath(tempDir)
          .constraintSet(constraintSets)
          .build();
    } catch (IOException ex) {
      throw new CommandExecutionException(ExitCode.RUNTIME_ERROR,
          String.format("Unable to initialize the binding context. %s", ex.getLocalizedMessage()),
          ex);
    }
  }

  private MetaschemaCommands() {
    // disable construction
  }
}