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}