001/*
002 * SPDX-FileCopyrightText: none
003 * SPDX-License-Identifier: CC0-1.0
004 */
005
006package dev.metaschema.cli.processor;
007
008import static org.jline.jansi.Ansi.ansi;
009
010import org.apache.commons.cli.Option;
011import org.apache.logging.log4j.Level;
012import org.apache.logging.log4j.LogManager;
013import org.apache.logging.log4j.Logger;
014import org.apache.logging.log4j.core.LoggerContext;
015import org.apache.logging.log4j.core.config.Configuration;
016import org.apache.logging.log4j.core.config.LoggerConfig;
017import org.eclipse.jdt.annotation.NotOwning;
018import org.jline.jansi.Ansi;
019
020import java.io.PrintStream;
021import java.util.Arrays;
022import java.util.LinkedList;
023import java.util.List;
024import java.util.Map;
025import java.util.function.Function;
026import java.util.stream.Collectors;
027
028import dev.metaschema.cli.processor.command.CommandService;
029import dev.metaschema.cli.processor.command.ICommand;
030import dev.metaschema.core.util.CollectionUtil;
031import dev.metaschema.core.util.IVersionInfo;
032import dev.metaschema.core.util.ObjectUtils;
033import edu.umd.cs.findbugs.annotations.NonNull;
034import edu.umd.cs.findbugs.annotations.Nullable;
035
036/**
037 * Processes command line arguments and dispatches called commands.
038 * <p>
039 * This implementation make significant use of the command pattern to support a
040 * delegation chain of commands based on implementations of {@link ICommand}.
041 */
042public class CLIProcessor {
043  private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
044
045  /**
046   * This option indicates if the help should be shown.
047   */
048  @NonNull
049  public static final Option HELP_OPTION = ObjectUtils.notNull(Option.builder("h")
050      .longOpt("help")
051      .desc("display this help message")
052      .get());
053  /**
054   * This option indicates if colorized output should be disabled.
055   */
056  @NonNull
057  public static final Option NO_COLOR_OPTION = ObjectUtils.notNull(Option.builder()
058      .longOpt("no-color")
059      .desc("do not colorize output")
060      .get());
061  /**
062   * This option indicates if non-errors should be suppressed.
063   */
064  @NonNull
065  public static final Option QUIET_OPTION = ObjectUtils.notNull(Option.builder("q")
066      .longOpt("quiet")
067      .desc("minimize output to include only errors")
068      .get());
069  /**
070   * This option indicates if a strack trace should be shown for an error
071   * {@link ExitStatus}.
072   */
073  @NonNull
074  public static final Option SHOW_STACK_TRACE_OPTION = ObjectUtils.notNull(Option.builder()
075      .longOpt("show-stack-trace")
076      .desc("display the stack trace associated with an error")
077      .get());
078  /**
079   * This option indicates if the version information should be shown.
080   */
081  @NonNull
082  public static final Option VERSION_OPTION = ObjectUtils.notNull(Option.builder()
083      .longOpt("version")
084      .desc("display the application version")
085      .get());
086
087  @NonNull
088  static final List<Option> OPTIONS = ObjectUtils.notNull(List.of(
089      HELP_OPTION,
090      NO_COLOR_OPTION,
091      QUIET_OPTION,
092      SHOW_STACK_TRACE_OPTION,
093      VERSION_OPTION));
094
095  /**
096   * Used to identify the version info for the command.
097   */
098  public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version";
099
100  @NonNull
101  private final List<ICommand> commands = new LinkedList<>();
102  @NonNull
103  private final String exec;
104  @NonNull
105  private final Map<String, IVersionInfo> versionInfos;
106  @NonNull
107  @NotOwning
108  private final PrintStream outputStream;
109
110  /**
111   * The main entry point for command execution.
112   *
113   * @param args
114   *          the command line arguments to process
115   */
116  public static void main(String... args) {
117    System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
118    CLIProcessor processor = new CLIProcessor("metaschema-cli");
119
120    CommandService.getInstance().getCommands().stream().forEach(command -> {
121      assert command != null;
122      processor.addCommandHandler(command);
123    });
124    System.exit(processor.process(args).getExitCode().getStatusCode());
125  }
126
127  /**
128   * The main entry point for CLI processing.
129   * <p>
130   * This uses the build-in version information.
131   *
132   * @param args
133   *          the command line arguments
134   */
135  public CLIProcessor(@NonNull String args) {
136    this(args, CollectionUtil.singletonMap(COMMAND_VERSION, new ProcessorVersion()));
137  }
138
139  /**
140   * The main entry point for CLI processing.
141   * <p>
142   * This uses the provided version information.
143   *
144   * @param exec
145   *          the command name
146   * @param versionInfos
147   *          the version info to display when the version option is provided
148   */
149  public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos) {
150    this(exec, versionInfos, null);
151  }
152
153  /**
154   * The main entry point for CLI processing.
155   * <p>
156   * This constructor allows specifying a custom output stream for testing
157   * purposes.
158   *
159   * @param exec
160   *          the command name
161   * @param versionInfos
162   *          the version info to display when the version option is provided
163   * @param outputStream
164   *          the output stream to write to, or {@code null} to use the default
165   *          console. The caller retains ownership of this stream and is
166   *          responsible for closing it.
167   */
168  @SuppressWarnings("resource")
169  public CLIProcessor(@NonNull String exec, @NonNull Map<String, IVersionInfo> versionInfos,
170      @Nullable @NotOwning PrintStream outputStream) {
171    this.exec = exec;
172    this.versionInfos = versionInfos;
173    // Use System.out directly - modern terminals (Windows 10+, Linux, macOS)
174    // support ANSI natively without requiring native terminal detection
175    this.outputStream = outputStream != null ? outputStream : ObjectUtils.notNull(System.out);
176  }
177
178  /**
179   * Gets the command used to execute for use in help text.
180   *
181   * @return the command name
182   */
183  @NonNull
184  public String getExec() {
185    return exec;
186  }
187
188  /**
189   * Retrieve the version information for this application.
190   *
191   * @return the versionInfo
192   */
193  @NonNull
194  public Map<String, IVersionInfo> getVersionInfos() {
195    return versionInfos;
196  }
197
198  /**
199   * Register a new command handler.
200   *
201   * @param handler
202   *          the command handler to register
203   */
204  public void addCommandHandler(@NonNull ICommand handler) {
205    commands.add(handler);
206  }
207
208  /**
209   * Process a set of CLIProcessor arguments.
210   * <p>
211   * process().getExitCode().getStatusCode()
212   *
213   * @param args
214   *          the arguments to process
215   * @return the exit status
216   */
217  @NonNull
218  public ExitStatus process(String... args) {
219    return parseCommand(args);
220  }
221
222  @NonNull
223  private ExitStatus parseCommand(String... args) {
224    List<String> commandArgs = Arrays.asList(args);
225    assert commandArgs != null;
226    CallingContext callingContext = new CallingContext(this, commandArgs);
227
228    if (LOGGER.isDebugEnabled()) {
229      String commandChain = callingContext.getCalledCommands().stream()
230          .map(ICommand::getName)
231          .collect(Collectors.joining(" -> "));
232      LOGGER.debug("Processing command chain: {}", commandChain);
233    }
234
235    ExitStatus status;
236    // the first two arguments should be the <command> and <operation>, where <type>
237    // is the object type
238    // the <operation> is performed against.
239    if (commandArgs.isEmpty()) {
240      status = ExitCode.INVALID_COMMAND.exit();
241      callingContext.showHelp();
242    } else {
243      status = callingContext.processCommand();
244    }
245    return status;
246  }
247
248  /**
249   * Get the root-level commands.
250   *
251   * @return the list of commands
252   */
253  @NonNull
254  public final List<ICommand> getTopLevelCommands() {
255    return CollectionUtil.unmodifiableList(commands);
256  }
257
258  /**
259   * Get the root-level commands, mapped from name to command.
260   *
261   * @return the map of command names to command
262   */
263  @NonNull
264  protected final Map<String, ICommand> getTopLevelCommandsByName() {
265    return ObjectUtils.notNull(getTopLevelCommands()
266        .stream()
267        .collect(Collectors.toUnmodifiableMap(ICommand::getName, Function.identity())));
268  }
269
270  /**
271   * Disable ANSI escape sequences in output.
272   * <p>
273   * When called, this method disables ANSI color codes, causing output to use
274   * plain text without formatting. This is useful for legacy consoles that do not
275   * support ANSI escape codes, CI/CD environments, or when redirecting output to
276   * a file.
277   */
278  static void handleNoColor() {
279    Ansi.setEnabled(false);
280  }
281
282  /**
283   * Get the output stream used for writing CLI output.
284   * <p>
285   * The caller does not own this stream and must not close it.
286   *
287   * @return the output stream
288   */
289  @NonNull
290  @NotOwning
291  PrintStream getOutputStream() {
292    return outputStream;
293  }
294
295  /**
296   * Configure the logger to only report errors.
297   */
298  static void handleQuiet() {
299    LoggerContext ctx = (LoggerContext) LogManager.getContext(false); // NOPMD not closable here
300    Configuration config = ctx.getConfiguration();
301    LoggerConfig loggerConfig = config.getLoggerConfig(LogManager.ROOT_LOGGER_NAME);
302    Level oldLevel = loggerConfig.getLevel();
303    if (oldLevel.isLessSpecificThan(Level.ERROR)) {
304      loggerConfig.setLevel(Level.ERROR);
305      ctx.updateLoggers();
306    }
307  }
308
309  /**
310   * Output version information.
311   */
312  protected void showVersion() {
313    getVersionInfos().values().stream().forEach(info -> {
314      outputStream.println(ansi()
315          .bold().a(info.getName()).boldOff()
316          .a(" ")
317          .bold().a(info.getVersion()).boldOff()
318          .a(" built at ")
319          .bold().a(info.getBuildTimestamp()).boldOff()
320          .a(" from branch ")
321          .bold().a(info.getGitBranch()).boldOff()
322          .a(" (")
323          .bold().a(info.getGitCommit()).boldOff()
324          .a(") at ")
325          .bold().a(info.getGitOriginUrl()).boldOff()
326          .reset());
327    });
328    outputStream.flush();
329  }
330}