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