This is an automated email from the ASF dual-hosted git repository. mpochatkin pushed a commit to branch IGNITE-27548 in repository https://gitbox.apache.org/repos/asf/ignite-3.git
commit 20ac0ff8c74aa0124bddf69ad89ff59de1260540 Author: Pochatkin Mikhail <[email protected]> AuthorDate: Tue Jan 13 17:44:42 2026 +0300 IGNITE-27548 Add recursive option to deploy unit command --- .../cli/call/unit/ItDeployUndeployCallsTest.java | 74 +++++++++++++ .../cli/commands/unit/ItDeploymentUnitTest.java | 74 +++++++++++++ .../cli/call/cluster/unit/DeployUnitCall.java | 123 ++++++++++++++++++++- .../cli/call/cluster/unit/DeployUnitCallInput.java | 18 ++- .../cli/call/cluster/unit/DeployUnitClient.java | 44 +++++++- .../ignite/internal/cli/commands/Options.java | 6 + .../cluster/unit/ClusterUnitDeployCommand.java | 2 +- .../cluster/unit/ClusterUnitDeployReplCommand.java | 2 +- .../cluster/unit/UnitDeployOptionsMixin.java | 14 +++ .../cli/commands/unit/DeployCommandTest.java | 45 ++++++++ 10 files changed, 392 insertions(+), 10 deletions(-) diff --git a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/call/unit/ItDeployUndeployCallsTest.java b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/call/unit/ItDeployUndeployCallsTest.java index 569e063bf8b..603382c673c 100644 --- a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/call/unit/ItDeployUndeployCallsTest.java +++ b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/call/unit/ItDeployUndeployCallsTest.java @@ -26,6 +26,8 @@ import static org.awaitility.Awaitility.await; import jakarta.inject.Inject; import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import org.apache.ignite.internal.cli.CliIntegrationTest; @@ -224,4 +226,76 @@ public class ItDeployUndeployCallsTest extends CliIntegrationTest { .containsExactly((new UnitVersionStatus()).version("1.1.0").status(DEPLOYED)); }); } + + @Test + @DisplayName("Should deploy unit recursively from directory with subdirectories") + void deployRecursive() throws IOException { + // Given a directory with subdirectories + Path recursiveDir = Files.createTempDirectory(WORK_DIR, "recursive"); + Path subDir = Files.createDirectory(recursiveDir.resolve("subdir")); + Files.createFile(recursiveDir.resolve("root.txt")); + Files.createFile(subDir.resolve("nested.txt")); + + DeployUnitCallInput input = DeployUnitCallInput.builder() + .id("recursive.test.id") + .version("1.0.0") + .path(recursiveDir) + .recursive(true) + .clusterUrl(NODE_URL) + .build(); + + // When deploy unit recursively + CallOutput<String> deployOutput = get( + deployUnitCallFactory.create(tracker()).execute(input) + ); + + // Then + assertThat(deployOutput.hasError()).isFalse(); + assertThat(deployOutput.body()).isEqualTo(MessageUiComponent.from(UiElements.done()).render()); + + await().untilAsserted(() -> { + // And list contains the deployed unit + List<UnitStatus> unitStatuses = listUnitCall.execute(listIdInput("recursive.test.id")).body(); + assertThat(unitStatuses.size()).isEqualTo(1); + Assertions.assertThat(unitStatuses.get(0).getVersionToStatus()) + .containsExactly((new UnitVersionStatus()).version("1.0.0").status(DEPLOYED)); + }); + } + + @Test + @DisplayName("Should deploy unit recursively from deeply nested directory") + void deployRecursiveDeepNesting() throws IOException { + // Given a directory with deeply nested subdirectories + Path deepDir = Files.createTempDirectory(WORK_DIR, "deep"); + Path level1 = Files.createDirectory(deepDir.resolve("level1")); + Path level2 = Files.createDirectory(level1.resolve("level2")); + Files.createFile(deepDir.resolve("root.txt")); + Files.createFile(level1.resolve("file1.txt")); + Files.createFile(level2.resolve("file2.txt")); + + DeployUnitCallInput input = DeployUnitCallInput.builder() + .id("deep.recursive.test.id") + .version("1.0.0") + .path(deepDir) + .recursive(true) + .clusterUrl(NODE_URL) + .build(); + + // When deploy unit recursively + CallOutput<String> deployOutput = get( + deployUnitCallFactory.create(tracker()).execute(input) + ); + + // Then + assertThat(deployOutput.hasError()).isFalse(); + assertThat(deployOutput.body()).isEqualTo(MessageUiComponent.from(UiElements.done()).render()); + + await().untilAsserted(() -> { + // And list contains the deployed unit + List<UnitStatus> unitStatuses = listUnitCall.execute(listIdInput("deep.recursive.test.id")).body(); + assertThat(unitStatuses.size()).isEqualTo(1); + Assertions.assertThat(unitStatuses.get(0).getVersionToStatus()) + .containsExactly((new UnitVersionStatus()).version("1.0.0").status(DEPLOYED)); + }); + } } diff --git a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItDeploymentUnitTest.java b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItDeploymentUnitTest.java index 277c1390f5e..06e806d0484 100644 --- a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItDeploymentUnitTest.java +++ b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItDeploymentUnitTest.java @@ -323,6 +323,80 @@ public class ItDeploymentUnitTest extends CliIntegrationTest { }); } + @Test + @DisplayName("Should deploy a unit from directory with subdirectories using --recursive") + void deployRecursive() throws IOException { + // Given a directory with subdirectories + Path recursiveDir = Files.createDirectory(WORK_DIR.resolve("recursive-test")); + Path subDir = Files.createDirectory(recursiveDir.resolve("subdir")); + Files.createFile(recursiveDir.resolve("root-file.txt")); + Files.createFile(subDir.resolve("nested-file.txt")); + + // When deploy with --recursive option + execute("cluster", "unit", "deploy", "test.unit.recursive.1", "--version", "1.0.0", + "--path", recursiveDir.toString(), "--recursive"); + + // Then + assertAll( + this::assertExitCodeIsZero, + this::assertErrOutputIsEmpty, + () -> assertOutputContains("Done") + ); + + // And unit is deployed + await().untilAsserted(() -> { + execute("cluster", "unit", "list", "--plain", "test.unit.recursive.1"); + + assertDeployed("test.unit.recursive.1"); + }); + } + + @Test + @DisplayName("Should deploy a unit from deeply nested directory using --recursive") + void deployRecursiveDeepNesting() throws IOException { + // Given a directory with deeply nested subdirectories + Path deepDir = Files.createDirectory(WORK_DIR.resolve("deep-test")); + Path level1 = Files.createDirectory(deepDir.resolve("level1")); + Path level2 = Files.createDirectory(level1.resolve("level2")); + Path level3 = Files.createDirectory(level2.resolve("level3")); + Files.createFile(deepDir.resolve("root.txt")); + Files.createFile(level1.resolve("file1.txt")); + Files.createFile(level2.resolve("file2.txt")); + Files.createFile(level3.resolve("file3.txt")); + + // When deploy with --recursive option + execute("cluster", "unit", "deploy", "test.unit.recursive.2", "--version", "1.0.0", + "--path", deepDir.toString(), "--recursive"); + + // Then + assertAll( + this::assertExitCodeIsZero, + this::assertErrOutputIsEmpty, + () -> assertOutputContains("Done") + ); + + // And unit is deployed + await().untilAsserted(() -> { + execute("cluster", "unit", "list", "--plain", "test.unit.recursive.2"); + + assertDeployed("test.unit.recursive.2"); + }); + } + + @Test + @DisplayName("Should display error when --recursive is used with a file path") + void deployRecursiveWithFile() { + // When deploy with --recursive option pointing to a file + execute("cluster", "unit", "deploy", "test.unit.recursive.error", "--version", "1.0.0", + "--path", testFile, "--recursive"); + + // Then error is displayed + assertAll( + () -> assertExitCodeIs(2), + () -> assertErrOutputContains("The --recursive option requires a directory path") + ); + } + private void assertDeployed(String id) { assertDeployed(id, "*1.0.0"); } diff --git a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCall.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCall.java index 664adb8fc03..1079e9b8d70 100644 --- a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCall.java +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCall.java @@ -22,12 +22,15 @@ import static java.util.concurrent.CompletableFuture.completedFuture; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import okhttp3.Call; import org.apache.ignite.internal.cli.core.call.AsyncCall; import org.apache.ignite.internal.cli.core.call.CallOutput; @@ -64,27 +67,52 @@ public class DeployUnitCall implements AsyncCall<DeployUnitCallInput, String> { if (Files.notExists(path)) { return completedFuture(DefaultCallOutput.failure(new FileNotFoundException(path.toString()))); } + + DeploymentContent content; + try { + content = input.recursive() ? createZipContent(path) : createFilesContent(path); + } catch (IOException e) { + return completedFuture(DefaultCallOutput.failure(e)); + } + + return executeDeploy(input, api, content); + } + + private static DeploymentContent createFilesContent(Path path) throws IOException { List<File> files; try (Stream<Path> stream = Files.walk(path, 1)) { files = stream .filter(Files::isRegularFile) .map(Path::toFile) .collect(Collectors.toList()); - } catch (IOException e) { - return completedFuture(DefaultCallOutput.failure(e)); } + return new FilesDeploymentContent(files); + } + + private static DeploymentContent createZipContent(Path path) throws IOException { + File zipFile = createZipFromDirectory(path); + return new ZipDeploymentContent(zipFile); + } + private CompletableFuture<CallOutput<String>> executeDeploy( + DeployUnitCallInput input, + DeployUnitClient api, + DeploymentContent content + ) { TrackingCallback<Boolean> callback = new TrackingCallback<>(tracker); String ver = input.version() == null ? "" : input.version(); DeployMode deployMode = inferDeployMode(input.nodes()); List<String> initialNodes = deployMode == null ? input.nodes() : null; - Call call = api.deployUnitAsync(input.id(), files, ver, deployMode, initialNodes, callback); + + Call call = content.deploy(api, input.id(), ver, deployMode, initialNodes, callback); return CompletableFuture.supplyAsync(() -> { try { callback.awaitDone(); } catch (InterruptedException e) { return DefaultCallOutput.failure(e); + } finally { + content.cleanup(); } if (call.isCanceled()) { return DefaultCallOutput.failure(new RuntimeException("Unit deployment process was canceled")); @@ -96,6 +124,95 @@ public class DeployUnitCall implements AsyncCall<DeployUnitCallInput, String> { }); } + private static File createZipFromDirectory(Path sourceDir) throws IOException { + Path zipPath = Files.createTempFile("deploy-unit-", ".zip"); + try (OutputStream os = Files.newOutputStream(zipPath); + ZipOutputStream zos = new ZipOutputStream(os)) { + try (Stream<Path> stream = Files.walk(sourceDir)) { + stream.filter(Files::isRegularFile).forEach(filePath -> { + Path relativePath = sourceDir.relativize(filePath); + try { + ZipEntry zipEntry = new ZipEntry(relativePath.toString().replace('\\', '/')); + zos.putNextEntry(zipEntry); + Files.copy(filePath, zos); + zos.closeEntry(); + } catch (IOException e) { + throw new RuntimeException("Failed to add file to ZIP: " + filePath, e); + } + }); + } + } + return zipPath.toFile(); + } + + /** Abstraction for deployment content that can be either files or a ZIP archive. */ + private interface DeploymentContent { + /** Executes the deployment and returns the HTTP call. */ + Call deploy( + DeployUnitClient api, + String unitId, + String version, + @Nullable DeployMode deployMode, + @Nullable List<String> initialNodes, + TrackingCallback<Boolean> callback + ); + + /** Performs cleanup after deployment (e.g., deletes temporary files). */ + void cleanup(); + } + + /** Deployment content for regular files (non-recursive). */ + private static class FilesDeploymentContent implements DeploymentContent { + private final List<File> files; + + FilesDeploymentContent(List<File> files) { + this.files = files; + } + + @Override + public Call deploy( + DeployUnitClient api, + String unitId, + String version, + @Nullable DeployMode deployMode, + @Nullable List<String> initialNodes, + TrackingCallback<Boolean> callback + ) { + return api.deployUnitAsync(unitId, files, version, deployMode, initialNodes, callback); + } + + @Override + public void cleanup() { + // No cleanup needed for regular files + } + } + + /** Deployment content for ZIP archive (recursive). */ + private static class ZipDeploymentContent implements DeploymentContent { + private final File zipFile; + + ZipDeploymentContent(File zipFile) { + this.zipFile = zipFile; + } + + @Override + public Call deploy( + DeployUnitClient api, + String unitId, + String version, + @Nullable DeployMode deployMode, + @Nullable List<String> initialNodes, + TrackingCallback<Boolean> callback + ) { + return api.deployZipUnitAsync(unitId, zipFile, version, deployMode, initialNodes, callback); + } + + @Override + public void cleanup() { + zipFile.delete(); + } + } + private static CallOutput<String> handleException(Exception exception, DeployUnitCallInput input) { if (exception instanceof ApiException) { ApiException apiException = (ApiException) exception; diff --git a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCallInput.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCallInput.java index f22b94c3155..6bef7760423 100644 --- a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCallInput.java +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCallInput.java @@ -32,13 +32,16 @@ public class DeployUnitCallInput implements CallInput { private final List<String> nodes; + private final boolean recursive; + private final String clusterUrl; - private DeployUnitCallInput(String id, String version, Path path, List<String> nodes, String clusterUrl) { + private DeployUnitCallInput(String id, String version, Path path, List<String> nodes, boolean recursive, String clusterUrl) { this.id = id; this.version = version; this.path = path; this.nodes = nodes; + this.recursive = recursive; this.clusterUrl = clusterUrl; } @@ -62,6 +65,10 @@ public class DeployUnitCallInput implements CallInput { return nodes; } + public boolean recursive() { + return recursive; + } + public String clusterUrl() { return clusterUrl; } @@ -76,6 +83,8 @@ public class DeployUnitCallInput implements CallInput { private List<String> nodes; + private boolean recursive; + private String clusterUrl; public DeployUnitCallBuilder id(String id) { @@ -98,13 +107,18 @@ public class DeployUnitCallInput implements CallInput { return this; } + public DeployUnitCallBuilder recursive(boolean recursive) { + this.recursive = recursive; + return this; + } + public DeployUnitCallBuilder clusterUrl(String clusterUrl) { this.clusterUrl = clusterUrl; return this; } public DeployUnitCallInput build() { - return new DeployUnitCallInput(id, version, path, nodes, clusterUrl); + return new DeployUnitCallInput(id, version, path, nodes, recursive, clusterUrl); } } } diff --git a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitClient.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitClient.java index e390e211545..f9766d12ab3 100644 --- a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitClient.java +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitClient.java @@ -95,11 +95,49 @@ public class DeployUnitClient { @Nullable DeployMode deployMode, @Nullable List<String> initialNodes, ApiCallback<Boolean> callback + ) { + return buildDeployCall(unitId, unitContent, unitVersion, deployMode, initialNodes, callback, false); + } + + /** + * Deploy unit from ZIP file asynchronously. + * + * @param unitId The ID of the deployment unit. + * @param zipFile The ZIP file to deploy. + * @param unitVersion The version of the deployment unit. + * @param deployMode The deployment mode. + * @param initialNodes The initial set of nodes where unit will be deployed. + * @param callback The callback for tracking progress. + * @return Request call. + */ + public Call deployZipUnitAsync( + String unitId, + File zipFile, + String unitVersion, + @Nullable DeployMode deployMode, + @Nullable List<String> initialNodes, + ApiCallback<Boolean> callback + ) { + Call call = buildDeployCall(unitId, List.of(zipFile), unitVersion, deployMode, initialNodes, callback, true); + apiClient.executeAsync(call, Boolean.class, callback); + return call; + } + + private Call buildDeployCall( + String unitId, + List<File> unitContent, + String unitVersion, + @Nullable DeployMode deployMode, + @Nullable List<String> initialNodes, + ApiCallback<Boolean> callback, + boolean isZip ) { StringBuilder url = new StringBuilder(apiClient.getBasePath()); - url - .append("/management/v1/deployment/units") - .append('/').append(apiClient.escapeString(unitId)) + url.append("/management/v1/deployment/units"); + if (isZip) { + url.append("/zip"); + } + url.append('/').append(apiClient.escapeString(unitId)) .append('/').append(apiClient.escapeString(unitVersion)); List<Pair> queryParams = new ArrayList<>(); diff --git a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/Options.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/Options.java index 6fc06452a33..8bd07db2c07 100644 --- a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/Options.java +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/Options.java @@ -247,6 +247,12 @@ public enum Options { /** Unit nodes option description. */ public static final String UNIT_NODES_OPTION_DESC = "Initial set of nodes where the unit will be deployed"; + /** Unit recursive option long name. */ + public static final String UNIT_RECURSIVE_OPTION = "--recursive"; + + /** Unit recursive option description. */ + public static final String UNIT_RECURSIVE_OPTION_DESC = "Deploy directory recursively (creates a ZIP file and uses ZIP deployment)"; + public static final String CLUSTER_CONFIG_OPTION = "--config"; public static final String CLUSTER_CONFIG_OPTION_DESC = "Cluster configuration that " diff --git a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployCommand.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployCommand.java index 0ae4f30a5e8..e59cb1b9867 100644 --- a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployCommand.java +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployCommand.java @@ -29,7 +29,7 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; /** Command to deploy a unit. */ -@Command(name = "deploy", description = "Deploys a unit from file or a directory (non-recursively)") +@Command(name = "deploy", description = "Deploys a unit from file or a directory (use --recursive for subdirectories)") public class ClusterUnitDeployCommand extends BaseCommand implements Callable<Integer> { @Mixin diff --git a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployReplCommand.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployReplCommand.java index f2e633a45a1..c22b1f4fba4 100644 --- a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployReplCommand.java +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployReplCommand.java @@ -29,7 +29,7 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; /** Command to deploy a unit in REPL mode. */ -@Command(name = "deploy", description = "Deploys a unit") +@Command(name = "deploy", description = "Deploys a unit from file or a directory (use --recursive for subdirectories)") public class ClusterUnitDeployReplCommand extends BaseCommand implements Runnable { @Mixin diff --git a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/UnitDeployOptionsMixin.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/UnitDeployOptionsMixin.java index 79198f01fb9..77a21599a2f 100644 --- a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/UnitDeployOptionsMixin.java +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/UnitDeployOptionsMixin.java @@ -21,6 +21,8 @@ import static org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_NOD import static org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_NODES_OPTION_DESC; import static org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_PATH_OPTION; import static org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_PATH_OPTION_DESC; +import static org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_RECURSIVE_OPTION; +import static org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_RECURSIVE_OPTION_DESC; import static org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_VERSION_OPTION_DESC; import static org.apache.ignite.internal.cli.commands.Options.Constants.VERSION_OPTION; @@ -92,12 +94,24 @@ class UnitDeployOptionsMixin { nodes = values; } + /** Recursive deployment flag. */ + @Option(names = UNIT_RECURSIVE_OPTION, description = UNIT_RECURSIVE_OPTION_DESC) + private boolean recursive; + DeployUnitCallInput toDeployUnitCallInput(String url) { + if (recursive && !Files.isDirectory(path)) { + throw new ParameterException( + spec.commandLine(), + "The --recursive option requires a directory path, but '" + path + "' is not a directory" + ); + } + return DeployUnitCallInput.builder() .id(id) .version(version) .path(path) .nodes(nodes) + .recursive(recursive) .clusterUrl(url) .build(); } diff --git a/modules/cli/src/test/java/org/apache/ignite/internal/cli/commands/unit/DeployCommandTest.java b/modules/cli/src/test/java/org/apache/ignite/internal/cli/commands/unit/DeployCommandTest.java index d3d11f7956d..d45093a1ce3 100644 --- a/modules/cli/src/test/java/org/apache/ignite/internal/cli/commands/unit/DeployCommandTest.java +++ b/modules/cli/src/test/java/org/apache/ignite/internal/cli/commands/unit/DeployCommandTest.java @@ -40,6 +40,51 @@ class DeployCommandTest extends CliCommandTestBase { return ClusterUnitDeployCommand.class; } + @Test + @DisplayName("Should display error when --recursive is used with a file path") + void recursiveWithFilePath(@WorkDirectory Path workDir) throws IOException { + Path testFile = Files.createFile(workDir.resolve("test.txt")); + + // When executed with --recursive option pointing to a file + execute("--path", testFile.toString(), "--version", "1.0.0", "--recursive", "id"); + + // Error is printed + assertAll( + () -> assertExitCodeIs(2), + this::assertOutputIsEmpty, + () -> assertErrOutputContains("The --recursive option requires a directory path") + ); + } + + @Test + @DisplayName("Should accept --recursive option with a directory path (passes validation)") + void recursiveWithDirectoryPath(@WorkDirectory Path workDir) throws IOException { + Path testDir = Files.createDirectory(workDir.resolve("testDir")); + Files.createFile(testDir.resolve("test.txt")); + + // When executed with --recursive option pointing to a directory + // Note: This will fail at execution stage (no server), but should pass option validation + execute("--path", testDir.toString(), "--version", "1.0.0", "--recursive", "id"); + + // Option validation passes - exit code 1 means execution error (no server), + // not exit code 2 which would mean parameter validation error + assertExitCodeIsError(); // Exit code 1, not 2 + } + + @Test + @DisplayName("Should display error when path does not exist") + void pathDoesNotExist(@WorkDirectory Path workDir) { + // When executed with non-existent path + execute("--path", workDir.resolve("nonexistent").toString(), "--version", "1.0.0", "id"); + + // Error is printed + assertAll( + () -> assertExitCodeIs(2), + this::assertOutputIsEmpty, + () -> assertErrOutputContains("No such file or directory") + ); + } + @Test @DisplayName("Aliases couldn't be used with explicit nodes list") void aliasesWithExplicitNodesList(@WorkDirectory Path workDir) throws IOException {
