com.globalmentor.application.BaseCliApplication Maven / Gradle / Ivy
Show all versions of globalmentor-application Show documentation
/*
* Copyright © 2019 GlobalMentor, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.globalmentor.application;
import static com.globalmentor.java.Conditions.*;
import static java.lang.String.format;
import static org.fusesource.jansi.Ansi.ansi;
import java.io.*;
import java.time.Duration;
import java.util.*;
import javax.annotation.*;
import org.fusesource.jansi.*;
import org.slf4j.Logger;
import org.slf4j.event.Level;
import com.github.dtmo.jfiglet.*;
import com.globalmentor.time.Durations;
import io.clogr.*;
import io.confound.config.*;
import picocli.CommandLine;
import picocli.CommandLine.*;
import picocli.CommandLine.Model.CommandSpec;
/**
* Base implementation for facilitating creation of a CLI application.
*
* A subclass should annotate itself as the main command, e.g.:
*
*
*
* {@code
* @Command(name = "foobar", description = "FooBar application.")
* }
*
*
* In order to return version information with the --version
CLI option, this class expects build-related information to be stored in a
* configuration file with the same name as the concrete application class (the subclass of this class) with a base extension of -build
(e.g.
* FooBarApp-build.properties
), loaded via Confound from the resources in the same path as the application class. For more information see
* {@link Application#loadBuildInfo(Class)}.
*
*
*
* By default this class merely prints the command-line usage. This can be overridden for programs with specific functionality, but if the application requires
* a command then the command methods can be added and annotated separately, with the default {@link #run()} method remaining for displaying an explanation.
*
*
*
* This class overrides the default CLI option converter for {@link Duration}, which only accepted input in the form
* P7dT6h5m4.321s
, allowing for more lenient input such as 7d6h5m4.321s
.
*
*
*
* This class sets up the following options:
*
*
* --debug
, -d
* - Turns on debug mode and enables debug level logging.
* --trace
* - Enables trace level logging.
* --quiet
, -q
* - Reduces or eliminates output of unnecessary information.
* --verbose
, -v
* - Provides additional output information.
*
* @implSpec This implementation adds ANSI support via Jansi.
* @author Garret Wilson
* @see Advanced Bash-Scripting Guide: G.1. Standard Command-Line Options
*/
@Command(versionProvider = BaseCliApplication.MetadataProvider.class, mixinStandardHelpOptions = true)
public abstract class BaseCliApplication extends AbstractApplication {
/**
* The default terminal width if one cannot be determined.
* @see Why is the default terminal width 80 characters?
* @see Is the 80 character line limit still relevant?
*/
public static final int DEFAULT_TERMINAL_WIDTH = 120;
/**
* {@inheritDoc}
* @implSpec This implementation retrieves the name from resources for the concrete application class using the resource key {@value #CONFIG_KEY_NAME}.
* @throws ConfigurationException if there was an error retrieving the configured name or the name could not be found.
* @see #getBuildInfo()
* @see #CONFIG_KEY_NAME
*/
@Override
public String getName() {
return getBuildInfo().getString(CONFIG_KEY_NAME);
}
/**
* {@inheritDoc}
* @implSpec This implementation retrieves the name from resources for the concrete application class using the resource key {@value #CONFIG_KEY_VERSION}.
* @throws ConfigurationException if there was an error retrieving the configured name or the name could not be found.
* @see #getBuildInfo()
* @see #CONFIG_KEY_VERSION
*/
@Override
public String getVersion() {
return getBuildInfo().getString(CONFIG_KEY_VERSION);
}
private final Level defaultLogLevel;
private boolean debug;
@Override
public boolean isDebug() {
return debug;
}
/**
* Enables or disables debug mode and debug level logging, which is disabled by default.
* @param debug The new state of debug mode.
*/
@Option(names = {"--debug", "-d"}, description = "Turns on debug mode and enables debug level logging.", scope = ScopeType.INHERIT)
protected void setDebug(final boolean debug) {
this.debug = debug;
updateLogLevel();
}
private boolean trace;
/** @return Whether trace-level logging has been requested. */
private boolean isTrace() {
return trace;
}
/**
* Enables or disables trace level logging, which is disabled by default.
* @apiNote This method does not turn on debug mode, so if debug mode is desired along with trace logging, be sure and call {@link #setDebug(boolean)} as
* well.
* @param trace The new state of trace mode.
*/
@Option(names = {"--trace"}, description = "Enables trace level logging.", scope = ScopeType.INHERIT)
protected void setTrace(final boolean trace) {
this.trace = trace;
updateLogLevel();
}
/**
* Updates the log level based upon the current debug setting. The current debug setting remains unchanged.
* @implSpec If {@link #isQuiet()} is enabled, it takes priority and {@link Level#WARN} is used. Otherwise {@link #isTrace()} results in {@link Level#TRACE},
* and {@link #isDebug()} results in {@link Level#DEBUG}. If no log level-related options are indicated the {@link #defaultLogLevel} is used.
* @see #isQuiet()
* @see #isDebug()
* @see #isTrace()
*/
protected void updateLogLevel() {
final Level logLevel;
if(isQuiet()) {
logLevel = Level.WARN;
} else if(isTrace()) {
logLevel = Level.TRACE;
} else if(isDebug()) {
logLevel = Level.DEBUG;
} else {
logLevel = defaultLogLevel;
}
Clogr.getLoggingConcern().setLogLevel(logLevel);
}
private boolean quiet = false;
/** @return Whether quiet output has been requested. */
protected boolean isQuiet() {
return quiet;
}
/**
* Enables or disables quiet output. Mutually exclusive with {@link #setVerbose(boolean)}.
* @param quiet The new state of quietness.
*/
@Option(names = {"--quiet",
"-q"}, description = "Reduces or eliminates output of unnecessary information. Mutually exclusive with the verbose option.", scope = ScopeType.INHERIT)
protected void setQuiet(final boolean quiet) {
checkState(!isVerbose(), "Quiet and verbose options are mutually exclusive.");
this.quiet = quiet;
updateLogLevel();
}
private boolean verbose = false;
/** @return Whether verbose output has been requested. */
protected boolean isVerbose() {
return verbose;
}
/**
* Enables or disables verbose output. Mutually exclusive with {@link #setQuiet(boolean)}.
* @param verbose true
if additional information should be output.
*/
@Option(names = {"--verbose",
"-v"}, description = "Provides additional output information. Mutually exclusive with the quiet option.", scope = ScopeType.INHERIT)
protected void setVerbose(final boolean verbose) {
checkState(!isQuiet(), "Quiet and verbose options are mutually exclusive.");
this.verbose = verbose;
}
/**
* Arguments constructor.
* @implSpec The {@link Level#WARN} log level is used by default if no other log level-related options are indicated.
* @param args The command line arguments.
*/
public BaseCliApplication(@Nonnull final String[] args) {
this(args, Level.WARN);
}
/**
* Arguments constructor.
* @param args The command line arguments.
* @param defaultLogLevel The default log level to use if no other log level-related options are indicated.
*/
public BaseCliApplication(@Nonnull final String[] args, final Level defaultLogLevel) {
super(args);
this.defaultLogLevel = defaultLogLevel;
updateLogLevel(); //update the log level based upon the debug setting
}
/**
* {@inheritDoc}
* @implSpec This implementation calls {@link AnsiConsole#systemInstall()}.
*/
@Override
public void initialize() throws Exception {
super.initialize();
AnsiConsole.systemInstall();
}
/**
* The picocli command line instance for the currently executing application. Only available while the program is executing; otherwise null
.
* @see #execute()
*/
@Nullable
private volatile CommandLine commandLine;
/**
* {@inheritDoc}
* @implSpec This implementation uses picocli to execute the application using {@link CommandLine#execute(String...)}.
*/
@Override
protected int execute() {
final IExecutionExceptionHandler errorHandler = (exception, commandLine, parseResult) -> {
reportError(exception);
return EXIT_CODE_SOFTWARE;
};
//run the application via picocli instead of using the default version, which will call appropriate command methods as needed
this.commandLine = new CommandLine(this);
try {
commandLine.setExecutionExceptionHandler(errorHandler);
commandLine.registerConverter(Duration.class, Durations::parseUserInput);
final int detectedTerminalWidth = System.out instanceof AnsiPrintStream ? ((AnsiPrintStream)System.out).getTerminalWidth() : 0;
//set the picocli width manually because 1) Jansi's detection is faster and maybe more accurate; and 2) we have a different preferred default width
commandLine.setUsageHelpWidth(detectedTerminalWidth > 0 ? detectedTerminalWidth : DEFAULT_TERMINAL_WIDTH);
return commandLine.execute(getArgs());
} finally {
commandLine = null;
}
}
/**
* {@inheritDoc}
* @implSpec The default implementation prints the command-line usage.
* @implNote This can be overridden for programs with specific functionality, but if the application requires a command then the command methods can be added
* and annotated separately, with the default {@link #run()} method remaining for displaying an explanation.
*/
@Override
public void run() {
commandLine.usage(System.out);
}
/**
* {@inheritDoc}
* @implSpec This implementation calls {@link AnsiConsole#systemUninstall()}.
*/
@Override
public void cleanup() {
AnsiConsole.systemUninstall();
super.cleanup();
}
/**
* {@inheritDoc}
* @implSpec This version logs an information message if the application was in the shutdown process already.
*/
@Override
protected void onExit() {
super.onExit();
if(isShuttingDown()) {
getLogger().info("Application shut down gracefully after termination.");
}
}
/**
* Logs startup app information, including application banner, name, and version.
* @see #getLogger()
* @see Level#INFO
* @throws ConfigurationException if some configuration information isn't present.
*/
protected void logAppInfo() {
final FigletRenderer figletRenderer;
try {
figletRenderer = new FigletRenderer(FigFontResources.loadFigFontResource(FigFontResources.BIG_FLF));
} catch(final IOException ioException) {
throw new ConfigurationException(ioException);
}
final Configuration buildInfo = getBuildInfo();
final String appName = buildInfo.getString(CONFIG_KEY_NAME);
final String appVersion = buildInfo.getString(CONFIG_KEY_VERSION);
final Logger logger = getLogger();
logger.info("\n{}{}{}", ansi().bold().fg(Ansi.Color.GREEN), figletRenderer.renderText(appName), ansi().reset());
logger.info("{} {}\n", appName, appVersion);
}
/**
* {@inheritDoc}
* @implSpec This version delegates to {@link #reportError(String, Throwable)} using the message determined by {@link #toErrorMessage(Throwable)}.
*/
@Override
public void reportError(final Throwable throwable) {
reportError(toErrorMessage(throwable), throwable);
}
/**
* {@inheritDoc}
* @implSpec This implementation calls {@link #reportError(String)}, and then logs both the error and exception using {@link Logger#debug(String)}.
* @implNote Double logging allows the message to be presented to the user at {@link Level#INFO} level, while still providing a stack trace at
* {@link Level#DEBUG} level, which is likely only enabled in debug mode.
* @see Throwable#printStackTrace(PrintStream)
*/
@Override
public void reportError(@Nonnull final String message, @Nonnull final Throwable throwable) {
reportError(message);
getLogger().debug("{}", message, throwable);
}
/**
* {@inheritDoc}
* @implSpec This implementation logs the error using Logger#error(String).
*/
@Override
public void reportError(final String message) {
getLogger().error("{}", message);
}
/**
* Strategy for retrieving the application name and version from the configuration. For more information on build information storage see
* {@link Application#loadBuildInfo(Class)}. The {@value Application#CONFIG_KEY_NAME} and {@value Application#CONFIG_KEY_VERSION} properties are required. The
* properties {@value Application#CONFIG_KEY_NAME} name
and version
properties are required. The
* {@value Application#CONFIG_KEY_BUILT_AT} and {@value Application#CONFIG_KEY_COPYRIGHT} properties are optional.
* @implSpec This class only works in an environment such as picocli that injects a {@link CommandSpec} with a {@link CommandSpec#userObject()} that is an
* instance of {@link AbstractApplication}.
* @author Garret Wilson
*/
protected static class MetadataProvider implements IVersionProvider {
/**
* Information on the command with which this provider is associated.
* @apiNote Injected by Picocli.
* @implNote This implementation uses the user object associated with the command for looking up resources.
* @see CommandSpec#userObject()
*/
@Spec
private CommandSpec commandSpec;
/**
* {@inheritDoc}
* @implSpec This implementation delegates to {@link AbstractApplication#getBuildInfo()} to load build information from the injected
* {@link CommandSpec#userObject()}.
* @see BaseCliApplication#CONFIG_KEY_NAME
* @see BaseCliApplication#CONFIG_KEY_VERSION
* @see BaseCliApplication#CONFIG_KEY_BUILT_AT
* @see BaseCliApplication#CONFIG_KEY_COPYRIGHT
* @throws ConfigurationException if there was an error retrieving the build information or the build information contained no name and/or version
* information.
*/
@Override
public String[] getVersion() throws Exception {
final Configuration buildInfo = ((AbstractApplication)commandSpec.userObject()).getBuildInfo();
final List versionLines = new ArrayList<>();
versionLines.add(format("%s %s", buildInfo.getString(CONFIG_KEY_NAME), buildInfo.getString(CONFIG_KEY_VERSION))); //e.g. "FooBar 1.2.3"
buildInfo.findString(CONFIG_KEY_BUILT_AT).ifPresent(builtAt -> versionLines.add(format("Built at %s.", builtAt))); //e.g. "Built at 2022-10-26T12:34:56Z."
final String javaVendor = System.getProperty("java.vendor");
versionLines.add(format("Java (%s) %s", javaVendor, Runtime.version())); //e.g. "Java (Vendor) 17.x.x"
buildInfo.findString(CONFIG_KEY_COPYRIGHT).ifPresent(versionLines::add); //e.g. "Copyright © 2022 Acme Company"
return versionLines.toArray(String[]::new);
}
}
}