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