diff --git a/.github/workflows/ci-test-build.yml b/.github/workflows/ci-test-build.yml new file mode 100644 index 00000000..54e92e05 --- /dev/null +++ b/.github/workflows/ci-test-build.yml @@ -0,0 +1,49 @@ +name: 'Test: CI build' + +on: + pull_request: + push: + branches: + - main + +permissions: + id-token: write + contents: read + +jobs: + linux-test-build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Run tests + run: | + cd ./streamx-cli && ./mvnw clean test + windows-test-build: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Run tests + run: | + cd ./streamx-cli && ./mvnw clean test diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..037384fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties +.flattened-pom.xml + +# Eclipse +.project +.classpath +.settings/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Plugin directory +/.quarkus/cli/plugins/ + +streamx.log +streamx.* +streamx-*.log \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a17cf63a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,20 @@ +# Contributing + +- All commands should extend `AbstractCommand` from [this package](./streamx-cli/src/main/java/com/streamx/cli/framework). +- There are helper classes which extend the `AbstractCommand` class: + - Use `AbstractCommandGroup` for commands which only contain subcommands and don't do anything else, e.g. `streamx settings`. + - Use `AbstractSilentCommand` for commands which don't print any user-faced output, e.g. `streamx settings set`. +- Commands should throw only the [`CliException`](./streamx-cli/src/main/java/com/streamx/cli/framework/CliException.java). +- All user facing messages should be provided by [`MessageProvider`](./streamx-cli/src/main/java/com/streamx/cli/i18n/MessageProvider.java). + +## Development + +- Enter Quarkus development console. + +`cd streamx-cli && ./mvnw quarkus:dev` + +- Use `e` button to edit CLI arguments. + +## Running tests + +`cd streamx-cli && ./mvnw test` \ No newline at end of file diff --git a/README.md b/README.md index 8fb21fae..68bca310 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,15 @@ -# **StreamX Dev Repository Template** +# **StreamX CLI v2.x** -This repository serves as a **template** for creating new repositories within the **streamx-dev** organization. It includes pre-configured settings, templates, and best practices to ensure consistency and compliance across all projects. +This project provides utilities for managing the mesh: +* It allows to run a defined mesh from commands, +* It allows to ingest data into mesh. -## **Included Configurations** +For more information, see the [StreamX CLI Reference](https://www.streamx.dev/guides/main/streamx-command-line-interface-reference.html). -1. **EULA License:** - - A sample **End-User License Agreement (EULA)** is included for repositories that require it. - - Located in the `LICENSE.md` file. - -2. **Issue Templates:** - - Standardized templates for reporting bugs, requesting features, and general contacts links. - - Located in the `.github/ISSUE_TEMPLATE/` directory. +⚠️ Please read the [contributing guidelines](./CONTRIBUTING.md) if you're a developer and wish to contribute to the project. -3. **Pull Request (PR) Templates:** - - A default PR template is provided to maintain quality and structure in submissions. - - Located in the `.github/PULL_REQUEST_TEMPLATE.md` file. +## Configuration -4. **Codeowners:** - - Defines the responsible individuals or teams for reviewing changes. - - Located in the `.github/CODEOWNERS` file. - -5. **Dependabot:** - - It is a tool that automates dependency updates, ensuring your project stays up-to-date with the latest security patches and version improvements. - - Located in the `.github/dependabot.yml` file. - - The provided Dependabot setup performs a monthly scan of the subfolders in your repository for Maven projects, checking dependencies for outdated versions. If any outdated dependencies are detected, Dependabot automatically creates a new Pull Request (PR) with the updates. - - It is often necessary to establish a CI/CD pipeline to validate and test updates whenever a new PR is opened. These pipelines require specific credentials to operate effectively. However, it is important to note that pipelines triggered by Dependabot use a different set of variables and secrets than those triggered by GitHub users manually opening a PR. -Please contact the Principal Engineer responisble for Infrastructure to provide the appropriate secrets for Dependabot-triggered pipelines. - - You can learn more about managing secrets for Dependabot-triggered pipelines in the [GitHub documentation on Accessing Secrets in Dependabot](https://docs.github.com/en/code-security/dependabot/troubleshooting-dependabot/troubleshooting-dependabot-on-github-actions#accessing-secrets). +There are several ways of configuring and several properties to configure. -## **Repository Settings Standards** - -1. **Main Branch Protection Rules** - - The main branch is protected to maintain code quality and stability. - - Merging to `main` requires: a review with approval from at least one **code owner**. - -3. **Default reposiotry settings**: - - Wikis - disabled - - Issues - enabled - - for PRs only squash merging are allowed - - Automatically delete head branches - allowed - -## **How to Use This Template** -1. **Clone or Use as Template:** - - Create by cloning the repository. - - Create a new repository based on this template. See example below: -image - -2. **Update Configurations:** - - **Adjust the License:** If needed, replace the default license with one that matches your project’s requirements. When there are any dobts, check the [decision log](https://teamds.atlassian.net/wiki/x/AYA1KQ) related to Licensing Policy. - - **Modify codeowners:** Ensure the responsible teams or individuals are correctly listed in `.github/CODEOWNERS`. It's importnant to avoid merge bottlenecks. - - **Adjust Dependabot configuration:** Adjust dependabot config to fit the project needs. Provide CI/CD pipelines to validate and test updates, as well. - - **Provide a JIRA release GitHub action:** [here](https://github.com/streamx-dev/streamx-common-github-actions) you can find more details on how and why to create it. - - **Update README.md:** Add specific details about your project to replace this template content. - -3. **Start Developing:** - - Get familiar with [Contribution Policy](https://github.com/streamx-dev/streamx/blob/main/CONTRIBUTING.md). - - Push your code, create issues, and submit PRs following the provided templates. - -## **Best Practices** -- Regularly review the codeowners file to ensure it reflects the correct reviewers. -- Keep your license file up to date with the project’s purpose and legal requirements. -- Ensure all team members are familiar with the repository’s templates and protection rules. - -By using this template, you’re setting up your repository for success with clear structure, strong protections, and organizational consistency. +For details refer to [StreamX CLI Reference](https://www.streamx.dev/guides/streamx-command-line-interface-reference.html). diff --git a/streamx-cli/.mvn/wrapper/.gitignore b/streamx-cli/.mvn/wrapper/.gitignore new file mode 100644 index 00000000..e72f5e8b --- /dev/null +++ b/streamx-cli/.mvn/wrapper/.gitignore @@ -0,0 +1 @@ +maven-wrapper.jar diff --git a/streamx-cli/.mvn/wrapper/MavenWrapperDownloader.java b/streamx-cli/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 00000000..84d1e60d --- /dev/null +++ b/streamx-cli/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,98 @@ +/* + * 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. + */ + +import java.io.IOException; +import java.io.InputStream; +import java.net.Authenticator; +import java.net.PasswordAuthentication; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +public final class MavenWrapperDownloader +{ + private static final String WRAPPER_VERSION = "3.2.0"; + + private static final boolean VERBOSE = Boolean.parseBoolean( System.getenv( "MVNW_VERBOSE" ) ); + + public static void main( String[] args ) + { + log( "Apache Maven Wrapper Downloader " + WRAPPER_VERSION ); + + if ( args.length != 2 ) + { + System.err.println( " - ERROR wrapperUrl or wrapperJarPath parameter missing" ); + System.exit( 1 ); + } + + try + { + log( " - Downloader started" ); + final URL wrapperUrl = new URL( args[0] ); + final String jarPath = args[1].replace( "..", "" ); // Sanitize path + final Path wrapperJarPath = Paths.get( jarPath ).toAbsolutePath().normalize(); + downloadFileFromURL( wrapperUrl, wrapperJarPath ); + log( "Done" ); + } + catch ( IOException e ) + { + System.err.println( "- Error downloading: " + e.getMessage() ); + if ( VERBOSE ) + { + e.printStackTrace(); + } + System.exit( 1 ); + } + } + + private static void downloadFileFromURL( URL wrapperUrl, Path wrapperJarPath ) + throws IOException + { + log( " - Downloading to: " + wrapperJarPath ); + if ( System.getenv( "MVNW_USERNAME" ) != null && System.getenv( "MVNW_PASSWORD" ) != null ) + { + final String username = System.getenv( "MVNW_USERNAME" ); + final char[] password = System.getenv( "MVNW_PASSWORD" ).toCharArray(); + Authenticator.setDefault( new Authenticator() + { + @Override + protected PasswordAuthentication getPasswordAuthentication() + { + return new PasswordAuthentication( username, password ); + } + } ); + } + try ( InputStream inStream = wrapperUrl.openStream() ) + { + Files.copy( inStream, wrapperJarPath, StandardCopyOption.REPLACE_EXISTING ); + } + log( " - Downloader complete" ); + } + + private static void log( String msg ) + { + if ( VERBOSE ) + { + System.out.println( msg ); + } + } + +} diff --git a/streamx-cli/.mvn/wrapper/maven-wrapper.properties b/streamx-cli/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..346d645f --- /dev/null +++ b/streamx-cli/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# 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. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/streamx-cli/codestyle/checkstyle-suppressions.xml b/streamx-cli/codestyle/checkstyle-suppressions.xml new file mode 100644 index 00000000..04ffb895 --- /dev/null +++ b/streamx-cli/codestyle/checkstyle-suppressions.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/streamx-cli/codestyle/intellij-java-google-style.xml b/streamx-cli/codestyle/intellij-java-google-style.xml new file mode 100644 index 00000000..f3a6743e --- /dev/null +++ b/streamx-cli/codestyle/intellij-java-google-style.xml @@ -0,0 +1,598 @@ + + + + + + diff --git a/streamx-cli/mvnw b/streamx-cli/mvnw new file mode 100755 index 00000000..8d937f4c --- /dev/null +++ b/streamx-cli/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/streamx-cli/pom.xml b/streamx-cli/pom.xml new file mode 100644 index 00000000..b04a62cd --- /dev/null +++ b/streamx-cli/pom.xml @@ -0,0 +1,209 @@ + + + 4.0.0 + + com.streamx.cli + streamx-cli + 2.0.0-SNAPSHOT + + + 21 + 21 + UTF-8 + UTF-8 + 3.30.6 + 3.2.5 + 3.13.0 + 3.6.0 + + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-picocli + + + + info.picocli + picocli-shell-jline3 + 4.7.5 + + + + org.jetbrains + annotations + 26.0.2-1 + provided + + + + io.quarkus + quarkus-arc + + + + org.jboss.logging + jboss-logging-annotations + 3.0.4.Final + provided + + + + org.jboss.logging + jboss-logging-processor + 3.0.4.Final + provided + true + + + + com.fasterxml.jackson.core + jackson-databind + 2.20.1 + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.20.1 + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + 2.20.1 + + + + + io.quarkus + quarkus-junit5 + test + + + + org.assertj + assertj-core + 3.27.5 + test + + + + org.instancio + instancio-junit + 5.5.1 + test + + + + + + + io.quarkus.platform + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle-plugin.version} + + google_checks.xml + true + true + true + true + warning + true + codestyle/checkstyle-suppressions.xml + + ${project.build.sourceDirectory} + ${project.build.testSourceDirectory} + + + + + checkstyle + + check + + validate + + + + + 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 + + + + diff --git a/streamx-cli/src/main/java/com/streamx/cli/Main.java b/streamx-cli/src/main/java/com/streamx/cli/Main.java new file mode 100644 index 00000000..f07b0c50 --- /dev/null +++ b/streamx-cli/src/main/java/com/streamx/cli/Main.java @@ -0,0 +1,30 @@ +package com.streamx.cli; + +import com.streamx.cli.framework.AbstractCommandGroup; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import com.streamx.cli.framework.ShortErrorMessageHandler; +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 = {} +) +public class Main extends AbstractCommandGroup { + @CommandLine.Spec + CommandLine.Model.CommandSpec commandSpec; + + @Override + public CommandResult runCommand() { + commandSpec + .commandLine() + .setParameterExceptionHandler(new ShortErrorMessageHandler()) + .usage(System.out); + + return new CommandResult<>(null); + } +} diff --git a/streamx-cli/src/main/java/com/streamx/cli/framework/AbstractCommand.java b/streamx-cli/src/main/java/com/streamx/cli/framework/AbstractCommand.java new file mode 100644 index 00000000..9be99ccd --- /dev/null +++ b/streamx-cli/src/main/java/com/streamx/cli/framework/AbstractCommand.java @@ -0,0 +1,135 @@ +package com.streamx.cli.framework; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import io.quarkus.runtime.Quarkus; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; +import org.jetbrains.annotations.Nullable; +import org.jline.reader.Completer; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.reader.impl.completer.StringsCompleter; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Model.OptionSpec; + +/** + * Each CLI command should extend this class. + * + * @param Must be serializable by Jackson (POJO, JsonSerializable, etc.) + */ +public abstract class AbstractCommand implements Runnable { + @CommandLine.Spec + public CommandSpec spec; + + @CommandLine.Spec + public void setSpec(CommandSpec spec) { + this.spec = spec; + applyHiddenOptions(); + } + + @CommandLine.Option( + names = {CommonOption.VERBOSE_SHORT, CommonOption.VERBOSE_LONG}, + description = "Print debug information" + ) + public boolean verbose; + + @CommandLine.Option( + names = {CommonOption.OUTPUT_SHORT, CommonOption.OUTPUT_LONG}, + description = "Specify output format: text, json, yaml", + defaultValue = "text" + ) + // Explicitly set default value here as a fallback for commands with the hidden output option. + public OutputFormat output = OutputFormat.text; + + // Override this method to implement the command logic. + public abstract CommandResult runCommand(); + + // Override this method to hide specific command line options. + // May be useful to hide the "--output" option for + // commands that don't print anything in case of success. + public List getHiddenOptions() { + return List.of(); + } + + // Override this method to provide human-readable output. + public String getTextOutput(CommandResult result) { + return result.toText(OutputFormat.json, null); + } + + private void applyHiddenOptions() { + List options = getHiddenOptions(); + + for (String option : options) { + OptionSpec optionSpec = spec.findOption(option); + if (optionSpec != null) { + spec.remove(optionSpec); + } + } + } + + public void printUsage() { + spec.commandLine().usage(System.out); + } + + // Use this method for asking user input in interactive commands. + public String promptForInput( + String prompt, + @Nullable List autocompleteOptions + ) { + try (Terminal terminal = createTerminal()) { + LineReaderBuilder builder = LineReaderBuilder.builder() + .terminal(terminal); + + Completer completer; + if (autocompleteOptions != null) { + completer = new StringsCompleter(autocompleteOptions); + builder.completer(completer); + } + + LineReader reader = builder.build(); + + return reader.readLine(prompt).strip(); + } catch (IOException e) { + throw new CliException(msg.failedToHandleInteractiveInput(), e); + } + } + + public int execute() { + int exitCode = 0; + + try { + CommandResult result = this.runCommand(); + String textOutput = result.toText(output, this::getTextOutput); + if (!textOutput.isEmpty()) { + System.out.println(textOutput); + } + } catch (Exception e) { + exitCode = ShortErrorMessageHandler.shortErrorMessage(e, spec.commandLine()); + if (verbose) { + // Print exception stacktrace + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + e.printStackTrace(pw); + System.err.println(sw); + } + } + + return exitCode; + } + + public void run() { + int exitCode = execute(); + Quarkus.asyncExit(exitCode); + } + + // For testing purposes mostly. + protected Terminal createTerminal() throws IOException { + return TerminalBuilder.builder().system(true).build(); + } +} diff --git a/streamx-cli/src/main/java/com/streamx/cli/framework/AbstractCommandGroup.java b/streamx-cli/src/main/java/com/streamx/cli/framework/AbstractCommandGroup.java new file mode 100644 index 00000000..64536801 --- /dev/null +++ b/streamx-cli/src/main/java/com/streamx/cli/framework/AbstractCommandGroup.java @@ -0,0 +1,25 @@ +package com.streamx.cli.framework; + +import java.util.List; + +// Extend this class for commands that don't do anything except display their subcommands. +public class AbstractCommandGroup extends AbstractCommand { + @Override + public CommandResult runCommand() { + this.printUsage(); + return new CommandResult<>(null); + } + + @Override + public String getTextOutput(CommandResult result) { + return ""; + } + + @Override + public List getHiddenOptions() { + return List.of( + CommonOption.OUTPUT_LONG, + CommonOption.VERBOSE_LONG + ); + } +} diff --git a/streamx-cli/src/main/java/com/streamx/cli/framework/AbstractSilentCommand.java b/streamx-cli/src/main/java/com/streamx/cli/framework/AbstractSilentCommand.java new file mode 100644 index 00000000..73253eca --- /dev/null +++ b/streamx-cli/src/main/java/com/streamx/cli/framework/AbstractSilentCommand.java @@ -0,0 +1,16 @@ +package com.streamx.cli.framework; + +import java.util.List; + +// Extend this class for commands that don't produce any output, e.g.: settings set. +public abstract class AbstractSilentCommand extends AbstractCommand { + @Override + public List getHiddenOptions() { + return List.of(CommonOption.OUTPUT_LONG); + } + + @Override + public String getTextOutput(CommandResult result) { + return ""; + } +} diff --git a/streamx-cli/src/main/java/com/streamx/cli/framework/CliException.java b/streamx-cli/src/main/java/com/streamx/cli/framework/CliException.java new file mode 100644 index 00000000..5d3ea4d9 --- /dev/null +++ b/streamx-cli/src/main/java/com/streamx/cli/framework/CliException.java @@ -0,0 +1,11 @@ +package com.streamx.cli.framework; + +public class CliException extends RuntimeException { + public CliException(String userFriendlyMessage) { + super(userFriendlyMessage); + } + + public CliException(String userFriendlyMessage, Throwable cause) { + super(userFriendlyMessage, cause); + } +} diff --git a/streamx-cli/src/main/java/com/streamx/cli/framework/CommandResult.java b/streamx-cli/src/main/java/com/streamx/cli/framework/CommandResult.java new file mode 100644 index 00000000..a0ebb583 --- /dev/null +++ b/streamx-cli/src/main/java/com/streamx/cli/framework/CommandResult.java @@ -0,0 +1,50 @@ +package com.streamx.cli.framework; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import java.util.function.Function; + +/** + * @param Must be serializable by Jackson (POJO, JsonSerializable, etc.) + */ +public class CommandResult { + public ResultT result; + + public CommandResult(ResultT result) { + this.result = result; + } + + public String toText( + OutputFormat outputFormat, + Function, String> textFormatter + ) throws CliException { + try { + switch (outputFormat) { + case OutputFormat.text -> { + return textFormatter.apply(this); + } + case OutputFormat.json -> { + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.valueToTree(result); + return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode); + } + case OutputFormat.yaml -> { + YAMLFactory yamlFactory = YAMLFactory.builder() + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .build(); + ObjectMapper mapper = new ObjectMapper(yamlFactory); + JsonNode jsonNode = mapper.valueToTree(result); + return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode).strip(); + } + default -> throw new CliException(msg.unsupportedOutputFormat()); + } + } catch (JsonProcessingException e) { + throw new CliException(msg.unableToSerializeJson(), e); + } + } +} diff --git a/streamx-cli/src/main/java/com/streamx/cli/framework/CommonOption.java b/streamx-cli/src/main/java/com/streamx/cli/framework/CommonOption.java new file mode 100644 index 00000000..24b49fd8 --- /dev/null +++ b/streamx-cli/src/main/java/com/streamx/cli/framework/CommonOption.java @@ -0,0 +1,9 @@ +package com.streamx.cli.framework; + +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/streamx-cli/src/main/java/com/streamx/cli/framework/OutputFormat.java b/streamx-cli/src/main/java/com/streamx/cli/framework/OutputFormat.java new file mode 100644 index 00000000..d811ada5 --- /dev/null +++ b/streamx-cli/src/main/java/com/streamx/cli/framework/OutputFormat.java @@ -0,0 +1,5 @@ +package com.streamx.cli.framework; + +public enum OutputFormat { + text, json, yaml +} diff --git a/streamx-cli/src/main/java/com/streamx/cli/framework/ShortErrorMessageHandler.java b/streamx-cli/src/main/java/com/streamx/cli/framework/ShortErrorMessageHandler.java new file mode 100644 index 00000000..7634b427 --- /dev/null +++ b/streamx-cli/src/main/java/com/streamx/cli/framework/ShortErrorMessageHandler.java @@ -0,0 +1,50 @@ +package com.streamx.cli.framework; + +import static com.streamx.cli.i18n.MessageProvider.msg; + +import java.io.PrintWriter; +import picocli.CommandLine; +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(); + + if ( + ex instanceof ParameterException + || ex instanceof IllegalArgumentException + || ex instanceof CliException + ) { + writer.println(cmd.getColorScheme().errorText(ex.getMessage())); + } else { + String errorMessage = msg.somethingWentWrong().strip(); + + writer.println(cmd.getColorScheme().errorText(errorMessage)); + } + + if (ex instanceof ParameterException) { + UnmatchedArgumentException.printSuggestions((ParameterException) ex, writer); + } + + if (ex instanceof ParameterException || ex instanceof IllegalArgumentException) { + CommandSpec spec = cmd.getCommandSpec(); + writer.printf(msg.tryForMoreInformationOnAvailableOptions( + spec.qualifiedName(), + "help".equals(spec.name()) ? "" : " --help" + )); + return cmd.getCommandSpec().exitCodeOnInvalidInput(); + } + return cmd.getCommandSpec().exitCodeOnExecutionException(); + } + +} diff --git a/streamx-cli/src/main/java/com/streamx/cli/i18n/MessageProvider.java b/streamx-cli/src/main/java/com/streamx/cli/i18n/MessageProvider.java new file mode 100644 index 00000000..7d11a641 --- /dev/null +++ b/streamx-cli/src/main/java/com/streamx/cli/i18n/MessageProvider.java @@ -0,0 +1,39 @@ +package com.streamx.cli.i18n; + +import java.lang.invoke.MethodHandles; +import org.jboss.logging.Messages; +import org.jboss.logging.annotations.Message; +import org.jboss.logging.annotations.MessageBundle; + +@MessageBundle(projectCode = "") +public interface MessageProvider { + + MessageProvider msg = Messages.getBundle(MethodHandles.lookup(), MessageProvider.class); + + @Message(id = 100, value = "Unsupported output format") + String unsupportedOutputFormat(); + + @Message(id = 101, value = "Try '%s%s' for more information on the available options.%n") + String tryForMoreInformationOnAvailableOptions( + String qualifiedCommandName, + String helpOptionName + ); + + @Message(id = 102, value = "Failed to handle interactive input") + String failedToHandleInteractiveInput(); + + @Message(id = 103, value = "Unable to serialize JSON") + String unableToSerializeJson(); + + @Message( + id = 104, + value = """ + ❌ Something went wrong while running the command. + + Please try again with `--verbose` for more details. + If the problem persists, report it here: + https://www.streamx.dev/contact-us.html + """ + ) + String somethingWentWrong(); +} diff --git a/streamx-cli/src/main/resources/application.properties b/streamx-cli/src/main/resources/application.properties new file mode 100644 index 00000000..cb541572 --- /dev/null +++ b/streamx-cli/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 diff --git a/streamx-cli/src/test/java/com/streamx/cli/MainTest.java b/streamx-cli/src/test/java/com/streamx/cli/MainTest.java new file mode 100644 index 00000000..3d223bcd --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/MainTest.java @@ -0,0 +1,46 @@ +package com.streamx.cli; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.streamx.cli.framework.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); + } + } +} diff --git a/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractCommandOutputOptionTest.java b/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractCommandOutputOptionTest.java new file mode 100644 index 00000000..dc7ddbf1 --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractCommandOutputOptionTest.java @@ -0,0 +1,238 @@ +package com.streamx.cli.framework; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.streamx.cli.framework.testing.AbstractCommandBaseTest; +import com.streamx.cli.framework.testing.AbstractTestCommand; +import com.streamx.cli.framework.testing.TestObject; +import java.util.List; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +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 shouldFormatResultAsJsonIfNoCustomFormatterProvided() { + AbstractTestCommand command = new AbstractTestCommand<>(); + CommandLine commandLine = new CommandLine(command); + commandLine.parseArgs(CommonOption.OUTPUT_LONG, "text"); + command.setRunCommandHandler(() -> new CommandResult<>(result)); + command.execute(); + + String 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 shouldFormatResultWithCustomFormatter() { + AbstractTestCommand 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(); + + String expectedStdOutOutput = """ + String value: Test string, + Total nested objects: 2 + """.strip() + "\n"; + + assertEquals(expectedStdOutOutput, outStream.toString()); + assertEquals("", errStream.toString()); + } + + @Test + void shouldFormatResultAsJson() { + AbstractTestCommand command = new AbstractTestCommand<>(); + CommandLine commandLine = new CommandLine(command); + commandLine.parseArgs(CommonOption.OUTPUT_LONG, "json"); + command.setRunCommandHandler(() -> new CommandResult<>(result)); + command.execute(); + + String 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 shouldFormatResultAsYaml() { + AbstractTestCommand command = new AbstractTestCommand<>(); + CommandLine commandLine = new CommandLine(command); + commandLine.parseArgs(CommonOption.OUTPUT_LONG, "yaml"); + command.setRunCommandHandler(() -> new CommandResult<>(result)); + command.execute(); + + String 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 shouldHandleVoidResult() { + AbstractTestCommand 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()); + } +} diff --git a/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractCommandTest.java b/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractCommandTest.java new file mode 100644 index 00000000..88885b0b --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractCommandTest.java @@ -0,0 +1,86 @@ +package com.streamx.cli.framework; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.streamx.cli.framework.testing.AbstractCommandBaseTest; +import com.streamx.cli.framework.testing.AbstractTestCommand; +import com.streamx.cli.framework.testing.TestObject; +import java.io.ByteArrayInputStream; +import java.util.List; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +class AbstractCommandTest extends AbstractCommandBaseTest { + @Test + void shouldExecuteSuccessfully() { + AbstractTestCommand command = new AbstractTestCommand<>(); + command.setRunCommandHandler(() -> new CommandResult<>(TestObject.random())); + new CommandLine(command); + int exitCode = command.execute(); + + assertEquals(0, exitCode); + assertFalse(outStream.toString().isEmpty()); + assertTrue(errStream.toString().isEmpty()); + } + + @Test + void shouldHandleExceptionGracefully() { + AbstractTestCommand command = new AbstractTestCommand<>(); + command.setRunCommandHandler(() -> { + throw new CliException("Test exception"); + }); + new CommandLine(command); + int exitCode = command.execute(); + + assertEquals(1, exitCode); + assertTrue(outStream.toString().isEmpty()); + assertTrue(errStream.toString().contains("Test exception")); + } + + @Test + void shouldHideOptionsBasedOnHandler() { + AbstractTestCommand command1 = new AbstractTestCommand<>(); + new CommandLine(command1); + + assertNotNull(command1.spec.findOption(CommonOption.OUTPUT_LONG)); + + AbstractTestCommand command2 = new AbstractTestCommand<>(); + command2.setHiddenOptionsHandler(() -> List.of(CommonOption.OUTPUT_LONG)); + new CommandLine(command2); + + assertNull(command2.spec.findOption(CommonOption.OUTPUT_LONG)); + } + + @Test + void shouldBeAbleToPromptForInput() { + AbstractTestCommand command = new AbstractTestCommand<>(); + CommandLine commandLine = new CommandLine(command); + command.setSpec(commandLine.getCommandSpec()); + + String input1 = "first\n"; + System.setIn(new ByteArrayInputStream(input1.getBytes())); + String result1; + try { + result1 = command.promptForInput("Enter first:", null); + } catch (CliException e) { + throw new RuntimeException(e); + } + + assertEquals("first", result1); + + String input2 = "second\n"; + System.setIn(new ByteArrayInputStream(input2.getBytes())); + String result2; + try { + result2 = command.promptForInput("Enter second:", null); + } catch (CliException e) { + throw new RuntimeException(e); + } + + assertEquals("second", result2); + } +} diff --git a/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractCommandVerboseOptionTest.java b/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractCommandVerboseOptionTest.java new file mode 100644 index 00000000..c4f38498 --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractCommandVerboseOptionTest.java @@ -0,0 +1,42 @@ +package com.streamx.cli.framework; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.streamx.cli.framework.testing.AbstractCommandBaseTest; +import com.streamx.cli.framework.testing.AbstractTestCommand; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +class AbstractCommandVerboseOptionTest extends AbstractCommandBaseTest { + @Test + void shouldPrintStackTraceIfProvided() { + AbstractTestCommand command = new AbstractTestCommand<>(); + command.setRunCommandHandler(() -> { + throw new CliException("Test exception"); + }); + CommandLine commandLine = new CommandLine(command); + commandLine.parseArgs(CommonOption.VERBOSE_LONG); + + command.execute(); + + assertTrue( + errStream.toString().contains("com.streamx.cli.framework.CliException: Test exception") + ); + } + + @Test + void shouldNotPrintStackTraceByDefault() { + AbstractTestCommand command = new AbstractTestCommand<>(); + command.setRunCommandHandler(() -> { + throw new CliException("Test exception"); + }); + new CommandLine(command); + command.execute(); + + assertTrue(errStream.toString().contains("Test exception")); + assertFalse( + errStream.toString().contains("com.streamx.cli.framework.CliException: Test exception") + ); + } +} diff --git a/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractSilentCommandTest.java b/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractSilentCommandTest.java new file mode 100644 index 00000000..f8454182 --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/framework/AbstractSilentCommandTest.java @@ -0,0 +1,29 @@ +package com.streamx.cli.framework; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.streamx.cli.framework.testing.AbstractCommandBaseTest; +import com.streamx.cli.framework.testing.AbstractSilentTestCommand; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +class AbstractSilentCommandTest extends AbstractCommandBaseTest { + @Test + void shouldNotHaveOutputOption() { + AbstractSilentTestCommand command = new AbstractSilentTestCommand(); + new CommandLine(command); // Trigger all PicocLi initialization + + assertNull(command.spec.findOption(CommonOption.OUTPUT_LONG)); + } + + @Test + void shouldReturnEmptyOutput() { + AbstractSilentTestCommand command = new AbstractSilentTestCommand(); + command.setRunCommandHandler(() -> new CommandResult<>(null)); + command.execute(); + + assertEquals("", outStream.toString()); + assertEquals("", errStream.toString()); + } +} diff --git a/streamx-cli/src/test/java/com/streamx/cli/framework/CommandResultTest.java b/streamx-cli/src/test/java/com/streamx/cli/framework/CommandResultTest.java new file mode 100644 index 00000000..2c06de54 --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/framework/CommandResultTest.java @@ -0,0 +1,207 @@ +package com.streamx.cli.framework; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.streamx.cli.framework.testing.TestObject; +import com.streamx.cli.framework.testing.UnserializableObject; +import java.util.List; +import java.util.function.Function; +import org.junit.jupiter.api.Test; + +class CommandResultTest { + private final TestObject result = new TestObject( + null, + true, + 100500, + 42.42, + "Test string", + new TestObject( + null, + false, + 7, + 3.14, + "Nested object test string", + null, + null + ), + List.of( + new TestObject( + null, + true, + 15, + 100.42, + "Nested list object 1 test string", + null, + null + ), + new TestObject( + null, + false, + 18, + 0.42, + "Nested list object 2 test string", + null, + null + ) + ) + ); + private final CommandResult commandResult = new CommandResult<>(result); + + @Test + void shouldUseTextFormatterWhenTextFormatProvided() throws CliException { + String expectedOutput = """ + Void Value: null + Boolean Value: true + Long Value: 100500 + Float Value: 42.42 + String Value: Test string + Nested Object Long Value: 7 + Nested Object String Value: Nested object test string + Total Nested Objects: 2 + """; + + Function, String> textFormatter = + cr -> """ + Void Value: %s + Boolean Value: %b + Long Value: %d + Float Value: %.2f + String Value: %s + Nested Object Long Value: %d + Nested Object String Value: %s + Total Nested Objects: %d + """.formatted( + cr.result.voidValue, + cr.result.booleanValue, + cr.result.longValue, + cr.result.floatValue, + cr.result.stringValue, + cr.result.nestedObject.longValue, + cr.result.nestedObject.stringValue, + cr.result.nestedObjects.size() + ); + + String output = commandResult.toText(OutputFormat.text, textFormatter); + + assertEquals(expectedOutput, output); + } + + @Test + void shouldReturnPrettyPrintedJsonWhenJsonFormatProvided() { + String expectedOutput = """ + { + "voidValue" : null, + "booleanValue" : true, + "longValue" : 100500, + "floatValue" : 42.42, + "stringValue" : "Test string", + "nestedObject" : { + "voidValue" : null, + "booleanValue" : false, + "longValue" : 7, + "floatValue" : 3.14, + "stringValue" : "Nested object test string", + "nestedObject" : null, + "nestedObjects" : null + }, + "nestedObjects" : [ { + "voidValue" : null, + "booleanValue" : true, + "longValue" : 15, + "floatValue" : 100.42, + "stringValue" : "Nested list object 1 test string", + "nestedObject" : null, + "nestedObjects" : null + }, { + "voidValue" : null, + "booleanValue" : false, + "longValue" : 18, + "floatValue" : 0.42, + "stringValue" : "Nested list object 2 test string", + "nestedObject" : null, + "nestedObjects" : null + } ] + } + """.strip(); + + String output; + try { + output = commandResult.toText(OutputFormat.json, null); + } catch (CliException e) { + throw new RuntimeException(e); + } + + assertEquals(expectedOutput, output); + } + + @Test + void shouldReturnYamlWhenYamlFormatProvided() { + String expectedOutput = """ + voidValue: null + booleanValue: true + longValue: 100500 + floatValue: 42.42 + stringValue: "Test string" + nestedObject: + voidValue: null + booleanValue: false + longValue: 7 + floatValue: 3.14 + stringValue: "Nested object test string" + nestedObject: null + nestedObjects: null + nestedObjects: + - voidValue: null + booleanValue: true + longValue: 15 + floatValue: 100.42 + stringValue: "Nested list object 1 test string" + nestedObject: null + nestedObjects: null + - voidValue: null + booleanValue: false + longValue: 18 + floatValue: 0.42 + stringValue: "Nested list object 2 test string" + nestedObject: null + nestedObjects: null + """.strip(); + + String output; + try { + output = commandResult.toText(OutputFormat.yaml, null); + } catch (CliException e) { + throw new RuntimeException(e); + } + + assertEquals(expectedOutput, output); + } + + @Test + void shouldHandleNullResultGracefully() { + CommandResult commandResult = new CommandResult<>(null); + + String jsonOutput; + String yamlOutput; + try { + jsonOutput = commandResult.toText(OutputFormat.json, null); + yamlOutput = commandResult.toText(OutputFormat.yaml, null); + } catch (CliException e) { + throw new RuntimeException(e); + } + + assertEquals("null", jsonOutput); + assertEquals("null", yamlOutput); + } + + @Test + void shouldThrowRuntimeExceptionForUnserializableObject() { + UnserializableObject unserializable = new UnserializableObject(); + CommandResult commandResult = new CommandResult<>(unserializable); + + assertThrows(RuntimeException.class, () -> + commandResult.toText(OutputFormat.json, null) + ); + } +} \ No newline at end of file diff --git a/streamx-cli/src/test/java/com/streamx/cli/framework/testing/AbstractCommandBaseTest.java b/streamx-cli/src/test/java/com/streamx/cli/framework/testing/AbstractCommandBaseTest.java new file mode 100644 index 00000000..641c530a --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/framework/testing/AbstractCommandBaseTest.java @@ -0,0 +1,26 @@ +package com.streamx.cli.framework.testing; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +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/streamx-cli/src/test/java/com/streamx/cli/framework/testing/AbstractSilentTestCommand.java b/streamx-cli/src/test/java/com/streamx/cli/framework/testing/AbstractSilentTestCommand.java new file mode 100644 index 00000000..449daeda --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/framework/testing/AbstractSilentTestCommand.java @@ -0,0 +1,37 @@ +package com.streamx.cli.framework.testing; + +import com.streamx.cli.framework.AbstractSilentCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import java.util.List; +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 CliException { + 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/streamx-cli/src/test/java/com/streamx/cli/framework/testing/AbstractTestCommand.java b/streamx-cli/src/test/java/com/streamx/cli/framework/testing/AbstractTestCommand.java new file mode 100644 index 00000000..af50ec4f --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/framework/testing/AbstractTestCommand.java @@ -0,0 +1,65 @@ +package com.streamx.cli.framework.testing; + +import com.streamx.cli.framework.AbstractCommand; +import com.streamx.cli.framework.CliException; +import com.streamx.cli.framework.CommandResult; +import java.io.IOException; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; + +// Helper class for testing AbstractCommand +public class AbstractTestCommand extends AbstractCommand { + public Supplier> runCommandHandler; + public Supplier> hiddenOptionsHandler; + public Function, String> getTextOutputHandler; + + public void setRunCommandHandler(Supplier> handler) { + this.runCommandHandler = handler; + } + + public void setHiddenOptionsHandler(Supplier> handler) { + this.hiddenOptionsHandler = handler; + } + + public void setGetTextOutputHandler( + Function, String> handler + ) { + this.getTextOutputHandler = handler; + } + + @Override + public CommandResult runCommand() { + if (runCommandHandler != null) { + return runCommandHandler.get(); + } + throw new CliException("No run command handler set"); + } + + @Override + public List getHiddenOptions() { + if (hiddenOptionsHandler != null) { + return hiddenOptionsHandler.get(); + } + return super.getHiddenOptions(); + } + + @Override + public String getTextOutput(CommandResult result) { + if (getTextOutputHandler != null) { + return getTextOutputHandler.apply(result); + } + return super.getTextOutput(result); + } + + @Override + protected Terminal createTerminal() throws IOException { + return TerminalBuilder.builder() + .system(false) + .streams(System.in, System.out) + .dumb(true) + .build(); + } +} diff --git a/streamx-cli/src/test/java/com/streamx/cli/framework/testing/TestObject.java b/streamx-cli/src/test/java/com/streamx/cli/framework/testing/TestObject.java new file mode 100644 index 00000000..cf46569d --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/framework/testing/TestObject.java @@ -0,0 +1,36 @@ +package com.streamx.cli.framework.testing; + +import java.util.List; +import org.instancio.Instancio; + +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); + } +} diff --git a/streamx-cli/src/test/java/com/streamx/cli/framework/testing/UnserializableObject.java b/streamx-cli/src/test/java/com/streamx/cli/framework/testing/UnserializableObject.java new file mode 100644 index 00000000..35dde7bc --- /dev/null +++ b/streamx-cli/src/test/java/com/streamx/cli/framework/testing/UnserializableObject.java @@ -0,0 +1,6 @@ +package com.streamx.cli.framework.testing; + +public class UnserializableObject { + // Object with circular reference to make it unserializable + public UnserializableObject self = this; +}