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.CommandLine;
011import org.apache.commons.cli.DefaultParser;
012import org.apache.commons.cli.Option;
013import org.apache.commons.cli.Options;
014import org.apache.commons.cli.ParseException;
015import org.apache.commons.cli.help.HelpFormatter;
016import org.apache.commons.cli.help.OptionFormatter;
017import org.apache.commons.cli.help.TextHelpAppendable;
018
019import java.io.IOException;
020import java.io.PrintStream;
021import java.io.PrintWriter;
022import java.io.UncheckedIOException;
023import java.nio.charset.StandardCharsets;
024import java.util.Collection;
025import java.util.LinkedList;
026import java.util.List;
027import java.util.Optional;
028import java.util.concurrent.atomic.AtomicBoolean;
029import java.util.stream.Collectors;
030
031import dev.metaschema.cli.processor.command.CommandExecutionException;
032import dev.metaschema.cli.processor.command.ExtraArgument;
033import dev.metaschema.cli.processor.command.ICommand;
034import dev.metaschema.cli.processor.command.ICommandExecutor;
035import dev.metaschema.core.util.AutoCloser;
036import dev.metaschema.core.util.CollectionUtil;
037import dev.metaschema.core.util.ObjectUtils;
038import edu.umd.cs.findbugs.annotations.NonNull;
039import edu.umd.cs.findbugs.annotations.Nullable;
040import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
041
042/**
043 * Records information about the command line options and called command
044 * hierarchy.
045 */
046public class CallingContext {
047  @NonNull
048  private final CLIProcessor cliProcessor;
049  @NonNull
050  private final List<Option> options;
051  @NonNull
052  private final List<ICommand> calledCommands;
053  @Nullable
054  private final ICommand targetCommand;
055  @NonNull
056  private final List<String> extraArgs;
057
058  @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW", justification = "Use of final fields")
059  CallingContext(@NonNull CLIProcessor cliProcessor, @NonNull List<String> args) {
060    this.cliProcessor = cliProcessor;
061
062    @SuppressWarnings("PMD.LooseCoupling") // needed to support getLast
063    LinkedList<ICommand> calledCommands = new LinkedList<>();
064    List<Option> options = new LinkedList<>(CLIProcessor.OPTIONS);
065    List<String> extraArgs = new LinkedList<>();
066
067    AtomicBoolean endArgs = new AtomicBoolean();
068    args.forEach(arg -> {
069      if (endArgs.get() || arg.startsWith("-")) {
070        extraArgs.add(arg);
071      } else if ("--".equals(arg)) {
072        endArgs.set(true);
073      } else {
074        ICommand command = calledCommands.isEmpty()
075            ? cliProcessor.getTopLevelCommandsByName().get(arg)
076            : calledCommands.getLast().getSubCommandByName(arg);
077
078        if (command == null) {
079          extraArgs.add(arg);
080          endArgs.set(true);
081        } else {
082          calledCommands.add(command);
083          options.addAll(command.gatherOptions());
084        }
085      }
086    });
087
088    this.calledCommands = CollectionUtil.unmodifiableList(calledCommands);
089    this.targetCommand = calledCommands.peekLast();
090    this.options = CollectionUtil.unmodifiableList(options);
091    this.extraArgs = CollectionUtil.unmodifiableList(extraArgs);
092  }
093
094  /**
095   * Get the command line processor instance that generated this calling context.
096   *
097   * @return the instance
098   */
099  @NonNull
100  public CLIProcessor getCLIProcessor() {
101    return cliProcessor;
102  }
103
104  /**
105   * Get the command that was triggered by the CLI arguments.
106   *
107   * @return the command or {@code null} if no command was triggered
108   */
109  @Nullable
110  public ICommand getTargetCommand() {
111    return targetCommand;
112  }
113
114  /**
115   * Get the options that are in scope for the current command context.
116   *
117   * @return the list of options
118   */
119  @NonNull
120  List<Option> getOptionsList() {
121    return options;
122  }
123
124  @NonNull
125  List<ICommand> getCalledCommands() {
126    return calledCommands;
127  }
128
129  /**
130   * Get any left over arguments that were not consumed by CLI options.
131   *
132   * @return the list of remaining arguments, which may be empty
133   */
134  @NonNull
135  List<String> getExtraArgs() {
136    return extraArgs;
137  }
138
139  /**
140   * Get the collections of in scope options as an options group.
141   *
142   * @return the options group
143   */
144  Options toOptions() {
145    Options retval = new Options();
146    for (Option option : getOptionsList()) {
147      retval.addOption(option);
148    }
149    return retval;
150  }
151
152  /**
153   * Check for --help and --version options before full parsing.
154   * <p>
155   * This is phase 1 of command processing.
156   *
157   * @return an exit status if help or version was requested, or empty to continue
158   */
159  @NonNull
160  protected Optional<ExitStatus> checkHelpAndVersion() {
161    Options phase1Options = new Options();
162    phase1Options.addOption(CLIProcessor.HELP_OPTION);
163    phase1Options.addOption(CLIProcessor.VERSION_OPTION);
164
165    try {
166      CommandLine cmdLine = new DefaultParser()
167          .parse(phase1Options, getExtraArgs().toArray(new String[0]), true);
168
169      if (cmdLine.hasOption(CLIProcessor.VERSION_OPTION)) {
170        cliProcessor.showVersion();
171        return ObjectUtils.notNull(Optional.of(ExitCode.OK.exit()));
172      }
173      if (cmdLine.hasOption(CLIProcessor.HELP_OPTION)) {
174        showHelp();
175        return ObjectUtils.notNull(Optional.of(ExitCode.OK.exit()));
176      }
177    } catch (ParseException ex) {
178      String msg = ex.getMessage();
179      return ObjectUtils.notNull(Optional.of(handleInvalidCommand(msg != null ? msg : "Invalid command")));
180    }
181    return ObjectUtils.notNull(Optional.empty());
182  }
183
184  /**
185   * Parse all command line options.
186   * <p>
187   * This is phase 2 of command processing.
188   *
189   * @return the parsed command line
190   * @throws ParseException
191   *           if parsing fails
192   */
193  @NonNull
194  protected CommandLine parseOptions() throws ParseException {
195    return ObjectUtils.notNull(
196        new DefaultParser().parse(toOptions(), getExtraArgs().toArray(new String[0])));
197  }
198
199  /**
200   * Validate extra arguments for the target command.
201   * <p>
202   * This is phase 3 of command processing.
203   *
204   * @param cmdLine
205   *          the parsed command line
206   * @return an exit status if validation failed, or empty to continue
207   */
208  @NonNull
209  protected Optional<ExitStatus> validateExtraArguments(@NonNull CommandLine cmdLine) {
210    ICommand target = getTargetCommand();
211    if (target == null) {
212      return ObjectUtils.notNull(Optional.empty());
213    }
214    try {
215      target.validateExtraArguments(this, cmdLine);
216      return ObjectUtils.notNull(Optional.empty());
217    } catch (InvalidArgumentException ex) {
218      return ObjectUtils.notNull(Optional.of(handleError(
219          ExitCode.INVALID_ARGUMENTS.exitMessage(ex.getLocalizedMessage()),
220          cmdLine,
221          true)));
222    }
223  }
224
225  /**
226   * Validate options for all called commands in the chain.
227   * <p>
228   * This is phase 4 of command processing.
229   *
230   * @param cmdLine
231   *          the parsed command line
232   * @return an exit status if validation failed, or empty to continue
233   */
234  @NonNull
235  protected Optional<ExitStatus> validateCalledCommands(@NonNull CommandLine cmdLine) {
236    for (ICommand cmd : getCalledCommands()) {
237      try {
238        cmd.validateOptions(this, cmdLine);
239      } catch (InvalidArgumentException ex) {
240        String msg = ex.getMessage();
241        return ObjectUtils.notNull(Optional.of(handleInvalidCommand(msg != null ? msg : "Invalid argument")));
242      }
243    }
244    return ObjectUtils.notNull(Optional.empty());
245  }
246
247  /**
248   * Apply global options like --no-color and --quiet.
249   * <p>
250   * This is phase 5 of command processing.
251   *
252   * @param cmdLine
253   *          the parsed command line
254   */
255  protected void applyGlobalOptions(@NonNull CommandLine cmdLine) {
256    if (cmdLine.hasOption(CLIProcessor.NO_COLOR_OPTION)) {
257      CLIProcessor.handleNoColor();
258    }
259    if (cmdLine.hasOption(CLIProcessor.QUIET_OPTION)) {
260      CLIProcessor.handleQuiet();
261    }
262  }
263
264  /**
265   * Process the command identified by the CLI arguments.
266   *
267   * @return the result of processing the command
268   */
269  @NonNull
270  public ExitStatus processCommand() {
271    // Phase 1: Check help/version before full parsing
272    Optional<ExitStatus> earlyExit = checkHelpAndVersion();
273    if (earlyExit.isPresent()) {
274      return earlyExit.get();
275    }
276
277    // Phase 2: Parse all options
278    CommandLine cmdLine;
279    try {
280      cmdLine = parseOptions();
281    } catch (ParseException ex) {
282      String msg = ex.getMessage();
283      return handleInvalidCommand(msg != null ? msg : "Parse error");
284    }
285
286    // Phase 3-4: Validate arguments and options
287    Optional<ExitStatus> validationResult = validateExtraArguments(cmdLine)
288        .or(() -> validateCalledCommands(cmdLine));
289    if (validationResult.isPresent()) {
290      return validationResult.get();
291    }
292
293    // Phase 5: Apply global options and execute
294    applyGlobalOptions(cmdLine);
295    return invokeCommand(cmdLine);
296  }
297
298  /**
299   * Directly execute the logic associated with the command.
300   *
301   * @param cmdLine
302   *          the command line information
303   * @return the result of executing the command
304   */
305  @NonNull
306  private ExitStatus invokeCommand(@NonNull CommandLine cmdLine) {
307    ExitStatus retval;
308    try {
309      ICommand targetCommand = getTargetCommand();
310      if (targetCommand == null) {
311        retval = ExitCode.INVALID_COMMAND.exit();
312      } else {
313        ICommandExecutor executor = targetCommand.newExecutor(this, cmdLine);
314        try {
315          executor.execute();
316          retval = ExitCode.OK.exit();
317        } catch (CommandExecutionException ex) {
318          retval = ex.toExitStatus();
319        } catch (RuntimeException ex) {
320          retval = ExitCode.RUNTIME_ERROR
321              .exitMessage("Unexpected error occurred: " + ex.getLocalizedMessage())
322              .withThrowable(ex);
323        }
324      }
325    } catch (RuntimeException ex) {
326      retval = ExitCode.RUNTIME_ERROR
327          .exitMessage(String.format("An uncaught runtime error occurred. %s", ex.getLocalizedMessage()))
328          .withThrowable(ex);
329    }
330
331    if (!ExitCode.OK.equals(retval.getExitCode())) {
332      retval.generateMessage(cmdLine.hasOption(CLIProcessor.SHOW_STACK_TRACE_OPTION));
333
334      if (ExitCode.INVALID_COMMAND.equals(retval.getExitCode())) {
335        showHelp();
336      }
337    }
338    return retval;
339  }
340
341  /**
342   * Handle an error that occurred while executing the command.
343   *
344   * @param exitStatus
345   *          the execution result
346   * @param cmdLine
347   *          the command line information
348   * @param showHelp
349   *          if {@code true} show the help information
350   * @return the resulting exit status
351   */
352  @NonNull
353  public ExitStatus handleError(
354      @NonNull ExitStatus exitStatus,
355      @NonNull CommandLine cmdLine,
356      boolean showHelp) {
357    exitStatus.generateMessage(cmdLine.hasOption(CLIProcessor.SHOW_STACK_TRACE_OPTION));
358    if (showHelp) {
359      showHelp();
360    }
361    return exitStatus;
362  }
363
364  /**
365   * Generate the help message and exit status for an invalid command using the
366   * provided message.
367   *
368   * @param message
369   *          the error message
370   * @return the resulting exit status
371   */
372  @NonNull
373  public ExitStatus handleInvalidCommand(
374      @NonNull String message) {
375    showHelp();
376
377    ExitStatus retval = ExitCode.INVALID_COMMAND.exitMessage(message);
378    retval.generateMessage(false);
379    return retval;
380  }
381
382  /**
383   * Callback for providing a help header.
384   *
385   * @return the header or {@code null}
386   */
387  @Nullable
388  private static String buildHelpHeader() {
389    // TODO: build a suitable header
390    return null;
391  }
392
393  /**
394   * Callback for providing a help footer.
395   *
396   * @param terminalWidth
397   *          the terminal width for text wrapping
398   * @return the footer or an empty string if no subcommands
399   */
400  @NonNull
401  private String buildHelpFooter(int terminalWidth) {
402    ICommand targetCommand = getTargetCommand();
403    Collection<ICommand> subCommands;
404    if (targetCommand == null) {
405      subCommands = cliProcessor.getTopLevelCommands();
406    } else {
407      subCommands = targetCommand.getSubCommands();
408    }
409
410    String retval;
411    if (subCommands.isEmpty()) {
412      retval = "";
413    } else {
414      StringBuilder builder = new StringBuilder(128);
415      builder
416          .append(System.lineSeparator())
417          .append("The following are available commands:")
418          .append(System.lineSeparator());
419
420      int commandColWidth = subCommands.stream()
421          .mapToInt(command -> command.getName().length())
422          .max().orElse(0);
423
424      // Calculate description column width: terminal - 3 (leading spaces) -
425      // commandCol - 1 (space)
426      int prefixWidth = 3 + commandColWidth + 1;
427      int descWidth = Math.max(terminalWidth - prefixWidth, 20);
428      String continuationIndent = " ".repeat(prefixWidth);
429
430      for (ICommand command : subCommands) {
431        String wrappedDesc = wrapText(command.getDescription(), descWidth, continuationIndent);
432        builder.append(
433            ansi()
434                .render(String.format("   @|bold %-" + commandColWidth + "s|@ %s%n",
435                    command.getName(),
436                    wrappedDesc)));
437      }
438      builder
439          .append(System.lineSeparator())
440          .append('\'')
441          .append(cliProcessor.getExec())
442          .append(" <command> --help' will show help on that specific command.")
443          .append(System.lineSeparator());
444      retval = builder.toString();
445      assert retval != null;
446    }
447    return retval;
448  }
449
450  /**
451   * Get the CLI syntax.
452   *
453   * @return the CLI syntax to display in help output
454   */
455  private String buildHelpCliSyntax() {
456    StringBuilder builder = new StringBuilder(64);
457    builder.append(cliProcessor.getExec());
458
459    List<ICommand> calledCommands = getCalledCommands();
460    if (!calledCommands.isEmpty()) {
461      builder.append(calledCommands.stream()
462          .map(ICommand::getName)
463          .collect(Collectors.joining(" ", " ", "")));
464    }
465
466    // output calling commands
467    ICommand targetCommand = getTargetCommand();
468    if (targetCommand == null) {
469      builder.append(" <command>");
470    } else {
471      builder.append(getSubCommands(targetCommand));
472    }
473
474    // output required options
475    getOptionsList().stream()
476        .filter(Option::isRequired)
477        .forEach(option -> {
478          builder
479              .append(' ')
480              .append(OptionUtils.toArgument(ObjectUtils.notNull(option)));
481          if (option.hasArg()) {
482            builder
483                .append('=')
484                .append(option.getArgName());
485          }
486        });
487
488    // output non-required option placeholder
489    builder.append(" [<options>]");
490
491    // output extra arguments
492    if (targetCommand != null) {
493      // handle extra arguments
494      builder.append(getExtraArguments(targetCommand));
495    }
496
497    String retval = builder.toString();
498    assert retval != null;
499    return retval;
500  }
501
502  @NonNull
503  private static CharSequence getSubCommands(@NonNull ICommand targetCommand) {
504    Collection<ICommand> subCommands = targetCommand.getSubCommands();
505
506    StringBuilder builder = new StringBuilder();
507    if (!subCommands.isEmpty()) {
508      builder.append(' ');
509      if (!targetCommand.isSubCommandRequired()) {
510        builder.append('[');
511      }
512
513      builder.append("<command>");
514
515      if (!targetCommand.isSubCommandRequired()) {
516        builder.append(']');
517      }
518    }
519    return builder;
520  }
521
522  @NonNull
523  private static CharSequence getExtraArguments(@NonNull ICommand targetCommand) {
524    StringBuilder builder = new StringBuilder();
525    for (ExtraArgument argument : targetCommand.getExtraArguments()) {
526      builder.append(' ');
527      if (!argument.isRequired()) {
528        builder.append('[');
529      }
530
531      builder.append('<')
532          .append(argument.getName())
533          .append('>');
534
535      if (argument.getNumber() > 1) {
536        builder.append("...");
537      }
538
539      if (!argument.isRequired()) {
540        builder.append(']');
541      }
542    }
543    return builder;
544  }
545
546  private static final int DEFAULT_TERMINAL_WIDTH = 80;
547
548  /**
549   * Get the terminal width from environment or use a default.
550   * <p>
551   * This method avoids native terminal detection which triggers Java 21+
552   * restricted method warnings. Instead, it uses the COLUMNS environment variable
553   * which is set by most shells.
554   *
555   * @return the terminal width in characters
556   */
557  private static int getTerminalWidth() {
558    String columns = System.getenv("COLUMNS");
559    if (columns != null) {
560      try {
561        int width = Integer.parseInt(columns);
562        if (width > 0) {
563          return width;
564        }
565      } catch (NumberFormatException e) {
566        // Ignore and use default
567      }
568    }
569    return DEFAULT_TERMINAL_WIDTH;
570  }
571
572  /**
573   * Wrap text to fit within the specified width, with proper indentation for
574   * continuation lines.
575   *
576   * @param text
577   *          the text to wrap
578   * @param maxWidth
579   *          the maximum line width
580   * @param indent
581   *          the indentation string for continuation lines
582   * @return the wrapped text
583   * @throws IllegalArgumentException
584   *           if maxWidth is less than or equal to zero, or if the indent length
585   *           is greater than or equal to maxWidth
586   */
587  @NonNull
588  static String wrapText(@NonNull String text, int maxWidth, @NonNull String indent) {
589    if (maxWidth <= 0) {
590      throw new IllegalArgumentException("maxWidth must be positive, got: " + maxWidth);
591    }
592    if (indent.length() >= maxWidth) {
593      throw new IllegalArgumentException(
594          "indent length (" + indent.length() + ") must be less than maxWidth (" + maxWidth + ")");
595    }
596    if (text.length() <= maxWidth) {
597      return text;
598    }
599
600    StringBuilder result = new StringBuilder(text.length() + 32);
601    int lineStart = 0;
602    boolean firstLine = true;
603    int effectiveWidth = maxWidth;
604
605    while (lineStart < text.length()) {
606      if (!firstLine) {
607        result.append(System.lineSeparator()).append(indent);
608        effectiveWidth = maxWidth - indent.length();
609      }
610
611      int remaining = text.length() - lineStart;
612      if (remaining <= effectiveWidth) {
613        result.append(text.substring(lineStart));
614        break;
615      }
616
617      // Find last space within the width limit
618      int lineEnd = lineStart + effectiveWidth;
619      int lastSpace = text.lastIndexOf(' ', lineEnd);
620
621      if (lastSpace <= lineStart) {
622        // No space found, force break at width
623        result.append(text, lineStart, lineEnd);
624        lineStart = lineEnd; // Continue from break point (no space to skip)
625      } else {
626        result.append(text, lineStart, lastSpace);
627        lineStart = lastSpace + 1; // Skip the space
628      }
629      firstLine = false;
630    }
631
632    return ObjectUtils.notNull(result.toString());
633  }
634
635  /**
636   * Output the help text to the console.
637   *
638   * @throws UncheckedIOException
639   *           if an error occurs while writing help output
640   */
641  public void showHelp() {
642    PrintStream out = cliProcessor.getOutputStream();
643    // Get terminal width from environment variable COLUMNS, or default to 80
644    // This avoids native terminal detection which triggers Java 21+ warnings
645    int terminalWidth = getTerminalWidth();
646
647    try (PrintWriter writer = new PrintWriter(
648        AutoCloser.preventClose(out),
649        true,
650        StandardCharsets.UTF_8)) {
651      TextHelpAppendable appendable = new TextHelpAppendable(writer);
652      appendable.setMaxWidth(Math.max(terminalWidth, 50));
653
654      HelpFormatter formatter = HelpFormatter.builder()
655          .setHelpAppendable(appendable)
656          .setOptionFormatBuilder(OptionFormatter.builder().setOptArgSeparator("="))
657          .setShowSince(false)
658          .get();
659
660      try {
661        // Print main help (syntax, header, options) through the formatter
662        formatter.printHelp(
663            buildHelpCliSyntax(),
664            buildHelpHeader(),
665            toOptions(),
666            "", // Empty footer - we print it directly below
667            false);
668      } catch (IOException ex) {
669        throw new UncheckedIOException("Failed to write help output", ex);
670      }
671
672      // Print footer directly to bypass TextHelpAppendable's text wrapping,
673      // which doesn't account for ANSI escape sequence lengths
674      writer.print(buildHelpFooter(terminalWidth));
675      writer.flush();
676    }
677  }
678}