From 3ea3d223da90d4f7ce01420ce31b5305655b8fae Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Thu, 15 Jan 2026 11:24:53 +0300 Subject: [PATCH 01/29] Experimenting with quarkus and picocli --- v2/mvnw | 308 ++++++++++++++++++ v2/pom.xml | 129 ++++++++ v2/pom.xml_ | 140 ++++++++ v2/src/main/java/dev/streamx/cli/v2/Main.java | 36 ++ .../cli/v2/commands/config/ConfigCommand.java | 21 ++ .../cli/v2/commands/config/ConfigFile.java | 28 ++ .../v2/commands/config/get/GetCommand.java | 13 + .../v2/commands/config/list/ListCommand.java | 77 +++++ .../v2/commands/config/set/SetCommand.java | 76 +++++ .../cli/v2/commands/run/RunCommand.java | 4 + 10 files changed, 832 insertions(+) create mode 100755 v2/mvnw create mode 100644 v2/pom.xml create mode 100644 v2/pom.xml_ create mode 100644 v2/src/main/java/dev/streamx/cli/v2/Main.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/commands/run/RunCommand.java 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..1d0e4b89 --- /dev/null +++ b/v2/pom.xml @@ -0,0 +1,129 @@ + + + 4.0.0 + + com.example + quarkus-picocli-app + 1.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 + + + + + io.quarkus + quarkus-arc + + + + + io.vavr + vavr + 0.11.0 + + + + + io.quarkus + quarkus-junit5 + 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/pom.xml_ b/v2/pom.xml_ new file mode 100644 index 00000000..14dd2b21 --- /dev/null +++ b/v2/pom.xml_ @@ -0,0 +1,140 @@ + + + 4.0.0 + + dev.streamx.cli + 1.1.3-SNAPSHOT + streamx-cli-v2 + + StreamX Cli + + 21 + + true + uber-jar + dev.streamx.cli.v2.Main + + UTF-8 + UTF-8 + + quarkus-bom + io.quarkus.platform + 3.30.6 + + graalvm + + 3.5.4 + 3.14.1 + 3.6.0 + 0.8.14 + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + provided + pom + + + io.quarkus + quarkus-picocli + ${quarkus.platform.version} + + + + + + + + + + + + + + + + + + org.apache.maven.plugins + maven-release-plugin + 3.1.1 + + @{project.version} + [no ci] [maven-release-plugin] + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + maven-compiler-plugin + ${compiler-plugin.version} + + + -parameters + + + + + maven-surefire-plugin + ${surefire-plugin.version} + + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + maven-failsafe-plugin + ${surefire-plugin.version} + + + ${project.build.directory}/${project.build.finalName}-runner + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + integration-test + verify + + + + + + + + + native + + + native + + + + false + native + + + + + 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..c8d6d17b --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -0,0 +1,36 @@ +package dev.streamx.cli.v2; + +import dev.streamx.cli.v2.commands.config.ConfigCommand; +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 = { + ConfigCommand.class + } +) +public class Main implements Runnable { + @CommandLine.Spec + CommandLine.Model.CommandSpec commandSpec; + + @CommandLine.Option( + names = {"--log-level"}, + description = "Set logging level (TRACE, DEBUG, INFO, WARN, ERROR)", + defaultValue = "INFO" + ) + private String logLevel; + + private void setLogLevel(String level) { + System.setProperty("quarkus.log.level", level); + } + + @Override + public void run() { + setLogLevel(logLevel); + commandSpec.commandLine().usage(System.out); + } +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java new file mode 100644 index 00000000..c55ef7cc --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java @@ -0,0 +1,21 @@ +package dev.streamx.cli.v2.commands.config; + +import dev.streamx.cli.v2.commands.config.get.GetCommand; +import dev.streamx.cli.v2.commands.config.list.ListCommand; +import dev.streamx.cli.v2.commands.config.set.SetCommand; +import picocli.CommandLine; + +@CommandLine.Command( + name = "config", + mixinStandardHelpOptions = true, + description = "Modify StreamX config", + abbreviateSynopsis = true, + synopsisHeading = "Synopsis example", + subcommands = { + ListCommand.class, + SetCommand.class, + GetCommand.class + } +) +public class ConfigCommand { +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java new file mode 100644 index 00000000..4fbed222 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java @@ -0,0 +1,28 @@ +package dev.streamx.cli.v2.commands.config; + +import io.vavr.control.Either; +import io.vavr.control.Try; +import org.jboss.logging.Logger; + +import java.net.URL; +import java.nio.file.Path; + +public class ConfigFile { + private static final Logger logger = Logger.getLogger(ConfigFile.class); + + public static Either getUrl() { + String rootDir = System.getProperty("user.home"); + String dotStreamxConfig = rootDir + "/.streamx/config"; + + Path pathToDir = Path.of(dotStreamxConfig); + Path pathToFile = pathToDir.resolve("application.properties"); + + return Try.of(() -> pathToFile.toUri().toURL()) + .toEither() + .mapLeft(e -> { + logger.debug(e); + + return "Unable to get StreamX config path"; + }); + } +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java new file mode 100644 index 00000000..cf6eaf1f --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java @@ -0,0 +1,13 @@ +package dev.streamx.cli.v2.commands.config.get; + +import dev.streamx.cli.v2.commands.config.list.ListCommand; +import dev.streamx.cli.v2.commands.config.set.SetCommand; +import picocli.CommandLine; + +@CommandLine.Command( + name = "get", + mixinStandardHelpOptions = true, + description = "Get configuration property" +) +public class GetCommand { +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java new file mode 100644 index 00000000..b2d9782e --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java @@ -0,0 +1,77 @@ +package dev.streamx.cli.v2.commands.config.list; + +import dev.streamx.cli.v2.commands.config.ConfigFile; +import io.vavr.control.Try; +import org.jboss.logging.Logger; +import picocli.CommandLine; + +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.TreeMap; + +@CommandLine.Command( + name = "list", + mixinStandardHelpOptions = true, + description = "Display configuration properties" +) +public class ListCommand implements Runnable { + private static final Logger logger = Logger.getLogger(ListCommand.class); + + private HashMap getProperties(URL url) { + return Try.withResources(url::openStream) + .of(input -> { + Properties properties = new Properties(); + properties.load(input); + + HashMap propertyMap = new HashMap<>(); + for (String key : properties.stringPropertyNames()) { + propertyMap.put(key, properties.getProperty(key)); + } + + return propertyMap; + }) + .onFailure(e -> { + logger.error("Failed to load properties from " + url); + logger.debug(e); + }) + .getOrElse(new HashMap<>()); + } + + private void printProperties(HashMap properties) { + Map sortedProperties = new TreeMap<>(properties); + + int maxKeyLength = sortedProperties.keySet().stream() + .mapToInt(String::length) + .max() + .orElse(0); + + System.out.println("\nConfiguration properties:"); + String repeat = "=".repeat(Math.min(80, maxKeyLength + 40)); + System.out.println(repeat); + + sortedProperties.forEach((key, value) -> { + String paddedKey = String.format("%-" + maxKeyLength + "s", key); + System.out.println(paddedKey + " = " + value); + }); + + System.out.println(repeat); + System.out.println("Total properties: " + properties.size()); + } + + @Override + public void run() { + ConfigFile.getUrl().fold( + err -> { + logger.error(err); + System.exit(1); + return null; + }, + url -> { + printProperties(getProperties(url)); + return null; + } + ); + } +} diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java new file mode 100644 index 00000000..aa30e151 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java @@ -0,0 +1,76 @@ +package dev.streamx.cli.v2.commands.config.set; + +import dev.streamx.cli.v2.commands.config.ConfigFile; +import io.vavr.control.Either; +import io.vavr.control.Try; +import org.jboss.logging.Logger; +import picocli.CommandLine; + +import java.net.URL; +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 implements Runnable { + private static final Logger logger = Logger.getLogger(SetCommand.class); + + @CommandLine.Parameters(index = "0", description = "Property key") + private String key; + + @CommandLine.Parameters(index = "1", description = "Property value") + private String value; + + @Override + public void run() { + ConfigFile.getUrl().fold( + err -> { + logger.error(err); + System.exit(1); + return null; + }, + url -> { + return setProperty(url, key, value).fold( + e -> { + System.exit(1); + return null; + }, + s -> null + ); + } + ); + } + + private Either setProperty(URL url, String key, String value) { + Try result = Try.of(() -> { + Properties properties = new Properties(); + + Try.withResources(url::openStream) + .of(input -> { + properties.load(input); + properties.setProperty(key, value); + return null; + }) + .getOrElseThrow(e -> new RuntimeException("Couldn't load config", e)); + + Try.withResources(() -> Files.newOutputStream(Paths.get(url.getPath()))) + .of(output -> { + properties.store(output, null); + return null; + }) + .getOrElseThrow(e -> new RuntimeException("Failed to save properties", e)); + + return null; + }); + + return result.toEither().mapLeft((e) -> { + logger.error("Failed to set property '" + key + "'"); + logger.debug(e); + return e.getMessage(); + }); + } +} \ 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 { +} From 94a062992b39d5ca8e4cca87ab9627c39b5e7c70 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Thu, 15 Jan 2026 13:50:11 +0300 Subject: [PATCH 02/29] Impelment config set, get, list commands --- .../cli/v2/commands/config/ConfigFile.java | 8 +-- .../v2/commands/config/get/GetCommand.java | 49 +++++++++++-- .../v2/commands/config/list/ListCommand.java | 67 +++++++++--------- .../v2/commands/config/set/SetCommand.java | 69 +++++++++---------- 4 files changed, 110 insertions(+), 83 deletions(-) diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java index 4fbed222..50604993 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java @@ -10,7 +10,7 @@ public class ConfigFile { private static final Logger logger = Logger.getLogger(ConfigFile.class); - public static Either getUrl() { + public static Either getUrl() { String rootDir = System.getProperty("user.home"); String dotStreamxConfig = rootDir + "/.streamx/config"; @@ -19,10 +19,6 @@ public static Either getUrl() { return Try.of(() -> pathToFile.toUri().toURL()) .toEither() - .mapLeft(e -> { - logger.debug(e); - - return "Unable to get StreamX config path"; - }); + .mapLeft(e -> new RuntimeException("Unable to get StreamX config path", e)); } } diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java index cf6eaf1f..c0575890 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java @@ -1,13 +1,54 @@ package dev.streamx.cli.v2.commands.config.get; -import dev.streamx.cli.v2.commands.config.list.ListCommand; -import dev.streamx.cli.v2.commands.config.set.SetCommand; +import dev.streamx.cli.v2.commands.config.ConfigFile; +import io.vavr.control.Either; +import io.vavr.control.Try; +import org.jboss.logging.Logger; import picocli.CommandLine; +import java.net.URL; +import java.util.Properties; + @CommandLine.Command( name = "get", mixinStandardHelpOptions = true, description = "Get configuration property" ) -public class GetCommand { -} +public class GetCommand implements Runnable { + private static final Logger logger = Logger.getLogger(GetCommand.class); + + @CommandLine.Parameters(index = "0", description = "Property key") + private String key; + + @Override + public void run() { + printPropertyIfExists(key).mapLeft(e -> { + logger.error(e.getMessage()); + System.exit(1); + return null; + }); + } + + private Either printPropertyIfExists(String key) { + return ConfigFile.getUrl() + .flatMap(url -> Try.withResources(url::openStream) + .of(input -> { + Properties properties = new Properties(); + properties.load(input); + return properties; + }) + .toEither() + .mapLeft(e -> new RuntimeException("Unable to load config file", e)) + .flatMap(properties -> { + var value = properties.getProperty(key); + if (value == null) { + return Either.left(new RuntimeException("No such config property found: " + key)); + } + + logger.info(value); + + return Either.right(null); + }) + ); + } +} \ No newline at end of file diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java index b2d9782e..d59b1f71 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java @@ -1,6 +1,7 @@ package dev.streamx.cli.v2.commands.config.list; import dev.streamx.cli.v2.commands.config.ConfigFile; +import io.vavr.control.Either; import io.vavr.control.Try; import org.jboss.logging.Logger; import picocli.CommandLine; @@ -19,7 +20,16 @@ public class ListCommand implements Runnable { private static final Logger logger = Logger.getLogger(ListCommand.class); - private HashMap getProperties(URL url) { + @Override + public void run() { + printProperties().mapLeft(e -> { + logger.error(e.getMessage()); + System.exit(1); + return null; + }); + } + + private Either> getProperties(URL url) { return Try.withResources(url::openStream) .of(input -> { Properties properties = new Properties(); @@ -32,46 +42,33 @@ private HashMap getProperties(URL url) { return propertyMap; }) - .onFailure(e -> { - logger.error("Failed to load properties from " + url); - logger.debug(e); - }) - .getOrElse(new HashMap<>()); + .toEither().mapLeft(e -> new RuntimeException("Failed to load properties from " + url, e)); } - private void printProperties(HashMap properties) { - Map sortedProperties = new TreeMap<>(properties); + private Either printProperties() { + return ConfigFile.getUrl() + .flatMap(this::getProperties) + .flatMap(properties -> { + Map sortedProperties = new TreeMap<>(properties); - int maxKeyLength = sortedProperties.keySet().stream() - .mapToInt(String::length) - .max() - .orElse(0); + int maxKeyLength = sortedProperties.keySet().stream() + .mapToInt(String::length) + .max() + .orElse(0); - System.out.println("\nConfiguration properties:"); - String repeat = "=".repeat(Math.min(80, maxKeyLength + 40)); - System.out.println(repeat); + logger.info("\nConfiguration properties:"); + String repeat = "=".repeat(Math.min(80, maxKeyLength + 40)); + logger.info(repeat); - sortedProperties.forEach((key, value) -> { - String paddedKey = String.format("%-" + maxKeyLength + "s", key); - System.out.println(paddedKey + " = " + value); - }); + sortedProperties.forEach((key, value) -> { + String paddedKey = String.format("%-" + maxKeyLength + "s", key); + logger.info(paddedKey + " = " + value); + }); - System.out.println(repeat); - System.out.println("Total properties: " + properties.size()); - } + logger.info(repeat); + logger.info("Total properties: " + properties.size()); - @Override - public void run() { - ConfigFile.getUrl().fold( - err -> { - logger.error(err); - System.exit(1); - return null; - }, - url -> { - printProperties(getProperties(url)); - return null; - } - ); + return Either.right(null); + }); } } diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java index aa30e151..d432d444 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java @@ -27,50 +27,43 @@ public class SetCommand implements Runnable { @Override public void run() { - ConfigFile.getUrl().fold( - err -> { - logger.error(err); - System.exit(1); - return null; - }, - url -> { - return setProperty(url, key, value).fold( - e -> { - System.exit(1); - return null; - }, - s -> null - ); - } - ); + setProperty(key, value).mapLeft(e -> { + logger.error(e.getMessage()); + System.exit(1); + return null; + }); } - private Either setProperty(URL url, String key, String value) { - Try result = Try.of(() -> { - Properties properties = new Properties(); - - Try.withResources(url::openStream) + private Either setProperty(String key, String value) { + return ConfigFile.getUrl() + .flatMap(url -> Try.withResources(url::openStream) .of(input -> { + Properties properties = new Properties(); properties.load(input); - properties.setProperty(key, value); - return null; + return properties; }) - .getOrElseThrow(e -> new RuntimeException("Couldn't load config", e)); + .toEither() + .mapLeft(e -> new RuntimeException("Unable to load config file", e)) + .flatMap(properties -> { + Try.withResources(url::openStream) + .of(input -> { + properties.load(input); + properties.setProperty(key, value); + return null; + }) + .toEither() + .mapLeft(e -> new RuntimeException("Couldn't load config", e)); - Try.withResources(() -> Files.newOutputStream(Paths.get(url.getPath()))) - .of(output -> { - properties.store(output, null); - return null; - }) - .getOrElseThrow(e -> new RuntimeException("Failed to save properties", e)); + Try.withResources(() -> Files.newOutputStream(Paths.get(url.getPath()))) + .of(output -> { + properties.store(output, null); + return null; + }) + .toEither() + .mapLeft(e -> new RuntimeException("Failed to save properties", e)); - return null; - }); - - return result.toEither().mapLeft((e) -> { - logger.error("Failed to set property '" + key + "'"); - logger.debug(e); - return e.getMessage(); - }); + return Either.right(null); + }) + ); } } \ No newline at end of file From ccdae98247d108101787921a02bf77af16737cbf Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Thu, 15 Jan 2026 13:50:44 +0300 Subject: [PATCH 03/29] Impelment config set, get, list commands --- v2/src/main/java/dev/streamx/cli/v2/Main.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/v2/src/main/java/dev/streamx/cli/v2/Main.java b/v2/src/main/java/dev/streamx/cli/v2/Main.java index c8d6d17b..585c6dd3 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/Main.java +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -18,19 +18,17 @@ public class Main implements Runnable { CommandLine.Model.CommandSpec commandSpec; @CommandLine.Option( - names = {"--log-level"}, - description = "Set logging level (TRACE, DEBUG, INFO, WARN, ERROR)", - defaultValue = "INFO" + names = {"-v", "--verbose"}, + description = "Print debug information" ) - private String logLevel; - - private void setLogLevel(String level) { - System.setProperty("quarkus.log.level", level); - } + private Boolean verbose = false; @Override public void run() { - setLogLevel(logLevel); + if (verbose) { + System.setProperty("quarkus.log.level", "DEBUG"); + } + commandSpec.commandLine().usage(System.out); } } From 355d98729ed190cdf0b5655d19b8f2db4a991e88 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Thu, 15 Jan 2026 15:37:45 +0300 Subject: [PATCH 04/29] Print errors with stacktrace if verbose flag provided --- v2/src/main/java/dev/streamx/cli/v2/Main.java | 6 +---- .../cli/v2/commands/config/ConfigCommand.java | 12 +++++++++- .../v2/commands/config/get/GetCommand.java | 10 +++++--- .../v2/commands/config/list/ListCommand.java | 7 +++++- .../v2/commands/config/set/SetCommand.java | 8 +++++-- .../streamx/cli/v2/errors/ErrorPrinter.java | 24 +++++++++++++++++++ 6 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 v2/src/main/java/dev/streamx/cli/v2/errors/ErrorPrinter.java diff --git a/v2/src/main/java/dev/streamx/cli/v2/Main.java b/v2/src/main/java/dev/streamx/cli/v2/Main.java index 585c6dd3..fb3c4dbe 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/Main.java +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -21,14 +21,10 @@ public class Main implements Runnable { names = {"-v", "--verbose"}, description = "Print debug information" ) - private Boolean verbose = false; + public boolean verbose; @Override public void run() { - if (verbose) { - System.setProperty("quarkus.log.level", "DEBUG"); - } - commandSpec.commandLine().usage(System.out); } } diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java index c55ef7cc..8403609c 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java @@ -1,5 +1,6 @@ package dev.streamx.cli.v2.commands.config; +import dev.streamx.cli.v2.Main; import dev.streamx.cli.v2.commands.config.get.GetCommand; import dev.streamx.cli.v2.commands.config.list.ListCommand; import dev.streamx.cli.v2.commands.config.set.SetCommand; @@ -17,5 +18,14 @@ GetCommand.class } ) -public class ConfigCommand { +public class ConfigCommand implements Runnable { + @CommandLine.Spec + CommandLine.Model.CommandSpec commandSpec; + + @CommandLine.ParentCommand + public Main mainCommand; + + public void run() { + commandSpec.commandLine().usage(System.out); + } } diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java index c0575890..153e73dd 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java @@ -1,12 +1,13 @@ package dev.streamx.cli.v2.commands.config.get; +import dev.streamx.cli.v2.commands.config.ConfigCommand; import dev.streamx.cli.v2.commands.config.ConfigFile; +import dev.streamx.cli.v2.errors.ErrorPrinter; import io.vavr.control.Either; import io.vavr.control.Try; import org.jboss.logging.Logger; import picocli.CommandLine; -import java.net.URL; import java.util.Properties; @CommandLine.Command( @@ -17,13 +18,16 @@ public class GetCommand implements Runnable { private static final Logger logger = Logger.getLogger(GetCommand.class); + @CommandLine.ParentCommand + public ConfigCommand configCommand; + @CommandLine.Parameters(index = "0", description = "Property key") private String key; @Override public void run() { printPropertyIfExists(key).mapLeft(e -> { - logger.error(e.getMessage()); + ErrorPrinter.print(logger, e, configCommand.mainCommand.verbose); System.exit(1); return null; }); @@ -42,7 +46,7 @@ private Either printPropertyIfExists(String key) { .flatMap(properties -> { var value = properties.getProperty(key); if (value == null) { - return Either.left(new RuntimeException("No such config property found: " + key)); + return Either.left(new RuntimeException("No such config property found: " + key)); } logger.info(value); diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java index d59b1f71..a1c4244f 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java @@ -1,6 +1,8 @@ package dev.streamx.cli.v2.commands.config.list; +import dev.streamx.cli.v2.commands.config.ConfigCommand; import dev.streamx.cli.v2.commands.config.ConfigFile; +import dev.streamx.cli.v2.errors.ErrorPrinter; import io.vavr.control.Either; import io.vavr.control.Try; import org.jboss.logging.Logger; @@ -20,10 +22,13 @@ public class ListCommand implements Runnable { private static final Logger logger = Logger.getLogger(ListCommand.class); + @CommandLine.ParentCommand + public ConfigCommand configCommand; + @Override public void run() { printProperties().mapLeft(e -> { - logger.error(e.getMessage()); + ErrorPrinter.print(logger, e, configCommand.mainCommand.verbose); System.exit(1); return null; }); diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java index d432d444..0ff75f81 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java @@ -1,12 +1,13 @@ package dev.streamx.cli.v2.commands.config.set; +import dev.streamx.cli.v2.commands.config.ConfigCommand; import dev.streamx.cli.v2.commands.config.ConfigFile; +import dev.streamx.cli.v2.errors.ErrorPrinter; import io.vavr.control.Either; import io.vavr.control.Try; import org.jboss.logging.Logger; import picocli.CommandLine; -import java.net.URL; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Properties; @@ -19,6 +20,9 @@ public class SetCommand implements Runnable { private static final Logger logger = Logger.getLogger(SetCommand.class); + @CommandLine.ParentCommand + public ConfigCommand configCommand; + @CommandLine.Parameters(index = "0", description = "Property key") private String key; @@ -28,7 +32,7 @@ public class SetCommand implements Runnable { @Override public void run() { setProperty(key, value).mapLeft(e -> { - logger.error(e.getMessage()); + ErrorPrinter.print(logger, e, configCommand.mainCommand.verbose); System.exit(1); return null; }); diff --git a/v2/src/main/java/dev/streamx/cli/v2/errors/ErrorPrinter.java b/v2/src/main/java/dev/streamx/cli/v2/errors/ErrorPrinter.java new file mode 100644 index 00000000..420d2873 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/errors/ErrorPrinter.java @@ -0,0 +1,24 @@ +package dev.streamx.cli.v2.errors; + +import org.jboss.logging.Logger; + +import java.io.PrintWriter; +import java.io.StringWriter; + +public class ErrorPrinter { + public static void print(Logger logger, Throwable e, boolean withStackTrace) { + if (withStackTrace) { + logger.error(getStackTraceAsString(e)); + return; + } + + logger.error(e.getMessage()); + } + + private static String getStackTraceAsString(Throwable throwable) { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + throwable.printStackTrace(pw); + return sw.toString(); + } +} From b2be6900e1cd8461004abb726f376fdfde34e399 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Thu, 15 Jan 2026 15:38:36 +0300 Subject: [PATCH 05/29] Remove dead code --- v2/pom.xml_ | 140 ---------------------------------------------------- 1 file changed, 140 deletions(-) delete mode 100644 v2/pom.xml_ diff --git a/v2/pom.xml_ b/v2/pom.xml_ deleted file mode 100644 index 14dd2b21..00000000 --- a/v2/pom.xml_ +++ /dev/null @@ -1,140 +0,0 @@ - - - 4.0.0 - - dev.streamx.cli - 1.1.3-SNAPSHOT - streamx-cli-v2 - - StreamX Cli - - 21 - - true - uber-jar - dev.streamx.cli.v2.Main - - UTF-8 - UTF-8 - - quarkus-bom - io.quarkus.platform - 3.30.6 - - graalvm - - 3.5.4 - 3.14.1 - 3.6.0 - 0.8.14 - - - - - io.quarkus.platform - quarkus-bom - ${quarkus.platform.version} - provided - pom - - - io.quarkus - quarkus-picocli - ${quarkus.platform.version} - - - - - - - - - - - - - - - - - - org.apache.maven.plugins - maven-release-plugin - 3.1.1 - - @{project.version} - [no ci] [maven-release-plugin] - - - - ${quarkus.platform.group-id} - quarkus-maven-plugin - ${quarkus.platform.version} - true - - - - build - generate-code - generate-code-tests - - - - - - maven-compiler-plugin - ${compiler-plugin.version} - - - -parameters - - - - - maven-surefire-plugin - ${surefire-plugin.version} - - - org.jboss.logmanager.LogManager - ${maven.home} - - - - - maven-failsafe-plugin - ${surefire-plugin.version} - - - ${project.build.directory}/${project.build.finalName}-runner - - org.jboss.logmanager.LogManager - ${maven.home} - - - - - - integration-test - verify - - - - - - - - - native - - - native - - - - false - native - - - - - From 8bc899ca2db9988fbbf2e6ccd196fea68f6e24e1 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Thu, 15 Jan 2026 16:59:59 +0300 Subject: [PATCH 06/29] Rename config command to settings --- v2/src/main/java/dev/streamx/cli/v2/Main.java | 4 ++-- .../SettingsCommand.java} | 14 +++++++------- .../SettingsFile.java} | 8 ++++---- .../{config => settings}/get/GetCommand.java | 16 ++++++++-------- .../{config => settings}/list/ListCommand.java | 12 ++++++------ .../{config => settings}/set/SetCommand.java | 18 +++++++++--------- 6 files changed, 36 insertions(+), 36 deletions(-) rename v2/src/main/java/dev/streamx/cli/v2/commands/{config/ConfigCommand.java => settings/SettingsCommand.java} (58%) rename v2/src/main/java/dev/streamx/cli/v2/commands/{config/ConfigFile.java => settings/SettingsFile.java} (76%) rename v2/src/main/java/dev/streamx/cli/v2/commands/{config => settings}/get/GetCommand.java (71%) rename v2/src/main/java/dev/streamx/cli/v2/commands/{config => settings}/list/ListCommand.java (86%) rename v2/src/main/java/dev/streamx/cli/v2/commands/{config => settings}/set/SetCommand.java (75%) diff --git a/v2/src/main/java/dev/streamx/cli/v2/Main.java b/v2/src/main/java/dev/streamx/cli/v2/Main.java index fb3c4dbe..072c5395 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/Main.java +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -1,6 +1,6 @@ package dev.streamx.cli.v2; -import dev.streamx.cli.v2.commands.config.ConfigCommand; +import dev.streamx.cli.v2.commands.settings.SettingsCommand; import io.quarkus.picocli.runtime.annotations.TopCommand; import picocli.CommandLine; @@ -10,7 +10,7 @@ mixinStandardHelpOptions = true, description = "StreamX CLI. More info at https://streamx.dev", subcommands = { - ConfigCommand.class + SettingsCommand.class } ) public class Main implements Runnable { diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsCommand.java similarity index 58% rename from v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java rename to v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsCommand.java index 8403609c..ab65bda8 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsCommand.java @@ -1,15 +1,15 @@ -package dev.streamx.cli.v2.commands.config; +package dev.streamx.cli.v2.commands.settings; import dev.streamx.cli.v2.Main; -import dev.streamx.cli.v2.commands.config.get.GetCommand; -import dev.streamx.cli.v2.commands.config.list.ListCommand; -import dev.streamx.cli.v2.commands.config.set.SetCommand; +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 = "config", + name = "settings", mixinStandardHelpOptions = true, - description = "Modify StreamX config", + description = "Modify StreamX settings", abbreviateSynopsis = true, synopsisHeading = "Synopsis example", subcommands = { @@ -18,7 +18,7 @@ GetCommand.class } ) -public class ConfigCommand implements Runnable { +public class SettingsCommand implements Runnable { @CommandLine.Spec CommandLine.Model.CommandSpec commandSpec; diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsFile.java similarity index 76% rename from v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java rename to v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsFile.java index 50604993..193caaf5 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/ConfigFile.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsFile.java @@ -1,4 +1,4 @@ -package dev.streamx.cli.v2.commands.config; +package dev.streamx.cli.v2.commands.settings; import io.vavr.control.Either; import io.vavr.control.Try; @@ -7,8 +7,8 @@ import java.net.URL; import java.nio.file.Path; -public class ConfigFile { - private static final Logger logger = Logger.getLogger(ConfigFile.class); +public class SettingsFile { + private static final Logger logger = Logger.getLogger(SettingsFile.class); public static Either getUrl() { String rootDir = System.getProperty("user.home"); @@ -19,6 +19,6 @@ public static Either getUrl() { return Try.of(() -> pathToFile.toUri().toURL()) .toEither() - .mapLeft(e -> new RuntimeException("Unable to get StreamX config path", e)); + .mapLeft(e -> new RuntimeException("Unable to get StreamX settings path", e)); } } diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/get/GetCommand.java similarity index 71% rename from v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java rename to v2/src/main/java/dev/streamx/cli/v2/commands/settings/get/GetCommand.java index 153e73dd..5f92f2f7 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/get/GetCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/get/GetCommand.java @@ -1,7 +1,7 @@ -package dev.streamx.cli.v2.commands.config.get; +package dev.streamx.cli.v2.commands.settings.get; -import dev.streamx.cli.v2.commands.config.ConfigCommand; -import dev.streamx.cli.v2.commands.config.ConfigFile; +import dev.streamx.cli.v2.commands.settings.SettingsCommand; +import dev.streamx.cli.v2.commands.settings.SettingsFile; import dev.streamx.cli.v2.errors.ErrorPrinter; import io.vavr.control.Either; import io.vavr.control.Try; @@ -19,7 +19,7 @@ public class GetCommand implements Runnable { private static final Logger logger = Logger.getLogger(GetCommand.class); @CommandLine.ParentCommand - public ConfigCommand configCommand; + public SettingsCommand settingsCommand; @CommandLine.Parameters(index = "0", description = "Property key") private String key; @@ -27,14 +27,14 @@ public class GetCommand implements Runnable { @Override public void run() { printPropertyIfExists(key).mapLeft(e -> { - ErrorPrinter.print(logger, e, configCommand.mainCommand.verbose); + ErrorPrinter.print(logger, e, settingsCommand.mainCommand.verbose); System.exit(1); return null; }); } private Either printPropertyIfExists(String key) { - return ConfigFile.getUrl() + return SettingsFile.getUrl() .flatMap(url -> Try.withResources(url::openStream) .of(input -> { Properties properties = new Properties(); @@ -42,11 +42,11 @@ private Either printPropertyIfExists(String key) { return properties; }) .toEither() - .mapLeft(e -> new RuntimeException("Unable to load config file", e)) + .mapLeft(e -> new RuntimeException("Unable to load settings file", e)) .flatMap(properties -> { var value = properties.getProperty(key); if (value == null) { - return Either.left(new RuntimeException("No such config property found: " + key)); + return Either.left(new RuntimeException("No such settings property found: " + key)); } logger.info(value); diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/ListCommand.java similarity index 86% rename from v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java rename to v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/ListCommand.java index a1c4244f..a4bd6ea1 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/list/ListCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/ListCommand.java @@ -1,7 +1,7 @@ -package dev.streamx.cli.v2.commands.config.list; +package dev.streamx.cli.v2.commands.settings.list; -import dev.streamx.cli.v2.commands.config.ConfigCommand; -import dev.streamx.cli.v2.commands.config.ConfigFile; +import dev.streamx.cli.v2.commands.settings.SettingsCommand; +import dev.streamx.cli.v2.commands.settings.SettingsFile; import dev.streamx.cli.v2.errors.ErrorPrinter; import io.vavr.control.Either; import io.vavr.control.Try; @@ -23,12 +23,12 @@ public class ListCommand implements Runnable { private static final Logger logger = Logger.getLogger(ListCommand.class); @CommandLine.ParentCommand - public ConfigCommand configCommand; + public SettingsCommand settingsCommand; @Override public void run() { printProperties().mapLeft(e -> { - ErrorPrinter.print(logger, e, configCommand.mainCommand.verbose); + ErrorPrinter.print(logger, e, settingsCommand.mainCommand.verbose); System.exit(1); return null; }); @@ -51,7 +51,7 @@ private Either> getProperties(URL url) } private Either printProperties() { - return ConfigFile.getUrl() + return SettingsFile.getUrl() .flatMap(this::getProperties) .flatMap(properties -> { Map sortedProperties = new TreeMap<>(properties); diff --git a/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/set/SetCommand.java similarity index 75% rename from v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java rename to v2/src/main/java/dev/streamx/cli/v2/commands/settings/set/SetCommand.java index 0ff75f81..9d59d3b9 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/commands/config/set/SetCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/set/SetCommand.java @@ -1,7 +1,7 @@ -package dev.streamx.cli.v2.commands.config.set; +package dev.streamx.cli.v2.commands.settings.set; -import dev.streamx.cli.v2.commands.config.ConfigCommand; -import dev.streamx.cli.v2.commands.config.ConfigFile; +import dev.streamx.cli.v2.commands.settings.SettingsCommand; +import dev.streamx.cli.v2.commands.settings.SettingsFile; import dev.streamx.cli.v2.errors.ErrorPrinter; import io.vavr.control.Either; import io.vavr.control.Try; @@ -21,7 +21,7 @@ public class SetCommand implements Runnable { private static final Logger logger = Logger.getLogger(SetCommand.class); @CommandLine.ParentCommand - public ConfigCommand configCommand; + public SettingsCommand settingsCommand; @CommandLine.Parameters(index = "0", description = "Property key") private String key; @@ -32,14 +32,14 @@ public class SetCommand implements Runnable { @Override public void run() { setProperty(key, value).mapLeft(e -> { - ErrorPrinter.print(logger, e, configCommand.mainCommand.verbose); + ErrorPrinter.print(logger, e, settingsCommand.mainCommand.verbose); System.exit(1); return null; }); } private Either setProperty(String key, String value) { - return ConfigFile.getUrl() + return SettingsFile.getUrl() .flatMap(url -> Try.withResources(url::openStream) .of(input -> { Properties properties = new Properties(); @@ -47,7 +47,7 @@ private Either setProperty(String key, String value) { return properties; }) .toEither() - .mapLeft(e -> new RuntimeException("Unable to load config file", e)) + .mapLeft(e -> new RuntimeException("Unable to load settings file", e)) .flatMap(properties -> { Try.withResources(url::openStream) .of(input -> { @@ -56,7 +56,7 @@ private Either setProperty(String key, String value) { return null; }) .toEither() - .mapLeft(e -> new RuntimeException("Couldn't load config", e)); + .mapLeft(e -> new RuntimeException("Couldn't load settings", e)); Try.withResources(() -> Files.newOutputStream(Paths.get(url.getPath()))) .of(output -> { @@ -64,7 +64,7 @@ private Either setProperty(String key, String value) { return null; }) .toEither() - .mapLeft(e -> new RuntimeException("Failed to save properties", e)); + .mapLeft(e -> new RuntimeException("Failed to save settings", e)); return Either.right(null); }) From ea481ae992ea3507428500ed53cc8b57f3c241c7 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Sun, 18 Jan 2026 22:59:29 +0300 Subject: [PATCH 07/29] Implement AbstractCommand --- v2/pom.xml | 21 ++- v2/src/main/java/dev/streamx/cli/v2/Main.java | 12 +- .../streamx/cli/v2/cli/AbstractCommand.java | 71 +++++++++ .../dev/streamx/cli/v2/cli/CommandResult.java | 49 ++++++ .../dev/streamx/cli/v2/cli/CommonOption.java | 9 ++ .../dev/streamx/cli/v2/cli/OutputFormat.java | 5 + .../cli/v2/cli/ShortErrorMessageHandler.java | 37 +++++ .../v2/commands/settings/SettingsCommand.java | 3 +- .../v2/commands/settings/SettingsFile.java | 17 +- .../v2/commands/settings/get/GetCommand.java | 61 +++----- .../commands/settings/list/ListCommand.java | 145 ++++++++---------- .../v2/commands/settings/set/SetCommand.java | 73 +++------ .../streamx/cli/v2/errors/ErrorPrinter.java | 24 --- 13 files changed, 312 insertions(+), 215 deletions(-) create mode 100644 v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/cli/CommonOption.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/cli/OutputFormat.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/cli/ShortErrorMessageHandler.java delete mode 100644 v2/src/main/java/dev/streamx/cli/v2/errors/ErrorPrinter.java diff --git a/v2/pom.xml b/v2/pom.xml index 1d0e4b89..152f21e4 100644 --- a/v2/pom.xml +++ b/v2/pom.xml @@ -32,23 +32,32 @@ - io.quarkus quarkus-picocli - io.quarkus quarkus-arc - - io.vavr - vavr - 0.11.0 + 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 diff --git a/v2/src/main/java/dev/streamx/cli/v2/Main.java b/v2/src/main/java/dev/streamx/cli/v2/Main.java index 072c5395..a7ed1bc0 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/Main.java +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -1,5 +1,6 @@ package dev.streamx.cli.v2; +import dev.streamx.cli.v2.cli.ShortErrorMessageHandler; import dev.streamx.cli.v2.commands.settings.SettingsCommand; import io.quarkus.picocli.runtime.annotations.TopCommand; import picocli.CommandLine; @@ -17,14 +18,11 @@ public class Main implements Runnable { @CommandLine.Spec CommandLine.Model.CommandSpec commandSpec; - @CommandLine.Option( - names = {"-v", "--verbose"}, - description = "Print debug information" - ) - public boolean verbose; - @Override public void run() { - commandSpec.commandLine().usage(System.out); + commandSpec + .commandLine() + .setParameterExceptionHandler(new ShortErrorMessageHandler()) + .usage(System.out); } } 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..c6519df0 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java @@ -0,0 +1,71 @@ +package dev.streamx.cli.v2.cli; + +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; + +// Each CLI command should extend this class +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(); + } + + private void applyHiddenOptions() { + var options = getHiddenOptions(); + + for (String option : options) { + var optionSpec = spec.findOption(option); + if (optionSpec != null) { + spec.remove(optionSpec); + } + } + } + + @CommandLine.Spec + private CommandSpec spec; + + @CommandLine.Spec + private void setSpec(CommandSpec spec) { + this.spec = spec; + applyHiddenOptions(); + } + + @CommandLine.Option( + names = {CommonOption.VERBOSE_SHORT, CommonOption.VERBOSE_LONG}, + description = "Print debug information" + ) + private boolean verbose; + + @CommandLine.Option( + names = {CommonOption.OUTPUT_SHORT, CommonOption.OUTPUT_LONG}, + description = "Specify output format: text, json, yaml", + defaultValue = "text" + ) + private OutputFormat outputFormat; + + public void run() { + try { + var result = this.runCommand(); + result.print(outputFormat); + } catch (Exception e) { + int 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); + } + + System.exit(exitCode); + } + } +} 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..d3e99b42 --- /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 com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import java.util.Optional; + +public class CommandResult { + public Optional text; + public Optional json; + + public static CommandResult empty() { + return new CommandResult(Optional.empty(), Optional.empty()); + } + + public CommandResult(Optional text, Optional json) { + this.text = text; + this.json = json; + } + + public void print(OutputFormat outputFormat) throws Exception { + if (outputFormat == OutputFormat.text && text.isPresent()) { + System.out.println(text.get()); + return; + } + + if (outputFormat == OutputFormat.json) { + if (json.isEmpty()) { + throw new RuntimeException("This command did not return any JSON output."); + } + + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.readTree(json.get()); + System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); + return; + } + + if (outputFormat == OutputFormat.yaml) { + if (json.isEmpty()) { + throw new RuntimeException("This command did not return any YAML output."); + } + + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + JsonNode jsonNode = mapper.readTree(json.get()); + System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); + } + } +} 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..de19f630 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/ShortErrorMessageHandler.java @@ -0,0 +1,37 @@ +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; + +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("Try '%s%s' for more information on the available options.%n", 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/settings/SettingsCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/SettingsCommand.java index ab65bda8..727c272b 100644 --- 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 @@ -2,7 +2,6 @@ import dev.streamx.cli.v2.Main; 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; @@ -13,7 +12,7 @@ abbreviateSynopsis = true, synopsisHeading = "Synopsis example", subcommands = { - ListCommand.class, +// ListCommand.class, SetCommand.class, GetCommand.class } 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 index 193caaf5..c6b9714d 100644 --- 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 @@ -1,24 +1,21 @@ package dev.streamx.cli.v2.commands.settings; -import io.vavr.control.Either; -import io.vavr.control.Try; -import org.jboss.logging.Logger; - +import java.net.MalformedURLException; import java.net.URL; import java.nio.file.Path; public class SettingsFile { - private static final Logger logger = Logger.getLogger(SettingsFile.class); - - public static Either getUrl() { + 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"); - return Try.of(() -> pathToFile.toUri().toURL()) - .toEither() - .mapLeft(e -> new RuntimeException("Unable to get StreamX settings path", e)); + try { + return pathToFile.toUri().toURL(); + } catch (MalformedURLException e) { + throw new RuntimeException("Unable to get StreamX settings path", 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 index 5f92f2f7..3ca2b0fa 100644 --- 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 @@ -1,13 +1,12 @@ package dev.streamx.cli.v2.commands.settings.get; -import dev.streamx.cli.v2.commands.settings.SettingsCommand; +import dev.streamx.cli.v2.cli.AbstractCommand; +import dev.streamx.cli.v2.cli.CommandResult; import dev.streamx.cli.v2.commands.settings.SettingsFile; -import dev.streamx.cli.v2.errors.ErrorPrinter; -import io.vavr.control.Either; -import io.vavr.control.Try; -import org.jboss.logging.Logger; import picocli.CommandLine; +import java.io.IOException; +import java.util.Optional; import java.util.Properties; @CommandLine.Command( @@ -15,44 +14,28 @@ mixinStandardHelpOptions = true, description = "Get configuration property" ) -public class GetCommand implements Runnable { - private static final Logger logger = Logger.getLogger(GetCommand.class); - - @CommandLine.ParentCommand - public SettingsCommand settingsCommand; - +public class GetCommand extends AbstractCommand { @CommandLine.Parameters(index = "0", description = "Property key") private String key; @Override - public void run() { - printPropertyIfExists(key).mapLeft(e -> { - ErrorPrinter.print(logger, e, settingsCommand.mainCommand.verbose); - System.exit(1); - return null; - }); - } + 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("No such settings property found: " + key); + } + + var json = String.format("{\"key\": \"%s\", \"value\": \"%s\"}", key, value); - private Either printPropertyIfExists(String key) { - return SettingsFile.getUrl() - .flatMap(url -> Try.withResources(url::openStream) - .of(input -> { - Properties properties = new Properties(); - properties.load(input); - return properties; - }) - .toEither() - .mapLeft(e -> new RuntimeException("Unable to load settings file", e)) - .flatMap(properties -> { - var value = properties.getProperty(key); - if (value == null) { - return Either.left(new RuntimeException("No such settings property found: " + key)); - } - - logger.info(value); - - return Either.right(null); - }) - ); + return new CommandResult(Optional.of(value), Optional.of(json)); + } catch (IOException e) { + throw new RuntimeException("Unable to get settings property", e); + } } } \ No newline at end of file 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 index a4bd6ea1..48462c40 100644 --- 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 @@ -1,79 +1,66 @@ -package dev.streamx.cli.v2.commands.settings.list; - -import dev.streamx.cli.v2.commands.settings.SettingsCommand; -import dev.streamx.cli.v2.commands.settings.SettingsFile; -import dev.streamx.cli.v2.errors.ErrorPrinter; -import io.vavr.control.Either; -import io.vavr.control.Try; -import org.jboss.logging.Logger; -import picocli.CommandLine; - -import java.net.URL; -import java.util.HashMap; -import java.util.Map; -import java.util.Properties; -import java.util.TreeMap; - -@CommandLine.Command( - name = "list", - mixinStandardHelpOptions = true, - description = "Display configuration properties" -) -public class ListCommand implements Runnable { - private static final Logger logger = Logger.getLogger(ListCommand.class); - - @CommandLine.ParentCommand - public SettingsCommand settingsCommand; - - @Override - public void run() { - printProperties().mapLeft(e -> { - ErrorPrinter.print(logger, e, settingsCommand.mainCommand.verbose); - System.exit(1); - return null; - }); - } - - private Either> getProperties(URL url) { - return Try.withResources(url::openStream) - .of(input -> { - Properties properties = new Properties(); - properties.load(input); - - HashMap propertyMap = new HashMap<>(); - for (String key : properties.stringPropertyNames()) { - propertyMap.put(key, properties.getProperty(key)); - } - - return propertyMap; - }) - .toEither().mapLeft(e -> new RuntimeException("Failed to load properties from " + url, e)); - } - - private Either printProperties() { - return SettingsFile.getUrl() - .flatMap(this::getProperties) - .flatMap(properties -> { - Map sortedProperties = new TreeMap<>(properties); - - int maxKeyLength = sortedProperties.keySet().stream() - .mapToInt(String::length) - .max() - .orElse(0); - - logger.info("\nConfiguration properties:"); - String repeat = "=".repeat(Math.min(80, maxKeyLength + 40)); - logger.info(repeat); - - sortedProperties.forEach((key, value) -> { - String paddedKey = String.format("%-" + maxKeyLength + "s", key); - logger.info(paddedKey + " = " + value); - }); - - logger.info(repeat); - logger.info("Total properties: " + properties.size()); - - return Either.right(null); - }); - } -} +//package dev.streamx.cli.v2.commands.settings.list; +// +//import dev.streamx.cli.v2.commands.settings.SettingsCommand; +//import dev.streamx.cli.v2.commands.settings.SettingsFile; +//import org.jboss.logging.Logger; +//import picocli.CommandLine; +// +//import java.net.URL; +//import java.util.*; +// +//@CommandLine.Command( +// name = "list", +// mixinStandardHelpOptions = true, +// description = "Display configuration properties" +//) +//public class ListCommand implements Runnable { +// private static final Logger logger = Logger.getLogger(ListCommand.class); +// +// @Override +// public void run() throws RuntimeException { +// printProperties(); +// } +// +// private Either> getProperties(URL url) { +// return Try.withResources(url::openStream) +// .of(input -> { +// Properties properties = new Properties(); +// properties.load(input); +// +// HashMap propertyMap = new HashMap<>(); +// for (String key : properties.stringPropertyNames()) { +// propertyMap.put(key, properties.getProperty(key)); +// } +// +// return propertyMap; +// }) +// .toEither().mapLeft(e -> new RuntimeException("Failed to load properties from " + url, e)); +// } +// +// private Either printProperties() { +// return SettingsFile.getUrl() +// .flatMap(this::getProperties) +// .flatMap(properties -> { +// Map sortedProperties = new TreeMap<>(properties); +// +// int maxKeyLength = sortedProperties.keySet().stream() +// .mapToInt(String::length) +// .max() +// .orElse(0); +// +// logger.info("\nConfiguration properties:"); +// String repeat = "=".repeat(Math.min(80, maxKeyLength + 40)); +// logger.info(repeat); +// +// sortedProperties.forEach((key, value) -> { +// String paddedKey = String.format("%-" + maxKeyLength + "s", key); +// logger.info(paddedKey + " = " + value); +// }); +// +// logger.info(repeat); +// logger.info("Total properties: " + properties.size()); +// +// return Either.right(null); +// }); +// } +//} 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 index 9d59d3b9..605a02e4 100644 --- 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 @@ -1,15 +1,15 @@ package dev.streamx.cli.v2.commands.settings.set; -import dev.streamx.cli.v2.commands.settings.SettingsCommand; +import dev.streamx.cli.v2.cli.AbstractCommand; +import dev.streamx.cli.v2.cli.CommandResult; +import dev.streamx.cli.v2.cli.CommonOption; import dev.streamx.cli.v2.commands.settings.SettingsFile; -import dev.streamx.cli.v2.errors.ErrorPrinter; -import io.vavr.control.Either; -import io.vavr.control.Try; -import org.jboss.logging.Logger; 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; @CommandLine.Command( @@ -17,12 +17,7 @@ mixinStandardHelpOptions = true, description = "Set configuration property" ) -public class SetCommand implements Runnable { - private static final Logger logger = Logger.getLogger(SetCommand.class); - - @CommandLine.ParentCommand - public SettingsCommand settingsCommand; - +public class SetCommand extends AbstractCommand { @CommandLine.Parameters(index = "0", description = "Property key") private String key; @@ -30,44 +25,26 @@ public class SetCommand implements Runnable { private String value; @Override - public void run() { - setProperty(key, value).mapLeft(e -> { - ErrorPrinter.print(logger, e, settingsCommand.mainCommand.verbose); - System.exit(1); - return null; - }); + public List getHiddenOptions() { + return List.of(CommonOption.OUTPUT_LONG); } - private Either setProperty(String key, String value) { - return SettingsFile.getUrl() - .flatMap(url -> Try.withResources(url::openStream) - .of(input -> { - Properties properties = new Properties(); - properties.load(input); - return properties; - }) - .toEither() - .mapLeft(e -> new RuntimeException("Unable to load settings file", e)) - .flatMap(properties -> { - Try.withResources(url::openStream) - .of(input -> { - properties.load(input); - properties.setProperty(key, value); - return null; - }) - .toEither() - .mapLeft(e -> new RuntimeException("Couldn't load settings", e)); - - Try.withResources(() -> Files.newOutputStream(Paths.get(url.getPath()))) - .of(output -> { - properties.store(output, null); - return null; - }) - .toEither() - .mapLeft(e -> new RuntimeException("Failed to save settings", e)); - - return Either.right(null); - }) - ); + @Override + public CommandResult runCommand() throws RuntimeException { + var url = SettingsFile.getUrl(); + + try ( + var inputStream = url.openStream(); + var outputStream = Files.newOutputStream(Paths.get(url.getPath())); + ) { + Properties properties = new Properties(); + properties.load(inputStream); + properties.setProperty(key, value); + properties.store(outputStream, null); + } catch (IOException e) { + throw new RuntimeException("Unable to set settings property", e); + } + + return CommandResult.empty(); } } \ No newline at end of file diff --git a/v2/src/main/java/dev/streamx/cli/v2/errors/ErrorPrinter.java b/v2/src/main/java/dev/streamx/cli/v2/errors/ErrorPrinter.java deleted file mode 100644 index 420d2873..00000000 --- a/v2/src/main/java/dev/streamx/cli/v2/errors/ErrorPrinter.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.streamx.cli.v2.errors; - -import org.jboss.logging.Logger; - -import java.io.PrintWriter; -import java.io.StringWriter; - -public class ErrorPrinter { - public static void print(Logger logger, Throwable e, boolean withStackTrace) { - if (withStackTrace) { - logger.error(getStackTraceAsString(e)); - return; - } - - logger.error(e.getMessage()); - } - - private static String getStackTraceAsString(Throwable throwable) { - StringWriter sw = new StringWriter(); - PrintWriter pw = new PrintWriter(sw); - throwable.printStackTrace(pw); - return sw.toString(); - } -} From ba687ec9ecddce64bf78c2132fb34a26b0280313 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Mon, 19 Jan 2026 10:18:10 +0300 Subject: [PATCH 08/29] Implement AbstractCommandGroup, AbstractSilentCommand --- v2/src/main/java/dev/streamx/cli/v2/Main.java | 8 +- .../streamx/cli/v2/cli/AbstractCommand.java | 22 ++- .../cli/v2/cli/AbstractCommandGroup.java | 20 +++ .../cli/v2/cli/AbstractSilentCommand.java | 11 ++ .../v2/commands/settings/SettingsCommand.java | 16 +- .../commands/settings/list/ListCommand.java | 154 ++++++++++-------- .../v2/commands/settings/set/SetCommand.java | 11 +- 7 files changed, 151 insertions(+), 91 deletions(-) create mode 100644 v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommandGroup.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java diff --git a/v2/src/main/java/dev/streamx/cli/v2/Main.java b/v2/src/main/java/dev/streamx/cli/v2/Main.java index a7ed1bc0..9f8b12f2 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/Main.java +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -1,5 +1,7 @@ package dev.streamx.cli.v2; +import dev.streamx.cli.v2.cli.AbstractCommand; +import dev.streamx.cli.v2.cli.CommandResult; import dev.streamx.cli.v2.cli.ShortErrorMessageHandler; import dev.streamx.cli.v2.commands.settings.SettingsCommand; import io.quarkus.picocli.runtime.annotations.TopCommand; @@ -14,15 +16,17 @@ SettingsCommand.class } ) -public class Main implements Runnable { +public class Main extends AbstractCommand { @CommandLine.Spec CommandLine.Model.CommandSpec commandSpec; @Override - public void run() { + public CommandResult runCommand() throws RuntimeException { commandSpec .commandLine() .setParameterExceptionHandler(new ShortErrorMessageHandler()) .usage(System.out); + + return CommandResult.empty(); } } 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 index c6519df0..910739d1 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java @@ -7,9 +7,9 @@ import java.io.StringWriter; import java.util.List; -// Each CLI command should extend this class +// Each CLI command should extend this class. public abstract class AbstractCommand implements Runnable { - // Override this method to implement the command logic + // Override this method to implement the command logic. public abstract CommandResult runCommand() throws RuntimeException; // Override this method to hide specific command line options. @@ -51,7 +51,25 @@ private void setSpec(CommandSpec spec) { ) private OutputFormat outputFormat; + private void validateSubcommands() { + for (CommandLine subcommand : spec.subcommands().values()) { + Object userObject = subcommand.getCommandSpec().userObject(); + if (!(userObject instanceof AbstractCommand)) { + throw new RuntimeException( + "All subcommands must extend AbstractCommand: " + + subcommand.getCommandName() + ); + } + } + } + + public void printUsage() { + spec.commandLine().usage(System.out); + } + public void run() { + validateSubcommands(); + try { var result = this.runCommand(); result.print(outputFormat); 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..93985f2b --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommandGroup.java @@ -0,0 +1,20 @@ +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 CommandResult.empty(); + } + + @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..a5c609cb --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java @@ -0,0 +1,11 @@ +package dev.streamx.cli.v2.cli; + +import java.util.List; + +// Extend this class for commands that doesn't print anything to stdout. +public abstract class AbstractSilentCommand extends AbstractCommand { + @Override + public List getHiddenOptions() { + return List.of(CommonOption.OUTPUT_LONG); + } +} 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 index 727c272b..7fcf3a37 100644 --- 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 @@ -1,7 +1,8 @@ package dev.streamx.cli.v2.commands.settings; -import dev.streamx.cli.v2.Main; +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; @@ -12,19 +13,10 @@ abbreviateSynopsis = true, synopsisHeading = "Synopsis example", subcommands = { -// ListCommand.class, + ListCommand.class, SetCommand.class, GetCommand.class } ) -public class SettingsCommand implements Runnable { - @CommandLine.Spec - CommandLine.Model.CommandSpec commandSpec; - - @CommandLine.ParentCommand - public Main mainCommand; - - public void run() { - commandSpec.commandLine().usage(System.out); - } +public class SettingsCommand extends AbstractCommandGroup { } 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 index 48462c40..aa10f0de 100644 --- 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 @@ -1,66 +1,88 @@ -//package dev.streamx.cli.v2.commands.settings.list; -// -//import dev.streamx.cli.v2.commands.settings.SettingsCommand; -//import dev.streamx.cli.v2.commands.settings.SettingsFile; -//import org.jboss.logging.Logger; -//import picocli.CommandLine; -// -//import java.net.URL; -//import java.util.*; -// -//@CommandLine.Command( -// name = "list", -// mixinStandardHelpOptions = true, -// description = "Display configuration properties" -//) -//public class ListCommand implements Runnable { -// private static final Logger logger = Logger.getLogger(ListCommand.class); -// -// @Override -// public void run() throws RuntimeException { -// printProperties(); -// } -// -// private Either> getProperties(URL url) { -// return Try.withResources(url::openStream) -// .of(input -> { -// Properties properties = new Properties(); -// properties.load(input); -// -// HashMap propertyMap = new HashMap<>(); -// for (String key : properties.stringPropertyNames()) { -// propertyMap.put(key, properties.getProperty(key)); -// } -// -// return propertyMap; -// }) -// .toEither().mapLeft(e -> new RuntimeException("Failed to load properties from " + url, e)); -// } -// -// private Either printProperties() { -// return SettingsFile.getUrl() -// .flatMap(this::getProperties) -// .flatMap(properties -> { -// Map sortedProperties = new TreeMap<>(properties); -// -// int maxKeyLength = sortedProperties.keySet().stream() -// .mapToInt(String::length) -// .max() -// .orElse(0); -// -// logger.info("\nConfiguration properties:"); -// String repeat = "=".repeat(Math.min(80, maxKeyLength + 40)); -// logger.info(repeat); -// -// sortedProperties.forEach((key, value) -> { -// String paddedKey = String.format("%-" + maxKeyLength + "s", key); -// logger.info(paddedKey + " = " + value); -// }); -// -// logger.info(repeat); -// logger.info("Total properties: " + properties.size()); -// -// return Either.right(null); -// }); -// } -//} +package dev.streamx.cli.v2.commands.settings.list; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.streamx.cli.v2.cli.AbstractCommand; +import dev.streamx.cli.v2.cli.CommandResult; +import dev.streamx.cli.v2.commands.settings.SettingsFile; +import org.jboss.logging.Logger; +import picocli.CommandLine; + +import java.net.URL; +import java.util.*; + +@CommandLine.Command( + name = "list", + mixinStandardHelpOptions = true, + description = "Display configuration properties" +) +public class ListCommand extends AbstractCommand { + private static final Logger logger = Logger.getLogger(ListCommand.class); + + @Override + public CommandResult runCommand() throws RuntimeException { + var url = SettingsFile.getUrl(); + var properties = getProperties(url); + + return new CommandResult( + Optional.of(convertToText(properties)), + Optional.of(convertToJson(properties).toString()) + ); + } + + private HashMap getProperties(URL url) throws RuntimeException { + try (var input = url.openStream()) { + Properties properties = new Properties(); + properties.load(input); + + HashMap propertyMap = new HashMap<>(); + for (String key : properties.stringPropertyNames()) { + propertyMap.put(key, properties.getProperty(key)); + } + + return propertyMap; + } catch (Exception e) { + throw new RuntimeException("Failed to load properties from " + url, e); + } + } + + public static JsonNode convertToJson(HashMap map) { + ObjectMapper mapper = new ObjectMapper(); + ArrayNode arrayNode = mapper.createArrayNode(); + + for (Map.Entry entry : map.entrySet()) { + ObjectNode objectNode = mapper.createObjectNode(); + objectNode.put("key", entry.getKey()); + objectNode.put("value", entry.getValue()); + arrayNode.add(objectNode); + } + + return arrayNode; + } + + private String convertToText(HashMap properties) { + StringBuilder result = new StringBuilder(); + Map sortedProperties = new TreeMap<>(properties); + + int maxKeyLength = sortedProperties.keySet().stream() + .mapToInt(String::length) + .max() + .orElse(0); + + result.append("\nConfiguration properties:\n"); + String repeat = "=".repeat(Math.min(80, maxKeyLength + 40)); + result.append(repeat).append("\n"); + + for (Map.Entry entry : sortedProperties.entrySet()) { + String paddedKey = String.format("%-" + maxKeyLength + "s", entry.getKey()); + result.append(paddedKey).append(" = ").append(entry.getValue()).append("\n"); + } + + result.append(repeat).append("\n"); + result.append("Total properties: ").append(properties.size()).append("\n"); + + return result.toString(); + } +} 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 index 605a02e4..d0e99a33 100644 --- 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 @@ -1,15 +1,13 @@ package dev.streamx.cli.v2.commands.settings.set; -import dev.streamx.cli.v2.cli.AbstractCommand; +import dev.streamx.cli.v2.cli.AbstractSilentCommand; import dev.streamx.cli.v2.cli.CommandResult; -import dev.streamx.cli.v2.cli.CommonOption; import dev.streamx.cli.v2.commands.settings.SettingsFile; 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; @CommandLine.Command( @@ -17,18 +15,13 @@ mixinStandardHelpOptions = true, description = "Set configuration property" ) -public class SetCommand extends AbstractCommand { +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 List getHiddenOptions() { - return List.of(CommonOption.OUTPUT_LONG); - } - @Override public CommandResult runCommand() throws RuntimeException { var url = SettingsFile.getUrl(); From 1abb64376bd36f95fbd219dbb5c9320008aa7ee1 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Mon, 19 Jan 2026 10:30:01 +0300 Subject: [PATCH 09/29] Use JsonNode in CommandOutput --- .../java/dev/streamx/cli/v2/cli/AbstractCommand.java | 1 + .../java/dev/streamx/cli/v2/cli/CommandResult.java | 10 ++++------ .../cli/v2/commands/settings/get/GetCommand.java | 7 ++++++- .../cli/v2/commands/settings/list/ListCommand.java | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) 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 index 910739d1..6b8f1d90 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java @@ -51,6 +51,7 @@ private void setSpec(CommandSpec spec) { ) private OutputFormat outputFormat; + // Force all subcommands to extend the AbstractCommand. private void validateSubcommands() { for (CommandLine subcommand : spec.subcommands().values()) { Object userObject = subcommand.getCommandSpec().userObject(); 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 index d3e99b42..37a35cb9 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java @@ -8,13 +8,13 @@ public class CommandResult { public Optional text; - public Optional json; + public Optional json; public static CommandResult empty() { return new CommandResult(Optional.empty(), Optional.empty()); } - public CommandResult(Optional text, Optional json) { + public CommandResult(Optional text, Optional json) { this.text = text; this.json = json; } @@ -31,8 +31,7 @@ public void print(OutputFormat outputFormat) throws Exception { } ObjectMapper mapper = new ObjectMapper(); - JsonNode jsonNode = mapper.readTree(json.get()); - System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); + System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(json.get())); return; } @@ -42,8 +41,7 @@ public void print(OutputFormat outputFormat) throws Exception { } ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); - JsonNode jsonNode = mapper.readTree(json.get()); - System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); + System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(json.get())); } } } 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 index 3ca2b0fa..6939dfe2 100644 --- 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 @@ -1,5 +1,7 @@ package dev.streamx.cli.v2.commands.settings.get; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import dev.streamx.cli.v2.cli.AbstractCommand; import dev.streamx.cli.v2.cli.CommandResult; import dev.streamx.cli.v2.commands.settings.SettingsFile; @@ -31,7 +33,10 @@ public CommandResult runCommand() throws RuntimeException { throw new RuntimeException("No such settings property found: " + key); } - var json = String.format("{\"key\": \"%s\", \"value\": \"%s\"}", key, value); + ObjectMapper mapper = new ObjectMapper(); + JsonNode json = mapper.createObjectNode() + .put("key", key) + .put("value", value); return new CommandResult(Optional.of(value), Optional.of(json)); } catch (IOException e) { 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 index aa10f0de..375e7788 100644 --- 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 @@ -28,7 +28,7 @@ public CommandResult runCommand() throws RuntimeException { return new CommandResult( Optional.of(convertToText(properties)), - Optional.of(convertToJson(properties).toString()) + Optional.of(convertToJson(properties)) ); } From f4695448acb743cd6d55ad9b0a02b6c966717ae0 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Mon, 19 Jan 2026 10:36:01 +0300 Subject: [PATCH 10/29] Rename AbstractSilentCommand -> AbstractPlainTextCommand --- ...stractSilentCommand.java => AbstractPlainTextCommand.java} | 4 ++-- .../dev/streamx/cli/v2/commands/settings/set/SetCommand.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename v2/src/main/java/dev/streamx/cli/v2/cli/{AbstractSilentCommand.java => AbstractPlainTextCommand.java} (51%) diff --git a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractPlainTextCommand.java similarity index 51% rename from v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java rename to v2/src/main/java/dev/streamx/cli/v2/cli/AbstractPlainTextCommand.java index a5c609cb..4119b1de 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractPlainTextCommand.java @@ -2,8 +2,8 @@ import java.util.List; -// Extend this class for commands that doesn't print anything to stdout. -public abstract class AbstractSilentCommand extends AbstractCommand { +// Extend this class for commands that doesn't provide output formatting options. +public abstract class AbstractPlainTextCommand extends AbstractCommand { @Override public List getHiddenOptions() { return List.of(CommonOption.OUTPUT_LONG); 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 index d0e99a33..29749d92 100644 --- 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 @@ -1,6 +1,6 @@ package dev.streamx.cli.v2.commands.settings.set; -import dev.streamx.cli.v2.cli.AbstractSilentCommand; +import dev.streamx.cli.v2.cli.AbstractPlainTextCommand; import dev.streamx.cli.v2.cli.CommandResult; import dev.streamx.cli.v2.commands.settings.SettingsFile; import picocli.CommandLine; @@ -15,7 +15,7 @@ mixinStandardHelpOptions = true, description = "Set configuration property" ) -public class SetCommand extends AbstractSilentCommand { +public class SetCommand extends AbstractPlainTextCommand { @CommandLine.Parameters(index = "0", description = "Property key") private String key; From 0cc821736da550ff84d9f26d2120478bbd87984c Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Mon, 19 Jan 2026 11:21:24 +0300 Subject: [PATCH 11/29] Use AbstractCommandGroup for Main --- v2/src/main/java/dev/streamx/cli/v2/Main.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/v2/src/main/java/dev/streamx/cli/v2/Main.java b/v2/src/main/java/dev/streamx/cli/v2/Main.java index 9f8b12f2..e63d3c68 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/Main.java +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -1,6 +1,7 @@ package dev.streamx.cli.v2; import dev.streamx.cli.v2.cli.AbstractCommand; +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.settings.SettingsCommand; @@ -16,7 +17,7 @@ SettingsCommand.class } ) -public class Main extends AbstractCommand { +public class Main extends AbstractCommandGroup { @CommandLine.Spec CommandLine.Model.CommandSpec commandSpec; From 4de179159bde39115f50e7d7658f1ed2301b1690 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Mon, 19 Jan 2026 11:21:35 +0300 Subject: [PATCH 12/29] Use AbstractCommandGroup for Main --- v2/src/main/java/dev/streamx/cli/v2/Main.java | 1 - 1 file changed, 1 deletion(-) diff --git a/v2/src/main/java/dev/streamx/cli/v2/Main.java b/v2/src/main/java/dev/streamx/cli/v2/Main.java index e63d3c68..6dfa705b 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/Main.java +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -1,6 +1,5 @@ package dev.streamx.cli.v2; -import dev.streamx.cli.v2.cli.AbstractCommand; import dev.streamx.cli.v2.cli.AbstractCommandGroup; import dev.streamx.cli.v2.cli.CommandResult; import dev.streamx.cli.v2.cli.ShortErrorMessageHandler; From 94d3a8844bd3ea96db0d2fa4f8c97fbd3a9874c8 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Mon, 19 Jan 2026 12:37:21 +0300 Subject: [PATCH 13/29] Remove unused code --- .../dev/streamx/cli/v2/commands/settings/list/ListCommand.java | 2 -- 1 file changed, 2 deletions(-) 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 index 375e7788..276e7087 100644 --- 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 @@ -19,8 +19,6 @@ description = "Display configuration properties" ) public class ListCommand extends AbstractCommand { - private static final Logger logger = Logger.getLogger(ListCommand.class); - @Override public CommandResult runCommand() throws RuntimeException { var url = SettingsFile.getUrl(); From 53b7392af6496845f2ab905a595336775cd285a2 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Mon, 19 Jan 2026 16:35:08 +0300 Subject: [PATCH 14/29] Reimplement CommandResult; Use getTextOutput override to provide human-readable result --- v2/src/main/java/dev/streamx/cli/v2/Main.java | 4 +- .../streamx/cli/v2/cli/AbstractCommand.java | 28 ++++++- .../cli/v2/cli/AbstractCommandGroup.java | 6 +- ...ommand.java => AbstractSilentCommand.java} | 2 +- .../dev/streamx/cli/v2/cli/CommandResult.java | 41 ++++------ .../v2/commands/settings/get/GetCommand.java | 18 ++--- .../settings/get/GetCommandResult.java | 7 ++ .../commands/settings/list/ListCommand.java | 77 +++++++------------ .../v2/commands/settings/list/Property.java | 4 + .../v2/commands/settings/set/SetCommand.java | 8 +- v2/src/main/resources/application.properties | 7 ++ 11 files changed, 103 insertions(+), 99 deletions(-) rename v2/src/main/java/dev/streamx/cli/v2/cli/{AbstractPlainTextCommand.java => AbstractSilentCommand.java} (76%) create mode 100644 v2/src/main/java/dev/streamx/cli/v2/commands/settings/get/GetCommandResult.java create mode 100644 v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/Property.java create mode 100644 v2/src/main/resources/application.properties diff --git a/v2/src/main/java/dev/streamx/cli/v2/Main.java b/v2/src/main/java/dev/streamx/cli/v2/Main.java index 6dfa705b..4a3338b2 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/Main.java +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -21,12 +21,12 @@ public class Main extends AbstractCommandGroup { CommandLine.Model.CommandSpec commandSpec; @Override - public CommandResult runCommand() throws RuntimeException { + public CommandResult runCommand() throws RuntimeException { commandSpec .commandLine() .setParameterExceptionHandler(new ShortErrorMessageHandler()) .usage(System.out); - return CommandResult.empty(); + 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 index 6b8f1d90..64f928f5 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java @@ -1,16 +1,23 @@ package dev.streamx.cli.v2.cli; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import picocli.CommandLine; import picocli.CommandLine.Model.CommandSpec; import java.io.PrintWriter; import java.io.StringWriter; import java.util.List; +import java.util.Optional; -// Each CLI command should extend this class. -public abstract class AbstractCommand implements Runnable { +/** + 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; + 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. @@ -18,6 +25,14 @@ public List getHiddenOptions() { return List.of(); } + // Override this method to provide human-readable output. + public Optional getTextOutput(CommandResult result) throws RuntimeException { + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.valueToTree(result.result); + + return Optional.of(jsonNode.toPrettyString()); + } + private void applyHiddenOptions() { var options = getHiddenOptions(); @@ -73,7 +88,12 @@ public void run() { try { var result = this.runCommand(); - result.print(outputFormat); + + if (outputFormat == OutputFormat.text) { + this.getTextOutput(result).ifPresent(System.out::println); + } else { + result.print(outputFormat); + } } catch (Exception e) { int exitCode = ShortErrorMessageHandler.shortErrorMessage(e, spec.commandLine()); if (verbose) { 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 index 93985f2b..8ecb7bac 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommandGroup.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommandGroup.java @@ -3,11 +3,11 @@ import java.util.List; // Extend this class for commands that don't do anything except display their subcommands. -public class AbstractCommandGroup extends AbstractCommand { +public class AbstractCommandGroup extends AbstractCommand { @Override - public CommandResult runCommand() { + public CommandResult runCommand() { this.printUsage(); - return CommandResult.empty(); + return new CommandResult<>(null); } @Override diff --git a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractPlainTextCommand.java b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java similarity index 76% rename from v2/src/main/java/dev/streamx/cli/v2/cli/AbstractPlainTextCommand.java rename to v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java index 4119b1de..e7f74802 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractPlainTextCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java @@ -3,7 +3,7 @@ import java.util.List; // Extend this class for commands that doesn't provide output formatting options. -public abstract class AbstractPlainTextCommand extends AbstractCommand { +public abstract class AbstractSilentCommand extends AbstractCommand { @Override public List getHiddenOptions() { return List.of(CommonOption.OUTPUT_LONG); 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 index 37a35cb9..bd07e3df 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java @@ -4,44 +4,29 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import java.util.Optional; - -public class CommandResult { - public Optional text; - public Optional json; - - public static CommandResult empty() { - return new CommandResult(Optional.empty(), Optional.empty()); - } - - public CommandResult(Optional text, Optional json) { - this.text = text; - this.json = json; +/** + * @param Must be serializable by Jackson (POJO, JsonSerializable, etc.) + */ +public class CommandResult { + public ResultT result; + + public CommandResult(ResultT result) { + this.result = result; } public void print(OutputFormat outputFormat) throws Exception { - if (outputFormat == OutputFormat.text && text.isPresent()) { - System.out.println(text.get()); - return; - } - if (outputFormat == OutputFormat.json) { - if (json.isEmpty()) { - throw new RuntimeException("This command did not return any JSON output."); - } - ObjectMapper mapper = new ObjectMapper(); - System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(json.get())); + JsonNode jsonNode = mapper.valueToTree(result); + System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); return; } + System.out.println(outputFormat); if (outputFormat == OutputFormat.yaml) { - if (json.isEmpty()) { - throw new RuntimeException("This command did not return any YAML output."); - } - ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); - System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(json.get())); + JsonNode jsonNode = mapper.valueToTree(result); + System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); } } } 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 index 6939dfe2..358ee266 100644 --- 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 @@ -1,7 +1,5 @@ package dev.streamx.cli.v2.commands.settings.get; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import dev.streamx.cli.v2.cli.AbstractCommand; import dev.streamx.cli.v2.cli.CommandResult; import dev.streamx.cli.v2.commands.settings.SettingsFile; @@ -16,12 +14,12 @@ mixinStandardHelpOptions = true, description = "Get configuration property" ) -public class GetCommand extends AbstractCommand { +public class GetCommand extends AbstractCommand { @CommandLine.Parameters(index = "0", description = "Property key") private String key; @Override - public CommandResult runCommand() throws RuntimeException { + public CommandResult runCommand() throws RuntimeException { var url = SettingsFile.getUrl(); try (var inputStream = url.openStream()) { @@ -33,14 +31,16 @@ public CommandResult runCommand() throws RuntimeException { throw new RuntimeException("No such settings property found: " + key); } - ObjectMapper mapper = new ObjectMapper(); - JsonNode json = mapper.createObjectNode() - .put("key", key) - .put("value", value); + var result = new GetCommandResult(key, value); - return new CommandResult(Optional.of(value), Optional.of(json)); + return new CommandResult<>(result); } catch (IOException e) { throw new RuntimeException("Unable to get settings property", e); } } + + @Override + public Optional getTextOutput(CommandResult result) throws RuntimeException { + return Optional.of(result.result.value()); + } } \ 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 index 276e7087..5b31c04f 100644 --- 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 @@ -1,86 +1,67 @@ package dev.streamx.cli.v2.commands.settings.list; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import dev.streamx.cli.v2.cli.AbstractCommand; import dev.streamx.cli.v2.cli.CommandResult; import dev.streamx.cli.v2.commands.settings.SettingsFile; -import org.jboss.logging.Logger; 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 { +public class ListCommand extends AbstractCommand> { @Override - public CommandResult runCommand() throws RuntimeException { + public CommandResult> runCommand() throws RuntimeException { var url = SettingsFile.getUrl(); var properties = getProperties(url); - return new CommandResult( - Optional.of(convertToText(properties)), - Optional.of(convertToJson(properties)) - ); + return new CommandResult<>(properties); } - private HashMap getProperties(URL url) throws RuntimeException { - try (var input = url.openStream()) { - Properties properties = new Properties(); - properties.load(input); - - HashMap propertyMap = new HashMap<>(); - for (String key : properties.stringPropertyNames()) { - propertyMap.put(key, properties.getProperty(key)); - } - - return propertyMap; - } catch (Exception e) { - throw new RuntimeException("Failed to load properties from " + url, e); - } - } - - public static JsonNode convertToJson(HashMap map) { - ObjectMapper mapper = new ObjectMapper(); - ArrayNode arrayNode = mapper.createArrayNode(); - - for (Map.Entry entry : map.entrySet()) { - ObjectNode objectNode = mapper.createObjectNode(); - objectNode.put("key", entry.getKey()); - objectNode.put("value", entry.getValue()); - arrayNode.add(objectNode); - } + @Override + public Optional getTextOutput(CommandResult> result) throws RuntimeException { + StringBuilder stringOutput = new StringBuilder(); - return arrayNode; - } + Map map = result.result.stream() + .collect(Collectors.toMap(Property::key, Property::value)); - private String convertToText(HashMap properties) { - StringBuilder result = new StringBuilder(); - Map sortedProperties = new TreeMap<>(properties); + Map sortedProperties = new TreeMap<>(map); int maxKeyLength = sortedProperties.keySet().stream() .mapToInt(String::length) .max() .orElse(0); - result.append("\nConfiguration properties:\n"); + stringOutput.append("\nConfiguration properties:\n"); String repeat = "=".repeat(Math.min(80, maxKeyLength + 40)); - result.append(repeat).append("\n"); + stringOutput.append(repeat).append("\n"); for (Map.Entry entry : sortedProperties.entrySet()) { String paddedKey = String.format("%-" + maxKeyLength + "s", entry.getKey()); - result.append(paddedKey).append(" = ").append(entry.getValue()).append("\n"); + stringOutput.append(paddedKey).append(" = ").append(entry.getValue()).append("\n"); } - result.append(repeat).append("\n"); - result.append("Total properties: ").append(properties.size()).append("\n"); + stringOutput.append(repeat).append("\n"); + stringOutput.append("Total properties: ").append(result.result.size()).append("\n"); + + return Optional.of(stringOutput.toString()); + } - return result.toString(); + 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("Failed to load properties from " + url, 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 index 29749d92..9ba5d0c5 100644 --- 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 @@ -1,6 +1,6 @@ package dev.streamx.cli.v2.commands.settings.set; -import dev.streamx.cli.v2.cli.AbstractPlainTextCommand; +import dev.streamx.cli.v2.cli.AbstractSilentCommand; import dev.streamx.cli.v2.cli.CommandResult; import dev.streamx.cli.v2.commands.settings.SettingsFile; import picocli.CommandLine; @@ -15,7 +15,7 @@ mixinStandardHelpOptions = true, description = "Set configuration property" ) -public class SetCommand extends AbstractPlainTextCommand { +public class SetCommand extends AbstractSilentCommand { @CommandLine.Parameters(index = "0", description = "Property key") private String key; @@ -23,7 +23,7 @@ public class SetCommand extends AbstractPlainTextCommand { private String value; @Override - public CommandResult runCommand() throws RuntimeException { + public CommandResult runCommand() throws RuntimeException { var url = SettingsFile.getUrl(); try ( @@ -38,6 +38,6 @@ public CommandResult runCommand() throws RuntimeException { throw new RuntimeException("Unable to set settings property", e); } - return CommandResult.empty(); + return new CommandResult<>(null); } } \ 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 From 9fde28aef31e45f672c4a72c97d6ff430142efef Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Mon, 19 Jan 2026 16:35:53 +0300 Subject: [PATCH 15/29] Reimplement CommandResult; Use getTextOutput override to provide human-readable result --- .../cli/v2/commands/settings/get/GetCommand.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 358ee266..82245453 100644 --- 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 @@ -18,6 +18,11 @@ public class GetCommand extends AbstractCommand { @CommandLine.Parameters(index = "0", description = "Property key") private String key; + @Override + public Optional getTextOutput(CommandResult result) throws RuntimeException { + return Optional.of(result.result.value()); + } + @Override public CommandResult runCommand() throws RuntimeException { var url = SettingsFile.getUrl(); @@ -38,9 +43,4 @@ public CommandResult runCommand() throws RuntimeException { throw new RuntimeException("Unable to get settings property", e); } } - - @Override - public Optional getTextOutput(CommandResult result) throws RuntimeException { - return Optional.of(result.result.value()); - } } \ No newline at end of file From d3d4eb9a3d6117415e2ada6f95739b278cd2de5c Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Mon, 19 Jan 2026 16:37:18 +0300 Subject: [PATCH 16/29] Reimplement CommandResult; Use getTextOutput override to provide human-readable result --- .../v2/commands/settings/list/ListCommand.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 index 5b31c04f..7f8b63c4 100644 --- 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 @@ -15,14 +15,6 @@ description = "Display configuration properties" ) public class ListCommand extends AbstractCommand> { - @Override - public CommandResult> runCommand() throws RuntimeException { - var url = SettingsFile.getUrl(); - var properties = getProperties(url); - - return new CommandResult<>(properties); - } - @Override public Optional getTextOutput(CommandResult> result) throws RuntimeException { StringBuilder stringOutput = new StringBuilder(); @@ -52,6 +44,14 @@ public Optional getTextOutput(CommandResult> result) thro return Optional.of(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(); From 31603f4e1badce9712139d76ca492b5288a54b1f Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Mon, 19 Jan 2026 16:48:42 +0300 Subject: [PATCH 17/29] Remove redundant code --- v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java | 1 - 1 file changed, 1 deletion(-) 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 index bd07e3df..cfca3ff9 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java @@ -22,7 +22,6 @@ public void print(OutputFormat outputFormat) throws Exception { return; } - System.out.println(outputFormat); if (outputFormat == OutputFormat.yaml) { ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); JsonNode jsonNode = mapper.valueToTree(result); From b387cd60c51bf3af08b8813bdfc33f69dfd8ebf6 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Tue, 20 Jan 2026 09:14:39 +0300 Subject: [PATCH 18/29] Use MessageProvider for exception messages --- v2/pom.xml | 15 +++++++++ .../cli/v2/cli/ShortErrorMessageHandler.java | 4 ++- .../v2/commands/settings/SettingsFile.java | 4 ++- .../v2/commands/settings/get/GetCommand.java | 7 +++-- .../commands/settings/list/ListCommand.java | 3 +- .../v2/commands/settings/set/SetCommand.java | 5 ++- .../streamx/cli/v2/i18n/MessageProvider.java | 31 +++++++++++++++++++ 7 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java diff --git a/v2/pom.xml b/v2/pom.xml index 152f21e4..8eb1c0d4 100644 --- a/v2/pom.xml +++ b/v2/pom.xml @@ -42,6 +42,21 @@ 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 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 index de19f630..c45a1361 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/ShortErrorMessageHandler.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/ShortErrorMessageHandler.java @@ -9,6 +9,8 @@ import picocli.CommandLine.ParameterException; import picocli.CommandLine.UnmatchedArgumentException; +import static dev.streamx.cli.v2.i18n.MessageProvider.msg; + public class ShortErrorMessageHandler implements IParameterExceptionHandler { @Override @@ -28,7 +30,7 @@ static int shortErrorMessage(Exception ex, CommandLine cmd) { if (ex instanceof ParameterException || ex instanceof IllegalArgumentException) { CommandSpec spec = cmd.getCommandSpec(); - writer.printf("Try '%s%s' for more information on the available options.%n", spec.qualifiedName(), "help".equals(spec.name()) ? "" : " --help"); + writer.printf(msg.tryForMoreInformationOnAvailableOptions(spec.qualifiedName(), "help".equals(spec.name()) ? "" : " --help")); return cmd.getCommandSpec().exitCodeOnInvalidInput(); } return cmd.getCommandSpec().exitCodeOnExecutionException(); 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 index c6b9714d..b5421afd 100644 --- 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 @@ -1,5 +1,7 @@ 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; @@ -15,7 +17,7 @@ public static URL getUrl() throws RuntimeException { try { return pathToFile.toUri().toURL(); } catch (MalformedURLException e) { - throw new RuntimeException("Unable to get StreamX settings path", 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 index 82245453..4c714bb0 100644 --- 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 @@ -3,6 +3,9 @@ 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; @@ -33,14 +36,14 @@ public CommandResult runCommand() throws RuntimeException { var value = properties.getProperty(key); if (value == null) { - throw new RuntimeException("No such settings property found: " + key); + throw new RuntimeException(msg.noSettingsPropertyFound(key)); } var result = new GetCommandResult(key, value); return new CommandResult<>(result); } catch (IOException e) { - throw new RuntimeException("Unable to get settings property", 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/list/ListCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/list/ListCommand.java index 7f8b63c4..0c663551 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -61,7 +62,7 @@ private List getProperties(URL url) throws RuntimeException { .map(key -> new Property(key, properties.getProperty(key))) .toList(); } catch (Exception e) { - throw new RuntimeException("Failed to load properties from " + url, e); + throw new RuntimeException(msg.failedToLoadPropertiesFrom(url.getPath()), e); } } } 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 index 9ba5d0c5..89c06f79 100644 --- 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 @@ -3,6 +3,9 @@ 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; @@ -35,7 +38,7 @@ public CommandResult runCommand() throws RuntimeException { properties.setProperty(key, value); properties.store(outputStream, null); } catch (IOException e) { - throw new RuntimeException("Unable to set settings property", e); + throw new RuntimeException(msg.unableToSetSettingsProperty(), e); } return new CommandResult<>(null); 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..eefd8cae --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java @@ -0,0 +1,31 @@ +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(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); +} \ No newline at end of file From e6b9cd25db888ca80101605f7e3f396cd584a471 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Tue, 20 Jan 2026 09:35:37 +0300 Subject: [PATCH 19/29] Move validation that each command extends AbstractCommand to tests --- v2/pom.xml | 7 +++ .../streamx/cli/v2/cli/AbstractCommand.java | 15 ------- .../streamx/cli/v2/i18n/MessageProvider.java | 2 +- .../java/dev/streamx/cli/v2/MainTest.java | 43 +++++++++++++++++++ 4 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 v2/src/test/java/dev/streamx/cli/v2/MainTest.java diff --git a/v2/pom.xml b/v2/pom.xml index 8eb1c0d4..702294f9 100644 --- a/v2/pom.xml +++ b/v2/pom.xml @@ -81,6 +81,13 @@ quarkus-junit5 test + + + org.assertj + assertj-core + 3.27.5 + test + 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 index 64f928f5..3e9c968e 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java @@ -66,26 +66,11 @@ private void setSpec(CommandSpec spec) { ) private OutputFormat outputFormat; - // Force all subcommands to extend the AbstractCommand. - private void validateSubcommands() { - for (CommandLine subcommand : spec.subcommands().values()) { - Object userObject = subcommand.getCommandSpec().userObject(); - if (!(userObject instanceof AbstractCommand)) { - throw new RuntimeException( - "All subcommands must extend AbstractCommand: " + - subcommand.getCommandName() - ); - } - } - } - public void printUsage() { spec.commandLine().usage(System.out); } public void run() { - validateSubcommands(); - try { var result = this.runCommand(); 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 index eefd8cae..2691b6d1 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java +++ b/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java @@ -9,7 +9,7 @@ @MessageBundle(projectCode = "STREAMXCLI") public interface MessageProvider { - MessageProvider msg = Messages.getBundle(MessageProvider.class); + MessageProvider msg = Messages.getBundle(MethodHandles.lookup(), MessageProvider.class); @Message(id = 100, value = "No such settings property found: %s") String noSettingsPropertyFound(String key); 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 From 4de319d59a5c62e0a047668356664ad0f37cc107 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Tue, 20 Jan 2026 10:40:48 +0300 Subject: [PATCH 20/29] Add interactive command input example --- v2/pom.xml | 6 + v2/src/main/java/dev/streamx/cli/v2/Main.java | 4 +- .../interactive/InteractiveCommand.java | 106 ++++++++++++++++++ .../v2/commands/settings/set/SetCommand.java | 17 ++- .../streamx/cli/v2/i18n/MessageProvider.java | 3 + 5 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 v2/src/main/java/dev/streamx/cli/v2/commands/interactive/InteractiveCommand.java diff --git a/v2/pom.xml b/v2/pom.xml index 702294f9..d64e79aa 100644 --- a/v2/pom.xml +++ b/v2/pom.xml @@ -37,6 +37,12 @@ quarkus-picocli + + info.picocli + picocli-shell-jline3 + 4.7.5 + + io.quarkus quarkus-arc diff --git a/v2/src/main/java/dev/streamx/cli/v2/Main.java b/v2/src/main/java/dev/streamx/cli/v2/Main.java index 4a3338b2..7706a026 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/Main.java +++ b/v2/src/main/java/dev/streamx/cli/v2/Main.java @@ -3,6 +3,7 @@ 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; @@ -13,7 +14,8 @@ mixinStandardHelpOptions = true, description = "StreamX CLI. More info at https://streamx.dev", subcommands = { - SettingsCommand.class + SettingsCommand.class, + InteractiveCommand.class } ) public class Main extends AbstractCommandGroup { 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..74b48b33 --- /dev/null +++ b/v2/src/main/java/dev/streamx/cli/v2/commands/interactive/InteractiveCommand.java @@ -0,0 +1,106 @@ +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 jakarta.annotation.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 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 = 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 { + List existingKeys = List.of("streamx.mesh.url", "streamx.some.property", "streamx.mesh.auth.token"); + Completer completer = new StringsCompleter(existingKeys); + + return promptForInput("Enter property key (TAB for autocomplete): ", completer); + } + + private String promptForInput(String prompt, @Nullable Completer completer) throws IOException { + try (Terminal terminal = TerminalBuilder.builder().system(true).build()) { + LineReaderBuilder builder = LineReaderBuilder.builder() + .terminal(terminal); + + if (completer != null) { + builder.completer(completer); + } + + LineReader reader = builder.build(); + + return reader.readLine(prompt).strip(); + } + } + + 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/settings/set/SetCommand.java b/v2/src/main/java/dev/streamx/cli/v2/commands/settings/set/SetCommand.java index 89c06f79..8a1361f7 100644 --- 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 @@ -28,14 +28,19 @@ public class SetCommand extends AbstractSilentCommand { @Override public CommandResult runCommand() throws RuntimeException { var url = SettingsFile.getUrl(); + var path = Paths.get(url.getPath()); - try ( - var inputStream = url.openStream(); - var outputStream = Files.newOutputStream(Paths.get(url.getPath())); - ) { - Properties properties = new Properties(); + Properties properties = new Properties(); + + try (var inputStream = url.openStream()) { properties.load(inputStream); - properties.setProperty(key, value); + } 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); 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 index 2691b6d1..4c8bb4e7 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java +++ b/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java @@ -28,4 +28,7 @@ public interface MessageProvider { @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(); } \ No newline at end of file From 4cbeb8c3077a323129cb96c4c489fd31bca91568 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Tue, 20 Jan 2026 10:51:02 +0300 Subject: [PATCH 21/29] Add interactive command input example --- .../streamx/cli/v2/cli/AbstractCommand.java | 30 ++++++++++++++++++- .../interactive/InteractiveCommand.java | 24 ++------------- .../streamx/cli/v2/i18n/MessageProvider.java | 3 ++ 3 files changed, 34 insertions(+), 23 deletions(-) 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 index 3e9c968e..cb5382a2 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java @@ -2,16 +2,26 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; + +import static dev.streamx.cli.v2.i18n.MessageProvider.msg; + +import jakarta.annotation.Nullable; +import org.jline.reader.Completer; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +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. + * Each CLI command should extend this class. * * @param Must be serializable by Jackson (POJO, JsonSerializable, etc.) */ @@ -70,6 +80,24 @@ public void printUsage() { spec.commandLine().usage(System.out); } + // Use this method for asking user input in interactive commands. + public String promptForInput(String prompt, @Nullable Completer completer) throws RuntimeException { + try (Terminal terminal = TerminalBuilder.builder().system(true).build()) { + LineReaderBuilder builder = LineReaderBuilder.builder() + .terminal(terminal); + + if (completer != null) { + 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 void run() { try { var result = this.runCommand(); 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 index 74b48b33..c2803723 100644 --- 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 @@ -3,13 +3,8 @@ import dev.streamx.cli.v2.cli.AbstractSilentCommand; import dev.streamx.cli.v2.cli.CommandResult; import dev.streamx.cli.v2.commands.settings.SettingsFile; -import jakarta.annotation.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 java.io.IOException; @@ -49,7 +44,7 @@ public CommandResult runCommand() throws RuntimeException { } if (value == null || value.isBlank()) { - value = promptForInput("Enter property value: ", null); + value = this.promptForInput("Enter property value: ", null); } saveProperty(key, value); @@ -65,22 +60,7 @@ private String promptForKey() throws IOException { List existingKeys = List.of("streamx.mesh.url", "streamx.some.property", "streamx.mesh.auth.token"); Completer completer = new StringsCompleter(existingKeys); - return promptForInput("Enter property key (TAB for autocomplete): ", completer); - } - - private String promptForInput(String prompt, @Nullable Completer completer) throws IOException { - try (Terminal terminal = TerminalBuilder.builder().system(true).build()) { - LineReaderBuilder builder = LineReaderBuilder.builder() - .terminal(terminal); - - if (completer != null) { - builder.completer(completer); - } - - LineReader reader = builder.build(); - - return reader.readLine(prompt).strip(); - } + return this.promptForInput("Enter property key", completer); } private void saveProperty(String key, String value) { 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 index 4c8bb4e7..bddc9464 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java +++ b/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java @@ -31,4 +31,7 @@ public interface MessageProvider { @Message(id = 106, value = "Failed to read user input") String failedToReadUserInput(); + + @Message(id = 107, value = "Failed to handle interactive input") + String failedToHandleInteractiveInput(); } \ No newline at end of file From 3801495b262f7cfac64dd3bbb9b1caa0a4c1a28b Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Tue, 20 Jan 2026 10:54:39 +0300 Subject: [PATCH 22/29] Add interactive command input example --- .../main/java/dev/streamx/cli/v2/cli/AbstractCommand.java | 7 +++++-- .../cli/v2/commands/interactive/InteractiveCommand.java | 5 ++--- 2 files changed, 7 insertions(+), 5 deletions(-) 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 index cb5382a2..36eb8fae 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java @@ -9,6 +9,7 @@ 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; @@ -81,12 +82,14 @@ public void printUsage() { } // Use this method for asking user input in interactive commands. - public String promptForInput(String prompt, @Nullable Completer completer) throws RuntimeException { + public String promptForInput(String prompt, @Nullable List autocompleteOptions) throws RuntimeException { try (Terminal terminal = TerminalBuilder.builder().system(true).build()) { LineReaderBuilder builder = LineReaderBuilder.builder() .terminal(terminal); - if (completer != null) { + Completer completer = null; + if (autocompleteOptions != null) { + completer = new StringsCompleter(autocompleteOptions); builder.completer(completer); } 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 index c2803723..3b79edb4 100644 --- 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 @@ -57,10 +57,9 @@ public CommandResult runCommand() throws RuntimeException { } private String promptForKey() throws IOException { - List existingKeys = List.of("streamx.mesh.url", "streamx.some.property", "streamx.mesh.auth.token"); - Completer completer = new StringsCompleter(existingKeys); + var autocompleteOptions = List.of("streamx.mesh.url", "streamx.some.property", "streamx.mesh.auth.token"); - return this.promptForInput("Enter property key", completer); + return this.promptForInput("Enter property key", autocompleteOptions); } private void saveProperty(String key, String value) { From 930ff3f84579da209b173650c58f81255c307b34 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Tue, 20 Jan 2026 10:56:59 +0300 Subject: [PATCH 23/29] Add interactive command input example --- .../streamx/cli/v2/commands/interactive/InteractiveCommand.java | 1 - 1 file changed, 1 deletion(-) 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 index 3b79edb4..0bf73590 100644 --- 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 @@ -58,7 +58,6 @@ public CommandResult runCommand() throws RuntimeException { 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); } From ea5c7a016cb85433393e9752b724b8d69b3145db Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Tue, 20 Jan 2026 11:18:28 +0300 Subject: [PATCH 24/29] Better doc comment for AbstractSilentCommand --- .../main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e7f74802..a630d036 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java @@ -2,7 +2,7 @@ import java.util.List; -// Extend this class for commands that doesn't provide output formatting options. +// 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() { From 02fc64e473ea11f7cf9ede70174aec36fe783189 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Tue, 20 Jan 2026 19:38:53 +0300 Subject: [PATCH 25/29] Better CommandResult output handling --- v2/pom.xml | 7 ++++ .../streamx/cli/v2/cli/AbstractCommand.java | 25 +++++-------- .../dev/streamx/cli/v2/cli/CommandResult.java | 37 +++++++++++++------ .../commands/settings/list/ListCommand.java | 2 + .../streamx/cli/v2/i18n/MessageProvider.java | 3 ++ 5 files changed, 48 insertions(+), 26 deletions(-) diff --git a/v2/pom.xml b/v2/pom.xml index d64e79aa..d07a3a49 100644 --- a/v2/pom.xml +++ b/v2/pom.xml @@ -43,6 +43,13 @@ 4.7.5 + + org.jetbrains + annotations + 26.0.2-1 + provided + + io.quarkus quarkus-arc 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 index 36eb8fae..a7b883e8 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java @@ -1,11 +1,8 @@ package dev.streamx.cli.v2.cli; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - import static dev.streamx.cli.v2.i18n.MessageProvider.msg; -import jakarta.annotation.Nullable; +import org.jetbrains.annotations.Nullable; import org.jline.reader.Completer; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; @@ -38,10 +35,11 @@ public List getHiddenOptions() { // Override this method to provide human-readable output. public Optional getTextOutput(CommandResult result) throws RuntimeException { - ObjectMapper mapper = new ObjectMapper(); - JsonNode jsonNode = mapper.valueToTree(result.result); + if (result.result == null) { + return Optional.empty(); + } - return Optional.of(jsonNode.toPrettyString()); + return result.toText(OutputFormat.json, null); } private void applyHiddenOptions() { @@ -75,7 +73,8 @@ private void setSpec(CommandSpec spec) { description = "Specify output format: text, json, yaml", defaultValue = "text" ) - private OutputFormat outputFormat; + // Explicitly set default value here as a fallback for commands with the hidden output option. + private OutputFormat outputFormat = OutputFormat.text; public void printUsage() { spec.commandLine().usage(System.out); @@ -103,13 +102,9 @@ public String promptForInput(String prompt, @Nullable List autocompleteO public void run() { try { - var result = this.runCommand(); - - if (outputFormat == OutputFormat.text) { - this.getTextOutput(result).ifPresent(System.out::println); - } else { - result.print(outputFormat); - } + this.runCommand() + .toText(outputFormat, this::getTextOutput) + .ifPresent(System.out::println); } catch (Exception e) { int exitCode = ShortErrorMessageHandler.shortErrorMessage(e, spec.commandLine()); if (verbose) { 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 index cfca3ff9..b4e1d150 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java @@ -1,9 +1,15 @@ 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 java.util.Optional; +import java.util.function.Function; + /** * @param Must be serializable by Jackson (POJO, JsonSerializable, etc.) */ @@ -14,18 +20,27 @@ public CommandResult(ResultT result) { this.result = result; } - public void print(OutputFormat outputFormat) throws Exception { - if (outputFormat == OutputFormat.json) { - ObjectMapper mapper = new ObjectMapper(); - JsonNode jsonNode = mapper.valueToTree(result); - System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); - return; - } + public Optional toText(OutputFormat outputFormat, Function, Optional> 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 Optional.of(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); + } + case OutputFormat.yaml -> { + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + JsonNode jsonNode = mapper.valueToTree(result); + return Optional.of(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); + } + } - if (outputFormat == OutputFormat.yaml) { - ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); - JsonNode jsonNode = mapper.valueToTree(result); - System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); + throw new RuntimeException(msg.unsupportedOutputFormat()); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); } } } 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 index 0c663551..d90e96ab 100644 --- 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 @@ -3,7 +3,9 @@ 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; 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 index bddc9464..2e36eca0 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java +++ b/v2/src/main/java/dev/streamx/cli/v2/i18n/MessageProvider.java @@ -34,4 +34,7 @@ public interface MessageProvider { @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 From dd01f1fca2ac8609faf03e7d22e4982f3b6e4c26 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Wed, 21 Jan 2026 10:18:11 +0300 Subject: [PATCH 26/29] Add CommandResultTest --- .../streamx/cli/v2/cli/CommandResultTest.java | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 v2/src/test/java/dev/streamx/cli/v2/cli/CommandResultTest.java 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..6a5db042 --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/CommandResultTest.java @@ -0,0 +1,136 @@ +package dev.streamx.cli.v2.cli; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.function.Function; + +class CommandResultTest { + @Test + void toText_withTextFormat_shouldUseTextFormatter() { + var result = new ComplexTestObject( + new TestResultObject("example", 42, true), + new String[]{"a", "b"} + ); + var commandResult = new CommandResult<>(result); + + Function, Optional> textFormatter = + cr -> { + var text = """ + Name: %s + Value: %d + Total items: %d + """.formatted(cr.result.nested.name, cr.result.nested.value, cr.result.items.length); + + return Optional.of(text); + }; + + var output = commandResult.toText(OutputFormat.text, textFormatter); + + var expectedOutput = """ + Name: example + Value: 42 + Total items: 2 + """; + + assertTrue(output.isPresent()); + assertEquals(expectedOutput, output.get()); + } + + @Test + void toText_withJsonFormat_shouldReturnPrettyPrintedJson() { + var result = new ComplexTestObject( + new TestResultObject("example", 42, true), + new String[]{"a", "b"} + ); + var commandResult = new CommandResult<>(result); + + var output = commandResult.toText(OutputFormat.json, null); + + var expectedOutput = """ + { + "nested" : { + "name" : "example", + "value" : 42, + "active" : true + }, + "items" : [ "a", "b" ] + } + """.strip(); + + assertTrue(output.isPresent()); + assertEquals(expectedOutput, output.get()); + } + + @Test + void toText_withYamlFormat_shouldReturnYaml() { + var result = new ComplexTestObject( + new TestResultObject("example", 42, true), + new String[]{"a", "b"} + ); + var commandResult = new CommandResult<>(result); + + var output = commandResult.toText(OutputFormat.yaml, null); + + var expectedOutput = """ + --- + nested: + name: "example" + value: 42 + active: true + items: + - "a" + - "b" + """; + + assertTrue(output.isPresent()); + assertEquals(expectedOutput, output.get()); + } + + @Test + void toText_withNullResult_shouldHandleGracefully() { + CommandResult commandResult = new CommandResult<>(null); + + assertEquals("null", commandResult.toText(OutputFormat.json, null).get()); + assertEquals("--- null\n", commandResult.toText(OutputFormat.yaml, null).get()); + } + + @Test + void toText_shouldThrowRuntimeExceptionForUnserializableObject() { + UnserializableObject unserializable = new UnserializableObject(); + CommandResult commandResult = new CommandResult<>(unserializable); + + assertThrows(RuntimeException.class, () -> + commandResult.toText(OutputFormat.json, null) + ); + } + + static class TestResultObject { + public String name; + public int value; + public boolean active; + + public TestResultObject(String name, int value, boolean active) { + this.name = name; + this.value = value; + this.active = active; + } + } + + static class ComplexTestObject { + public TestResultObject nested; + public String[] items; + + public ComplexTestObject(TestResultObject nested, String[] items) { + this.nested = nested; + this.items = items; + } + } + + static class UnserializableObject { + // Object with circular reference to make it unserializable + public UnserializableObject self = this; + } +} \ No newline at end of file From 6b649913007d4576b463ed969e1698a631f3f16d Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Wed, 21 Jan 2026 16:12:36 +0300 Subject: [PATCH 27/29] Improve CommandResultTest --- v2/pom.xml | 7 + .../dev/streamx/cli/v2/cli/CommandResult.java | 8 +- .../cli/v2/cli/AbstractCommandTest.java | 352 ++++++++++++++++++ .../streamx/cli/v2/cli/CommandResultTest.java | 229 +++++++++--- .../v2/cli/testing/AbstractTestCommand.java | 52 +++ .../cli/v2/cli/testing/TestObject.java | 37 ++ .../v2/cli/testing/UnserializableObject.java | 6 + 7 files changed, 628 insertions(+), 63 deletions(-) create mode 100644 v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandTest.java create mode 100644 v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractTestCommand.java create mode 100644 v2/src/test/java/dev/streamx/cli/v2/cli/testing/TestObject.java create mode 100644 v2/src/test/java/dev/streamx/cli/v2/cli/testing/UnserializableObject.java diff --git a/v2/pom.xml b/v2/pom.xml index d07a3a49..dd8455db 100644 --- a/v2/pom.xml +++ b/v2/pom.xml @@ -101,6 +101,13 @@ 3.27.5 test + + + org.instancio + instancio-junit + 5.5.1 + test + 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 index b4e1d150..67e85059 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java @@ -6,6 +6,7 @@ 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.Optional; import java.util.function.Function; @@ -32,9 +33,12 @@ public Optional toText(OutputFormat outputFormat, Function { - ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + var yamlFactory = YAMLFactory.builder() + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .build(); + ObjectMapper mapper = new ObjectMapper(yamlFactory); JsonNode jsonNode = mapper.valueToTree(result); - return Optional.of(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); + return Optional.of(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode).strip()); } } 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..984c7468 --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandTest.java @@ -0,0 +1,352 @@ +//package dev.streamx.cli.v2.cli; +// +//import static org.junit.jupiter.api.Assertions.*; +// +//import dev.streamx.cli.v2.cli.testing.AbstractTestCommand; +//import org.junit.jupiter.api.AfterEach; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.Test; +//import picocli.CommandLine; +//import picocli.CommandLine.Model.CommandSpec; +// +//import java.io.ByteArrayOutputStream; +//import java.io.PrintStream; +//import java.lang.reflect.Field; +//import java.lang.reflect.Method; +//import java.util.Arrays; +//import java.util.List; +//import java.util.Optional; +// +//class AbstractCommandTest { +// private final ByteArrayOutputStream outStream = new ByteArrayOutputStream(); +// private final ByteArrayOutputStream errStream = new ByteArrayOutputStream(); +// +// @BeforeEach +// void redirectStreams() { +// System.setOut(new PrintStream(outStream)); +// System.setErr(new PrintStream(errStream)); +// } +// +// @AfterEach +// void restoreStreams() { +// System.setOut(System.out); +// System.setErr(System.err); +// } +// +// @Test +// void runCommand_Success() { +// CommandResult expectedResult = new CommandResult<>("test data"); +// testCommand.resultToReturn = expectedResult; +// +// CommandResult result = testCommand.runCommand(); +// +// assertNotNull(result); +// assertEquals("test data", result.result); +// } +// +// @Test +// void testRunCommand_ThrowsException() { +// RuntimeException expectedException = new RuntimeException("Test exception"); +// testCommand.exceptionToThrow = expectedException; +// +// RuntimeException thrown = assertThrows(RuntimeException.class, () -> testCommand.runCommand()); +// assertEquals("Test exception", thrown.getMessage()); +// } +// +// @Test +// void testGetHiddenOptions_DefaultEmpty() { +// List hiddenOptions = testCommand.getHiddenOptions(); +// +// assertNotNull(hiddenOptions); +// assertTrue(hiddenOptions.isEmpty()); +// } +// +// @Test +// void testGetHiddenOptions_CustomList() { +// List hiddenOptions = testCommandWithHidden.getHiddenOptions(); +// +// assertNotNull(hiddenOptions); +// assertEquals(2, hiddenOptions.size()); +// assertTrue(hiddenOptions.contains("--output")); +// assertTrue(hiddenOptions.contains("-o")); +// } +// +// @Test +// void testGetTextOutput_WithNullResult() { +// TestCommandWithNullResult command = new TestCommandWithNullResult(); +// CommandResult result = new CommandResult<>(null); +// +// Optional output = command.getTextOutput(result); +// +// assertTrue(output.isEmpty()); +// } +// +// @Test +// void testGetTextOutput_WithResultAsJson() { +// CommandResult result = new CommandResult<>("test data"); +// +// Optional output = testCommand.getTextOutput(result); +// +// // Default implementation uses JSON format +// assertTrue(output.isPresent()); +// String jsonOutput = output.get(); +// assertTrue(jsonOutput.contains("test data")); +// } +// +// @Test +// void testGetTextOutput_CustomImplementation() { +// TestCommandWithCustomOutput command = new TestCommandWithCustomOutput(); +// CommandResult result = new CommandResult<>("data"); +// +// Optional output = command.getTextOutput(result); +// +// assertTrue(output.isPresent()); +// assertEquals("Custom output: data", output.get()); +// } +// +// @Test +// void testGetTextOutput_WithComplexObject() { +// TestCommandWithComplexResult command = new TestCommandWithComplexResult(); +// CommandResult result = command.runCommand(); +// +// Optional output = command.getTextOutput(result); +// +// assertTrue(output.isPresent()); +// String jsonOutput = output.get(); +// assertTrue(jsonOutput.contains("test")); +// assertTrue(jsonOutput.contains("42")); +// } +// +// @Test +// void testPrintUsage_WithCommandLine() { +// CommandLine cmd = new CommandLine(testCommand); +// +// testCommand.printUsage(); +// +// String output = outStream.toString(); +// assertTrue(output.length() > 0); +// assertTrue(output.contains("Usage:") || output.contains("-v")); +// } +// +// @Test +// void testApplyHiddenOptions_ViaCommandLine() { +// CommandLine cmd = new CommandLine(testCommandWithHidden); +// CommandSpec spec = cmd.getCommandSpec(); +// +// assertNotNull(spec); +// } +// +// @Test +// void testPromptForInput_WithoutAutocomplete() { +// // Terminal interaction test - will fail without real terminal +// assertThrows(RuntimeException.class, () -> { +// testCommand.promptForInput("Enter value:", null); +// }); +// } +// +// @Test +// void testPromptForInput_WithAutocomplete() { +// List options = Arrays.asList("option1", "option2", "option3"); +// +// // Terminal interaction test - will fail without real terminal +// assertThrows(RuntimeException.class, () -> { +// testCommand.promptForInput("Select:", options); +// }); +// } +// +// @Test +// void testPromptForInput_NullPrompt() { +// // Test that null prompt is handled +// assertThrows(RuntimeException.class, () -> { +// testCommand.promptForInput(null, null); +// }); +// } +// +// @Test +// void testVerboseOption_DefaultFalse() throws Exception { +// Field verboseField = AbstractCommand.class.getDeclaredField("verbose"); +// verboseField.setAccessible(true); +// +// boolean verbose = (boolean) verboseField.get(testCommand); +// assertFalse(verbose); +// } +// +// @Test +// void testOutputFormatOption_DefaultText() throws Exception { +// Field outputFormatField = AbstractCommand.class.getDeclaredField("outputFormat"); +// outputFormatField.setAccessible(true); +// +// OutputFormat format = (OutputFormat) outputFormatField.get(testCommand); +// assertEquals(OutputFormat.text, format); +// } +// +// @Test +// void testCommandLineOptionsPresent() { +// CommandLine cmd = new CommandLine(testCommand); +// CommandSpec spec = cmd.getCommandSpec(); +// +// // Verify verbose option exists +// assertNotNull(spec.findOption("-v")); +// assertNotNull(spec.findOption("--verbose")); +// +// // Verify output option exists +// assertNotNull(spec.findOption("-o")); +// assertNotNull(spec.findOption("--output")); +// } +// +// @Test +// void testSetOutputFormatViaCommandLine() throws Exception { +// CommandLine cmd = new CommandLine(testCommand); +// +// // Parse command line with output format +// cmd.parseArgs("--output", "json"); +// +// Field outputFormatField = AbstractCommand.class.getDeclaredField("outputFormat"); +// outputFormatField.setAccessible(true); +// +// OutputFormat format = (OutputFormat) outputFormatField.get(testCommand); +// assertEquals(OutputFormat.json, format); +// } +// +// @Test +// void testSetVerboseViaCommandLine() throws Exception { +// CommandLine cmd = new CommandLine(testCommand); +// +// // Parse command line with verbose flag +// cmd.parseArgs("--verbose"); +// +// Field verboseField = AbstractCommand.class.getDeclaredField("verbose"); +// verboseField.setAccessible(true); +// +// boolean verbose = (boolean) verboseField.get(testCommand); +// assertTrue(verbose); +// } +// +// @Test +// void testSetSpec_InitializesCorrectly() throws Exception { +// CommandLine cmd = new CommandLine(testCommand); +// +// Method setSpecMethod = AbstractCommand.class.getDeclaredMethod("setSpec", CommandSpec.class); +// setSpecMethod.setAccessible(true); +// +// CommandSpec spec = cmd.getCommandSpec(); +// assertDoesNotThrow(() -> { +// try { +// setSpecMethod.invoke(testCommand, spec); +// } catch (Exception e) { +// throw new RuntimeException(e); +// } +// }); +// } +// +// @Test +// void testHiddenOptions_Integration() { +// CommandLine cmd = new CommandLine(testCommandWithHidden); +// CommandSpec spec = cmd.getCommandSpec(); +// +// assertNotNull(spec); +// +// List hiddenOptions = testCommandWithHidden.getHiddenOptions(); +// assertEquals(2, hiddenOptions.size()); +// } +// +// @Test +// void testDefaultCommandResult() { +// AbstractTestCommand command = new AbstractTestCommand(); +// CommandResult result = command.runCommand(); +// +// assertNotNull(result); +// assertEquals("test result", result.result); +// } +// +// @Test +// void testGetTextOutput_PreservesResultData() { +// CommandResult result = new CommandResult<>("important data"); +// +// Optional output = testCommand.getTextOutput(result); +// +// assertTrue(output.isPresent()); +// assertTrue(output.get().contains("important data")); +// } +// +// @Test +// void testCommandResultToText_TextFormat() { +// TestCommandWithCustomOutput command = new TestCommandWithCustomOutput(); +// CommandResult result = new CommandResult<>("test"); +// +// Optional textOutput = result.toText(OutputFormat.text, command::getTextOutput); +// +// assertTrue(textOutput.isPresent()); +// assertEquals("Custom output: test", textOutput.get()); +// } +// +// @Test +// void testCommandResultToText_JsonFormat() { +// CommandResult result = new CommandResult<>("test data"); +// +// Optional jsonOutput = result.toText(OutputFormat.json, null); +// +// assertTrue(jsonOutput.isPresent()); +// assertTrue(jsonOutput.get().contains("test data")); +// } +// +// @Test +// void testCommandResultToText_YamlFormat() { +// CommandResult result = new CommandResult<>("test data"); +// +// Optional yamlOutput = result.toText(OutputFormat.yaml, null); +// +// assertTrue(yamlOutput.isPresent()); +// assertTrue(yamlOutput.get().contains("test data")); +// } +// +// @Test +// void testCommandResultToText_ComplexObject() { +// CommandResult result = new CommandResult<>(new TestDataObject("example", 123)); +// +// Optional jsonOutput = result.toText(OutputFormat.json, null); +// +// assertTrue(jsonOutput.isPresent()); +// String json = jsonOutput.get(); +// assertTrue(json.contains("example")); +// assertTrue(json.contains("123")); +// } +// +// @Test +// void testShortOptionsWork() throws Exception { +// CommandLine cmd = new CommandLine(testCommand); +// +// cmd.parseArgs("-v", "-o", "yaml"); +// +// Field verboseField = AbstractCommand.class.getDeclaredField("verbose"); +// verboseField.setAccessible(true); +// assertTrue((boolean) verboseField.get(testCommand)); +// +// Field outputFormatField = AbstractCommand.class.getDeclaredField("outputFormat"); +// outputFormatField.setAccessible(true); +// assertEquals(OutputFormat.yaml, outputFormatField.get(testCommand)); +// } +// +// @Test +// void testCommandWithNullResultDoesNotPrintOutput() { +// TestCommandWithNullResult command = new TestCommandWithNullResult(); +// CommandResult result = command.runCommand(); +// +// Optional output = command.getTextOutput(result); +// +// assertTrue(output.isEmpty()); +// } +// +// @Test +// void testPrintUsageOutputsToSystemOut() { +// CommandLine cmd = new CommandLine(testCommand); +// +// testCommand.printUsage(); +// +// String output = outStream.toString(); +// assertFalse(output.isEmpty()); +// } +// +// record TestDataObject(String name, int value) { +// } +//} \ 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 index 6a5db042..b3f1fb0d 100644 --- a/v2/src/test/java/dev/streamx/cli/v2/cli/CommandResultTest.java +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/CommandResultTest.java @@ -2,27 +2,48 @@ 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.Optional; import java.util.function.Function; class CommandResultTest { @Test void toText_withTextFormat_shouldUseTextFormatter() { - var result = new ComplexTestObject( - new TestResultObject("example", 42, true), - new String[]{"a", "b"} + var result = new TestObject( + null, + true, + 100500, + 42.42, + "Test string", + null, + List.of(TestObject.random(), TestObject.random()) ); + var commandResult = new CommandResult<>(result); - Function, Optional> textFormatter = + Function, Optional> textFormatter = cr -> { var text = """ - Name: %s - Value: %d - Total items: %d - """.formatted(cr.result.nested.name, cr.result.nested.value, cr.result.items.length); + Void Value: %s + Boolean Value: %b + Long Value: %d + Float Value: %.2f + String Value: %s + Nested Object: %s + Total Nested Objects: %d + """.formatted( + cr.result.voidValue, + cr.result.booleanValue, + cr.result.longValue, + cr.result.floatValue, + cr.result.stringValue, + cr.result.nestedObject, + cr.result.nestedObjects.size() + ); return Optional.of(text); }; @@ -30,9 +51,13 @@ void toText_withTextFormat_shouldUseTextFormatter() { var output = commandResult.toText(OutputFormat.text, textFormatter); var expectedOutput = """ - Name: example - Value: 42 - Total items: 2 + Void Value: null + Boolean Value: true + Long Value: 100500 + Float Value: 42.42 + String Value: Test string + Nested Object: null + Total Nested Objects: 2 """; assertTrue(output.isPresent()); @@ -41,22 +66,79 @@ void toText_withTextFormat_shouldUseTextFormatter() { @Test void toText_withJsonFormat_shouldReturnPrettyPrintedJson() { - var result = new ComplexTestObject( - new TestResultObject("example", 42, true), - new String[]{"a", "b"} + var 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 + ) + ) ); var commandResult = new CommandResult<>(result); var output = commandResult.toText(OutputFormat.json, null); var expectedOutput = """ - { - "nested" : { - "name" : "example", - "value" : 42, - "active" : true + { + "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 }, - "items" : [ "a", "b" ] + "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(); @@ -66,24 +148,76 @@ void toText_withJsonFormat_shouldReturnPrettyPrintedJson() { @Test void toText_withYamlFormat_shouldReturnYaml() { - var result = new ComplexTestObject( - new TestResultObject("example", 42, true), - new String[]{"a", "b"} + var 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 + ) + ) ); var commandResult = new CommandResult<>(result); var output = commandResult.toText(OutputFormat.yaml, null); var expectedOutput = """ - --- - nested: - name: "example" - value: 42 - active: true - items: - - "a" - - "b" - """; + 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(); assertTrue(output.isPresent()); assertEquals(expectedOutput, output.get()); @@ -91,10 +225,10 @@ void toText_withYamlFormat_shouldReturnYaml() { @Test void toText_withNullResult_shouldHandleGracefully() { - CommandResult commandResult = new CommandResult<>(null); + CommandResult commandResult = new CommandResult<>(null); assertEquals("null", commandResult.toText(OutputFormat.json, null).get()); - assertEquals("--- null\n", commandResult.toText(OutputFormat.yaml, null).get()); + assertEquals("null", commandResult.toText(OutputFormat.yaml, null).get()); } @Test @@ -106,31 +240,4 @@ void toText_shouldThrowRuntimeExceptionForUnserializableObject() { commandResult.toText(OutputFormat.json, null) ); } - - static class TestResultObject { - public String name; - public int value; - public boolean active; - - public TestResultObject(String name, int value, boolean active) { - this.name = name; - this.value = value; - this.active = active; - } - } - - static class ComplexTestObject { - public TestResultObject nested; - public String[] items; - - public ComplexTestObject(TestResultObject nested, String[] items) { - this.nested = nested; - this.items = items; - } - } - - static class UnserializableObject { - // Object with circular reference to make it unserializable - public UnserializableObject self = this; - } } \ No newline at end of file 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..eec7c181 --- /dev/null +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractTestCommand.java @@ -0,0 +1,52 @@ +package dev.streamx.cli.v2.cli.testing; + +import dev.streamx.cli.v2.cli.AbstractCommand; +import dev.streamx.cli.v2.cli.CommandResult; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +// Helper class for testing AbstractCommand and it's subclasses +class AbstractTestCommand extends AbstractCommand { + private Supplier> runCommandHandler; + private Supplier> hiddenOptionsHandler; + private Function, Optional> getTextOutputHandler; + + public void setRunCommandHandler(Supplier> handler) { + this.runCommandHandler = handler; + } + + public void setHiddenOptionsHandler(Supplier> handler) { + this.hiddenOptionsHandler = handler; + } + + public void setGetTextOutputHandler(Function, Optional> 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 Optional getTextOutput(CommandResult result) throws RuntimeException { + if (getTextOutputHandler != null) { + return getTextOutputHandler.apply(result); + } + return super.getTextOutput(result); + } +} 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 From 9368efd6fbf96492c74441693a7a496ee3056f79 Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Thu, 22 Jan 2026 11:16:18 +0300 Subject: [PATCH 28/29] Add tests --- .../streamx/cli/v2/cli/AbstractCommand.java | 43 +- .../cli/v2/cli/AbstractCommandGroup.java | 5 + .../cli/v2/cli/AbstractSilentCommand.java | 5 + .../dev/streamx/cli/v2/cli/CommandResult.java | 7 +- .../v2/commands/settings/get/GetCommand.java | 4 +- .../commands/settings/list/ListCommand.java | 4 +- .../cli/AbstractCommandOutputOptionTest.java | 239 ++++++++++ .../cli/v2/cli/AbstractCommandTest.java | 428 ++++-------------- .../cli/AbstractCommandVerboseOptionTest.java | 37 ++ .../cli/v2/cli/AbstractSilentCommandTest.java | 28 ++ .../streamx/cli/v2/cli/CommandResultTest.java | 197 +++----- .../cli/testing/AbstractCommandBaseTest.java | 27 ++ .../testing/AbstractSilentTestCommand.java | 38 ++ .../v2/cli/testing/AbstractTestCommand.java | 27 +- 14 files changed, 578 insertions(+), 511 deletions(-) create mode 100644 v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandOutputOptionTest.java create mode 100644 v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandVerboseOptionTest.java create mode 100644 v2/src/test/java/dev/streamx/cli/v2/cli/AbstractSilentCommandTest.java create mode 100644 v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractCommandBaseTest.java create mode 100644 v2/src/test/java/dev/streamx/cli/v2/cli/testing/AbstractSilentTestCommand.java 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 index a7b883e8..1b2e01ee 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommand.java @@ -34,11 +34,7 @@ public List getHiddenOptions() { } // Override this method to provide human-readable output. - public Optional getTextOutput(CommandResult result) throws RuntimeException { - if (result.result == null) { - return Optional.empty(); - } - + public String getTextOutput(CommandResult result) throws RuntimeException { return result.toText(OutputFormat.json, null); } @@ -54,10 +50,10 @@ private void applyHiddenOptions() { } @CommandLine.Spec - private CommandSpec spec; + public CommandSpec spec; @CommandLine.Spec - private void setSpec(CommandSpec spec) { + public void setSpec(CommandSpec spec) { this.spec = spec; applyHiddenOptions(); } @@ -66,7 +62,7 @@ private void setSpec(CommandSpec spec) { names = {CommonOption.VERBOSE_SHORT, CommonOption.VERBOSE_LONG}, description = "Print debug information" ) - private boolean verbose; + public boolean verbose; @CommandLine.Option( names = {CommonOption.OUTPUT_SHORT, CommonOption.OUTPUT_LONG}, @@ -74,15 +70,20 @@ private void setSpec(CommandSpec spec) { defaultValue = "text" ) // Explicitly set default value here as a fallback for commands with the hidden output option. - private OutputFormat outputFormat = OutputFormat.text; + 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 = TerminalBuilder.builder().system(true).build()) { + try (Terminal terminal = createTerminal()) { LineReaderBuilder builder = LineReaderBuilder.builder() .terminal(terminal); @@ -100,13 +101,16 @@ public String promptForInput(String prompt, @Nullable List autocompleteO } } - public void run() { + public int execute() { + int exitCode = 0; + try { - this.runCommand() - .toText(outputFormat, this::getTextOutput) - .ifPresent(System.out::println); + var textOutput = this.runCommand().toText(output, this::getTextOutput); + if (!textOutput.isEmpty()) { + System.out.println(textOutput); + } } catch (Exception e) { - int exitCode = ShortErrorMessageHandler.shortErrorMessage(e, spec.commandLine()); + exitCode = ShortErrorMessageHandler.shortErrorMessage(e, spec.commandLine()); if (verbose) { // Print exception stacktrace StringWriter sw = new StringWriter(); @@ -114,8 +118,13 @@ public void run() { e.printStackTrace(pw); System.err.println(sw); } - - System.exit(exitCode); } + + 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 index 8ecb7bac..aabfe6b2 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommandGroup.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractCommandGroup.java @@ -10,6 +10,11 @@ public CommandResult runCommand() { return new CommandResult<>(null); } + @Override + public String getTextOutput(CommandResult result) throws RuntimeException { + return ""; + } + @Override public List getHiddenOptions() { return List.of( 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 index a630d036..b61068f0 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/AbstractSilentCommand.java @@ -8,4 +8,9 @@ public abstract class AbstractSilentCommand extends AbstractCommand { 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 index 67e85059..f2cc050e 100644 --- a/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java +++ b/v2/src/main/java/dev/streamx/cli/v2/cli/CommandResult.java @@ -8,7 +8,6 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; -import java.util.Optional; import java.util.function.Function; /** @@ -21,7 +20,7 @@ public CommandResult(ResultT result) { this.result = result; } - public Optional toText(OutputFormat outputFormat, Function, Optional> textFormatter) throws RuntimeException { + public String toText(OutputFormat outputFormat, Function, String> textFormatter) throws RuntimeException { try { switch (outputFormat) { case OutputFormat.text -> { @@ -30,7 +29,7 @@ public Optional toText(OutputFormat outputFormat, Function { ObjectMapper mapper = new ObjectMapper(); JsonNode jsonNode = mapper.valueToTree(result); - return Optional.of(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode)); + return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode); } case OutputFormat.yaml -> { var yamlFactory = YAMLFactory.builder() @@ -38,7 +37,7 @@ public Optional toText(OutputFormat outputFormat, Function { private String key; @Override - public Optional getTextOutput(CommandResult result) throws RuntimeException { - return Optional.of(result.result.value()); + public String getTextOutput(CommandResult result) throws RuntimeException { + return result.result.value(); } @Override 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 index d90e96ab..15c771ce 100644 --- 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 @@ -19,7 +19,7 @@ ) public class ListCommand extends AbstractCommand> { @Override - public Optional getTextOutput(CommandResult> result) throws RuntimeException { + public String getTextOutput(CommandResult> result) throws RuntimeException { StringBuilder stringOutput = new StringBuilder(); Map map = result.result.stream() @@ -44,7 +44,7 @@ public Optional getTextOutput(CommandResult> result) thro stringOutput.append(repeat).append("\n"); stringOutput.append("Total properties: ").append(result.result.size()).append("\n"); - return Optional.of(stringOutput.toString()); + return stringOutput.toString(); } @Override 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 index 984c7468..bf436485 100644 --- a/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandTest.java +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/AbstractCommandTest.java @@ -1,352 +1,76 @@ -//package dev.streamx.cli.v2.cli; -// -//import static org.junit.jupiter.api.Assertions.*; -// -//import dev.streamx.cli.v2.cli.testing.AbstractTestCommand; -//import org.junit.jupiter.api.AfterEach; -//import org.junit.jupiter.api.BeforeEach; -//import org.junit.jupiter.api.Test; -//import picocli.CommandLine; -//import picocli.CommandLine.Model.CommandSpec; -// -//import java.io.ByteArrayOutputStream; -//import java.io.PrintStream; -//import java.lang.reflect.Field; -//import java.lang.reflect.Method; -//import java.util.Arrays; -//import java.util.List; -//import java.util.Optional; -// -//class AbstractCommandTest { -// private final ByteArrayOutputStream outStream = new ByteArrayOutputStream(); -// private final ByteArrayOutputStream errStream = new ByteArrayOutputStream(); -// -// @BeforeEach -// void redirectStreams() { -// System.setOut(new PrintStream(outStream)); -// System.setErr(new PrintStream(errStream)); -// } -// -// @AfterEach -// void restoreStreams() { -// System.setOut(System.out); -// System.setErr(System.err); -// } -// -// @Test -// void runCommand_Success() { -// CommandResult expectedResult = new CommandResult<>("test data"); -// testCommand.resultToReturn = expectedResult; -// -// CommandResult result = testCommand.runCommand(); -// -// assertNotNull(result); -// assertEquals("test data", result.result); -// } -// -// @Test -// void testRunCommand_ThrowsException() { -// RuntimeException expectedException = new RuntimeException("Test exception"); -// testCommand.exceptionToThrow = expectedException; -// -// RuntimeException thrown = assertThrows(RuntimeException.class, () -> testCommand.runCommand()); -// assertEquals("Test exception", thrown.getMessage()); -// } -// -// @Test -// void testGetHiddenOptions_DefaultEmpty() { -// List hiddenOptions = testCommand.getHiddenOptions(); -// -// assertNotNull(hiddenOptions); -// assertTrue(hiddenOptions.isEmpty()); -// } -// -// @Test -// void testGetHiddenOptions_CustomList() { -// List hiddenOptions = testCommandWithHidden.getHiddenOptions(); -// -// assertNotNull(hiddenOptions); -// assertEquals(2, hiddenOptions.size()); -// assertTrue(hiddenOptions.contains("--output")); -// assertTrue(hiddenOptions.contains("-o")); -// } -// -// @Test -// void testGetTextOutput_WithNullResult() { -// TestCommandWithNullResult command = new TestCommandWithNullResult(); -// CommandResult result = new CommandResult<>(null); -// -// Optional output = command.getTextOutput(result); -// -// assertTrue(output.isEmpty()); -// } -// -// @Test -// void testGetTextOutput_WithResultAsJson() { -// CommandResult result = new CommandResult<>("test data"); -// -// Optional output = testCommand.getTextOutput(result); -// -// // Default implementation uses JSON format -// assertTrue(output.isPresent()); -// String jsonOutput = output.get(); -// assertTrue(jsonOutput.contains("test data")); -// } -// -// @Test -// void testGetTextOutput_CustomImplementation() { -// TestCommandWithCustomOutput command = new TestCommandWithCustomOutput(); -// CommandResult result = new CommandResult<>("data"); -// -// Optional output = command.getTextOutput(result); -// -// assertTrue(output.isPresent()); -// assertEquals("Custom output: data", output.get()); -// } -// -// @Test -// void testGetTextOutput_WithComplexObject() { -// TestCommandWithComplexResult command = new TestCommandWithComplexResult(); -// CommandResult result = command.runCommand(); -// -// Optional output = command.getTextOutput(result); -// -// assertTrue(output.isPresent()); -// String jsonOutput = output.get(); -// assertTrue(jsonOutput.contains("test")); -// assertTrue(jsonOutput.contains("42")); -// } -// -// @Test -// void testPrintUsage_WithCommandLine() { -// CommandLine cmd = new CommandLine(testCommand); -// -// testCommand.printUsage(); -// -// String output = outStream.toString(); -// assertTrue(output.length() > 0); -// assertTrue(output.contains("Usage:") || output.contains("-v")); -// } -// -// @Test -// void testApplyHiddenOptions_ViaCommandLine() { -// CommandLine cmd = new CommandLine(testCommandWithHidden); -// CommandSpec spec = cmd.getCommandSpec(); -// -// assertNotNull(spec); -// } -// -// @Test -// void testPromptForInput_WithoutAutocomplete() { -// // Terminal interaction test - will fail without real terminal -// assertThrows(RuntimeException.class, () -> { -// testCommand.promptForInput("Enter value:", null); -// }); -// } -// -// @Test -// void testPromptForInput_WithAutocomplete() { -// List options = Arrays.asList("option1", "option2", "option3"); -// -// // Terminal interaction test - will fail without real terminal -// assertThrows(RuntimeException.class, () -> { -// testCommand.promptForInput("Select:", options); -// }); -// } -// -// @Test -// void testPromptForInput_NullPrompt() { -// // Test that null prompt is handled -// assertThrows(RuntimeException.class, () -> { -// testCommand.promptForInput(null, null); -// }); -// } -// -// @Test -// void testVerboseOption_DefaultFalse() throws Exception { -// Field verboseField = AbstractCommand.class.getDeclaredField("verbose"); -// verboseField.setAccessible(true); -// -// boolean verbose = (boolean) verboseField.get(testCommand); -// assertFalse(verbose); -// } -// -// @Test -// void testOutputFormatOption_DefaultText() throws Exception { -// Field outputFormatField = AbstractCommand.class.getDeclaredField("outputFormat"); -// outputFormatField.setAccessible(true); -// -// OutputFormat format = (OutputFormat) outputFormatField.get(testCommand); -// assertEquals(OutputFormat.text, format); -// } -// -// @Test -// void testCommandLineOptionsPresent() { -// CommandLine cmd = new CommandLine(testCommand); -// CommandSpec spec = cmd.getCommandSpec(); -// -// // Verify verbose option exists -// assertNotNull(spec.findOption("-v")); -// assertNotNull(spec.findOption("--verbose")); -// -// // Verify output option exists -// assertNotNull(spec.findOption("-o")); -// assertNotNull(spec.findOption("--output")); -// } -// -// @Test -// void testSetOutputFormatViaCommandLine() throws Exception { -// CommandLine cmd = new CommandLine(testCommand); -// -// // Parse command line with output format -// cmd.parseArgs("--output", "json"); -// -// Field outputFormatField = AbstractCommand.class.getDeclaredField("outputFormat"); -// outputFormatField.setAccessible(true); -// -// OutputFormat format = (OutputFormat) outputFormatField.get(testCommand); -// assertEquals(OutputFormat.json, format); -// } -// -// @Test -// void testSetVerboseViaCommandLine() throws Exception { -// CommandLine cmd = new CommandLine(testCommand); -// -// // Parse command line with verbose flag -// cmd.parseArgs("--verbose"); -// -// Field verboseField = AbstractCommand.class.getDeclaredField("verbose"); -// verboseField.setAccessible(true); -// -// boolean verbose = (boolean) verboseField.get(testCommand); -// assertTrue(verbose); -// } -// -// @Test -// void testSetSpec_InitializesCorrectly() throws Exception { -// CommandLine cmd = new CommandLine(testCommand); -// -// Method setSpecMethod = AbstractCommand.class.getDeclaredMethod("setSpec", CommandSpec.class); -// setSpecMethod.setAccessible(true); -// -// CommandSpec spec = cmd.getCommandSpec(); -// assertDoesNotThrow(() -> { -// try { -// setSpecMethod.invoke(testCommand, spec); -// } catch (Exception e) { -// throw new RuntimeException(e); -// } -// }); -// } -// -// @Test -// void testHiddenOptions_Integration() { -// CommandLine cmd = new CommandLine(testCommandWithHidden); -// CommandSpec spec = cmd.getCommandSpec(); -// -// assertNotNull(spec); -// -// List hiddenOptions = testCommandWithHidden.getHiddenOptions(); -// assertEquals(2, hiddenOptions.size()); -// } -// -// @Test -// void testDefaultCommandResult() { -// AbstractTestCommand command = new AbstractTestCommand(); -// CommandResult result = command.runCommand(); -// -// assertNotNull(result); -// assertEquals("test result", result.result); -// } -// -// @Test -// void testGetTextOutput_PreservesResultData() { -// CommandResult result = new CommandResult<>("important data"); -// -// Optional output = testCommand.getTextOutput(result); -// -// assertTrue(output.isPresent()); -// assertTrue(output.get().contains("important data")); -// } -// -// @Test -// void testCommandResultToText_TextFormat() { -// TestCommandWithCustomOutput command = new TestCommandWithCustomOutput(); -// CommandResult result = new CommandResult<>("test"); -// -// Optional textOutput = result.toText(OutputFormat.text, command::getTextOutput); -// -// assertTrue(textOutput.isPresent()); -// assertEquals("Custom output: test", textOutput.get()); -// } -// -// @Test -// void testCommandResultToText_JsonFormat() { -// CommandResult result = new CommandResult<>("test data"); -// -// Optional jsonOutput = result.toText(OutputFormat.json, null); -// -// assertTrue(jsonOutput.isPresent()); -// assertTrue(jsonOutput.get().contains("test data")); -// } -// -// @Test -// void testCommandResultToText_YamlFormat() { -// CommandResult result = new CommandResult<>("test data"); -// -// Optional yamlOutput = result.toText(OutputFormat.yaml, null); -// -// assertTrue(yamlOutput.isPresent()); -// assertTrue(yamlOutput.get().contains("test data")); -// } -// -// @Test -// void testCommandResultToText_ComplexObject() { -// CommandResult result = new CommandResult<>(new TestDataObject("example", 123)); -// -// Optional jsonOutput = result.toText(OutputFormat.json, null); -// -// assertTrue(jsonOutput.isPresent()); -// String json = jsonOutput.get(); -// assertTrue(json.contains("example")); -// assertTrue(json.contains("123")); -// } -// -// @Test -// void testShortOptionsWork() throws Exception { -// CommandLine cmd = new CommandLine(testCommand); -// -// cmd.parseArgs("-v", "-o", "yaml"); -// -// Field verboseField = AbstractCommand.class.getDeclaredField("verbose"); -// verboseField.setAccessible(true); -// assertTrue((boolean) verboseField.get(testCommand)); -// -// Field outputFormatField = AbstractCommand.class.getDeclaredField("outputFormat"); -// outputFormatField.setAccessible(true); -// assertEquals(OutputFormat.yaml, outputFormatField.get(testCommand)); -// } -// -// @Test -// void testCommandWithNullResultDoesNotPrintOutput() { -// TestCommandWithNullResult command = new TestCommandWithNullResult(); -// CommandResult result = command.runCommand(); -// -// Optional output = command.getTextOutput(result); -// -// assertTrue(output.isEmpty()); -// } -// -// @Test -// void testPrintUsageOutputsToSystemOut() { -// CommandLine cmd = new CommandLine(testCommand); -// -// testCommand.printUsage(); -// -// String output = outStream.toString(); -// assertFalse(output.isEmpty()); -// } -// -// record TestDataObject(String name, int value) { -// } -//} \ No newline at end of file +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 index b3f1fb0d..ded0c35c 100644 --- a/v2/src/test/java/dev/streamx/cli/v2/cli/CommandResultTest.java +++ b/v2/src/test/java/dev/streamx/cli/v2/cli/CommandResultTest.java @@ -7,105 +7,88 @@ import org.junit.jupiter.api.Test; import java.util.List; -import java.util.Optional; import java.util.function.Function; class CommandResultTest { - @Test - void toText_withTextFormat_shouldUseTextFormatter() { - var result = new TestObject( + private final TestObject result = new TestObject( + null, + true, + 100500, + 42.42, + "Test string", + new TestObject( null, - true, - 100500, - 42.42, - "Test string", + false, + 7, + 3.14, + "Nested object test string", null, - List.of(TestObject.random(), TestObject.random()) - ); - - var commandResult = new CommandResult<>(result); - - Function, Optional> textFormatter = - cr -> { - var text = """ - Void Value: %s - Boolean Value: %b - Long Value: %d - Float Value: %.2f - String Value: %s - Nested Object: %s - Total Nested Objects: %d - """.formatted( - cr.result.voidValue, - cr.result.booleanValue, - cr.result.longValue, - cr.result.floatValue, - cr.result.stringValue, - cr.result.nestedObject, - cr.result.nestedObjects.size() - ); - - return Optional.of(text); - }; - - var output = commandResult.toText(OutputFormat.text, textFormatter); + 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: null + Nested Object Long Value: 7 + Nested Object String Value: Nested object test string Total Nested Objects: 2 """; - assertTrue(output.isPresent()); - assertEquals(expectedOutput, output.get()); + 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 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 - ) - ) - ); - var commandResult = new CommandResult<>(result); - - var output = commandResult.toText(OutputFormat.json, null); - var expectedOutput = """ { "voidValue" : null, @@ -142,52 +125,13 @@ void toText_withJsonFormat_shouldReturnPrettyPrintedJson() { } """.strip(); - assertTrue(output.isPresent()); - assertEquals(expectedOutput, output.get()); + var output = commandResult.toText(OutputFormat.json, null); + + assertEquals(expectedOutput, output); } @Test void toText_withYamlFormat_shouldReturnYaml() { - var 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 - ) - ) - ); - var commandResult = new CommandResult<>(result); - - var output = commandResult.toText(OutputFormat.yaml, null); - var expectedOutput = """ voidValue: null booleanValue: true @@ -219,16 +163,17 @@ void toText_withYamlFormat_shouldReturnYaml() { nestedObjects: null """.strip(); - assertTrue(output.isPresent()); - assertEquals(expectedOutput, output.get()); + 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).get()); - assertEquals("null", commandResult.toText(OutputFormat.yaml, null).get()); + assertEquals("null", commandResult.toText(OutputFormat.json, null)); + assertEquals("null", commandResult.toText(OutputFormat.yaml, null)); } @Test 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 index eec7c181..fab6a451 100644 --- 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 @@ -2,17 +2,19 @@ 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.Optional; import java.util.function.Function; import java.util.function.Supplier; -// Helper class for testing AbstractCommand and it's subclasses -class AbstractTestCommand extends AbstractCommand { - private Supplier> runCommandHandler; - private Supplier> hiddenOptionsHandler; - private Function, Optional> getTextOutputHandler; +// 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; @@ -22,7 +24,7 @@ public void setHiddenOptionsHandler(Supplier> handler) { this.hiddenOptionsHandler = handler; } - public void setGetTextOutputHandler(Function, Optional> handler) { + public void setGetTextOutputHandler(Function, String> handler) { this.getTextOutputHandler = handler; } @@ -43,10 +45,19 @@ public List getHiddenOptions() { } @Override - public Optional getTextOutput(CommandResult result) throws RuntimeException { + 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(); + } } From 015de89a385867f4ef633ece67cb9b051dc0019b Mon Sep 17 00:00:00 2001 From: Kiryl Valkovich Date: Thu, 22 Jan 2026 11:17:57 +0300 Subject: [PATCH 29/29] Rename mvn artifact --- v2/pom.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/v2/pom.xml b/v2/pom.xml index dd8455db..0c2bcd34 100644 --- a/v2/pom.xml +++ b/v2/pom.xml @@ -5,9 +5,9 @@ http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.example - quarkus-picocli-app - 1.0.0-SNAPSHOT + dev.streamx.cli.v2 + streamx-cli + 2.0.0-SNAPSHOT 21