From c6dd451c25830c4fafa03fbf3cba17aff7b22cec Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Fri, 18 Jul 2025 16:17:22 +0200 Subject: [PATCH 1/5] feat: add cli entrypoint and fat jar to use standalone Signed-off-by: Ruben Romero Montes --- README.md | 67 ++++++ pom.xml | 40 +++- src/main/java/com/redhat/exhort/Cli.java | 256 +++++++++++++++++++++++ 3 files changed, 360 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/redhat/exhort/Cli.java diff --git a/README.md b/README.md index 097c9984..33b539e9 100644 --- a/README.md +++ b/README.md @@ -503,6 +503,73 @@ By Default, The API algorithm will use native commands of PIP installer as data It's also possible, to use lightweight Python PIP utility [pipdeptree](https://pypi.org/project/pipdeptree/) as data source instead, in order to activate this, Need to set environment variable/system property - `EXHORT_PIP_USE_DEP_TREE` to true. +### CLI Support + +The Exhort Java API includes a command-line interface for standalone usage. + +#### Building the CLI + +To build the CLI JAR with all dependencies included: + +```shell +mvn clean package +``` + +This creates two JAR files in the `target/` directory: +- `exhort-java-api.jar` - Library JAR (for programmatic use) +- `exhort-java-api-cli.jar` - CLI JAR (includes all dependencies) + +#### Usage + +```shell +java -jar target/exhort-java-api-cli.jar [OPTIONS] +``` + +#### Commands + +**Stack Analysis** +```shell +java -jar exhort-java-api-cli.jar stack [--summary|--html] +``` +Perform stack analysis on the specified manifest file. + +Options: +- `--summary` - Output summary in JSON format +- `--html` - Output full report in HTML format +- (default) - Output full report in JSON format + +**Component Analysis** +```shell +java -jar exhort-java-api-cli.jar component [--summary] +``` +Perform component analysis on the specified manifest file. + +Options: +- `--summary` - Output summary in JSON format +- (default) - Output full report in JSON format + +#### Examples + +```shell +# Stack analysis with JSON output (default) +java -jar exhort-java-api-cli.jar stack /path/to/pom.xml + +# Stack analysis with summary +java -jar exhort-java-api-cli.jar stack /path/to/package.json --summary + +# Stack analysis with HTML output +java -jar exhort-java-api-cli.jar stack /path/to/build.gradle --html + +# Component analysis with JSON output (default) +java -jar exhort-java-api-cli.jar component /path/to/requirements.txt + +# Component analysis with summary +java -jar exhort-java-api-cli.jar component /path/to/go.mod --summary + +# Show help +java -jar exhort-java-api-cli.jar --help +``` + ### Image Support Generate vulnerability analysis report for container images. diff --git a/pom.xml b/pom.xml index 643357c9..279c7966 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,7 @@ 4.0.0-M6 3.2.1 3.5.3 + 3.4.1 3.4.0 1.6.2 1.4.1 @@ -454,6 +455,11 @@ limitations under the License.]]> spotless-maven-plugin ${spotless-maven-plugin.version} + + org.apache.maven.plugins + maven-shade-plugin + ${maven-shade-plugin.version} + @@ -651,6 +657,37 @@ limitations under the License.]]> + + maven-jar-plugin + + + + com.redhat.exhort.Cli + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + true + cli + + + com.redhat.exhort.Cli + + + + + + com.diffplug.spotless spotless-maven-plugin @@ -813,9 +850,6 @@ limitations under the License.]]> de.sormuras.junit junit-platform-maven-plugin - ${junit-platform-maven-plugin.version} - - diff --git a/src/main/java/com/redhat/exhort/Cli.java b/src/main/java/com/redhat/exhort/Cli.java new file mode 100644 index 00000000..6711611d --- /dev/null +++ b/src/main/java/com/redhat/exhort/Cli.java @@ -0,0 +1,256 @@ +/* + * Copyright © 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.redhat.exhort; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.redhat.exhort.api.v4.AnalysisReport; +import com.redhat.exhort.api.v4.ProviderReport; +import com.redhat.exhort.api.v4.Source; +import com.redhat.exhort.impl.ExhortApi; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +public class Cli { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + static { + MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + private enum Command { + STACK, + COMPONENT + } + + private enum OutputFormat { + JSON, + SUMMARY, + HTML + } + + public static void main(String[] args) { + if (args.length == 0 || isHelpRequested(args)) { + printHelp(); + return; + } + + try { + CliArgs cliArgs = parseArgs(args); + String result = executeCommand(cliArgs).get(); + System.out.println(result); + } catch (IllegalArgumentException e) { + System.err.println("Error: " + e.getMessage()); + System.err.println(); + printHelp(); + System.exit(1); + } catch (IOException | InterruptedException | ExecutionException e) { + System.err.println("Unexpected error: " + e.getMessage()); + System.exit(1); + } + } + + private static boolean isHelpRequested(String[] args) { + for (String arg : args) { + if ("--help".equals(arg) || "-h".equals(arg)) { + return true; + } + } + return false; + } + + private static CliArgs parseArgs(String[] args) { + if (args.length < 2) { + throw new IllegalArgumentException("Missing required arguments"); + } + + String commandStr = args[0].toLowerCase(); + Command command; + + switch (commandStr) { + case "stack": + command = Command.STACK; + break; + case "component": + command = Command.COMPONENT; + break; + default: + throw new IllegalArgumentException( + "Unknown command: " + commandStr + ". Use 'stack' or 'component'"); + } + + String filePath = args[1]; + Path path = Paths.get(filePath); + + if (!Files.exists(path)) { + throw new IllegalArgumentException("File does not exist: " + filePath); + } + + if (!Files.isRegularFile(path)) { + throw new IllegalArgumentException("Path is not a file: " + filePath); + } + + OutputFormat outputFormat = OutputFormat.JSON; // default + + // Parse additional options for stack command + if (args.length > 2) { + for (int i = 2; i < args.length; i++) { + switch (args[i]) { + case "--summary": + outputFormat = OutputFormat.SUMMARY; + break; + case "--html": + if (command != Command.STACK) { + throw new IllegalArgumentException("HTML format is only supported for stack command"); + } + outputFormat = OutputFormat.HTML; + break; + default: + throw new IllegalArgumentException("Unknown option for stack command: " + args[i]); + } + } + } else if (command == Command.COMPONENT && args.length > 2) { + throw new IllegalArgumentException("Component command does not accept additional options"); + } + + return new CliArgs(command, path, outputFormat); + } + + private static CompletableFuture executeCommand(CliArgs args) throws IOException { + switch (args.command) { + case STACK: + return executeStackAnalysis(args.filePath.toAbsolutePath().toString(), args.outputFormat); + case COMPONENT: + return executeComponentAnalysis( + args.filePath.toAbsolutePath().toString(), args.outputFormat); + default: + throw new AssertionError(); + } + } + + private static CompletableFuture executeStackAnalysis( + String filePath, OutputFormat outputFormat) throws IOException { + Api api = new ExhortApi(); + switch (outputFormat) { + case JSON: + return api.stackAnalysis(filePath).thenApply(Cli::toJsonString); + case HTML: + return api.stackAnalysisHtml(filePath).thenApply(bytes -> new String(bytes)); + case SUMMARY: + return api.stackAnalysis(filePath) + .thenApply(Cli::extractSummary) + .thenApply(Cli::toJsonString); + default: + throw new AssertionError(); + } + } + + private static CompletableFuture executeComponentAnalysis( + String filePath, OutputFormat outputFormat) throws IOException { + Api api = new ExhortApi(); + CompletableFuture analysis = api.componentAnalysis(filePath); + if (outputFormat.equals(OutputFormat.SUMMARY)) { + analysis = analysis.thenApply(Cli::extractSummary); + } + return analysis.thenApply(Cli::toJsonString); + } + + private static String toJsonString(Object obj) { + try { + return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize to JSON", e); + } + } + + private static AnalysisReport extractSummary(AnalysisReport report) { + AnalysisReport summary = new AnalysisReport(); + summary.setScanned(report.getScanned()); + if (report.getProviders() == null) { + return summary; + } + report + .getProviders() + .entrySet() + .forEach( + entry -> { + var provider = new ProviderReport(); + provider.setStatus(entry.getValue().getStatus()); + if (entry.getValue().getSources() != null) { + entry + .getValue() + .getSources() + .entrySet() + .forEach( + sourceEntry -> { + var source = new Source(); + source.setSummary(sourceEntry.getValue().getSummary()); + provider.putSourcesItem(sourceEntry.getKey(), source); + }); + } + summary.putProvidersItem(entry.getKey(), provider); + }); + return summary; + } + + private static void printHelp() { + System.out.println("Exhort Java API CLI"); + System.out.println(); + System.out.println("USAGE:"); + System.out.println(" java -jar exhort-java-api.jar [OPTIONS]"); + System.out.println(); + System.out.println("COMMANDS:"); + System.out.println(" stack [--summary|--html]"); + System.out.println(" Perform stack analysis on the specified manifest file"); + System.out.println(" Options:"); + System.out.println(" --summary Output summary in JSON format"); + System.out.println(" --html Output full report in HTML format"); + System.out.println(" (default) Output full report in JSON format"); + System.out.println(); + System.out.println(" component [--summary]"); + System.out.println(" Perform component analysis on the specified manifest file"); + System.out.println(" Options:"); + System.out.println(" --summary Output summary in JSON format"); + System.out.println(" (default) Output full report in JSON format"); + System.out.println(); + System.out.println("OPTIONS:"); + System.out.println(" -h, --help Show this help message"); + System.out.println(); + System.out.println("EXAMPLES:"); + System.out.println(" java -jar exhort-java-api.jar stack /path/to/pom.xml"); + System.out.println(" java -jar exhort-java-api.jar stack /path/to/package.json --summary"); + System.out.println(" java -jar exhort-java-api.jar stack /path/to/build.gradle --html"); + System.out.println(" java -jar exhort-java-api.jar component /path/to/requirements.txt"); + } + + private static class CliArgs { + final Command command; + final Path filePath; + final OutputFormat outputFormat; + + CliArgs(Command command, Path filePath, OutputFormat outputFormat) { + this.command = command; + this.filePath = filePath; + this.outputFormat = outputFormat; + } + } +} From f524e7da6087debc2e2be6e65e791bfdc04de9f9 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Mon, 21 Jul 2025 08:29:31 +0200 Subject: [PATCH 2/5] feat: added tests and refactor App.java Signed-off-by: Ruben Romero Montes --- .gitignore | 1 + .../redhat/exhort/{Cli.java => cli/App.java} | 158 ++++++------- .../java/com/redhat/exhort/cli/AppUtils.java | 43 ++++ .../java/com/redhat/exhort/cli/CliArgs.java | 30 +++ .../java/com/redhat/exhort/cli/Command.java | 21 ++ .../com/redhat/exhort/cli/OutputFormat.java | 22 ++ src/main/resources/cli_help.txt | 27 +++ .../java/com/redhat/exhort/cli/AppTest.java | 210 ++++++++++++++++++ 8 files changed, 417 insertions(+), 95 deletions(-) rename src/main/java/com/redhat/exhort/{Cli.java => cli/App.java} (58%) create mode 100644 src/main/java/com/redhat/exhort/cli/AppUtils.java create mode 100644 src/main/java/com/redhat/exhort/cli/CliArgs.java create mode 100644 src/main/java/com/redhat/exhort/cli/Command.java create mode 100644 src/main/java/com/redhat/exhort/cli/OutputFormat.java create mode 100644 src/main/resources/cli_help.txt create mode 100644 src/test/java/com/redhat/exhort/cli/AppTest.java diff --git a/.gitignore b/.gitignore index 97c4cb7a..5a08838a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ # Maven target .flattened-pom.xml +dependency-reduced-pom.xml # Gradle .gradle diff --git a/src/main/java/com/redhat/exhort/Cli.java b/src/main/java/com/redhat/exhort/cli/App.java similarity index 58% rename from src/main/java/com/redhat/exhort/Cli.java rename to src/main/java/com/redhat/exhort/cli/App.java index 6711611d..04f7983a 100644 --- a/src/main/java/com/redhat/exhort/Cli.java +++ b/src/main/java/com/redhat/exhort/cli/App.java @@ -13,11 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.redhat.exhort; +package com.redhat.exhort.cli; + +import static com.redhat.exhort.cli.AppUtils.exitWithError; +import static com.redhat.exhort.cli.AppUtils.printException; +import static com.redhat.exhort.cli.AppUtils.printLine; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.redhat.exhort.Api; import com.redhat.exhort.api.v4.AnalysisReport; import com.redhat.exhort.api.v4.ProviderReport; import com.redhat.exhort.api.v4.Source; @@ -29,25 +34,15 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -public class Cli { +public class App { private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String CLI_HELPTXT = "cli_help.txt"; static { MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); } - private enum Command { - STACK, - COMPONENT - } - - private enum OutputFormat { - JSON, - SUMMARY, - HTML - } - public static void main(String[] args) { if (args.length == 0 || isHelpRequested(args)) { printHelp(); @@ -57,15 +52,14 @@ public static void main(String[] args) { try { CliArgs cliArgs = parseArgs(args); String result = executeCommand(cliArgs).get(); - System.out.println(result); + printLine(result); } catch (IllegalArgumentException e) { - System.err.println("Error: " + e.getMessage()); - System.err.println(); + printException(e); printHelp(); - System.exit(1); + exitWithError(); } catch (IOException | InterruptedException | ExecutionException e) { - System.err.println("Unexpected error: " + e.getMessage()); - System.exit(1); + printException(e); + exitWithError(); } } @@ -83,56 +77,58 @@ private static CliArgs parseArgs(String[] args) { throw new IllegalArgumentException("Missing required arguments"); } - String commandStr = args[0].toLowerCase(); - Command command; + Command command = parseCommand(args[0]); + + Path path = validateFile(args[1]); + OutputFormat outputFormat = parseOutputFormat(command, args[2]); + + return new CliArgs(command, path, outputFormat); + } + + private static Command parseCommand(String commandStr) { switch (commandStr) { case "stack": - command = Command.STACK; - break; + return Command.STACK; case "component": - command = Command.COMPONENT; - break; + return Command.COMPONENT; default: throw new IllegalArgumentException( "Unknown command: " + commandStr + ". Use 'stack' or 'component'"); } + } - String filePath = args[1]; - Path path = Paths.get(filePath); + private static OutputFormat parseOutputFormat(Command command, String formatArg) { + if (formatArg == null) { + return OutputFormat.JSON; + } + + switch (formatArg) { + case "--summary": + return OutputFormat.SUMMARY; + case "--html": + if (command != Command.STACK) { + throw new IllegalArgumentException("HTML format is only supported for stack command"); + } + return OutputFormat.HTML; + default: + throw new IllegalArgumentException( + "Unknown option for " + command + " command: " + formatArg); + } + } + private static Path validateFile(String filePath) { + Path path = Paths.get(filePath); if (!Files.exists(path)) { throw new IllegalArgumentException("File does not exist: " + filePath); } - if (!Files.isRegularFile(path)) { - throw new IllegalArgumentException("Path is not a file: " + filePath); + throw new IllegalArgumentException("File is not a regular file: " + filePath); } - - OutputFormat outputFormat = OutputFormat.JSON; // default - - // Parse additional options for stack command - if (args.length > 2) { - for (int i = 2; i < args.length; i++) { - switch (args[i]) { - case "--summary": - outputFormat = OutputFormat.SUMMARY; - break; - case "--html": - if (command != Command.STACK) { - throw new IllegalArgumentException("HTML format is only supported for stack command"); - } - outputFormat = OutputFormat.HTML; - break; - default: - throw new IllegalArgumentException("Unknown option for stack command: " + args[i]); - } - } - } else if (command == Command.COMPONENT && args.length > 2) { - throw new IllegalArgumentException("Component command does not accept additional options"); + if (!Files.isReadable(path)) { + throw new IllegalArgumentException("File is not readable: " + filePath); } - - return new CliArgs(command, path, outputFormat); + return path; } private static CompletableFuture executeCommand(CliArgs args) throws IOException { @@ -152,13 +148,13 @@ private static CompletableFuture executeStackAnalysis( Api api = new ExhortApi(); switch (outputFormat) { case JSON: - return api.stackAnalysis(filePath).thenApply(Cli::toJsonString); + return api.stackAnalysis(filePath).thenApply(App::toJsonString); case HTML: return api.stackAnalysisHtml(filePath).thenApply(bytes -> new String(bytes)); case SUMMARY: return api.stackAnalysis(filePath) - .thenApply(Cli::extractSummary) - .thenApply(Cli::toJsonString); + .thenApply(App::extractSummary) + .thenApply(App::toJsonString); default: throw new AssertionError(); } @@ -169,9 +165,9 @@ private static CompletableFuture executeComponentAnalysis( Api api = new ExhortApi(); CompletableFuture analysis = api.componentAnalysis(filePath); if (outputFormat.equals(OutputFormat.SUMMARY)) { - analysis = analysis.thenApply(Cli::extractSummary); + analysis = analysis.thenApply(App::extractSummary); } - return analysis.thenApply(Cli::toJsonString); + return analysis.thenApply(App::toJsonString); } private static String toJsonString(Object obj) { @@ -213,44 +209,16 @@ private static AnalysisReport extractSummary(AnalysisReport report) { } private static void printHelp() { - System.out.println("Exhort Java API CLI"); - System.out.println(); - System.out.println("USAGE:"); - System.out.println(" java -jar exhort-java-api.jar [OPTIONS]"); - System.out.println(); - System.out.println("COMMANDS:"); - System.out.println(" stack [--summary|--html]"); - System.out.println(" Perform stack analysis on the specified manifest file"); - System.out.println(" Options:"); - System.out.println(" --summary Output summary in JSON format"); - System.out.println(" --html Output full report in HTML format"); - System.out.println(" (default) Output full report in JSON format"); - System.out.println(); - System.out.println(" component [--summary]"); - System.out.println(" Perform component analysis on the specified manifest file"); - System.out.println(" Options:"); - System.out.println(" --summary Output summary in JSON format"); - System.out.println(" (default) Output full report in JSON format"); - System.out.println(); - System.out.println("OPTIONS:"); - System.out.println(" -h, --help Show this help message"); - System.out.println(); - System.out.println("EXAMPLES:"); - System.out.println(" java -jar exhort-java-api.jar stack /path/to/pom.xml"); - System.out.println(" java -jar exhort-java-api.jar stack /path/to/package.json --summary"); - System.out.println(" java -jar exhort-java-api.jar stack /path/to/build.gradle --html"); - System.out.println(" java -jar exhort-java-api.jar component /path/to/requirements.txt"); - } - - private static class CliArgs { - final Command command; - final Path filePath; - final OutputFormat outputFormat; + try (var inputStream = App.class.getClassLoader().getResourceAsStream(CLI_HELPTXT)) { + if (inputStream == null) { + AppUtils.printError("Help file not found."); + return; + } - CliArgs(Command command, Path filePath, OutputFormat outputFormat) { - this.command = command; - this.filePath = filePath; - this.outputFormat = outputFormat; + String helpText = new String(inputStream.readAllBytes()); + printLine(helpText); + } catch (IOException e) { + AppUtils.printError("Error reading help file: " + e.getMessage()); } } } diff --git a/src/main/java/com/redhat/exhort/cli/AppUtils.java b/src/main/java/com/redhat/exhort/cli/AppUtils.java new file mode 100644 index 00000000..e05975f0 --- /dev/null +++ b/src/main/java/com/redhat/exhort/cli/AppUtils.java @@ -0,0 +1,43 @@ +/* + * Copyright © 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.redhat.exhort.cli; + +public class AppUtils { + public static void exitWithError() { + System.exit(1); + } + + public static void exitWithSuccess() { + System.exit(0); + } + + public static void printLine(String message) { + System.out.println(message); + } + + public static void printLine() { + System.out.println(); + } + + public static void printError(String message) { + System.err.println(message); + System.err.println(); + } + + public static void printException(Exception e) { + printError("Error: " + e.getMessage()); + } +} diff --git a/src/main/java/com/redhat/exhort/cli/CliArgs.java b/src/main/java/com/redhat/exhort/cli/CliArgs.java new file mode 100644 index 00000000..4a0cff47 --- /dev/null +++ b/src/main/java/com/redhat/exhort/cli/CliArgs.java @@ -0,0 +1,30 @@ +/* + * Copyright © 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.redhat.exhort.cli; + +import java.nio.file.Path; + +public class CliArgs { + public final Command command; + public final Path filePath; + public final OutputFormat outputFormat; + + public CliArgs(Command command, Path filePath, OutputFormat outputFormat) { + this.command = command; + this.filePath = filePath; + this.outputFormat = outputFormat; + } +} diff --git a/src/main/java/com/redhat/exhort/cli/Command.java b/src/main/java/com/redhat/exhort/cli/Command.java new file mode 100644 index 00000000..53a43d4b --- /dev/null +++ b/src/main/java/com/redhat/exhort/cli/Command.java @@ -0,0 +1,21 @@ +/* + * Copyright © 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.redhat.exhort.cli; + +public enum Command { + STACK, + COMPONENT +} diff --git a/src/main/java/com/redhat/exhort/cli/OutputFormat.java b/src/main/java/com/redhat/exhort/cli/OutputFormat.java new file mode 100644 index 00000000..52a67442 --- /dev/null +++ b/src/main/java/com/redhat/exhort/cli/OutputFormat.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.redhat.exhort.cli; + +public enum OutputFormat { + JSON, + SUMMARY, + HTML +} diff --git a/src/main/resources/cli_help.txt b/src/main/resources/cli_help.txt new file mode 100644 index 00000000..8301f1cf --- /dev/null +++ b/src/main/resources/cli_help.txt @@ -0,0 +1,27 @@ +Exhort Java API CLI + +USAGE: + java -jar exhort-java-api.jar [OPTIONS] + +COMMANDS: + stack [--summary|--html] + Perform stack analysis on the specified manifest file + Options: + --summary Output summary in JSON format + --html Output full report in HTML format + (default) Output full report in JSON format + + component [--summary] + Perform component analysis on the specified manifest file + Options: + --summary Output summary in JSON format + (default) Output full report in JSON format + +OPTIONS: + -h, --help Show this help message + +EXAMPLES: + java -jar exhort-java-api.jar stack /path/to/pom.xml + java -jar exhort-java-api.jar stack /path/to/package.json --summary + java -jar exhort-java-api.jar stack /path/to/build.gradle --html + java -jar exhort-java-api.jar component /path/to/requirements.txt diff --git a/src/test/java/com/redhat/exhort/cli/AppTest.java b/src/test/java/com/redhat/exhort/cli/AppTest.java new file mode 100644 index 00000000..7bdb39ff --- /dev/null +++ b/src/test/java/com/redhat/exhort/cli/AppTest.java @@ -0,0 +1,210 @@ +/* + * Copyright © 2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ +package com.redhat.exhort.cli; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mockStatic; + +import com.redhat.exhort.ExhortTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AppTest extends ExhortTest { + + @Nested + class HelpFunctionalityTests { + + @Test + void main_with_no_args_should_print_help() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[0]); + + mockedAppUtils.verify(() -> AppUtils.printLine(contains("Exhort Java API CLI"))); + } + } + + @ParameterizedTest + @ValueSource(strings = {"--help", "-h"}) + void main_with_help_flag_should_print_help(String helpFlag) { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {helpFlag}); + + mockedAppUtils.verify(() -> AppUtils.printLine(contains("Exhort Java API CLI"))); + mockedAppUtils.verify(() -> AppUtils.printLine(contains("USAGE:"))); + mockedAppUtils.verify( + () -> + AppUtils.printLine( + contains("java -jar exhort-java-api.jar [OPTIONS]"))); + } + } + + @Test + void main_with_help_flag_after_other_args_should_print_help() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"stack", "--help"}); + + mockedAppUtils.verify(() -> AppUtils.printLine(contains("Exhort Java API CLI"))); + } + } + + @Test + void help_should_contain_usage_section() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify(() -> AppUtils.printLine(contains("USAGE:"))); + mockedAppUtils.verify( + () -> + AppUtils.printLine( + contains("java -jar exhort-java-api.jar [OPTIONS]"))); + } + } + + @Test + void help_should_contain_commands_section() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify(() -> AppUtils.printLine(contains("COMMANDS:"))); + mockedAppUtils.verify( + () -> AppUtils.printLine(contains("stack [--summary|--html]"))); + mockedAppUtils.verify( + () -> AppUtils.printLine(contains("component [--summary]"))); + } + } + + @Test + void help_should_contain_stack_command_description() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify( + () -> + AppUtils.printLine( + contains("Perform stack analysis on the specified manifest file"))); + mockedAppUtils.verify( + () -> AppUtils.printLine(contains("--summary Output summary in JSON format"))); + mockedAppUtils.verify( + () -> AppUtils.printLine(contains("--html Output full report in HTML format"))); + mockedAppUtils.verify( + () -> AppUtils.printLine(contains("(default) Output full report in JSON format"))); + } + } + + @Test + void help_should_contain_component_command_description() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify( + () -> + AppUtils.printLine( + contains("Perform component analysis on the specified manifest file"))); + mockedAppUtils.verify( + () -> AppUtils.printLine(contains("--summary Output summary in JSON format"))); + mockedAppUtils.verify( + () -> AppUtils.printLine(contains("(default) Output full report in JSON format"))); + } + } + + @Test + void help_should_contain_options_section() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify(() -> AppUtils.printLine(contains("OPTIONS:"))); + mockedAppUtils.verify( + () -> AppUtils.printLine(contains("-h, --help Show this help message"))); + } + } + + @Test + void help_should_contain_examples_section() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify(() -> AppUtils.printLine(contains("EXAMPLES:"))); + mockedAppUtils.verify( + () -> + AppUtils.printLine( + contains("java -jar exhort-java-api.jar stack /path/to/pom.xml"))); + mockedAppUtils.verify( + () -> + AppUtils.printLine( + contains( + "java -jar exhort-java-api.jar stack /path/to/package.json --summary"))); + mockedAppUtils.verify( + () -> + AppUtils.printLine( + contains("java -jar exhort-java-api.jar stack /path/to/build.gradle --html"))); + mockedAppUtils.verify( + () -> + AppUtils.printLine( + contains("java -jar exhort-java-api.jar component /path/to/requirements.txt"))); + } + } + } + + @Nested + class ErrorHandlingTests { + + @Test + void main_with_missing_arguments_should_exit_with_error() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"stack"}); + + // The app should print exception and exit - matches actual App.java behavior + mockedAppUtils.verify(() -> AppUtils.printException(any(Exception.class))); + mockedAppUtils.verify(() -> AppUtils.exitWithError()); + } + } + + @Test + void main_with_invalid_command_should_exit_with_error() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"invalidcommand", "somefile.txt"}); + + // The app should print exception and exit - matches actual App.java behavior + mockedAppUtils.verify(() -> AppUtils.printException(any(Exception.class))); + mockedAppUtils.verify(() -> AppUtils.exitWithError()); + } + } + } + + @Nested + class HelpFileHandlingTests { + + @Test + void help_loads_from_external_file() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + // Verify that help content is printed (loaded from cli_help.txt) + mockedAppUtils.verify(() -> AppUtils.printLine(contains("Exhort Java API CLI"))); + mockedAppUtils.verify(() -> AppUtils.printLine(contains("USAGE:"))); + mockedAppUtils.verify(() -> AppUtils.printLine(contains("COMMANDS:"))); + mockedAppUtils.verify(() -> AppUtils.printLine(contains("EXAMPLES:"))); + } + } + } +} From ff8528fc8a37bc2ede7fc8f9f6e0ae1daabb14a5 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Mon, 21 Jul 2025 08:56:23 +0200 Subject: [PATCH 3/5] feat: update entrypoint and shade plugin config Signed-off-by: Ruben Romero Montes --- pom.xml | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 279c7966..37a14d56 100644 --- a/pom.xml +++ b/pom.xml @@ -662,7 +662,7 @@ limitations under the License.]]> - com.redhat.exhort.Cli + com.redhat.exhort.cli.App @@ -679,11 +679,61 @@ limitations under the License.]]> true cli + + + + + *:* + + + module-info.class + META-INF/versions/*/module-info.class + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + META-INF/MANIFEST.MF + + + + + + - com.redhat.exhort.Cli + com.redhat.exhort.cli.App + + + + + + + META-INF/NOTICE + + + META-INF/NOTICE.txt + + + META-INF/NOTICE.md + + + + + META-INF/LICENSE + + + META-INF/LICENSE.txt + + + META-INF/LICENSE.md + + + + + false From 529ba737780a61585211ba12bf87036be454bc56 Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Mon, 21 Jul 2025 15:16:12 +0200 Subject: [PATCH 4/5] chore: add better test coverage Signed-off-by: Ruben Romero Montes --- src/main/java/com/redhat/exhort/cli/App.java | 12 +- .../java/com/redhat/exhort/cli/AppUtils.java | 4 - .../java/com/redhat/exhort/cli/AppTest.java | 819 ++++++++++++++---- 3 files changed, 648 insertions(+), 187 deletions(-) diff --git a/src/main/java/com/redhat/exhort/cli/App.java b/src/main/java/com/redhat/exhort/cli/App.java index 04f7983a..d1132fb7 100644 --- a/src/main/java/com/redhat/exhort/cli/App.java +++ b/src/main/java/com/redhat/exhort/cli/App.java @@ -81,7 +81,10 @@ private static CliArgs parseArgs(String[] args) { Path path = validateFile(args[1]); - OutputFormat outputFormat = parseOutputFormat(command, args[2]); + OutputFormat outputFormat = OutputFormat.JSON; + if (args.length == 3) { + outputFormat = parseOutputFormat(command, args[2]); + } return new CliArgs(command, path, outputFormat); } @@ -99,10 +102,6 @@ private static Command parseCommand(String commandStr) { } private static OutputFormat parseOutputFormat(Command command, String formatArg) { - if (formatArg == null) { - return OutputFormat.JSON; - } - switch (formatArg) { case "--summary": return OutputFormat.SUMMARY; @@ -125,9 +124,6 @@ private static Path validateFile(String filePath) { if (!Files.isRegularFile(path)) { throw new IllegalArgumentException("File is not a regular file: " + filePath); } - if (!Files.isReadable(path)) { - throw new IllegalArgumentException("File is not readable: " + filePath); - } return path; } diff --git a/src/main/java/com/redhat/exhort/cli/AppUtils.java b/src/main/java/com/redhat/exhort/cli/AppUtils.java index e05975f0..863e1743 100644 --- a/src/main/java/com/redhat/exhort/cli/AppUtils.java +++ b/src/main/java/com/redhat/exhort/cli/AppUtils.java @@ -20,10 +20,6 @@ public static void exitWithError() { System.exit(1); } - public static void exitWithSuccess() { - System.exit(0); - } - public static void printLine(String message) { System.out.println(message); } diff --git a/src/test/java/com/redhat/exhort/cli/AppTest.java b/src/test/java/com/redhat/exhort/cli/AppTest.java index 7bdb39ff..e68ad7de 100644 --- a/src/test/java/com/redhat/exhort/cli/AppTest.java +++ b/src/test/java/com/redhat/exhort/cli/AppTest.java @@ -15,196 +15,665 @@ */ package com.redhat.exhort.cli; +import static com.redhat.exhort.cli.AppUtils.exitWithError; +import static com.redhat.exhort.cli.AppUtils.printException; +import static com.redhat.exhort.cli.AppUtils.printLine; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; import com.redhat.exhort.ExhortTest; -import org.junit.jupiter.api.Nested; +import com.redhat.exhort.api.v4.AnalysisReport; +import com.redhat.exhort.api.v4.ProviderReport; +import com.redhat.exhort.api.v4.ProviderStatus; +import com.redhat.exhort.api.v4.Scanned; +import com.redhat.exhort.api.v4.Source; +import com.redhat.exhort.api.v4.SourceSummary; +import com.redhat.exhort.impl.ExhortApi; +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class AppTest extends ExhortTest { - @Nested - class HelpFunctionalityTests { - - @Test - void main_with_no_args_should_print_help() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[0]); - - mockedAppUtils.verify(() -> AppUtils.printLine(contains("Exhort Java API CLI"))); - } - } - - @ParameterizedTest - @ValueSource(strings = {"--help", "-h"}) - void main_with_help_flag_should_print_help(String helpFlag) { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {helpFlag}); - - mockedAppUtils.verify(() -> AppUtils.printLine(contains("Exhort Java API CLI"))); - mockedAppUtils.verify(() -> AppUtils.printLine(contains("USAGE:"))); - mockedAppUtils.verify( - () -> - AppUtils.printLine( - contains("java -jar exhort-java-api.jar [OPTIONS]"))); - } - } - - @Test - void main_with_help_flag_after_other_args_should_print_help() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {"stack", "--help"}); - - mockedAppUtils.verify(() -> AppUtils.printLine(contains("Exhort Java API CLI"))); - } - } - - @Test - void help_should_contain_usage_section() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {"--help"}); - - mockedAppUtils.verify(() -> AppUtils.printLine(contains("USAGE:"))); - mockedAppUtils.verify( - () -> - AppUtils.printLine( - contains("java -jar exhort-java-api.jar [OPTIONS]"))); - } - } - - @Test - void help_should_contain_commands_section() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {"--help"}); - - mockedAppUtils.verify(() -> AppUtils.printLine(contains("COMMANDS:"))); - mockedAppUtils.verify( - () -> AppUtils.printLine(contains("stack [--summary|--html]"))); - mockedAppUtils.verify( - () -> AppUtils.printLine(contains("component [--summary]"))); - } - } - - @Test - void help_should_contain_stack_command_description() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {"--help"}); - - mockedAppUtils.verify( - () -> - AppUtils.printLine( - contains("Perform stack analysis on the specified manifest file"))); - mockedAppUtils.verify( - () -> AppUtils.printLine(contains("--summary Output summary in JSON format"))); - mockedAppUtils.verify( - () -> AppUtils.printLine(contains("--html Output full report in HTML format"))); - mockedAppUtils.verify( - () -> AppUtils.printLine(contains("(default) Output full report in JSON format"))); - } - } - - @Test - void help_should_contain_component_command_description() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {"--help"}); - - mockedAppUtils.verify( - () -> - AppUtils.printLine( - contains("Perform component analysis on the specified manifest file"))); - mockedAppUtils.verify( - () -> AppUtils.printLine(contains("--summary Output summary in JSON format"))); - mockedAppUtils.verify( - () -> AppUtils.printLine(contains("(default) Output full report in JSON format"))); - } - } - - @Test - void help_should_contain_options_section() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {"--help"}); - - mockedAppUtils.verify(() -> AppUtils.printLine(contains("OPTIONS:"))); - mockedAppUtils.verify( - () -> AppUtils.printLine(contains("-h, --help Show this help message"))); - } - } - - @Test - void help_should_contain_examples_section() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {"--help"}); - - mockedAppUtils.verify(() -> AppUtils.printLine(contains("EXAMPLES:"))); - mockedAppUtils.verify( - () -> - AppUtils.printLine( - contains("java -jar exhort-java-api.jar stack /path/to/pom.xml"))); - mockedAppUtils.verify( - () -> - AppUtils.printLine( - contains( - "java -jar exhort-java-api.jar stack /path/to/package.json --summary"))); - mockedAppUtils.verify( - () -> - AppUtils.printLine( - contains("java -jar exhort-java-api.jar stack /path/to/build.gradle --html"))); - mockedAppUtils.verify( - () -> - AppUtils.printLine( - contains("java -jar exhort-java-api.jar component /path/to/requirements.txt"))); - } - } - } - - @Nested - class ErrorHandlingTests { - - @Test - void main_with_missing_arguments_should_exit_with_error() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {"stack"}); - - // The app should print exception and exit - matches actual App.java behavior - mockedAppUtils.verify(() -> AppUtils.printException(any(Exception.class))); - mockedAppUtils.verify(() -> AppUtils.exitWithError()); - } - } - - @Test - void main_with_invalid_command_should_exit_with_error() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {"invalidcommand", "somefile.txt"}); - - // The app should print exception and exit - matches actual App.java behavior - mockedAppUtils.verify(() -> AppUtils.printException(any(Exception.class))); - mockedAppUtils.verify(() -> AppUtils.exitWithError()); - } - } - } - - @Nested - class HelpFileHandlingTests { - - @Test - void help_loads_from_external_file() { - try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { - App.main(new String[] {"--help"}); - - // Verify that help content is printed (loaded from cli_help.txt) - mockedAppUtils.verify(() -> AppUtils.printLine(contains("Exhort Java API CLI"))); - mockedAppUtils.verify(() -> AppUtils.printLine(contains("USAGE:"))); - mockedAppUtils.verify(() -> AppUtils.printLine(contains("COMMANDS:"))); - mockedAppUtils.verify(() -> AppUtils.printLine(contains("EXAMPLES:"))); - } + private static final Path TEST_FILE = Paths.get("/test/path/manifest.xml"); + private static final String NON_EXISTENT_FILE = "/non/existent/file.xml"; + private static final String DIRECTORY_PATH = "/some/directory"; + + @Test + void main_with_no_args_should_print_help() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[0]); + + mockedAppUtils.verify(() -> printLine(contains("Exhort Java API CLI"))); + } + } + + @ParameterizedTest + @ValueSource(strings = {"--help", "-h"}) + void main_with_help_flag_should_print_help(String helpFlag) { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {helpFlag}); + + mockedAppUtils.verify(() -> printLine(contains("Exhort Java API CLI"))); + mockedAppUtils.verify(() -> printLine(contains("USAGE:"))); + mockedAppUtils.verify( + () -> + printLine(contains("java -jar exhort-java-api.jar [OPTIONS]"))); + } + } + + @Test + void main_with_help_flag_after_other_args_should_print_help() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"stack", "--help"}); + + mockedAppUtils.verify(() -> printLine(contains("Exhort Java API CLI"))); + } + } + + @Test + void help_should_contain_usage_section() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify(() -> printLine(contains("USAGE:"))); + mockedAppUtils.verify( + () -> + printLine(contains("java -jar exhort-java-api.jar [OPTIONS]"))); + } + } + + @Test + void help_should_contain_commands_section() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify(() -> printLine(contains("COMMANDS:"))); + mockedAppUtils.verify(() -> printLine(contains("stack [--summary|--html]"))); + mockedAppUtils.verify(() -> printLine(contains("component [--summary]"))); + } + } + + @Test + void help_should_contain_stack_command_description() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify( + () -> printLine(contains("Perform stack analysis on the specified manifest file"))); + mockedAppUtils.verify( + () -> printLine(contains("--summary Output summary in JSON format"))); + mockedAppUtils.verify( + () -> printLine(contains("--html Output full report in HTML format"))); + mockedAppUtils.verify( + () -> printLine(contains("(default) Output full report in JSON format"))); + } + } + + @Test + void help_should_contain_component_command_description() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify( + () -> printLine(contains("Perform component analysis on the specified manifest file"))); + mockedAppUtils.verify( + () -> printLine(contains("--summary Output summary in JSON format"))); + mockedAppUtils.verify( + () -> printLine(contains("(default) Output full report in JSON format"))); + } + } + + @Test + void help_should_contain_options_section() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify(() -> printLine(contains("OPTIONS:"))); + mockedAppUtils.verify(() -> printLine(contains("-h, --help Show this help message"))); + } + } + + @Test + void help_should_contain_examples_section() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + mockedAppUtils.verify(() -> printLine(contains("EXAMPLES:"))); + mockedAppUtils.verify( + () -> printLine(contains("java -jar exhort-java-api.jar stack /path/to/pom.xml"))); + mockedAppUtils.verify( + () -> + printLine( + contains("java -jar exhort-java-api.jar stack /path/to/package.json --summary"))); + mockedAppUtils.verify( + () -> + printLine( + contains("java -jar exhort-java-api.jar stack /path/to/build.gradle --html"))); + mockedAppUtils.verify( + () -> + printLine( + contains("java -jar exhort-java-api.jar component /path/to/requirements.txt"))); + } + } + + @Test + void main_with_missing_arguments_should_exit_with_error() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"stack"}); + + // The app should print exception and exit - matches actual App.java behavior + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_invalid_command_should_exit_with_error() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"invalidcommand", "somefile.txt"}); + + // The app should print exception and exit - matches actual App.java behavior + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void help_loads_from_external_file() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"--help"}); + + // Verify that help content is printed (loaded from cli_help.txt) + mockedAppUtils.verify(() -> printLine(contains("Exhort Java API CLI"))); + mockedAppUtils.verify(() -> printLine(contains("USAGE:"))); + mockedAppUtils.verify(() -> printLine(contains("COMMANDS:"))); + mockedAppUtils.verify(() -> printLine(contains("EXAMPLES:"))); } } + + @Test + void executeCommand_with_stack_analysis_should_complete_successfully() throws Exception { + // Create CliArgs + CliArgs args = new CliArgs(Command.STACK, TEST_FILE, OutputFormat.JSON); + + // Mock the AnalysisReport + AnalysisReport mockReport = mock(AnalysisReport.class); + + // Mock ExhortApi constructor and its methods + try (MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.stackAnalysis(any(String.class))) + .thenReturn(CompletableFuture.completedFuture(mockReport)); + })) { + + // Use reflection to access the private executeCommand method + Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class); + executeCommandMethod.setAccessible(true); + + // Execute the method + CompletableFuture result = + (CompletableFuture) executeCommandMethod.invoke(null, args); + + // Verify the result + assertThat(result).isNotNull(); + assertThat(result.get()).isNotNull(); + } + } + + @Test + void executeCommand_with_component_analysis_should_complete_successfully() throws Exception { + // Create CliArgs + CliArgs args = new CliArgs(Command.COMPONENT, TEST_FILE, OutputFormat.SUMMARY); + + // Mock the AnalysisReport + AnalysisReport mockReport = mock(AnalysisReport.class); + + // Mock ExhortApi constructor and its methods + try (MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.componentAnalysis(any(String.class))) + .thenReturn(CompletableFuture.completedFuture(mockReport)); + })) { + + // Use reflection to access the private executeCommand method + Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class); + executeCommandMethod.setAccessible(true); + + // Execute the method + CompletableFuture result = + (CompletableFuture) executeCommandMethod.invoke(null, args); + + // Verify the result + assertThat(result).isNotNull(); + assertThat(result.get()).isNotNull(); + } + } + + @Test + void executeCommand_with_IOException_should_propagate_exception() throws Exception { + // Create CliArgs + CliArgs args = new CliArgs(Command.STACK, TEST_FILE, OutputFormat.JSON); + + // Mock ExhortApi constructor to throw IOException + try (MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.stackAnalysis(any(String.class))) + .thenThrow(new IOException("Network error")); + })) { + + // Use reflection to access the private executeCommand method + Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class); + executeCommandMethod.setAccessible(true); + + // Execute and verify exception + assertThatThrownBy( + () -> { + executeCommandMethod.invoke(null, args); + }) + .hasCauseInstanceOf(IOException.class); + } + } + + @Test + void executeCommand_with_ExecutionException_should_propagate_exception() throws Exception { + // Create CliArgs + CliArgs args = new CliArgs(Command.COMPONENT, TEST_FILE, OutputFormat.JSON); + + // Create a failed future to simulate ExecutionException + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Analysis failed")); + + // Mock ExhortApi constructor + try (MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.componentAnalysis(any(String.class))).thenReturn(failedFuture); + })) { + + // Use reflection to access the private executeCommand method + Method executeCommandMethod = App.class.getDeclaredMethod("executeCommand", CliArgs.class); + executeCommandMethod.setAccessible(true); + + // Execute the method + CompletableFuture result = + (CompletableFuture) executeCommandMethod.invoke(null, args); + + // Verify the result throws ExecutionException when accessed + assertThatThrownBy(() -> result.get()).isInstanceOf(ExecutionException.class); + } + } + + @Test + void main_with_invalid_file_should_handle_IOException() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"stack", "/non/existent/file.xml"}); + + // Verify that the exception is caught and handled + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_directory_instead_of_file_should_handle_IOException() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"stack", DIRECTORY_PATH}); + + // Verify that the exception is caught and handled + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_execution_exception_should_handle_gracefully() { + // Test with a file path that will cause ExecutionException in processing + String unsupportedFile = "/path/to/unsupported.txt"; + + // Create a failed future to simulate ExecutionException + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Analysis failed")); + + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.stackAnalysis(any(String.class))).thenReturn(failedFuture); + })) { + + App.main(new String[] {"stack", unsupportedFile}); + + // Verify that the exception is caught and handled + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void command_enum_should_have_correct_values() { + assertThat(Command.STACK).isNotNull(); + assertThat(Command.COMPONENT).isNotNull(); + assertThat(Command.values()).hasSize(2); + assertThat(Command.valueOf("STACK")).isEqualTo(Command.STACK); + assertThat(Command.valueOf("COMPONENT")).isEqualTo(Command.COMPONENT); + } + + @Test + void output_format_enum_should_have_correct_values() { + assertThat(OutputFormat.JSON).isNotNull(); + assertThat(OutputFormat.HTML).isNotNull(); + assertThat(OutputFormat.SUMMARY).isNotNull(); + assertThat(OutputFormat.values()).hasSize(3); + assertThat(OutputFormat.valueOf("JSON")).isEqualTo(OutputFormat.JSON); + assertThat(OutputFormat.valueOf("HTML")).isEqualTo(OutputFormat.HTML); + assertThat(OutputFormat.valueOf("SUMMARY")).isEqualTo(OutputFormat.SUMMARY); + } + + @Test + void cli_args_should_store_values_correctly() { + CliArgs args = new CliArgs(Command.STACK, TEST_FILE, OutputFormat.JSON); + + assertThat(args.command).isEqualTo(Command.STACK); + assertThat(args.filePath).isEqualTo(TEST_FILE); + assertThat(args.outputFormat).isEqualTo(OutputFormat.JSON); + } + + @Test + void app_utils_exit_methods_should_be_mockable() { + // These will actually call System.exit(), so we test them with mocking + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + exitWithError(); + + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_invalid_command_should_handle_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"invalidcommand", "pom.xml"}); + + // Verify that the exception is caught and handled + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_unknown_command_should_handle_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"unknown", "pom.xml"}); + + // Verify that the exception is caught and handled + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_empty_command_should_handle_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"", "pom.xml"}); + + // Verify that the exception is caught and handled + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_valid_formats_should_work_with_mocked_api() { + // Mock the AnalysisReport + AnalysisReport mockReport = defaultAnalysisReport(); + + // Test summary format for stack command + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.stackAnalysis(any(String.class))) + .thenReturn(CompletableFuture.completedFuture(mockReport)); + })) { + + App.main(new String[] {"stack", "pom.xml", "--summary"}); + + mockedAppUtils.verify(() -> printLine(any(String.class))); + } + + // Test HTML format for stack command + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.stackAnalysisHtml(any(String.class))) + .thenReturn(CompletableFuture.completedFuture(new byte[0])); + })) { + + App.main(new String[] {"stack", "pom.xml", "--html"}); + + mockedAppUtils.verify(() -> printLine(any(String.class))); + } + + // Test summary format for component command + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.componentAnalysis(any(String.class))) + .thenReturn(CompletableFuture.completedFuture(mockReport)); + })) { + + App.main(new String[] {"component", "pom.xml", "--summary"}); + + mockedAppUtils.verify(() -> printLine(any(String.class))); + } + } + + @Test + void main_for_component_should_handle_interrupted_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.componentAnalysis(any(String.class))) + .thenThrow(new IOException("Example exception")); + })) { + + App.main(new String[] {"component", "pom.xml", "--summary"}); + + mockedAppUtils.verify(() -> printException(any(IOException.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_html_for_component_should_handle_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"component", "pom.xml", "--html"}); + + // HTML format is not supported for component analysis + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_invalid_format_should_handle_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"stack", "pom.xml", "--invalid"}); + + // Invalid format should cause exception + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_xml_format_should_handle_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"component", "pom.xml", "--xml"}); + + // XML format is not supported + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_valid_existing_file_should_work_with_mocked_api() { + // Mock the AnalysisReport + AnalysisReport mockReport = mock(AnalysisReport.class); + + // Test with the current pom.xml file which should exist + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.stackAnalysis(any(String.class))) + .thenReturn(CompletableFuture.completedFuture(mockReport)); + })) { + + App.main(new String[] {"stack", "pom.xml"}); + + mockedAppUtils.verify(() -> printLine(any(String.class))); + } + + // Test with absolute path to pom.xml + String absolutePomPath = System.getProperty("user.dir") + "/pom.xml"; + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.componentAnalysis(any(String.class))) + .thenReturn(CompletableFuture.completedFuture(mockReport)); + })) { + + App.main(new String[] {"component", absolutePomPath}); + + mockedAppUtils.verify(() -> printLine(any(String.class))); + } + } + + @Test + void main_with_non_existent_file_should_handle_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"stack", NON_EXISTENT_FILE}); + + // File validation should fail + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_definitely_non_existent_file_should_handle_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"component", "/definitely/does/not/exist.xml"}); + + // File validation should fail + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_tmp_directory_should_handle_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"stack", "/tmp"}); + + // Directory validation should fail + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_system_temp_directory_should_handle_exception() { + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class)) { + App.main(new String[] {"component", System.getProperty("java.io.tmpdir")}); + + // Directory validation should fail + mockedAppUtils.verify(() -> printException(any(Exception.class))); + mockedAppUtils.verify(() -> exitWithError()); + } + } + + @Test + void main_with_default_json_format_should_work_with_mocked_api() { + // Mock the AnalysisReport + AnalysisReport mockReport = mock(AnalysisReport.class); + + // Test default JSON format for stack command (no format flag) + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.stackAnalysis(any(String.class))) + .thenReturn(CompletableFuture.completedFuture(mockReport)); + })) { + + App.main(new String[] {"stack", "pom.xml"}); + + mockedAppUtils.verify(() -> printLine(any(String.class))); + } + + // Test default JSON format for component command (no format flag) + try (MockedStatic mockedAppUtils = mockStatic(AppUtils.class); + MockedConstruction mockedExhortApi = + mockConstruction( + ExhortApi.class, + (mock, context) -> { + when(mock.componentAnalysis(any(String.class))) + .thenReturn(CompletableFuture.completedFuture(mockReport)); + })) { + + App.main(new String[] {"component", "pom.xml"}); + + mockedAppUtils.verify(() -> printLine(any(String.class))); + } + } + + private AnalysisReport defaultAnalysisReport() { + AnalysisReport report = new AnalysisReport(); + report.setScanned(new Scanned().direct(10).transitive(10).total(20)); + report.putProvidersItem( + "tpa", + new ProviderReport() + .status(new ProviderStatus().code(200).message("OK")) + .putSourcesItem("osv", new Source().summary(new SourceSummary()))); + return report; + } } From 30209d872635c7dbce21a53f3fdb8d0f3f6a66ae Mon Sep 17 00:00:00 2001 From: Ruben Romero Montes Date: Mon, 21 Jul 2025 15:40:09 +0200 Subject: [PATCH 5/5] docs: update license header Signed-off-by: Ruben Romero Montes --- src/main/java/com/redhat/exhort/cli/App.java | 2 +- src/main/java/com/redhat/exhort/cli/AppUtils.java | 2 +- src/main/java/com/redhat/exhort/cli/CliArgs.java | 2 +- src/main/java/com/redhat/exhort/cli/Command.java | 2 +- src/main/java/com/redhat/exhort/cli/OutputFormat.java | 2 +- src/test/java/com/redhat/exhort/cli/AppTest.java | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/redhat/exhort/cli/App.java b/src/main/java/com/redhat/exhort/cli/App.java index d1132fb7..ee11827c 100644 --- a/src/main/java/com/redhat/exhort/cli/App.java +++ b/src/main/java/com/redhat/exhort/cli/App.java @@ -1,5 +1,5 @@ /* - * Copyright © 2025 Red Hat, Inc. + * Copyright © 2023 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/redhat/exhort/cli/AppUtils.java b/src/main/java/com/redhat/exhort/cli/AppUtils.java index 863e1743..4f6973d9 100644 --- a/src/main/java/com/redhat/exhort/cli/AppUtils.java +++ b/src/main/java/com/redhat/exhort/cli/AppUtils.java @@ -1,5 +1,5 @@ /* - * Copyright © 2025 Red Hat, Inc. + * Copyright © 2023 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/redhat/exhort/cli/CliArgs.java b/src/main/java/com/redhat/exhort/cli/CliArgs.java index 4a0cff47..6201dc74 100644 --- a/src/main/java/com/redhat/exhort/cli/CliArgs.java +++ b/src/main/java/com/redhat/exhort/cli/CliArgs.java @@ -1,5 +1,5 @@ /* - * Copyright © 2025 Red Hat, Inc. + * Copyright © 2023 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/redhat/exhort/cli/Command.java b/src/main/java/com/redhat/exhort/cli/Command.java index 53a43d4b..a79a3867 100644 --- a/src/main/java/com/redhat/exhort/cli/Command.java +++ b/src/main/java/com/redhat/exhort/cli/Command.java @@ -1,5 +1,5 @@ /* - * Copyright © 2025 Red Hat, Inc. + * Copyright © 2023 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/redhat/exhort/cli/OutputFormat.java b/src/main/java/com/redhat/exhort/cli/OutputFormat.java index 52a67442..ccdd4b9d 100644 --- a/src/main/java/com/redhat/exhort/cli/OutputFormat.java +++ b/src/main/java/com/redhat/exhort/cli/OutputFormat.java @@ -1,5 +1,5 @@ /* - * Copyright © 2025 Red Hat, Inc. + * Copyright © 2023 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/test/java/com/redhat/exhort/cli/AppTest.java b/src/test/java/com/redhat/exhort/cli/AppTest.java index e68ad7de..8e8e0bd3 100644 --- a/src/test/java/com/redhat/exhort/cli/AppTest.java +++ b/src/test/java/com/redhat/exhort/cli/AppTest.java @@ -1,5 +1,5 @@ /* - * Copyright © 2025 Red Hat, Inc. + * Copyright © 2023 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.