diff --git a/v2/mvnw b/v2/mvnw new file mode 100755 index 00000000..8d937f4c --- /dev/null +++ b/v2/mvnw @@ -0,0 +1,308 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.2.0 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "$(uname)" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=$(java-config --jre-home) + fi +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --unix "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --unix "$CLASSPATH") +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && + JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=$(which readlink) + if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then + if $darwin ; then + javaHome="$(dirname "\"$javaExecutable\"")" + javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" + else + javaExecutable="$(readlink -f "\"$javaExecutable\"")" + fi + javaHome="$(dirname "\"$javaExecutable\"")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=$(cd "$wdir/.." || exit 1; pwd) + fi + # end of workaround + done + printf '%s' "$(cd "$basedir" || exit 1; pwd)" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + # Remove \r in case we run on Windows within Git Bash + # and check out the repository with auto CRLF management + # enabled. Otherwise, we may read lines that are delimited with + # \r\n and produce $'-Xarg\r' rather than -Xarg due to word + # splitting rules. + tr -s '\r\n' ' ' < "$1" + fi +} + +log() { + if [ "$MVNW_VERBOSE" = true ]; then + printf '%s\n' "$1" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname "$0")") +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +log "$MAVEN_PROJECTBASEDIR" + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" +if [ -r "$wrapperJarPath" ]; then + log "Found $wrapperJarPath" +else + log "Couldn't find $wrapperJarPath, downloading it ..." + + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + fi + while IFS="=" read -r key value; do + # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) + safeValue=$(echo "$value" | tr -d '\r') + case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; + esac + done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + + if $cygwin; then + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + fi + + if command -v wget > /dev/null; then + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + else + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + fi + else + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") + fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +# If specified, validate the SHA-256 sum of the Maven wrapper jar file +wrapperSha256Sum="" +while IFS="=" read -r key value; do + case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; + esac +done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +if [ -n "$wrapperSha256Sum" ]; then + wrapperSha256Result=false + if command -v sha256sum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + elif command -v shasum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + exit 1 + fi + if [ $wrapperSha256Result = false ]; then + echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 + echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 + echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + exit 1 + fi +fi + +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --windows "$CLASSPATH") + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +# shellcheck disable=SC2086 # safe args +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/v2/pom.xml b/v2/pom.xml new file mode 100644 index 00000000..0c2bcd34 --- /dev/null +++ b/v2/pom.xml @@ -0,0 +1,180 @@ + + + 4.0.0 + + dev.streamx.cli.v2 + streamx-cli + 2.0.0-SNAPSHOT + + + 21 + 21 + UTF-8 + UTF-8 + 3.30.6 + 3.2.5 + 3.13.0 + + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-picocli + + + + info.picocli + picocli-shell-jline3 + 4.7.5 + + + + org.jetbrains + annotations + 26.0.2-1 + provided + + + + io.quarkus + quarkus-arc + + + + org.jboss.logging + jboss-logging-annotations + 3.0.4.Final + provided + + + + org.jboss.logging + jboss-logging-processor + 3.0.4.Final + provided + true + + + + com.fasterxml.jackson.core + jackson-databind + 2.20.1 + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.20.1 + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + 2.20.1 + + + + + io.quarkus + quarkus-junit5 + test + + + + org.assertj + assertj-core + 3.27.5 + test + + + + org.instancio + instancio-junit + 5.5.1 + test + + + + + + + io.quarkus.platform + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + true + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + + integration-test + verify + + + + + + org.jboss.logmanager.LogManager + + + + + + + + + native + + + native + + + + false + native + + + + \ No newline at end of file diff --git a/v2/src/main/java/dev/streamx/cli/v2/Main.java b/v2/src/main/java/dev/streamx/cli/v2/Main.java new file mode 100644 index 00000000..7706a026 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -0,0 +1,34 @@ +package dev.streamx.cli.v2; + +import dev.streamx.cli.v2.cli.AbstractCommandGroup; +import dev.streamx.cli.v2.cli.CommandResult; +import dev.streamx.cli.v2.cli.ShortErrorMessageHandler; +import dev.streamx.cli.v2.commands.interactive.InteractiveCommand; +import dev.streamx.cli.v2.commands.settings.SettingsCommand; +import io.quarkus.picocli.runtime.annotations.TopCommand; +import picocli.CommandLine; + +@TopCommand +@CommandLine.Command( + name = "streamx", + mixinStandardHelpOptions = true, + description = "StreamX CLI. More info at https://streamx.dev", + subcommands = { + SettingsCommand.class, + InteractiveCommand.class + } +) +public class Main extends AbstractCommandGroup { + @CommandLine.Spec + CommandLine.Model.CommandSpec commandSpec; + + @Override + public CommandResult runCommand() throws RuntimeException { + commandSpec + .commandLine() + .setParameterExceptionHandler(new ShortErrorMessageHandler()) + .usage(System.out); + + return new CommandResult<>(null); + } +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java new file mode 100644 index 00000000..1b2e01ee --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java @@ -0,0 +1,130 @@ +package dev.streamx.cli.v2.cli; + +import static dev.streamx.cli.v2.i18n.MessageProvider.msg; + +import org.jetbrains.annotations.Nullable; +import org.jline.reader.Completer; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.reader.impl.completer.StringsCompleter; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; +import java.util.Optional; + +/** + * Each CLI command should extend this class. + * + * @param Must be serializable by Jackson (POJO, JsonSerializable, etc.) + */ +public abstract class AbstractCommand implements Runnable { + // Override this method to implement the command logic. + public abstract CommandResult runCommand() throws RuntimeException; + + // Override this method to hide specific command line options. + // May be useful to hide the "--output" option for commands that don't print anything in case of success. + public List getHiddenOptions() { + return List.of(); + } + + // Override this method to provide human-readable output. + public String getTextOutput(CommandResult result) throws RuntimeException { + return result.toText(OutputFormat.json, null); + } + + private void applyHiddenOptions() { + var options = getHiddenOptions(); + + for (String option : options) { + var optionSpec = spec.findOption(option); + if (optionSpec != null) { + spec.remove(optionSpec); + } + } + } + + @CommandLine.Spec + public CommandSpec spec; + + @CommandLine.Spec + public void setSpec(CommandSpec spec) { + this.spec = spec; + applyHiddenOptions(); + } + + @CommandLine.Option( + names = {CommonOption.VERBOSE_SHORT, CommonOption.VERBOSE_LONG}, + description = "Print debug information" + ) + public boolean verbose; + + @CommandLine.Option( + names = {CommonOption.OUTPUT_SHORT, CommonOption.OUTPUT_LONG}, + description = "Specify output format: text, json, yaml", + defaultValue = "text" + ) + // Explicitly set default value here as a fallback for commands with the hidden output option. + public OutputFormat output = OutputFormat.text; + + public void printUsage() { + spec.commandLine().usage(System.out); + } + + // For testing purposes mostly. + protected Terminal createTerminal() throws IOException { + return TerminalBuilder.builder().system(true).build(); + } + + // Use this method for asking user input in interactive commands. + public String promptForInput(String prompt, @Nullable List autocompleteOptions) throws RuntimeException { + try (Terminal terminal = createTerminal()) { + LineReaderBuilder builder = LineReaderBuilder.builder() + .terminal(terminal); + + Completer completer = null; + if (autocompleteOptions != null) { + completer = new StringsCompleter(autocompleteOptions); + builder.completer(completer); + } + + LineReader reader = builder.build(); + + return reader.readLine(completer == null ? prompt : prompt + " (TAB for autocomplete):").strip(); + } catch (IOException e) { + throw new RuntimeException(msg.failedToHandleInteractiveInput(), e); + } + } + + public int execute() { + int exitCode = 0; + + try { + var textOutput = this.runCommand().toText(output, this::getTextOutput); + if (!textOutput.isEmpty()) { + System.out.println(textOutput); + } + } catch (Exception e) { + exitCode = ShortErrorMessageHandler.shortErrorMessage(e, spec.commandLine()); + if (verbose) { + // Print exception stacktrace + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + e.printStackTrace(pw); + System.err.println(sw); + } + } + + return exitCode; + } + + public void run() { + int exitCode = execute(); + System.exit(exitCode); + } +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommandGroup.java b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommandGroup.java new file mode 100644 index 00000000..aabfe6b2 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommandGroup.java @@ -0,0 +1,25 @@ +package dev.streamx.cli.v2.cli; + +import java.util.List; + +// Extend this class for commands that don't do anything except display their subcommands. +public class AbstractCommandGroup extends AbstractCommand { + @Override + public CommandResult runCommand() { + this.printUsage(); + return new CommandResult<>(null); + } + + @Override + public String getTextOutput(CommandResult result) throws RuntimeException { + return ""; + } + + @Override + public List getHiddenOptions() { + return List.of( + CommonOption.OUTPUT_LONG, + CommonOption.VERBOSE_LONG + ); + } +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java new file mode 100644 index 00000000..b61068f0 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java @@ -0,0 +1,16 @@ +package dev.streamx.cli.v2.cli; + +import java.util.List; + +// Extend this class for commands that don't produce any output, e.g.: settings set. +public abstract class AbstractSilentCommand extends AbstractCommand { + @Override + public List getHiddenOptions() { + return List.of(CommonOption.OUTPUT_LONG); + } + + @Override + public String getTextOutput(CommandResult result) throws RuntimeException { + return ""; + } +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java b/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java new file mode 100644 index 00000000..f2cc050e --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java @@ -0,0 +1,49 @@ +package dev.streamx.cli.v2.cli; + +import static dev.streamx.cli.v2.i18n.MessageProvider.msg; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; + +import java.util.function.Function; + +/** + * @param Must be serializable by Jackson (POJO, JsonSerializable, etc.) + */ +public class CommandResult { + public ResultT result; + + public CommandResult(ResultT result) { + this.result = result; + } + + public String toText(OutputFormat outputFormat, Function, String> textFormatter) throws RuntimeException { + try { + switch (outputFormat) { + case OutputFormat.text -> { + return textFormatter.apply(this); + } + case OutputFormat.json -> { + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.valueToTree(result); + return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode); + } + case OutputFormat.yaml -> { + var yamlFactory = YAMLFactory.builder() + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .build(); + ObjectMapper mapper = new ObjectMapper(yamlFactory); + JsonNode jsonNode = mapper.valueToTree(result); + return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode).strip(); + } + } + + throw new RuntimeException(msg.unsupportedOutputFormat()); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/cli/CommonOption.java b/v2/src/main/java/dev/streamx/cli/v2/cli/CommonOption.java new file mode 100644 index 00000000..0cb4f7fa --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/CommonOption.java @@ -0,0 +1,9 @@ +package dev.streamx.cli.v2.cli; + +public class CommonOption { + public static final String VERBOSE_SHORT = "-v"; + public static final String VERBOSE_LONG = "--verbose"; + + public static final String OUTPUT_SHORT = "-o"; + public static final String OUTPUT_LONG = "--output"; +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/cli/OutputFormat.java b/v2/src/main/java/dev/streamx/cli/v2/cli/OutputFormat.java new file mode 100644 index 00000000..73b7005c --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/OutputFormat.java @@ -0,0 +1,5 @@ +package dev.streamx.cli.v2.cli; + +public enum OutputFormat { + text, json, yaml +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/cli/ShortErrorMessageHandler.java b/v2/src/main/java/dev/streamx/cli/v2/cli/ShortErrorMessageHandler.java new file mode 100644 index 00000000..c45a1361 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/ShortErrorMessageHandler.java @@ -0,0 +1,39 @@ +package dev.streamx.cli.v2.cli; + +import picocli.CommandLine; + +import java.io.PrintWriter; + +import picocli.CommandLine.IParameterExceptionHandler; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.UnmatchedArgumentException; + +import static dev.streamx.cli.v2.i18n.MessageProvider.msg; + +public class ShortErrorMessageHandler implements IParameterExceptionHandler { + + @Override + public int handleParseException(ParameterException ex, String[] args) { + CommandLine cmd = ex.getCommandLine(); + return shortErrorMessage(ex, cmd); + } + + static int shortErrorMessage(Exception ex, CommandLine cmd) { + PrintWriter writer = cmd.getErr(); + String errorMessage = ex.getMessage(); + + writer.println(cmd.getColorScheme().errorText(errorMessage)); + if (ex instanceof ParameterException) { + UnmatchedArgumentException.printSuggestions((ParameterException) ex, writer); + } + + if (ex instanceof ParameterException || ex instanceof IllegalArgumentException) { + CommandSpec spec = cmd.getCommandSpec(); + writer.printf(msg.tryForMoreInformationOnAvailableOptions(spec.qualifiedName(), "help".equals(spec.name()) ? "" : " --help")); + return cmd.getCommandSpec().exitCodeOnInvalidInput(); + } + return cmd.getCommandSpec().exitCodeOnExecutionException(); + } + +} \ No newline at end of file diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/interactive/InteractiveCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/interactive/InteractiveCommand.java new file mode 100644 index 00000000..0bf73590 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/interactive/InteractiveCommand.java @@ -0,0 +1,84 @@ +package dev.streamx.cli.v2.commands.interactive; + +import dev.streamx.cli.v2.cli.AbstractSilentCommand; +import dev.streamx.cli.v2.cli.CommandResult; +import dev.streamx.cli.v2.commands.settings.SettingsFile; +import org.jline.reader.Completer; +import org.jline.reader.impl.completer.StringsCompleter; +import picocli.CommandLine; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.Properties; + +import static dev.streamx.cli.v2.i18n.MessageProvider.msg; + +@CommandLine.Command( + name = "interactive", + mixinStandardHelpOptions = true, + description = "Set configuration property" +) +public class InteractiveCommand extends AbstractSilentCommand { + + @CommandLine.Parameters( + index = "0", + description = "Property key", + arity = "0..1" + ) + private String key; + + @CommandLine.Parameters( + index = "1", + description = "Property value", + arity = "0..1" + ) + private String value; + + @Override + public CommandResult runCommand() throws RuntimeException { + try { + if (key == null || key.isBlank()) { + key = promptForKey(); + } + + if (value == null || value.isBlank()) { + value = this.promptForInput("Enter property value: ", null); + } + + saveProperty(key, value); + + return new CommandResult<>(null); + + } catch (IOException e) { + throw new RuntimeException(msg.failedToReadUserInput(), e); + } + } + + private String promptForKey() throws IOException { + var autocompleteOptions = List.of("streamx.mesh.url", "streamx.some.property", "streamx.mesh.auth.token"); + return this.promptForInput("Enter property key", autocompleteOptions); + } + + private void saveProperty(String key, String value) { + var url = SettingsFile.getUrl(); + var path = Paths.get(url.getPath()); + + Properties properties = new Properties(); + + try (var inputStream = url.openStream()) { + properties.load(inputStream); + } catch (IOException e) { + throw new RuntimeException(msg.unableToSetSettingsProperty(), e); + } + + properties.setProperty(key, value); + + try (var outputStream = Files.newOutputStream(path)) { + properties.store(outputStream, null); + } catch (IOException e) { + throw new RuntimeException(msg.unableToSetSettingsProperty(), e); + } + } +} \ No newline at end of file diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/run/RunCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/run/RunCommand.java new file mode 100644 index 00000000..7156b4d7 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/run/RunCommand.java @@ -0,0 +1,4 @@ +package dev.streamx.cli.v2.commands.run; + +public class RunCommand { +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsCommand.java new file mode 100644 index 00000000..7fcf3a37 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsCommand.java @@ -0,0 +1,22 @@ +package dev.streamx.cli.v2.commands.settings; + +import dev.streamx.cli.v2.cli.AbstractCommandGroup; +import dev.streamx.cli.v2.commands.settings.get.GetCommand; +import dev.streamx.cli.v2.commands.settings.list.ListCommand; +import dev.streamx.cli.v2.commands.settings.set.SetCommand; +import picocli.CommandLine; + +@CommandLine.Command( + name = "settings", + mixinStandardHelpOptions = true, + description = "Modify StreamX settings", + abbreviateSynopsis = true, + synopsisHeading = "Synopsis example", + subcommands = { + ListCommand.class, + SetCommand.class, + GetCommand.class + } +) +public class SettingsCommand extends AbstractCommandGroup { +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsFile.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsFile.java new file mode 100644 index 00000000..b5421afd --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsFile.java @@ -0,0 +1,23 @@ +package dev.streamx.cli.v2.commands.settings; + +import static dev.streamx.cli.v2.i18n.MessageProvider.msg; + +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Path; + +public class SettingsFile { + public static URL getUrl() throws RuntimeException { + String rootDir = System.getProperty("user.home"); + String dotStreamxConfig = rootDir + "/.streamx/config"; + + Path pathToDir = Path.of(dotStreamxConfig); + Path pathToFile = pathToDir.resolve("application.properties"); + + try { + return pathToFile.toUri().toURL(); + } catch (MalformedURLException e) { + throw new RuntimeException(msg.unableToGetSettingsFilePath(), e); + } + } +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/settings/get/GetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/get/GetCommand.java new file mode 100644 index 00000000..a9e50938 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/get/GetCommand.java @@ -0,0 +1,49 @@ +package dev.streamx.cli.v2.commands.settings.get; + +import dev.streamx.cli.v2.cli.AbstractCommand; +import dev.streamx.cli.v2.cli.CommandResult; +import dev.streamx.cli.v2.commands.settings.SettingsFile; + +import static dev.streamx.cli.v2.i18n.MessageProvider.msg; + +import picocli.CommandLine; + +import java.io.IOException; +import java.util.Optional; +import java.util.Properties; + +@CommandLine.Command( + name = "get", + mixinStandardHelpOptions = true, + description = "Get configuration property" +) +public class GetCommand extends AbstractCommand { + @CommandLine.Parameters(index = "0", description = "Property key") + private String key; + + @Override + public String getTextOutput(CommandResult result) throws RuntimeException { + return result.result.value(); + } + + @Override + public CommandResult runCommand() throws RuntimeException { + var url = SettingsFile.getUrl(); + + try (var inputStream = url.openStream()) { + Properties properties = new Properties(); + properties.load(inputStream); + + var value = properties.getProperty(key); + if (value == null) { + throw new RuntimeException(msg.noSettingsPropertyFound(key)); + } + + var result = new GetCommandResult(key, value); + + return new CommandResult<>(result); + } catch (IOException e) { + throw new RuntimeException(msg.unableToGetSettingsProperty(), e); + } + } +} \ No newline at end of file diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/settings/get/GetCommandResult.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/get/GetCommandResult.java new file mode 100644 index 00000000..82b5985e --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/get/GetCommandResult.java @@ -0,0 +1,7 @@ +package dev.streamx.cli.v2.commands.settings.get; + +public record GetCommandResult( + String key, + String value +) { +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/ListCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/ListCommand.java new file mode 100644 index 00000000..15c771ce --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/ListCommand.java @@ -0,0 +1,70 @@ +package dev.streamx.cli.v2.commands.settings.list; + +import dev.streamx.cli.v2.cli.AbstractCommand; +import dev.streamx.cli.v2.cli.CommandResult; +import dev.streamx.cli.v2.commands.settings.SettingsFile; + +import static dev.streamx.cli.v2.i18n.MessageProvider.msg; + +import picocli.CommandLine; + +import java.net.URL; +import java.util.*; +import java.util.stream.Collectors; + +@CommandLine.Command( + name = "list", + mixinStandardHelpOptions = true, + description = "Display configuration properties" +) +public class ListCommand extends AbstractCommand> { + @Override + public String getTextOutput(CommandResult> result) throws RuntimeException { + StringBuilder stringOutput = new StringBuilder(); + + Map map = result.result.stream() + .collect(Collectors.toMap(Property::key, Property::value)); + + Map sortedProperties = new TreeMap<>(map); + + int maxKeyLength = sortedProperties.keySet().stream() + .mapToInt(String::length) + .max() + .orElse(0); + + stringOutput.append("\nConfiguration properties:\n"); + String repeat = "=".repeat(Math.min(80, maxKeyLength + 40)); + stringOutput.append(repeat).append("\n"); + + for (Map.Entry entry : sortedProperties.entrySet()) { + String paddedKey = String.format("%-" + maxKeyLength + "s", entry.getKey()); + stringOutput.append(paddedKey).append(" = ").append(entry.getValue()).append("\n"); + } + + stringOutput.append(repeat).append("\n"); + stringOutput.append("Total properties: ").append(result.result.size()).append("\n"); + + return stringOutput.toString(); + } + + @Override + public CommandResult> runCommand() throws RuntimeException { + var url = SettingsFile.getUrl(); + var properties = getProperties(url); + + return new CommandResult<>(properties); + } + + private List getProperties(URL url) throws RuntimeException { + try (var input = url.openStream()) { + Properties properties = new Properties(); + properties.load(input); + + return properties.stringPropertyNames().stream() + .map(key -> new Property(key, properties.getProperty(key))) + .toList(); + } catch (Exception e) { + throw new RuntimeException(msg.failedToLoadPropertiesFrom(url.getPath()), e); + } + } +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/Property.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/Property.java new file mode 100644 index 00000000..33909d00 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/Property.java @@ -0,0 +1,4 @@ +package dev.streamx.cli.v2.commands.settings.list; + +public record Property(String key, String value) { +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/settings/set/SetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/set/SetCommand.java new file mode 100644 index 00000000..8a1361f7 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/set/SetCommand.java @@ -0,0 +1,51 @@ +package dev.streamx.cli.v2.commands.settings.set; + +import dev.streamx.cli.v2.cli.AbstractSilentCommand; +import dev.streamx.cli.v2.cli.CommandResult; +import dev.streamx.cli.v2.commands.settings.SettingsFile; + +import static dev.streamx.cli.v2.i18n.MessageProvider.msg; + +import picocli.CommandLine; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Properties; + +@CommandLine.Command( + name = "set", + mixinStandardHelpOptions = true, + description = "Set configuration property" +) +public class SetCommand extends AbstractSilentCommand { + @CommandLine.Parameters(index = "0", description = "Property key") + private String key; + + @CommandLine.Parameters(index = "1", description = "Property value") + private String value; + + @Override + public CommandResult runCommand() throws RuntimeException { + var url = SettingsFile.getUrl(); + var path = Paths.get(url.getPath()); + + Properties properties = new Properties(); + + try (var inputStream = url.openStream()) { + properties.load(inputStream); + } catch (IOException e) { + throw new RuntimeException(msg.unableToSetSettingsProperty(), e); + } + + properties.setProperty(key, value); + + try (var outputStream = Files.newOutputStream(path)) { + properties.store(outputStream, null); + } catch (IOException e) { + throw new RuntimeException(msg.unableToSetSettingsProperty(), e); + } + + return new CommandResult<>(null); + } +} \ No newline at end of file diff --git a/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java b/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java new file mode 100644 index 00000000..2e36eca0 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java @@ -0,0 +1,40 @@ +package dev.streamx.cli.v2.i18n; + +import org.jboss.logging.Messages; +import org.jboss.logging.annotations.Message; +import org.jboss.logging.annotations.MessageBundle; + +import java.lang.invoke.MethodHandles; + +@MessageBundle(projectCode = "STREAMXCLI") +public interface MessageProvider { + + MessageProvider msg = Messages.getBundle(MethodHandles.lookup(), MessageProvider.class); + + @Message(id = 100, value = "No such settings property found: %s") + String noSettingsPropertyFound(String key); + + @Message(id = 101, value = "Unable to get settings property") + String unableToGetSettingsProperty(); + + @Message(id = 102, value = "Failed to load properties from: %s") + String failedToLoadPropertiesFrom(String path); + + @Message(id = 103, value = "Unable to set settings property") + String unableToSetSettingsProperty(); + + @Message(id = 104, value = "Unable to get settings file path") + String unableToGetSettingsFilePath(); + + @Message(id = 105, value = "Try '%s%s' for more information on the available options.%n") + String tryForMoreInformationOnAvailableOptions(String qualifiedCommandName, String helpOptionName); + + @Message(id = 106, value = "Failed to read user input") + String failedToReadUserInput(); + + @Message(id = 107, value = "Failed to handle interactive input") + String failedToHandleInteractiveInput(); + + @Message(id = 108, value = "Unsupported output format") + String unsupportedOutputFormat(); +} \ No newline at end of file diff --git a/v2/src/main/resources/application.properties b/v2/src/main/resources/application.properties new file mode 100644 index 00000000..184395a7 --- /dev/null +++ b/v2/src/main/resources/application.properties @@ -0,0 +1,7 @@ +quarkus.banner.enabled=false +quarkus.log.level=ERROR +quarkus.log.console.enable=true +quarkus.log.console.level=ERROR + +quarkus.log.category."io.quarkus".level=OFF +quarkus.log.category."org.jboss".level=OFF \ No newline at end of file diff --git a/v2/src/test/java/dev/streamx/cli/v2/MainTest.java b/v2/src/test/java/dev/streamx/cli/v2/MainTest.java new file mode 100644 index 00000000..ac36e958 --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/MainTest.java @@ -0,0 +1,43 @@ +package dev.streamx.cli.v2; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.streamx.cli.v2.cli.AbstractCommand; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +@QuarkusTest +class MainTest { + + @Inject + CommandLine.IFactory factory; + + @Test + void allCommandsAndSubcommandsShouldExtendAbstractCommand() { + CommandLine commandLine = new CommandLine(Main.class, factory); + + Set> allCommandClasses = new HashSet<>(); + collectAllCommands(commandLine.getCommandSpec(), allCommandClasses); + + assertThat(allCommandClasses) + .as("All commands and subcommands should extend AbstractCommand") + .allSatisfy(commandClass -> + assertThat(AbstractCommand.class.isAssignableFrom(commandClass)) + .as("Command %s should extend AbstractCommand", commandClass.getName()) + .isTrue() + ); + } + + private void collectAllCommands(CommandLine.Model.CommandSpec commandSpec, Set> commands) { + Class userObject = commandSpec.userObject().getClass(); + commands.add(userObject); + + for (CommandLine subcommand : commandSpec.subcommands().values()) { + collectAllCommands(subcommand.getCommandSpec(), commands); + } + } +} \ No newline at end of file diff --git a/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandOutputOptionTest.java b/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandOutputOptionTest.java new file mode 100644 index 00000000..c114acc8 --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandOutputOptionTest.java @@ -0,0 +1,239 @@ +package dev.streamx.cli.v2.cli; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.streamx.cli.v2.cli.testing.AbstractCommandBaseTest; +import dev.streamx.cli.v2.cli.testing.AbstractTestCommand; +import dev.streamx.cli.v2.cli.testing.TestObject; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import java.util.List; + +class AbstractCommandOutputOptionTest extends AbstractCommandBaseTest { + private final TestObject result = new TestObject( + null, + true, + 100500, + 42.42, + "Test string", + new TestObject( + null, + false, + 7, + 3.14, + "Nested object test string", + null, + null + ), + List.of( + new TestObject( + null, + true, + 15, + 100.42, + "Nested list object 1 test string", + null, + null + ), + new TestObject( + null, + false, + 18, + 0.42, + "Nested list object 2 test string", + null, + null + ) + ) + ); + + @Test + void textOutputFlag_FormatsOutputAsJsonIfNoCustomFormatterProvided() { + var command = new AbstractTestCommand<>(); + CommandLine commandLine = new CommandLine(command); + commandLine.parseArgs(CommonOption.OUTPUT_LONG, "text"); + command.setRunCommandHandler(() -> new CommandResult<>(result)); + command.execute(); + + var expectedStdOutOutput = """ + { + "voidValue" : null, + "booleanValue" : true, + "longValue" : 100500, + "floatValue" : 42.42, + "stringValue" : "Test string", + "nestedObject" : { + "voidValue" : null, + "booleanValue" : false, + "longValue" : 7, + "floatValue" : 3.14, + "stringValue" : "Nested object test string", + "nestedObject" : null, + "nestedObjects" : null + }, + "nestedObjects" : [ { + "voidValue" : null, + "booleanValue" : true, + "longValue" : 15, + "floatValue" : 100.42, + "stringValue" : "Nested list object 1 test string", + "nestedObject" : null, + "nestedObjects" : null + }, { + "voidValue" : null, + "booleanValue" : false, + "longValue" : 18, + "floatValue" : 0.42, + "stringValue" : "Nested list object 2 test string", + "nestedObject" : null, + "nestedObjects" : null + } ] + } + """.strip() + "\n"; + + assertEquals(expectedStdOutOutput, outStream.toString()); + assertEquals("", errStream.toString()); + } + + @Test + void textOutputFlag_FormatsWithCustomFormatter() { + var command = new AbstractTestCommand(); + CommandLine commandLine = new CommandLine(command); + commandLine.parseArgs(CommonOption.OUTPUT_LONG, "text"); + command.setRunCommandHandler(() -> new CommandResult<>(result)); + command.setGetTextOutputHandler((cr) -> """ + String value: %s, + Total nested objects: %d + """.formatted(cr.result.stringValue, cr.result.nestedObjects.size()).strip()); + + command.execute(); + + var expectedStdOutOutput = """ + String value: Test string, + Total nested objects: 2 + """.strip() + "\n"; + + assertEquals(expectedStdOutOutput, outStream.toString()); + assertEquals("", errStream.toString()); + } + + @Test + void jsonOutputFlag() { + var command = new AbstractTestCommand<>(); + CommandLine commandLine = new CommandLine(command); + commandLine.parseArgs(CommonOption.OUTPUT_LONG, "json"); + command.setRunCommandHandler(() -> new CommandResult<>(result)); + command.execute(); + + var expectedStdOutOutput = """ + { + "voidValue" : null, + "booleanValue" : true, + "longValue" : 100500, + "floatValue" : 42.42, + "stringValue" : "Test string", + "nestedObject" : { + "voidValue" : null, + "booleanValue" : false, + "longValue" : 7, + "floatValue" : 3.14, + "stringValue" : "Nested object test string", + "nestedObject" : null, + "nestedObjects" : null + }, + "nestedObjects" : [ { + "voidValue" : null, + "booleanValue" : true, + "longValue" : 15, + "floatValue" : 100.42, + "stringValue" : "Nested list object 1 test string", + "nestedObject" : null, + "nestedObjects" : null + }, { + "voidValue" : null, + "booleanValue" : false, + "longValue" : 18, + "floatValue" : 0.42, + "stringValue" : "Nested list object 2 test string", + "nestedObject" : null, + "nestedObjects" : null + } ] + } + """.strip() + "\n"; + + assertEquals(expectedStdOutOutput, outStream.toString()); + assertEquals("", errStream.toString()); + } + + @Test + void yamlOutputFlag() { + var command = new AbstractTestCommand(); + CommandLine commandLine = new CommandLine(command); + commandLine.parseArgs(CommonOption.OUTPUT_LONG, "yaml"); + command.setRunCommandHandler(() -> new CommandResult<>(result)); + command.execute(); + + var expectedStdOutOutput = """ + voidValue: null + booleanValue: true + longValue: 100500 + floatValue: 42.42 + stringValue: "Test string" + nestedObject: + voidValue: null + booleanValue: false + longValue: 7 + floatValue: 3.14 + stringValue: "Nested object test string" + nestedObject: null + nestedObjects: null + nestedObjects: + - voidValue: null + booleanValue: true + longValue: 15 + floatValue: 100.42 + stringValue: "Nested list object 1 test string" + nestedObject: null + nestedObjects: null + - voidValue: null + booleanValue: false + longValue: 18 + floatValue: 0.42 + stringValue: "Nested list object 2 test string" + nestedObject: null + nestedObjects: null + """.strip() + "\n"; + + assertEquals(expectedStdOutOutput, outStream.toString()); + assertEquals("", errStream.toString()); + } + + @Test + void voidResult() { + var command = new AbstractTestCommand(); + command.setRunCommandHandler(() -> new CommandResult<>(null)); + + command.output = OutputFormat.text; + command.execute(); + + assertEquals("null\n", outStream.toString()); + assertEquals("", errStream.toString()); + + restoreStreams(); + redirectStreams(); + command.output = OutputFormat.json; + command.execute(); + + assertEquals("null\n", outStream.toString()); + assertEquals("", errStream.toString()); + + restoreStreams(); + redirectStreams(); + command.output = OutputFormat.yaml; + command.execute(); + + assertEquals("null\n", outStream.toString()); + assertEquals("", errStream.toString()); + } +} \ No newline at end of file diff --git a/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandTest.java b/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandTest.java new file mode 100644 index 00000000..bf436485 --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandTest.java @@ -0,0 +1,76 @@ +package dev.streamx.cli.v2.cli; + +import dev.streamx.cli.v2.cli.testing.AbstractCommandBaseTest; +import dev.streamx.cli.v2.cli.testing.AbstractTestCommand; +import dev.streamx.cli.v2.cli.testing.TestObject; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import java.io.ByteArrayInputStream; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/* +--enable-native-access=ALL-UNNAMED -Dorg.jline.terminal.dumb=true + */ +class AbstractCommandTest extends AbstractCommandBaseTest { + @Test + void execute_success() { + var command = new AbstractTestCommand(); + command.setRunCommandHandler(() -> new CommandResult<>(TestObject.random())); + new CommandLine(command); + var exitCode = command.execute(); + + assertEquals(0, exitCode); + assertFalse(outStream.toString().isEmpty()); + assertTrue(errStream.toString().isEmpty()); + } + + @Test + void execute_fail() { + var command = new AbstractTestCommand(); + command.setRunCommandHandler(() -> { + throw new RuntimeException("Test exception"); + }); + new CommandLine(command); + var exitCode = command.execute(); + + assertEquals(1, exitCode); + assertTrue(outStream.toString().isEmpty()); + assertTrue(errStream.toString().contains("Test exception")); + } + + @Test + void getHiddenOptionsOverride() { + var command1 = new AbstractTestCommand<>(); + new CommandLine(command1); + + assertNotNull(command1.spec.findOption(CommonOption.OUTPUT_LONG)); + + var command2 = new AbstractTestCommand<>(); + command2.setHiddenOptionsHandler(() -> List.of(CommonOption.OUTPUT_LONG)); + new CommandLine(command2); + + assertNull(command2.spec.findOption(CommonOption.OUTPUT_LONG)); + } + + @Test + void testPromptForInputMultipleCalls() { + var command = new AbstractTestCommand<>(); + CommandLine commandLine = new CommandLine(command); + command.setSpec(commandLine.getCommandSpec()); + + String input1 = "first\n"; + System.setIn(new ByteArrayInputStream(input1.getBytes())); + String result1 = command.promptForInput("Enter first:", null); + + assertEquals("first", result1); + + String input2 = "second\n"; + System.setIn(new ByteArrayInputStream(input2.getBytes())); + String result2 = command.promptForInput("Enter second:", null); + + assertEquals("second", result2); + } +} \ No newline at end of file diff --git a/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandVerboseOptionTest.java b/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandVerboseOptionTest.java new file mode 100644 index 00000000..36953ace --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandVerboseOptionTest.java @@ -0,0 +1,37 @@ +package dev.streamx.cli.v2.cli; + +import dev.streamx.cli.v2.cli.testing.AbstractCommandBaseTest; +import dev.streamx.cli.v2.cli.testing.AbstractTestCommand; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AbstractCommandVerboseOptionTest extends AbstractCommandBaseTest { + @Test + void ifProvided_printsStackTrace() { + var command = new AbstractTestCommand<>(); + command.setRunCommandHandler(() -> { + throw new RuntimeException("Test exception"); + }); + var commandLine = new CommandLine(command); + commandLine.parseArgs(CommonOption.VERBOSE_LONG); + + command.execute(); + + assertTrue(errStream.toString().contains("java.lang.RuntimeException: Test exception")); + } + + @Test + void ifNotProvided_doesntPrintStackTrace() { + var command = new AbstractTestCommand<>(); + command.setRunCommandHandler(() -> { + throw new RuntimeException("Test exception"); + }); + new CommandLine(command); + command.execute(); + + assertFalse(errStream.toString().contains("java.lang.RuntimeException: Test exception")); + } +} \ No newline at end of file diff --git a/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractSilentCommandTest.java b/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractSilentCommandTest.java new file mode 100644 index 00000000..090fbdd3 --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractSilentCommandTest.java @@ -0,0 +1,28 @@ +package dev.streamx.cli.v2.cli; + +import dev.streamx.cli.v2.cli.testing.AbstractCommandBaseTest; +import dev.streamx.cli.v2.cli.testing.AbstractSilentTestCommand; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import static org.junit.jupiter.api.Assertions.*; + +class AbstractSilentCommandTest extends AbstractCommandBaseTest { + @Test + void outputFlagIsAbsent() { + var command = new AbstractSilentTestCommand(); + new CommandLine(command); // Trigger all PicocLi initialization + + assertNull(command.spec.findOption(CommonOption.OUTPUT_LONG)); + } + + @Test + void outputIsEmpty() { + var command = new AbstractSilentTestCommand(); + command.setRunCommandHandler(() -> new CommandResult<>(null)); + command.execute(); + + assertEquals("", outStream.toString()); + assertEquals("", errStream.toString()); + } +} \ No newline at end of file diff --git a/v2/src/test/java/dev/streamx/cli/v2/cli/CommandResultTest.java b/v2/src/test/java/dev/streamx/cli/v2/cli/CommandResultTest.java new file mode 100644 index 00000000..ded0c35c --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/CommandResultTest.java @@ -0,0 +1,188 @@ +package dev.streamx.cli.v2.cli; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.streamx.cli.v2.cli.testing.TestObject; +import dev.streamx.cli.v2.cli.testing.UnserializableObject; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Function; + +class CommandResultTest { + private final TestObject result = new TestObject( + null, + true, + 100500, + 42.42, + "Test string", + new TestObject( + null, + false, + 7, + 3.14, + "Nested object test string", + null, + null + ), + List.of( + new TestObject( + null, + true, + 15, + 100.42, + "Nested list object 1 test string", + null, + null + ), + new TestObject( + null, + false, + 18, + 0.42, + "Nested list object 2 test string", + null, + null + ) + ) + ); + private final CommandResult commandResult = new CommandResult<>(result); + + @Test + void toText_withTextFormat_shouldUseTextFormatter() { + var expectedOutput = """ + Void Value: null + Boolean Value: true + Long Value: 100500 + Float Value: 42.42 + String Value: Test string + Nested Object Long Value: 7 + Nested Object String Value: Nested object test string + Total Nested Objects: 2 + """; + + Function, String> textFormatter = + cr -> """ + Void Value: %s + Boolean Value: %b + Long Value: %d + Float Value: %.2f + String Value: %s + Nested Object Long Value: %d + Nested Object String Value: %s + Total Nested Objects: %d + """.formatted( + cr.result.voidValue, + cr.result.booleanValue, + cr.result.longValue, + cr.result.floatValue, + cr.result.stringValue, + cr.result.nestedObject.longValue, + cr.result.nestedObject.stringValue, + cr.result.nestedObjects.size() + ); + + var output = commandResult.toText(OutputFormat.text, textFormatter); + + assertEquals(expectedOutput, output); + } + + @Test + void toText_withJsonFormat_shouldReturnPrettyPrintedJson() { + var expectedOutput = """ + { + "voidValue" : null, + "booleanValue" : true, + "longValue" : 100500, + "floatValue" : 42.42, + "stringValue" : "Test string", + "nestedObject" : { + "voidValue" : null, + "booleanValue" : false, + "longValue" : 7, + "floatValue" : 3.14, + "stringValue" : "Nested object test string", + "nestedObject" : null, + "nestedObjects" : null + }, + "nestedObjects" : [ { + "voidValue" : null, + "booleanValue" : true, + "longValue" : 15, + "floatValue" : 100.42, + "stringValue" : "Nested list object 1 test string", + "nestedObject" : null, + "nestedObjects" : null + }, { + "voidValue" : null, + "booleanValue" : false, + "longValue" : 18, + "floatValue" : 0.42, + "stringValue" : "Nested list object 2 test string", + "nestedObject" : null, + "nestedObjects" : null + } ] + } + """.strip(); + + var output = commandResult.toText(OutputFormat.json, null); + + assertEquals(expectedOutput, output); + } + + @Test + void toText_withYamlFormat_shouldReturnYaml() { + var expectedOutput = """ + voidValue: null + booleanValue: true + longValue: 100500 + floatValue: 42.42 + stringValue: "Test string" + nestedObject: + voidValue: null + booleanValue: false + longValue: 7 + floatValue: 3.14 + stringValue: "Nested object test string" + nestedObject: null + nestedObjects: null + nestedObjects: + - voidValue: null + booleanValue: true + longValue: 15 + floatValue: 100.42 + stringValue: "Nested list object 1 test string" + nestedObject: null + nestedObjects: null + - voidValue: null + booleanValue: false + longValue: 18 + floatValue: 0.42 + stringValue: "Nested list object 2 test string" + nestedObject: null + nestedObjects: null + """.strip(); + + var output = commandResult.toText(OutputFormat.yaml, null); + + assertEquals(expectedOutput, output); + } + + @Test + void toText_withNullResult_shouldHandleGracefully() { + CommandResult commandResult = new CommandResult<>(null); + + assertEquals("null", commandResult.toText(OutputFormat.json, null)); + assertEquals("null", commandResult.toText(OutputFormat.yaml, null)); + } + + @Test + void toText_shouldThrowRuntimeExceptionForUnserializableObject() { + UnserializableObject unserializable = new UnserializableObject(); + CommandResult commandResult = new CommandResult<>(unserializable); + + assertThrows(RuntimeException.class, () -> + commandResult.toText(OutputFormat.json, null) + ); + } +} \ No newline at end of file diff --git a/v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractCommandBaseTest.java b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractCommandBaseTest.java new file mode 100644 index 00000000..ba1190bc --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractCommandBaseTest.java @@ -0,0 +1,27 @@ +package dev.streamx.cli.v2.cli.testing; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; + +public class AbstractCommandBaseTest { + public final ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + public final ByteArrayOutputStream errStream = new ByteArrayOutputStream(); + + @BeforeEach + public void redirectStreams() { + System.setOut(new PrintStream(outStream)); + System.setErr(new PrintStream(errStream)); + } + + @AfterEach + public void restoreStreams() { + outStream.reset(); + errStream.reset(); + + System.setOut(System.out); + System.setErr(System.err); + } +} diff --git a/v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractSilentTestCommand.java b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractSilentTestCommand.java new file mode 100644 index 00000000..01fee4ac --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractSilentTestCommand.java @@ -0,0 +1,38 @@ +package dev.streamx.cli.v2.cli.testing; + +import dev.streamx.cli.v2.cli.AbstractCommand; +import dev.streamx.cli.v2.cli.AbstractSilentCommand; +import dev.streamx.cli.v2.cli.CommandResult; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +// Helper class for testing SilentAbstractCommand +public class AbstractSilentTestCommand extends AbstractSilentCommand { + public Supplier> runCommandHandler; + public Supplier> hiddenOptionsHandler; + + public void setRunCommandHandler(Supplier> handler) { + this.runCommandHandler = handler; + } + public void setHiddenOptionsHandler(Supplier> handler) { + this.hiddenOptionsHandler = handler; + } + + @Override + public CommandResult runCommand() throws RuntimeException { + if (runCommandHandler != null) { + return runCommandHandler.get(); + } + throw new IllegalStateException("No run command handler set"); + } + + @Override + public List getHiddenOptions() { + if (hiddenOptionsHandler != null) { + return hiddenOptionsHandler.get(); + } + return super.getHiddenOptions(); + } +} diff --git a/v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractTestCommand.java b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractTestCommand.java new file mode 100644 index 00000000..fab6a451 --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractTestCommand.java @@ -0,0 +1,63 @@ +package dev.streamx.cli.v2.cli.testing; + +import dev.streamx.cli.v2.cli.AbstractCommand; +import dev.streamx.cli.v2.cli.CommandResult; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; + +import java.io.IOException; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +// Helper class for testing AbstractCommand +public class AbstractTestCommand extends AbstractCommand { + public Supplier> runCommandHandler; + public Supplier> hiddenOptionsHandler; + public Function, String> getTextOutputHandler; + + public void setRunCommandHandler(Supplier> handler) { + this.runCommandHandler = handler; + } + + public void setHiddenOptionsHandler(Supplier> handler) { + this.hiddenOptionsHandler = handler; + } + + public void setGetTextOutputHandler(Function, String> handler) { + this.getTextOutputHandler = handler; + } + + @Override + public CommandResult runCommand() throws RuntimeException { + if (runCommandHandler != null) { + return runCommandHandler.get(); + } + throw new IllegalStateException("No run command handler set"); + } + + @Override + public List getHiddenOptions() { + if (hiddenOptionsHandler != null) { + return hiddenOptionsHandler.get(); + } + return super.getHiddenOptions(); + } + + @Override + public String getTextOutput(CommandResult result) throws RuntimeException { + if (getTextOutputHandler != null) { + return getTextOutputHandler.apply(result); + } + return super.getTextOutput(result); + } + + @Override + protected Terminal createTerminal() throws IOException { + return TerminalBuilder.builder() + .system(false) + .streams(System.in, System.out) + .dumb(true) + .build(); + } +} diff --git a/v2/src/test/java/dev/streamx/cli/v2/cli/testing/TestObject.java b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/TestObject.java new file mode 100644 index 00000000..8b6347c4 --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/TestObject.java @@ -0,0 +1,37 @@ +package dev.streamx.cli.v2.cli.testing; + +import org.instancio.Instancio; + +import java.util.List; + +public class TestObject { + public Void voidValue; + public boolean booleanValue; + public long longValue; + public double floatValue; + public String stringValue; + public TestObject nestedObject; + public List nestedObjects; + + public TestObject( + Void voidValue, + boolean booleanValue, + long longValue, + double floatValue, + String stringValue, + TestObject nestedObject, + List nestedObjects + ) { + this.voidValue = voidValue; + this.booleanValue = booleanValue; + this.longValue = longValue; + this.floatValue = floatValue; + this.stringValue = stringValue; + this.nestedObject = nestedObject; + this.nestedObjects = nestedObjects; + } + + public static TestObject random() { + return Instancio.create(TestObject.class); + } +} \ No newline at end of file diff --git a/v2/src/test/java/dev/streamx/cli/v2/cli/testing/UnserializableObject.java b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/UnserializableObject.java new file mode 100644 index 00000000..ef8f9068 --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/UnserializableObject.java @@ -0,0 +1,6 @@ +package dev.streamx.cli.v2.cli.testing; + +public class UnserializableObject { + // Object with circular reference to make it unserializable + public UnserializableObject self = this; +} \ No newline at end of file