1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.cli.processor;
7   
8   import static org.jline.jansi.Ansi.ansi;
9   
10  import org.apache.commons.cli.Option;
11  import org.apache.logging.log4j.Level;
12  import org.apache.logging.log4j.LogManager;
13  import org.apache.logging.log4j.Logger;
14  import org.apache.logging.log4j.core.LoggerContext;
15  import org.apache.logging.log4j.core.config.Configuration;
16  import org.apache.logging.log4j.core.config.LoggerConfig;
17  import org.eclipse.jdt.annotation.NotOwning;
18  import org.jline.jansi.Ansi;
19  
20  import java.io.PrintStream;
21  import java.util.Arrays;
22  import java.util.LinkedList;
23  import java.util.List;
24  import java.util.Map;
25  import java.util.function.Function;
26  import java.util.stream.Collectors;
27  
28  import dev.metaschema.cli.processor.command.CommandService;
29  import dev.metaschema.cli.processor.command.ICommand;
30  import dev.metaschema.core.util.CollectionUtil;
31  import dev.metaschema.core.util.IVersionInfo;
32  import dev.metaschema.core.util.ObjectUtils;
33  import edu.umd.cs.findbugs.annotations.NonNull;
34  import edu.umd.cs.findbugs.annotations.Nullable;
35  
36  /**
37   * Processes command line arguments and dispatches called commands.
38   * <p>
39   * This implementation make significant use of the command pattern to support a
40   * delegation chain of commands based on implementations of {@link ICommand}.
41   */
42  public class CLIProcessor {
43    private static final Logger LOGGER = LogManager.getLogger(CLIProcessor.class);
44  
45    /**
46     * This option indicates if the help should be shown.
47     */
48    @NonNull
49    public static final Option HELP_OPTION = ObjectUtils.notNull(Option.builder("h")
50        .longOpt("help")
51        .desc("display this help message")
52        .get());
53    /**
54     * This option indicates if colorized output should be disabled.
55     */
56    @NonNull
57    public static final Option NO_COLOR_OPTION = ObjectUtils.notNull(Option.builder()
58        .longOpt("no-color")
59        .desc("do not colorize output")
60        .get());
61    /**
62     * This option indicates if non-errors should be suppressed.
63     */
64    @NonNull
65    public static final Option QUIET_OPTION = ObjectUtils.notNull(Option.builder("q")
66        .longOpt("quiet")
67        .desc("minimize output to include only errors")
68        .get());
69    /**
70     * This option indicates if a strack trace should be shown for an error
71     * {@link ExitStatus}.
72     */
73    @NonNull
74    public static final Option SHOW_STACK_TRACE_OPTION = ObjectUtils.notNull(Option.builder()
75        .longOpt("show-stack-trace")
76        .desc("display the stack trace associated with an error")
77        .get());
78    /**
79     * This option indicates if the version information should be shown.
80     */
81    @NonNull
82    public static final Option VERSION_OPTION = ObjectUtils.notNull(Option.builder()
83        .longOpt("version")
84        .desc("display the application version")
85        .get());
86  
87    @NonNull
88    static final List<Option> OPTIONS = ObjectUtils.notNull(List.of(
89        HELP_OPTION,
90        NO_COLOR_OPTION,
91        QUIET_OPTION,
92        SHOW_STACK_TRACE_OPTION,
93        VERSION_OPTION));
94  
95    /**
96     * Used to identify the version info for the command.
97     */
98    public static final String COMMAND_VERSION = "http://csrc.nist.gov/ns/metaschema-java/cli/command-version";
99  
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 }