diff --git a/app/src/main/scala/net/kemitix/thorp/Program.scala b/app/src/main/scala/net/kemitix/thorp/Program.scala index c23edd2..fec8adf 100644 --- a/app/src/main/scala/net/kemitix/thorp/Program.scala +++ b/app/src/main/scala/net/kemitix/thorp/Program.scala @@ -30,7 +30,7 @@ trait Program { args: List[String] ): ZIO[Storage with Clock with FileScanner, Nothing, Unit] = { (for { - cli <- CliArgs.parse(args) + cli <- UIO(CliArgs.parse(args.toArray)) config <- IO(ConfigurationBuilder.buildConfig(cli)) _ <- UIO(Console.putStrLn(versionLabel)) _ <- ZIO.when(!showVersion(cli))( diff --git a/cli/pom.xml b/cli/pom.xml index 35b5efc..c115954 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -12,49 +12,36 @@ cli + + + info.picocli + picocli + 4.3.2 + + + + + org.projectlombok + lombok + true + + net.kemitix.thorp thorp-config - - net.kemitix.thorp - thorp-filesystem - - + - com.github.scopt - scopt_2.13 + org.junit.jupiter + junit-jupiter + test - - - org.scala-lang - scala-library - - - - - dev.zio - zio_2.13 - - - - - org.scalatest - scalatest_2.13 + org.assertj + assertj-core test - - - - - net.alchim31.maven - scala-maven-plugin - - - - diff --git a/cli/src/main/java/net/kemitix/thorp/cli/CliArgs.java b/cli/src/main/java/net/kemitix/thorp/cli/CliArgs.java new file mode 100644 index 0000000..9f9a439 --- /dev/null +++ b/cli/src/main/java/net/kemitix/thorp/cli/CliArgs.java @@ -0,0 +1,162 @@ +package net.kemitix.thorp.cli; + +import net.kemitix.thorp.config.ConfigOption; +import net.kemitix.thorp.config.ConfigOptions; +import picocli.CommandLine; +import picocli.CommandLine.Option; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +public class CliArgs { + @Override + public String toString() { + return "CliArgs{" + + "showVersion=" + showVersion + + ",\n batchMode=" + batchMode + + ",\n sources=" + sources + + ",\n bucket='" + bucket + '\'' + + ",\n prefix='" + prefix + '\'' + + ",\n parallel=" + parallel + + ",\n includes=" + includes + + ",\n excludes=" + excludes + + ",\n debug=" + debug + + ",\n ignoreUserOptions=" + ignoreUserOptions + + ",\n ignoreGlobalOptions=" + ignoreGlobalOptions + + "\n}"; + } + + public static ConfigOptions parse(String[] args) { + CliArgs cliArgs = new CliArgs(); + new CommandLine(cliArgs).parseArgs(args); + return ConfigOptions.empty() + .merge(flag(cliArgs.showVersion, ConfigOption::version)) + .merge(flag(cliArgs.debug, ConfigOption::debug)) + .merge(flag(cliArgs.batchMode, ConfigOption::batchMode)) + .merge(flag(cliArgs.ignoreGlobalOptions, ConfigOption::ignoreGlobalOptions)) + .merge(flag(cliArgs.ignoreUserOptions, ConfigOption::ignoreUserOptions)) + .merge(option(cliArgs.bucket, ConfigOption::bucket)) + .merge(option(cliArgs.prefix, ConfigOption::prefix)) + .merge(paths(cliArgs.sources, ConfigOption::source)) + .merge(strings(cliArgs.includes, ConfigOption::include)) + .merge(strings(cliArgs.excludes, ConfigOption::exclude)) + .merge(integer(cliArgs.parallel, 1, ConfigOption::parallel)); + } + + private static ConfigOptions flag( + boolean flag, + Supplier configOption + ) { + if (flag) + return ConfigOptions.create(Collections.singletonList( + configOption.get())); + return ConfigOptions.empty(); + } + + private static ConfigOptions option( + String value, + Function configOption + ) { + if (value.isEmpty()) + return ConfigOptions.empty(); + return ConfigOptions.create(Collections.singletonList( + configOption.apply(value))); + } + + private static ConfigOptions integer( + int value, + int defaultValue, + Function configOption + ) { + if (value == defaultValue) return ConfigOptions.empty(); + return ConfigOptions.create(Collections.singletonList( + configOption.apply(value))); + } + + private static ConfigOptions paths( + List values, + Function configOption + ) { + if (values == null) return ConfigOptions.empty(); + return ConfigOptions.create( + values.stream() + .map(Paths::get) + .map(configOption) + .collect(Collectors.toList())); + } + + private static ConfigOptions strings( + List values, + Function configOption + ) { + if (values == null) return ConfigOptions.empty(); + return ConfigOptions.create( + values.stream() + .map(configOption) + .collect(Collectors.toList())); + } + + @Option( + names = {"-V", "--version"}, + description = "Show version") + boolean showVersion; + + @Option( + names = {"-B", "--batch"}, + description = "Enable batch-mode") + boolean batchMode; + + @Option( + names = {"-s", "--source"}, + description = "Source directory to sync to destination") + List sources; + + @Option( + names = {"-b", "--bucket"}, + defaultValue = "", + description = "S3 bucket name") + String bucket; + + @Option( + names = {"-p", "--prefix"}, + defaultValue = "", + description = "Prefix within the S3 Bucket") + String prefix; + + @Option( + names = {"-P", "--parallel"}, + defaultValue = "1", + description = "Maximum Parallel uploads") + int parallel; + + @Option( + names = {"-i", "--include"}, + description = "Include only matching paths") + List includes; + + @Option( + names = {"-x", "--exclude"}, + description = "Exclude matching paths") + List excludes; + + @Option( + names = {"-d", "--debug"}, + description = "Enable debug logging") + boolean debug; + + @Option( + names = {"--no-user"}, + description = "Ignore user configuration") + boolean ignoreUserOptions; + + @Option( + names = {"--no-global"}, + description = "Ignore global configuration") + boolean ignoreGlobalOptions; + +} diff --git a/cli/src/main/scala/net/kemitix/thorp/cli/CliArgs.scala b/cli/src/main/scala/net/kemitix/thorp/cli/CliArgs.scala deleted file mode 100644 index bf24cd2..0000000 --- a/cli/src/main/scala/net/kemitix/thorp/cli/CliArgs.scala +++ /dev/null @@ -1,65 +0,0 @@ -package net.kemitix.thorp.cli - -import java.nio.file.Paths - -import scala.jdk.CollectionConverters._ - -import net.kemitix.thorp.config.{ConfigOption, ConfigOptions} -import scopt.OParser -import zio.Task - -object CliArgs { - - def parse(args: List[String]): Task[ConfigOptions] = Task { - OParser - .parse(configParser, args, List()) - .map(options => ConfigOptions.create(options.asJava)) - .getOrElse(ConfigOptions.empty) - } - - val configParser: OParser[Unit, List[ConfigOption]] = { - val parserBuilder = OParser.builder[List[ConfigOption]] - import parserBuilder._ - OParser.sequence( - programName("thorp"), - head("thorp"), - opt[Unit]('V', "version") - .action((_, cos) => ConfigOption.version() :: cos) - .text("Show version"), - opt[Unit]('B', "batch") - .action((_, cos) => ConfigOption.batchMode() :: cos) - .text("Enable batch-mode"), - opt[String]('s', "source") - .unbounded() - .action((str, cos) => ConfigOption.source(Paths.get(str)) :: cos) - .text("Source directory to sync to destination"), - opt[String]('b', "bucket") - .action((str, cos) => ConfigOption.bucket(str) :: cos) - .text("S3 bucket name"), - opt[String]('p', "prefix") - .action((str, cos) => ConfigOption.prefix(str) :: cos) - .text("Prefix within the S3 Bucket"), - opt[Int]('P', "parallel") - .action((int, cos) => ConfigOption.parallel(int) :: cos) - .text("Maximum Parallel uploads"), - opt[String]('i', "include") - .unbounded() - .action((str, cos) => ConfigOption.include(str) :: cos) - .text("Include only matching paths"), - opt[String]('x', "exclude") - .unbounded() - .action((str, cos) => ConfigOption.exclude(str) :: cos) - .text("Exclude matching paths"), - opt[Unit]('d', "debug") - .action((_, cos) => ConfigOption.debug() :: cos) - .text("Enable debug logging"), - opt[Unit]("no-global") - .action((_, cos) => ConfigOption.ignoreGlobalOptions() :: cos) - .text("Ignore global configuration"), - opt[Unit]("no-user") - .action((_, cos) => ConfigOption.ignoreUserOptions() :: cos) - .text("Ignore user configuration") - ) - } - -} diff --git a/cli/src/test/java/net/kemitix/thorp/cli/CliArgsTest.java b/cli/src/test/java/net/kemitix/thorp/cli/CliArgsTest.java new file mode 100644 index 0000000..68b1d2c --- /dev/null +++ b/cli/src/test/java/net/kemitix/thorp/cli/CliArgsTest.java @@ -0,0 +1,190 @@ +package net.kemitix.thorp.cli; + +import net.kemitix.thorp.config.ConfigOption; +import net.kemitix.thorp.config.ConfigOptions; +import org.assertj.core.api.WithAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class CliArgsTest + implements WithAssertions { + @Test + @DisplayName("when no args then empty options") + public void whenNoArgs_thenEmptyOptions() { + assertThat(invoke(new String[]{})) + .isEqualTo(ConfigOptions.empty()); + } + + @Test + @DisplayName("version") + public void version() { + testOptions(new String[]{"-V", "--version"}, + ConfigOption::version); + } + + @Test + @DisplayName("batch") + public void batch() { + testOptions(new String[]{"-B", "--batch"}, + ConfigOption::batchMode); + } + + @Test + @DisplayName("debug") + public void debug() { + testOptions(new String[]{"-d", "--debug"}, + ConfigOption::debug); + } + + @Test + @DisplayName("ignore user options") + public void ignoreUserOptions() { + testOptions(new String[]{"--no-user"}, + ConfigOption::ignoreUserOptions); + } + + @Test + @DisplayName("ignore global options") + public void ignoreGlobalOptions() { + testOptions(new String[]{"--no-global"}, + ConfigOption::ignoreGlobalOptions); + } + + @Test + @DisplayName("bucket") + public void bucket() { + testStringOptions(new String[]{"--bucket"}, + ConfigOption::bucket); + } + + @Test + @DisplayName("prefix") + public void prefix() { + testStringOptions(new String[]{"--prefix"}, + ConfigOption::prefix); + } + + @Test + @DisplayName("source") + public void source() { + testPathsOptions( + new String[]{"--source"}, + ConfigOption::source); + } + + @Test + @DisplayName("include") + public void include() { + testStringListOptions( + new String[]{"--include"}, + ConfigOption::include); + } + + @Test + @DisplayName("exclude") + public void exclude() { + testStringListOptions( + new String[]{"--exclude"}, + ConfigOption::exclude); + } + + @Test + @DisplayName("parallel") + public void parallel() { + testIntOptions( + new String[]{"--parallel"}, + ConfigOption::parallel); + } + + private void testOptions( + String[] parameters, + Supplier optionSupplier + ) { + assertThat(Arrays.asList(parameters)) + .allSatisfy(arg -> assertThat( + invoke(new String[]{arg})) + .isEqualTo( + ConfigOptions.create( + Collections.singletonList( + optionSupplier.get() + )))); + } + + private void testStringOptions( + String[] parameters, + Function valueToOption + ) { + String value = UUID.randomUUID().toString(); + assertThat(Arrays.asList(parameters)) + .allSatisfy(arg -> assertThat( + invoke(new String[]{arg, value})) + .isEqualTo( + ConfigOptions.create( + Collections.singletonList( + valueToOption.apply(value) + )))); + } + + private void testIntOptions( + String[] parameters, + Function valueToOption + ) { + Integer value = new Random().nextInt(); + assertThat(Arrays.asList(parameters)) + .allSatisfy(arg -> assertThat( + invoke(new String[]{arg, "" + value})) + .isEqualTo( + ConfigOptions.create( + Collections.singletonList( + valueToOption.apply(value) + )))); + } + + private void testPathsOptions( + String[] parameters, + Function pathToOption + ) { + String value1 = UUID.randomUUID().toString(); + String value2 = UUID.randomUUID().toString(); + List paths = Stream.of(value1, value2) + .map(Paths::get) + .map(pathToOption) + .collect(Collectors.toList()); + assertThat(Arrays.asList(parameters)) + .allSatisfy(arg -> assertThat( + invoke(new String[]{ + arg, value1, + arg, value2})) + .isEqualTo( + ConfigOptions.create( + paths + ))); + } + + private void testStringListOptions( + String[] parameters, + Function stringToOption + ) { + String value1 = UUID.randomUUID().toString(); + String value2 = UUID.randomUUID().toString(); + List expected = Stream.of(value1, value2) + .map(stringToOption) + .collect(Collectors.toList()); + assertThat(Arrays.asList(parameters)) + .allSatisfy(arg -> assertThat( + invoke(new String[]{arg, value1, arg, value2})) + .isEqualTo(ConfigOptions.create(expected))); + } + + ConfigOptions invoke(String[] args) { + return CliArgs.parse(args); + } +} diff --git a/cli/src/test/scala/net/kemitix/thorp/cli/CliArgsTest.scala b/cli/src/test/scala/net/kemitix/thorp/cli/CliArgsTest.scala deleted file mode 100644 index 8246cfb..0000000 --- a/cli/src/test/scala/net/kemitix/thorp/cli/CliArgsTest.scala +++ /dev/null @@ -1,113 +0,0 @@ -package net.kemitix.thorp.cli - -import java.nio.file.Paths - -import net.kemitix.thorp.config.{ConfigOption, ConfigOptions, ConfigQuery} -import net.kemitix.thorp.filesystem.Resource -import org.scalatest.FunSpec -import zio.DefaultRuntime - -import scala.jdk.CollectionConverters._ -import scala.util.Try - -class CliArgsTest extends FunSpec { - - private val runtime = new DefaultRuntime {} - - val source = Resource.select(this, "") - - describe("parse - source") { - def invokeWithSource(path: String) = - invoke(List("--source", path, "--bucket", "bucket")) - - describe("when source is a directory") { - it("should succeed") { - val result = invokeWithSource(pathTo(".")) - assert(result.isDefined) - } - } - describe("when source is a relative path to a directory") { - val result = invokeWithSource(pathTo(".")) - it("should succeed") { pending } - } - describe("when there are multiple sources") { - val args = - List("--source", "path1", "--source", "path2", "--bucket", "bucket") - it("should get multiple sources") { - val expected = Some(Set("path1", "path2").map(Paths.get(_))) - val configOptions = invoke(args) - val result = - configOptions.map(ConfigQuery.sources(_).paths.asScala.toSet) - assertResult(expected)(result) - } - } - } - - describe("parse - debug") { - def invokeWithArgument(arg: String): ConfigOptions = { - val strings = List("--source", pathTo("."), "--bucket", "bucket", arg) - .filter(_ != "") - val maybeOptions = invoke(strings) - maybeOptions.getOrElse(ConfigOptions.empty) - } - - val containsDebug = (options: ConfigOptions) => - options.options.stream().anyMatch(_.isInstanceOf[ConfigOption.Debug]) - - describe("when no debug flag") { - val configOptions = invokeWithArgument("") - it("debug should be false") { - assertResult(false)(containsDebug(configOptions)) - } - } - describe("when long debug flag") { - val configOptions = invokeWithArgument("--debug") - it("debug should be true") { - assert(containsDebug(configOptions)) - } - } - describe("when short debug flag") { - val configOptions = invokeWithArgument("-d") - it("debug should be true") { - assert(containsDebug(configOptions)) - } - } - } - - describe("parse - parallel") { - def invokeWithArguments(args: List[String]): ConfigOptions = { - val strings = List("--source", pathTo("."), "--bucket", "bucket") - .concat(args) - .filter(_ != "") - val maybeOptions = invoke(strings) - maybeOptions.getOrElse(ConfigOptions.empty) - } - - describe("when no parallel parameter") { - val configOptions = invokeWithArguments(List.empty[String]) - it("should have parallel of 1") { - assertResult(1)(ConfigOptions.parallel(configOptions)) - } - } - describe("when parallel parameter given") { - val configOptions = invokeWithArguments(List("--parallel", "5")) - it("should have parallel of 5") { - assertResult(5)(ConfigOptions.parallel(configOptions)) - } - } - } - - private def pathTo(value: String): String = - Try(Resource.select(this, value)) - .map(_.getCanonicalPath) - .getOrElse("[not-found]") - - private def invoke(args: List[String]) = - runtime - .unsafeRunSync { - CliArgs.parse(args) - } - .toEither - .toOption - -} diff --git a/config/src/main/java/net/kemitix/thorp/config/ConfigOption.java b/config/src/main/java/net/kemitix/thorp/config/ConfigOption.java index 61540bb..8711ce8 100644 --- a/config/src/main/java/net/kemitix/thorp/config/ConfigOption.java +++ b/config/src/main/java/net/kemitix/thorp/config/ConfigOption.java @@ -103,6 +103,7 @@ public interface ConfigOption { static ConfigOption batchMode() { return new BatchMode(); } + @EqualsAndHashCode class BatchMode implements ConfigOption { @Override public Configuration update(Configuration config) { @@ -117,6 +118,7 @@ public interface ConfigOption { static ConfigOption version() { return new Version(); } + @EqualsAndHashCode class Version implements ConfigOption { @Override public Configuration update(Configuration config) { @@ -131,6 +133,7 @@ public interface ConfigOption { static ConfigOption ignoreUserOptions() { return new IgnoreUserOptions(); } + @EqualsAndHashCode class IgnoreUserOptions implements ConfigOption { @Override public Configuration update(Configuration config) { @@ -145,6 +148,7 @@ public interface ConfigOption { static ConfigOption ignoreGlobalOptions() { return new IgnoreGlobalOptions(); } + @EqualsAndHashCode class IgnoreGlobalOptions implements ConfigOption { @Override public Configuration update(Configuration config) { diff --git a/config/src/main/java/net/kemitix/thorp/config/ConfigOptions.java b/config/src/main/java/net/kemitix/thorp/config/ConfigOptions.java index bc74f0c..6b70924 100644 --- a/config/src/main/java/net/kemitix/thorp/config/ConfigOptions.java +++ b/config/src/main/java/net/kemitix/thorp/config/ConfigOptions.java @@ -2,6 +2,7 @@ package net.kemitix.thorp.config; import lombok.EqualsAndHashCode; import lombok.RequiredArgsConstructor; +import lombok.ToString; import java.util.*; @@ -25,6 +26,7 @@ public interface ConfigOptions { static ConfigOptions create(List options) { return new ConfigOptionsImpl(options); } + @ToString @EqualsAndHashCode @RequiredArgsConstructor class ConfigOptionsImpl implements ConfigOptions { diff --git a/docs/images/reactor-graph.png b/docs/images/reactor-graph.png index 922c497..7d3db38 100644 Binary files a/docs/images/reactor-graph.png and b/docs/images/reactor-graph.png differ