diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5e29293e..f30430d6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ { "name": "Java", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/java:17", + "image": "mcr.microsoft.com/devcontainers/java:21", "features": { "ghcr.io/devcontainers/features/java:1": { diff --git a/README.md b/README.md index ea455ca3..b3f9e3c2 100644 --- a/README.md +++ b/README.md @@ -2,78 +2,92 @@ [![test](https://github.com/itzg/mc-image-helper/actions/workflows/test.yml/badge.svg)](https://github.com/itzg/mc-image-helper/actions/workflows/test.yml) ![LOC](https://img.shields.io/endpoint?url=https%3A%2F%2Fshields-codetab-code-loc-bridge.vercel.app%2Fapi%2Fcodeloc%3Fgithub%3Ditzg%2Fmc-image-helper%26language%3Djava) -This tool does the complicated bits for the [itzg/minecraft-server](https://github.com/itzg/docker-minecraft-server) and [itzg/bungeecord](https://github.com/itzg/docker-bungeecord/) Docker images. +This tool does the complicated bits for the [itzg/minecraft-server](https://github.com/itzg/docker-minecraft-server) and [itzg/docker-mc-proxy](https://github.com/itzg/docker-mc-proxy) Docker images. ## Usage > **NOTE** The following documentation may not always be up-to-date. Please be sure to use `-h` or `--help` after any subcommand to view the current usage. ``` -Usage: mc-image-helper [-hs] [--debug] [COMMAND] - --debug Enable debug output. Can also set environment variable - DEBUG_HELPER - -h, --help Show this usage and exit - -s, --silent Don't output logs even if there's an error +Usage: mc-image-helper [-hsV] [--debug | --logging=] [COMMAND] + --debug Enable debug output. Can also set environment variables + DEBUG_HELPER or DEBUG + -h, --help Show this usage and exit + --logging= + Set logging to specific level. + Valid values: + -s, --silent Don't output logs even if there's an error + -V, --version Commands: - asciify Converts UTF-8 on stdin to ASCII by escaping - Unicode characters - assert Provides assertion operators for verifying - container setup - compare-versions Used for shell scripting, exits with success(0) - when comparison is satisfied or 1 when not - curseforge-files Download and manage individual mod/plugin files - from CurseForge - find Specialized replacement for GNU's find - get Download a file + asciify Converts UTF-8 on stdin to ASCII by escaping + Unicode characters + assert Provides assertion operators for verifying + container setup + compare-versions Used for shell scripting, exits with success + (0) when comparison is satisfied or 1 when + not + curseforge-files Download and manage individual mod/plugin + files from CurseForge + find Specialized replacement for GNU's find + get Download a file github - hash Outputs an MD5 hash of the standard input - install-curseforge Downloads, installs, and upgrades CurseForge - modpacks - install-fabric-loader Provides a few ways to obtain a Fabric loader with - simple cleanup of previous loader instances - install-forge Downloads and installs a requested version of Forge - install-modrinth-modpack Supports installation of Modrinth modpacks along - with the associated mod loader - install-neoforge Downloads and installs a requested version of - NeoForge - install-paper Installs selected PaperMC - install-purpur Downloads latest or selected version of Purpur - install-quilt Installs Quilt mod loader - interpolate Interpolates existing files in one or more - directories - java-release Outputs the Java release number, such as 8, 11, 17 + hash Outputs an MD5 hash of the standard input + install-curseforge Downloads, installs, and upgrades CurseForge + modpacks + install-fabric-loader Provides a few ways to obtain a Fabric loader + with simple cleanup of previous loader + instances + install-forge Downloads and installs a requested version of + Forge + install-modrinth-modpack Supports installation of Modrinth modpacks + along with the associated mod loader + install-neoforge Downloads and installs a requested version of + NeoForge + install-paper Installs selected PaperMC + install-purpur Downloads latest or selected version of Purpur + install-quilt Installs Quilt mod loader + interpolate Interpolates existing files in one or more + directories + java-release Outputs the Java release number, such as 8, + 11, 17 manage-users - maven-download Downloads a maven artifact from a Maven repository - modrinth Automates downloading of modrinth resources - mcopy Multi-source file copy operation with with managed - cleanup. Supports auto-detected sourcing from - file list, directories, and URLs - network-interfaces Provides simple operations to list network - interface names and check existence - patch Patches one or more existing files using JSON path - based operations - Supports the file formats: - - JSON - - JSON5 - - Yaml - - TOML, but processed output is not pretty - resolve-minecraft-version Resolves and validate latest, snapshot, and - specific versions - set-properties Maps environment variables to a properties file - show-all-subcommand-usage Renders all of the subcommand usage as markdown - sections for README - sync Synchronizes the contents of one directory to - another. - sync-and-interpolate Synchronizes the contents of one directory to - another with conditional variable interpolation. + maven-download Downloads a maven artifact from a Maven + repository + modrinth Automates downloading of modrinth resources + mcopy Multi-source file copy operation with with + managed cleanup. Supports auto-detected + sourcing from file list, directories, and + URLs + network-interfaces Provides simple operations to list network + interface names and check existence + patch Patches one or more existing files using JSON + path based operations + Supports the file formats: + - JSON + - JSON5 + - Yaml + - TOML, but processed output is not pretty + resolve-minecraft-version Resolves and validate latest, snapshot, and + specific versions + set-properties Maps environment variables to a properties + file + show-all-subcommand-usage Renders all of the subcommand usage as + markdown sections for README + sync Synchronizes the contents of one directory to + another. + sync-and-interpolate Synchronizes the contents of one directory to + another with conditional variable + interpolation. test-logging-levels - toml-path Extracts a path from a TOML file using json-path - syntax - yaml-path Extracts a path from a YAML file using json-path - syntax - vanillatweaks Downloads Vanilla Tweaks resource packs, data - packs, or crafting tweaks given a share code or - pack file + toml-path Extracts a path from a TOML file using + json-path syntax + vanillatweaks Downloads Vanilla Tweaks resource packs, data + packs, or crafting tweaks given a share + code or pack file + version-from-modrinth-projects Finds a compatible Minecraft version across + given Modrinth projects + yaml-path Extracts a path from a YAML file using + json-path syntax ``` For [patch](#patch) command [see below](#patch-schemas) for a description of [PatchSet](#patchset) and [PatchDefinition](#patchdefinition) JSON schemas. @@ -851,7 +865,7 @@ Downloads a maven artifact from a Maven repository ### mcopy ``` -Usage: mc-image-helper mcopy [-hz] [--file-is-listing] +Usage: mc-image-helper mcopy [-hz] [--file-is-listing] [--ignore-missing-sources] [--quiet-when-skipped] [--skip-existing] [--glob=GLOB] [--scope=] --to= SRC[, @@ -860,6 +874,8 @@ Multi-source file copy operation with with managed cleanup. Supports auto-detected sourcing from file list, directories, and URLs SRC[,|SRC...]... Any mix of source file, directory, or URLs. Can be optionally comma or newline separated. + Per-file destinations can be assigned by + destination -z, --skip-up-to-date + ``` ### modrinth diff --git a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java index 206685f4..1aa7b038 100644 --- a/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java +++ b/src/main/java/me/itzg/helpers/sync/MulitCopyCommand.java @@ -17,6 +17,7 @@ import me.itzg.helpers.errors.GenericException; import me.itzg.helpers.errors.InvalidParameterException; import me.itzg.helpers.files.Manifests; +import me.itzg.helpers.files.ReactiveFileUtils; import me.itzg.helpers.http.FailedRequestException; import me.itzg.helpers.http.Fetch; import me.itzg.helpers.http.SharedFetch; @@ -74,18 +75,18 @@ public class MulitCopyCommand implements Callable { paramLabel = "SRC", description = "Any mix of source file, directory, or URLs." + "%nCan be optionally comma or newline separated." + + "%nPer-file destinations can be assigned by destination sources; + private final static String destinationDelimiter = "<"; + @Override public Integer call() throws Exception { - - Files.createDirectories(dest); - Flux.fromIterable(sources) .map(String::trim) .filter(s -> !s.isEmpty()) - .flatMap(source -> processSource(source, fileIsListingOption)) + .flatMap(source -> processSource(source, fileIsListingOption, dest)) .collectList() .flatMap(this::cleanupAndSaveManifest) .block(); @@ -113,24 +114,55 @@ private Mono cleanupAndSaveManifest(List paths) { }); } - private Publisher processSource(String source, boolean fileIsListing) { - if (Uris.isUri(source)) { - return fileIsListing ? processRemoteListingFile(source) : processRemoteSource(source); - } else { - final Path path = Paths.get(source); - if (!Files.exists(path)) { - throw new GenericException(String.format("Source file '%s' does not exist", source)); - } + private Publisher processSource(String source, boolean fileIsListing, Path parentDestination) { + final Path destination; + final String resolvedSource; - if (Files.isDirectory(path)) { - return processDirectory(path); + final int delimiterPos = source.indexOf(destinationDelimiter); + if (delimiterPos > 0) { + destination = parentDestination.resolve(Paths.get(source.substring(0, delimiterPos))); + resolvedSource = source.substring(delimiterPos + 1); + } + else { + destination = parentDestination; + resolvedSource = source; + } + + if (fileIsListing) { + if (Uris.isUri(resolvedSource)) { + return processRemoteListingFile(resolvedSource, destination); } else { - return fileIsListing ? processListingFile(path) : processFile(path); + final Path path = Paths.get(resolvedSource); + if (Files.isDirectory(path)) { + throw new GenericException(String.format("Specified listing file '%s' is a directory", resolvedSource)); + } + if (!Files.exists(path)) { + throw new GenericException(String.format("Source file '%s' does not exist", resolvedSource)); + } + return processListingFile(path, destination); } } + + return ReactiveFileUtils.createDirectories(destination) + .flatMapMany(ignored -> { + if (Uris.isUri(resolvedSource)) { + return processRemoteSource(resolvedSource, destination); + } else { + final Path path = Paths.get(resolvedSource); + if (!Files.exists(path)) { + return Mono.error(new GenericException(String.format("Source file '%s' does not exist", resolvedSource))); + } + + if (Files.isDirectory(path)) { + return processDirectory(path, destination); + } else { + return processFile(path, destination); + } + } + }); } - private Flux processListingFile(Path listingFile) { + private Flux processListingFile(Path listingFile, Path destination) { return Mono.just(listingFile) .publishOn(Schedulers.boundedElastic()) .flatMapMany(path -> { @@ -141,22 +173,23 @@ private Flux processListingFile(Path listingFile) { .filter(this::isListingLine) .flatMap(src -> processSource(src, // avoid recursive file-listing processing - false)); + false, + destination)); } catch (IOException e) { return Mono.error(new GenericException("Failed to read file listing from " + path)); } }); } - private Mono processFile(Path source) { + private Mono processFile(Path source, Path destination) { return Mono.just(source) .publishOn(Schedulers.boundedElastic()) - .map(path -> processFileImmediate(source, dest)); + .map(path -> processFileImmediate(source, destination)); } /** - * Non-mono version of {@link #processFile(Path)} + * Non-mono version of {@link #processFile(Path, Path)} * * @param scopedDest allows for sub-directory destinations */ @@ -203,7 +236,7 @@ private Path processFileImmediate(Path source, Path scopedDest) { return destFile; } - private Flux processDirectory(Path srcDir) { + private Flux processDirectory(Path srcDir, Path destination) { return Mono.just(srcDir) .publishOn(Schedulers.boundedElastic()) .flatMapMany(path -> { @@ -220,7 +253,7 @@ private Flux processDirectory(Path srcDir) { try (DirectoryStream files = Files.newDirectoryStream(srcDir, fileGlob)) { for (final Path file : files) { //noinspection BlockingMethodInNonBlockingContext because IntelliJ is confused - results.add(processFileImmediate(file, dest)); + results.add(processFileImmediate(file, destination)); } } return Flux.fromIterable(results); @@ -230,10 +263,10 @@ private Flux processDirectory(Path srcDir) { }); } - private Mono processRemoteSource(String source) { + private Mono processRemoteSource(String source, Path destination) { return Fetch.fetch(URI.create(source)) .userAgentCommand("mcopy") - .toDirectory(dest) + .toDirectory(destination) .skipUpToDate(skipUpToDate) .skipExisting(skipExisting) .handleDownloaded((downloaded, uri, size) -> @@ -263,7 +296,7 @@ private Mono processRemoteSource(String source) { .checkpoint("Retrieving " + source, true); } - private Flux processRemoteListingFile(String source) { + private Flux processRemoteListingFile(String source, Path destination) { @SuppressWarnings("resource") // closed on terminate SharedFetch sharedFetch = Fetch.sharedFetch("mcopy", SharedFetch.Options.builder().build()); return Mono.just(source) @@ -273,7 +306,7 @@ private Flux processRemoteListingFile(String source) { .flatMapMany(content -> Flux.just(content.split("\\r?\\n"))) .filter(this::isListingLine) ) - .flatMap(this::processRemoteSource) + .flatMap(url -> processSource(url, false, destination)) .doOnTerminate(sharedFetch::close) .checkpoint("Processing remote listing at " + source, true); } diff --git a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java index 4a34aae8..3672f788 100644 --- a/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java +++ b/src/test/java/me/itzg/helpers/sync/MulitCopyCommandTest.java @@ -47,6 +47,23 @@ void one() throws IOException { .hasSameTextualContentAs(srcFile); } + @Test + void oneWithInlineDestination() throws IOException { + final Path srcFile = writeLine(tempDir, "source2.txt", "content"); + final Path destDir = tempDir.resolve("dest"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "dest<" +srcFile.toString() + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir.resolve("source2.txt")) + .exists() + .hasSameTextualContentAs(srcFile); + } + @Test void commaDelimited() throws IOException { final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); @@ -67,6 +84,56 @@ void commaDelimited() throws IOException { assertThat(destDir.resolve("two.jar")) .hasSameTextualContentAs(srcJar); } + + @Test + void commaDelimitedMultipleDest() throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + String.join(",", + "dest1<" + srcTxt.toString(), + "dest2<" + srcJar.toString() + ) + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("one.txt")) + .hasSameTextualContentAs(srcTxt); + assertThat(destDir2.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + } + + @Test + void commaDelimitedRelativeDest() throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + String.join(",", + "./dest1<" + srcTxt.toString(), + "./dest2<" + srcJar.toString() + ) + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("one.txt")) + .hasSameTextualContentAs(srcTxt); + assertThat(destDir2.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + } } @Nested @@ -97,6 +164,33 @@ void justFiles() throws IOException { .hasSameTextualContentAs(srcJar); } + @Test + void justFilesWithDifferentDest() throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + final Path listing = Files.write(tempDir.resolve("listing.txt"), Arrays.asList( + "dest1<" + srcTxt.toString(), + "dest2<" + srcJar.toString() + )); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "--file-is-listing", + listing.toString() + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("one.txt")) + .hasSameTextualContentAs(srcTxt); + assertThat(destDir2.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + } } @Nested @@ -122,6 +216,27 @@ void noGlob() throws IOException { .hasSameTextualContentAs(srcJar); } + @Test + void toInlineDir() throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + final Path destDir = tempDir.resolve("dest"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "dest<" + srcDir + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir.resolve("one.txt")) + .hasSameTextualContentAs(srcTxt); + assertThat(destDir.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + } + @Test void handlesUpdatedFile() throws IOException { final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); @@ -193,6 +308,50 @@ void managedWithManifest() throws IOException { .doesNotExist(); } + @Test + void managedWithManifestAndMultipleDest() throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + final Path destTxt = destDir1.resolve("one.txt"); + final Path destJar = destDir2.resolve("two.jar"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "--scope", "managedWithManifest", + "dest1<" + srcTxt + "," + "dest2<" + srcJar + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destTxt) + .hasSameTextualContentAs(srcTxt); + assertThat(destJar) + .hasSameTextualContentAs(srcJar); + assertThat(destDir1.resolve("one.txt")) + .hasSameTextualContentAs(srcTxt); + assertThat(destDir2.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + + Files.delete(srcTxt); + assertThat( + new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "--scope", "managedWithManifest", + destDir2 + "<" + srcJar + ) + ).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destTxt) + .doesNotExist(); + assertThat(destDir2.resolve("two.jar")) + .hasSameTextualContentAs(srcJar); + } + @Test void withGlob() throws IOException { final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); @@ -236,6 +395,23 @@ void remoteFile(WireMockRuntimeInfo wmInfo) { .hasContent("remote"); } + @Test + void remoteFileWithInlineDest(WireMockRuntimeInfo wmInfo) { + stubRemoteSrc("file.jar", "remote"); + + final Path destDir = tempDir.resolve("dest"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "dest<" + wmInfo.getHttpBaseUrl() + "/file.jar" + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir.resolve("file.jar")) + .hasContent("remote"); + } + @Test void listingOfRemoteFiles(WireMockRuntimeInfo wmInfo) throws IOException { stubRemoteSrc("file1.jar", "one"); @@ -262,6 +438,33 @@ void listingOfRemoteFiles(WireMockRuntimeInfo wmInfo) throws IOException { .hasContent("two"); } + @Test + void listingOfRemoteFilesWithInlineDest(WireMockRuntimeInfo wmInfo) throws IOException { + stubRemoteSrc("file1.jar", "one"); + stubRemoteSrc("file2.jar", "two"); + + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + final Path listing = Files.write(tempDir.resolve("listing.txt"), Arrays.asList( + "dest1<" + wmInfo.getHttpBaseUrl() + "/file1.jar", + "dest2<" +wmInfo.getHttpBaseUrl() + "/file2.jar" + )); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "--file-is-listing", + listing.toString() + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("file1.jar")) + .hasContent("one"); + assertThat(destDir2.resolve("file2.jar")) + .hasContent("two"); + } + @Test void remoteListingOfRemoteFiles(WireMockRuntimeInfo wmInfo) { stubRemoteSrc("listing.txt", @@ -287,6 +490,120 @@ void remoteListingOfRemoteFiles(WireMockRuntimeInfo wmInfo) { .hasContent("two"); } + @Test + void remoteListingOfRemoteFilesWithInlineDest(WireMockRuntimeInfo wmInfo) { + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + stubRemoteSrc("listing.txt", + "dest1<" + wmInfo.getHttpBaseUrl() + "/file1.jar\n" + + "dest2<" + wmInfo.getHttpBaseUrl() + "/file2.jar\n" + ); + stubRemoteSrc("file1.jar", "one"); + stubRemoteSrc("file2.jar", "two"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "--file-is-listing", + wmInfo.getHttpBaseUrl() + "/listing.txt" + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("file1.jar")) + .hasContent("one"); + assertThat(destDir2.resolve("file2.jar")) + .hasContent("two"); + } + + @Test + void remoteListingOfLocalFiles(WireMockRuntimeInfo wmInfo) throws IOException { + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + stubRemoteSrc("listing.txt", + srcTxt + "\n" + + srcJar + "\n" + ); + + final Path destDir = tempDir.resolve("dest"); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", destDir.toString(), + "--file-is-listing", + wmInfo.getHttpBaseUrl() + "/listing.txt" + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir.resolve("one.txt")) + .hasContent("one"); + assertThat(destDir.resolve("two.jar")) + .hasContent("two"); + } + + @Test + void remoteListingOfLocalFilesWithInlineDest(WireMockRuntimeInfo wmInfo) throws IOException { + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + + stubRemoteSrc("listing.txt", + "dest1<" + srcTxt + "\n" + + "dest2<" + srcJar + "\n" + ); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", tempDir.toString(), + "--file-is-listing", + wmInfo.getHttpBaseUrl() + "/listing.txt" + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir1.resolve("one.txt")) + .hasContent("one"); + assertThat(destDir2.resolve("two.jar")) + .hasContent("two"); + } + + @Test + void overrideDestOrder(WireMockRuntimeInfo wmInfo) throws IOException { + final Path destDir1 = tempDir.resolve("dest1"); + final Path destDir2 = tempDir.resolve("dest2"); + final Path destDir3 = tempDir.resolve("dest3"); + + final Path srcDir = Files.createDirectories(tempDir.resolve("srcDir")); + final Path srcTxt = writeLine(srcDir, "one.txt", "one"); + final Path srcJar = writeLine(srcDir, "two.jar", "two"); + final Path srcYaml = writeLine(srcDir, "three.yaml", "three"); + + stubRemoteSrc("listing.txt", + srcTxt + "\n" + + srcJar + "\n" + + destDir3 + "<" + srcYaml + "\n" + ); + + final int exitCode = new CommandLine(new MulitCopyCommand()) + .execute( + "--to", destDir1.toString(), + "--file-is-listing", + destDir2 + "<" + wmInfo.getHttpBaseUrl() + "/listing.txt" + ); + assertThat(exitCode).isEqualTo(CommandLine.ExitCode.OK); + + assertThat(destDir2.resolve("one.txt")) + .hasContent("one"); + assertThat(destDir2.resolve("two.jar")) + .hasContent("two"); + assertThat(destDir3.resolve("three.yaml")) + .hasContent("three"); + } + private void stubRemoteSrc(String filename, String content) { stubFor(head(urlPathEqualTo("/" + filename)) .willReturn(