001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package gov.nist.secauto.metaschema.cli.processor;
007
008import static org.fusesource.jansi.Ansi.ansi;
009
010import gov.nist.secauto.metaschema.cli.processor.command.CommandExecutionException;
011import gov.nist.secauto.metaschema.cli.processor.command.CommandService;
012import gov.nist.secauto.metaschema.cli.processor.command.ExtraArgument;
013import gov.nist.secauto.metaschema.cli.processor.command.ICommand;
014import gov.nist.secauto.metaschema.cli.processor.command.ICommandExecutor;
015import gov.nist.secauto.metaschema.core.util.AutoCloser;
016import gov.nist.secauto.metaschema.core.util.CollectionUtil;
017import gov.nist.secauto.metaschema.core.util.IVersionInfo;
018import gov.nist.secauto.metaschema.core.util.ObjectUtils;
019
020import org.apache.commons.cli.CommandLine;
021import org.apache.commons.cli.CommandLineParser;
022import org.apache.commons.cli.DefaultParser;
023import org.apache.commons.cli.HelpFormatter;
024import org.apache.commons.cli.Option;
025import org.apache.commons.cli.Options;
026import org.apache.commons.cli.ParseException;
027import org.apache.logging.log4j.Level;
028import org.apache.logging.log4j.LogManager;
029import org.apache.logging.log4j.Logger;
030import org.apache.logging.log4j.core.LoggerContext;
031import org.apache.logging.log4j.core.config.Configuration;
032import org.apache.logging.log4j.core.config.LoggerConfig;
033import org.fusesource.jansi.AnsiConsole;
034import org.fusesource.jansi.AnsiPrintStream;
035
036import java.io.PrintStream;
037import java.io.PrintWriter;
038import java.nio.charset.StandardCharsets;
039import java.util.Arrays;
040import java.util.Collection;
041import java.util.LinkedList;
042import java.util.List;
043import java.util.Map;
044import java.util.concurrent.atomic.AtomicBoolean;
045import java.util.function.Function;
046import java.util.stream.Collectors;
047
048import edu.umd.cs.findbugs.annotations.NonNull;
049import edu.umd.cs.findbugs.annotations.Nullable;
050import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
051
052/**
053 * Processes command line arguments and dispatches called commands.
054 * <p>
055 * This implementation make significant use of the command pattern to support a
056 * delegation chain of commands based on implementations of {@link ICommand}.
057 */
058@SuppressWarnings("PMD.CouplingBetweenObjects")
059public class CLIProcessor {
060  private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
061
062  /**
063   * This option indicates if the help should be shown.
064   */
065  @NonNull
066  public static final Option HELP_OPTION = ObjectUtils.notNull(Option.builder("h")
067      .longOpt("help")
068      .desc("display this help message")
069      .get());
070  /**
071   * This option indicates if colorized output should be disabled.
072   */
073  @NonNull
074  public static final Option NO_COLOR_OPTION = ObjectUtils.notNull(Option.builder()
075      .longOpt("no-color")
076      .desc("do not colorize output")
077      .get());
078  /**
079   * This option indicates if non-errors should be suppressed.
080   */
081  @NonNull
082  public static final Option QUIET_OPTION = ObjectUtils.notNull(Option.builder("q")
083      .longOpt("quiet")
084      .desc("minimize output to include only errors")
085      .get());
086  /**
087   * This option indicates if a strack trace should be shown for an error
088   * {@link ExitStatus}.
089   */
090  @NonNull
091  public static final Option SHOW_STACK_TRACE_OPTION = ObjectUtils.notNull(Option.builder()
092      .longOpt("show-stack-trace")
093      .desc("display the stack trace associated with an error")
094      .get());
095  /**
096   * This option indicates if the version information should be shown.
097   */
098  @NonNull
099  public static final Option VERSION_OPTION = ObjectUtils.notNull(Option.builder()
100      .longOpt("version")
101      .desc("display the application version")
102      .get());
103
104  @NonNull
105  private static final List<Option> OPTIONS = ObjectUtils.notNull(List.of(
106      HELP_OPTION,
107      NO_COLOR_OPTION,
108      QUIET_OPTION,
109      SHOW_STACK_TRACE_OPTION,
110      VERSION_OPTION));
111
112  /**
113   * Used to identify the version info for the command.
114   */
115  public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version";
116
117  @NonNull
118  private final List<ICommand> commands = new LinkedList<>();
119  @NonNull
120  private final String exec;
121  @NonNull
122  private final Map<String, IVersionInfo> versionInfos;
123  @NonNull
124  private final PrintStream outputStream;
125
126  /**
127   * The main entry point for command execution.
128   *
129   * @param args
130   *          the command line arguments to process
131   */
132  public static void main(String... args) {
133    System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
134    CLIProcessor processor = new CLIProcessor("metaschema-cli");
135
136    CommandService.getInstance().getCommands().stream().forEach(command -> {
137      assert command != null;
138      processor.addCommandHandler(command);
139    });
140    System.exit(processor.process(args).getExitCode().getStatusCode());
141  }
142
143  /**
144   * The main entry point for CLI processing.
145   * <p>
146   * This uses the build-in version information.
147   *
148   * @param args
149   *          the command line arguments
150   */
151  public CLIProcessor(@NonNull String args) {
152    this(args, CollectionUtil.singletonMap(COMMAND_VERSION, new ProcessorVersion()));
153  }
154
155  /**
156   * The main entry point for CLI processing.
157   * <p>
158   * This uses the provided version information.
159   *
160   * @param exec
161   *          the command name
162   * @param versionInfos
163   *          the version info to display when the version option is provided
164   */
165  public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos) {
166    this(exec, versionInfos, null);
167  }
168
169  /**
170   * The main entry point for CLI processing.
171   * <p>
172   * This constructor allows specifying a custom output stream for testing
173   * purposes.
174   *
175   * @param exec
176   *          the command name
177   * @param versionInfos
178   *          the version info to display when the version option is provided
179   * @param outputStream
180   *          the output stream to write to, or {@code null} to use the default
181   *          console
182   */
183  public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos,
184      @Nullable PrintStream outputStream) {
185    this.exec = exec;
186    this.versionInfos = versionInfos;
187    if (outputStream == null) {
188      AnsiConsole.systemInstall();
189      this.outputStream = ObjectUtils.notNull(AnsiConsole.out());
190    } else {
191      this.outputStream = outputStream;
192    }
193  }
194
195  /**
196   * Gets the command used to execute for use in help text.
197   *
198   * @return the command name
199   */
200  @NonNull
201  public String getExec() {
202    return exec;
203  }
204
205  /**
206   * Retrieve the version information for this application.
207   *
208   * @return the versionInfo
209   */
210  @NonNull
211  public Map<String, IVersionInfo> getVersionInfos() {
212    return versionInfos;
213  }
214
215  /**
216   * Register a new command handler.
217   *
218   * @param handler
219   *          the command handler to register
220   */
221  public void addCommandHandler(@NonNull ICommand handler) {
222    commands.add(handler);
223  }
224
225  /**
226   * Process a set of CLIProcessor arguments.
227   * <p>
228   * process().getExitCode().getStatusCode()
229   *
230   * @param args
231   *          the arguments to process
232   * @return the exit status
233   */
234  @NonNull
235  public ExitStatus process(String... args) {
236    return parseCommand(args);
237  }
238
239  @NonNull
240  private ExitStatus parseCommand(String... args) {
241    List<String> commandArgs = Arrays.asList(args);
242    assert commandArgs != null;
243    CallingContext callingContext = new CallingContext(commandArgs);
244
245    if (LOGGER.isDebugEnabled()) {
246      String commandChain = callingContext.getCalledCommands().stream()
247          .map(ICommand::getName)
248          .collect(Collectors.joining(" -> "));
249      LOGGER.debug("Processing command chain: {}", commandChain);
250    }
251
252    ExitStatus status;
253    // the first two arguments should be the <command> and <operation>, where <type>
254    // is the object type
255    // the <operation> is performed against.
256    if (commandArgs.isEmpty()) {
257      status = ExitCode.INVALID_COMMAND.exit();
258      callingContext.showHelp();
259    } else {
260      status = callingContext.processCommand();
261    }
262    return status;
263  }
264
265  /**
266   * Get the root-level commands.
267   *
268   * @return the list of commands
269   */
270  @NonNull
271  protected final List<ICommand> getTopLevelCommands() {
272    return CollectionUtil.unmodifiableList(commands);
273  }
274
275  /**
276   * Get the root-level commands, mapped from name to command.
277   *
278   * @return the map of command names to command
279   */
280  @NonNull
281  protected final Map<String, ICommand> getTopLevelCommandsByName() {
282    return ObjectUtils.notNull(getTopLevelCommands()
283        .stream()
284        .collect(Collectors.toUnmodifiableMap(ICommand::getName, Function.identity())));
285  }
286
287  private static void handleNoColor() {
288    System.setProperty(AnsiConsole.JANSI_MODE, AnsiConsole.JANSI_MODE_STRIP);
289    AnsiConsole.systemUninstall();
290  }
291
292  /**
293   * Configure the logger to only report errors.
294   */
295  public static void handleQuiet() {
296    LoggerContext ctx = (LoggerContext) LogManager.getContext(false); // NOPMD not closable here
297    Configuration config = ctx.getConfiguration();
298    LoggerConfig loggerConfig = config.getLoggerConfig(LogManager.ROOT_LOGGER_NAME);
299    Level oldLevel = loggerConfig.getLevel();
300    if (oldLevel.isLessSpecificThan(Level.ERROR)) {
301      loggerConfig.setLevel(Level.ERROR);
302      ctx.updateLoggers();
303    }
304  }
305
306  /**
307   * Output version information.
308   */
309  protected void showVersion() {
310    getVersionInfos().values().stream().forEach(info -> {
311      outputStream.println(ansi()
312          .bold().a(info.getName()).boldOff()
313          .a(" ")
314          .bold().a(info.getVersion()).boldOff()
315          .a(" built at ")
316          .bold().a(info.getBuildTimestamp()).boldOff()
317          .a(" from branch ")
318          .bold().a(info.getGitBranch()).boldOff()
319          .a(" (")
320          .bold().a(info.getGitCommit()).boldOff()
321          .a(") at ")
322          .bold().a(info.getGitOriginUrl()).boldOff()
323          .reset());
324    });
325    outputStream.flush();
326  }
327
328  /**
329   * Records information about the command line options and called command
330   * hierarchy.
331   */
332  @SuppressWarnings("PMD.GodClass")
333  public final class CallingContext {
334    @NonNull
335    private final List<Option> options;
336    @NonNull
337    private final List<ICommand> calledCommands;
338    @Nullable
339    private final ICommand targetCommand;
340    @NonNull
341    private final List<String> extraArgs;
342
343    @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields")
344    private CallingContext(@NonNull List<String> args) {
345      @SuppressWarnings("PMD.LooseCoupling") // needed to support getLast
346      LinkedList<ICommand> calledCommands = new LinkedList<>();
347      List<Option> options = new LinkedList<>(OPTIONS);
348      List<String> extraArgs = new LinkedList<>();
349
350      AtomicBoolean endArgs = new AtomicBoolean();
351      args.forEach(arg -> {
352        if (endArgs.get() || arg.startsWith("-")) {
353          extraArgs.add(arg);
354        } else if ("--".equals(arg)) {
355          endArgs.set(true);
356        } else {
357          ICommand command = calledCommands.isEmpty()
358              ? getTopLevelCommandsByName().get(arg)
359              : calledCommands.getLast().getSubCommandByName(arg);
360
361          if (command == null) {
362            extraArgs.add(arg);
363            endArgs.set(true);
364          } else {
365            calledCommands.add(command);
366            options.addAll(command.gatherOptions());
367          }
368        }
369      });
370
371      this.calledCommands = CollectionUtil.unmodifiableList(calledCommands);
372      this.targetCommand = calledCommands.peekLast();
373      this.options = CollectionUtil.unmodifiableList(options);
374      this.extraArgs = CollectionUtil.unmodifiableList(extraArgs);
375    }
376
377    /**
378     * Get the command line processor instance that generated this calling context.
379     *
380     * @return the instance
381     */
382    @NonNull
383    public CLIProcessor getCLIProcessor() {
384      return CLIProcessor.this;
385    }
386
387    /**
388     * Get the command that was triggered by the CLI arguments.
389     *
390     * @return the command or {@code null} if no command was triggered
391     */
392    @Nullable
393    public ICommand getTargetCommand() {
394      return targetCommand;
395    }
396
397    /**
398     * Get the options that are in scope for the current command context.
399     *
400     * @return the list of options
401     */
402    @NonNull
403    private List<Option> getOptionsList() {
404      return options;
405    }
406
407    @NonNull
408    private List<ICommand> getCalledCommands() {
409      return calledCommands;
410    }
411
412    /**
413     * Get any left over arguments that were not consumed by CLI options.
414     *
415     * @return the list of remaining arguments, which may be empty
416     */
417    @NonNull
418    private List<String> getExtraArgs() {
419      return extraArgs;
420    }
421
422    /**
423     * Get the collections of in scope options as an options group.
424     *
425     * @return the options group
426     */
427    private Options toOptions() {
428      Options retval = new Options();
429      for (Option option : getOptionsList()) {
430        retval.addOption(option);
431      }
432      return retval;
433    }
434
435    /**
436     * Process the command identified by the CLI arguments.
437     *
438     * @return the result of processing the command
439     */
440    @SuppressWarnings({
441        "PMD.OnlyOneReturn",
442        "PMD.NPathComplexity",
443        "PMD.CyclomaticComplexity"
444    })
445    @NonNull
446    public ExitStatus processCommand() {
447      // TODO: Consider refactoring as follows to reduce complexity:
448      // - Extract the parsing logic for each phase into separate methods (e.g.,
449      // parsePhaseOneOptions, parsePhaseTwoOptions).
450      // - Encapsulate error handling into dedicated methods.
451      // - Separate command execution logic into its own method if possible.
452      CommandLineParser parser = new DefaultParser();
453
454      // this uses a three phase approach where:
455      // phase 1: checks if help or version are used
456      // phase 2: parse and validate arguments
457      // phase 3: executes the command
458
459      // phase 1
460      CommandLine cmdLine;
461      try {
462        Options phase1Options = new Options();
463        phase1Options.addOption(HELP_OPTION);
464        phase1Options.addOption(VERSION_OPTION);
465
466        cmdLine = ObjectUtils.notNull(parser.parse(phase1Options, getExtraArgs().toArray(new String[0]), true));
467      } catch (ParseException ex) {
468        String msg = ex.getMessage();
469        assert msg != null;
470        return handleInvalidCommand(msg);
471      }
472
473      if (cmdLine.hasOption(VERSION_OPTION)) {
474        showVersion();
475        return ExitCode.OK.exit();
476      }
477      if (cmdLine.hasOption(HELP_OPTION)) {
478        showHelp();
479        return ExitCode.OK.exit();
480      }
481
482      // phase 2
483      try {
484        cmdLine = ObjectUtils.notNull(parser.parse(toOptions(), getExtraArgs().toArray(new String[0])));
485      } catch (ParseException ex) {
486        String msg = ex.getMessage();
487        assert msg != null;
488        return handleInvalidCommand(msg);
489      }
490
491      ICommand targetCommand = getTargetCommand();
492      if (targetCommand != null) {
493        try {
494          targetCommand.validateExtraArguments(this, cmdLine);
495        } catch (InvalidArgumentException ex) {
496          return handleError(
497              ExitCode.INVALID_ARGUMENTS.exitMessage(ex.getLocalizedMessage()),
498              cmdLine,
499              true);
500        }
501      }
502
503      for (ICommand cmd : getCalledCommands()) {
504        try {
505          cmd.validateOptions(this, cmdLine);
506        } catch (InvalidArgumentException ex) {
507          String msg = ex.getMessage();
508          assert msg != null;
509          return handleInvalidCommand(msg);
510        }
511      }
512
513      // phase 3
514      if (cmdLine.hasOption(NO_COLOR_OPTION)) {
515        handleNoColor();
516      }
517
518      if (cmdLine.hasOption(QUIET_OPTION)) {
519        handleQuiet();
520      }
521      return invokeCommand(cmdLine);
522    }
523
524    /**
525     * Directly execute the logic associated with the command.
526     *
527     * @param cmdLine
528     *          the command line information
529     * @return the result of executing the command
530     */
531    @SuppressWarnings({
532        "PMD.OnlyOneReturn", // readability
533        "PMD.AvoidCatchingGenericException" // needed here
534    })
535    @NonNull
536    private ExitStatus invokeCommand(@NonNull CommandLine cmdLine) {
537      ExitStatus retval;
538      try {
539        ICommand targetCommand = getTargetCommand();
540        if (targetCommand == null) {
541          retval = ExitCode.INVALID_COMMAND.exit();
542        } else {
543          ICommandExecutor executor = targetCommand.newExecutor(this, cmdLine);
544          try {
545            executor.execute();
546            retval = ExitCode.OK.exit();
547          } catch (CommandExecutionException ex) {
548            retval = ex.toExitStatus();
549          } catch (RuntimeException ex) {
550            retval = ExitCode.RUNTIME_ERROR
551                .exitMessage("Unexpected error occured: " + ex.getLocalizedMessage())
552                .withThrowable(ex);
553          }
554        }
555      } catch (RuntimeException ex) {
556        retval = ExitCode.RUNTIME_ERROR
557            .exitMessage(String.format("An uncaught runtime error occurred. %s", ex.getLocalizedMessage()))
558            .withThrowable(ex);
559      }
560
561      if (!ExitCode.OK.equals(retval.getExitCode())) {
562        retval.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));
563
564        if (ExitCode.INVALID_COMMAND.equals(retval.getExitCode())) {
565          showHelp();
566        }
567      }
568      return retval;
569    }
570
571    /**
572     * Handle an error that occurred while executing the command.
573     *
574     * @param exitStatus
575     *          the execution result
576     * @param cmdLine
577     *          the command line information
578     * @param showHelp
579     *          if {@code true} show the help information
580     * @return the resulting exit status
581     */
582    @NonNull
583    public ExitStatus handleError(
584        @NonNull ExitStatus exitStatus,
585        @NonNull CommandLine cmdLine,
586        boolean showHelp) {
587      exitStatus.generateMessage(cmdLine.hasOption(SHOW_STACK_TRACE_OPTION));
588      if (showHelp) {
589        showHelp();
590      }
591      return exitStatus;
592    }
593
594    /**
595     * Generate the help message and exit status for an invalid command using the
596     * provided message.
597     *
598     * @param message
599     *          the error message
600     * @return the resulting exit status
601     */
602    @NonNull
603    public ExitStatus handleInvalidCommand(
604        @NonNull String message) {
605      showHelp();
606
607      ExitStatus retval = ExitCode.INVALID_COMMAND.exitMessage(message);
608      retval.generateMessage(false);
609      return retval;
610    }
611
612    /**
613     * Callback for providing a help header.
614     *
615     * @return the header or {@code null}
616     */
617    @Nullable
618    private String buildHelpHeader() {
619      // TODO: build a suitable header
620      return null;
621    }
622
623    /**
624     * Callback for providing a help footer.
625     *
626     * @param exec
627     *          the executable name
628     *
629     * @return the footer or {@code null}
630     */
631    @NonNull
632    private String buildHelpFooter() {
633
634      ICommand targetCommand = getTargetCommand();
635      Collection<ICommand> subCommands;
636      if (targetCommand == null) {
637        subCommands = getTopLevelCommands();
638      } else {
639        subCommands = targetCommand.getSubCommands();
640      }
641
642      String retval;
643      if (subCommands.isEmpty()) {
644        retval = "";
645      } else {
646        StringBuilder builder = new StringBuilder(128);
647        builder
648            .append(System.lineSeparator())
649            .append("The following are available commands:")
650            .append(System.lineSeparator());
651
652        int length = subCommands.stream()
653            .mapToInt(command -> command.getName().length())
654            .max().orElse(0);
655
656        for (ICommand command : subCommands) {
657          builder.append(
658              ansi()
659                  .render(String.format("   @|bold %-" + length + "s|@ %s%n",
660                      command.getName(),
661                      command.getDescription())));
662        }
663        builder
664            .append(System.lineSeparator())
665            .append('\'')
666            .append(getExec())
667            .append(" <command> --help' will show help on that specific command.")
668            .append(System.lineSeparator());
669        retval = builder.toString();
670        assert retval != null;
671      }
672      return retval;
673    }
674
675    /**
676     * Get the CLI syntax.
677     *
678     * @return the CLI syntax to display in help output
679     */
680    private String buildHelpCliSyntax() {
681
682      StringBuilder builder = new StringBuilder(64);
683      builder.append(getExec());
684
685      List<ICommand> calledCommands = getCalledCommands();
686      if (!calledCommands.isEmpty()) {
687        builder.append(calledCommands.stream()
688            .map(ICommand::getName)
689            .collect(Collectors.joining(" ", " ", "")));
690      }
691
692      // output calling commands
693      ICommand targetCommand = getTargetCommand();
694      if (targetCommand == null) {
695        builder.append(" <command>");
696      } else {
697        builder.append(getSubCommands(targetCommand));
698      }
699
700      // output required options
701      getOptionsList().stream()
702          .filter(Option::isRequired)
703          .forEach(option -> {
704            builder
705                .append(' ')
706                .append(OptionUtils.toArgument(ObjectUtils.notNull(option)));
707            if (option.hasArg()) {
708              builder
709                  .append('=')
710                  .append(option.getArgName());
711            }
712          });
713
714      // output non-required option placeholder
715      builder.append(" [<options>]");
716
717      // output extra arguments
718      if (targetCommand != null) {
719        // handle extra arguments
720        builder.append(getExtraArguments(targetCommand));
721      }
722
723      String retval = builder.toString();
724      assert retval != null;
725      return retval;
726    }
727
728    @NonNull
729    private CharSequence getSubCommands(ICommand targetCommand) {
730      Collection<ICommand> subCommands = targetCommand.getSubCommands();
731
732      StringBuilder builder = new StringBuilder();
733      if (!subCommands.isEmpty()) {
734        builder.append(' ');
735        if (!targetCommand.isSubCommandRequired()) {
736          builder.append('[');
737        }
738
739        builder.append("<command>");
740
741        if (!targetCommand.isSubCommandRequired()) {
742          builder.append(']');
743        }
744      }
745      return builder;
746    }
747
748    @NonNull
749    private CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
750      StringBuilder builder = new StringBuilder();
751      for (ExtraArgument argument : targetCommand.getExtraArguments()) {
752        builder.append(' ');
753        if (!argument.isRequired()) {
754          builder.append('[');
755        }
756
757        builder.append('<')
758            .append(argument.getName())
759            .append('>');
760
761        if (argument.getNumber() > 1) {
762          builder.append("...");
763        }
764
765        if (!argument.isRequired()) {
766          builder.append(']');
767        }
768      }
769      return builder;
770    }
771
772    /**
773     * Output the help text to the console.
774     */
775    public void showHelp() {
776
777      HelpFormatter formatter = new HelpFormatter();
778      formatter.setLongOptSeparator("=");
779
780      PrintStream out = outputStream;
781      int terminalWidth = (out instanceof AnsiPrintStream)
782          ? ((AnsiPrintStream) out).getTerminalWidth()
783          : 80;
784
785      try (PrintWriter writer = new PrintWriter( // NOPMD not owned
786          AutoCloser.preventClose(out),
787          true,
788          StandardCharsets.UTF_8)) {
789        formatter.printHelp(
790            writer,
791            Math.max(terminalWidth, 50),
792            buildHelpCliSyntax(),
793            buildHelpHeader(),
794            toOptions(),
795            HelpFormatter.DEFAULT_LEFT_PAD,
796            HelpFormatter.DEFAULT_DESC_PAD,
797            buildHelpFooter(),
798            false);
799        writer.flush();
800      }
801    }
802  }
803}