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/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..37a14d56 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,87 @@ limitations under the License.]]> + + maven-jar-plugin + + + + com.redhat.exhort.cli.App + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + 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.App + + + + + + + + META-INF/NOTICE + + + META-INF/NOTICE.txt + + + META-INF/NOTICE.md + + + + + META-INF/LICENSE + + + META-INF/LICENSE.txt + + + META-INF/LICENSE.md + + + + + + false + + + + com.diffplug.spotless spotless-maven-plugin @@ -813,9 +900,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/App.java b/src/main/java/com/redhat/exhort/cli/App.java new file mode 100644 index 00000000..ee11827c --- /dev/null +++ b/src/main/java/com/redhat/exhort/cli/App.java @@ -0,0 +1,220 @@ +/* + * 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. + * 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 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; +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 App { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String CLI_HELPTXT = "cli_help.txt"; + + static { + MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + public static void main(String[] args) { + if (args.length == 0 || isHelpRequested(args)) { + printHelp(); + return; + } + + try { + CliArgs cliArgs = parseArgs(args); + String result = executeCommand(cliArgs).get(); + printLine(result); + } catch (IllegalArgumentException e) { + printException(e); + printHelp(); + exitWithError(); + } catch (IOException | InterruptedException | ExecutionException e) { + printException(e); + exitWithError(); + } + } + + 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"); + } + + Command command = parseCommand(args[0]); + + Path path = validateFile(args[1]); + + OutputFormat outputFormat = OutputFormat.JSON; + if (args.length == 3) { + outputFormat = parseOutputFormat(command, args[2]); + } + + return new CliArgs(command, path, outputFormat); + } + + private static Command parseCommand(String commandStr) { + switch (commandStr) { + case "stack": + return Command.STACK; + case "component": + return Command.COMPONENT; + default: + throw new IllegalArgumentException( + "Unknown command: " + commandStr + ". Use 'stack' or 'component'"); + } + } + + private static OutputFormat parseOutputFormat(Command command, String formatArg) { + 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("File is not a regular file: " + filePath); + } + return path; + } + + 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(App::toJsonString); + case HTML: + return api.stackAnalysisHtml(filePath).thenApply(bytes -> new String(bytes)); + case SUMMARY: + return api.stackAnalysis(filePath) + .thenApply(App::extractSummary) + .thenApply(App::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(App::extractSummary); + } + return analysis.thenApply(App::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() { + try (var inputStream = App.class.getClassLoader().getResourceAsStream(CLI_HELPTXT)) { + if (inputStream == null) { + AppUtils.printError("Help file not found."); + return; + } + + 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..4f6973d9 --- /dev/null +++ b/src/main/java/com/redhat/exhort/cli/AppUtils.java @@ -0,0 +1,39 @@ +/* + * 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. + * 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 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..6201dc74 --- /dev/null +++ b/src/main/java/com/redhat/exhort/cli/CliArgs.java @@ -0,0 +1,30 @@ +/* + * 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. + * 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..a79a3867 --- /dev/null +++ b/src/main/java/com/redhat/exhort/cli/Command.java @@ -0,0 +1,21 @@ +/* + * 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. + * 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..ccdd4b9d --- /dev/null +++ b/src/main/java/com/redhat/exhort/cli/OutputFormat.java @@ -0,0 +1,22 @@ +/* + * 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. + * 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..8e8e0bd3 --- /dev/null +++ b/src/test/java/com/redhat/exhort/cli/AppTest.java @@ -0,0 +1,679 @@ +/* + * 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. + * 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 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 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 { + + 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; + } +}