Install WartRemover pluging (#150)

* [sbt] Install WartRemover

* remove warts

* remote warts

* fix tests

* [domain] UploadEventListener fix progress bar (again)

* [domain] Remove LastModified - wasn't being used for anything
This commit is contained in:
Paul Campbell 2019-08-06 18:19:05 +01:00 committed by GitHub
parent 9a6208025c
commit af7733952c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
85 changed files with 645 additions and 631 deletions

View file

@ -26,6 +26,7 @@ val commonSettings = Seq(
"-language:postfixOps", "-language:postfixOps",
"-language:higherKinds", "-language:higherKinds",
"-Ypartial-unification"), "-Ypartial-unification"),
wartremoverErrors ++= Warts.unsafe.filterNot(wart => List(Wart.Any, Wart.Nothing, Wart.Serializable).contains(wart)),
test in assembly := {} test in assembly := {}
) )

View file

@ -11,7 +11,7 @@ object CliArgs {
OParser OParser
.parse(configParser, args, List()) .parse(configParser, args, List())
.map(ConfigOptions(_)) .map(ConfigOptions(_))
.getOrElse(ConfigOptions()) .getOrElse(ConfigOptions.empty)
} }
val configParser: OParser[Unit, List[ConfigOption]] = { val configParser: OParser[Unit, List[ConfigOption]] = {

View file

@ -23,7 +23,7 @@ object Config {
trait Live extends Config { trait Live extends Config {
val config: Service = new Service { val config: Service = new Service {
private val configRef = new AtomicReference(Configuration()) private val configRef = new AtomicReference(Configuration.empty)
override def setConfiguration( override def setConfiguration(
config: Configuration): ZIO[Config, Nothing, Unit] = config: Configuration): ZIO[Config, Nothing, Unit] =
UIO(configRef.set(config)) UIO(configRef.set(config))

View file

@ -12,12 +12,12 @@ sealed trait ConfigOption {
object ConfigOption { object ConfigOption {
case class Source(path: Path) extends ConfigOption { final case class Source(path: Path) extends ConfigOption {
override def update(config: Configuration): Configuration = override def update(config: Configuration): Configuration =
sources.modify(_ + path)(config) sources.modify(_ + path)(config)
} }
case class Bucket(name: String) extends ConfigOption { final case class Bucket(name: String) extends ConfigOption {
override def update(config: Configuration): Configuration = override def update(config: Configuration): Configuration =
if (config.bucket.name.isEmpty) if (config.bucket.name.isEmpty)
bucket.set(domain.Bucket(name))(config) bucket.set(domain.Bucket(name))(config)
@ -25,7 +25,7 @@ object ConfigOption {
config config
} }
case class Prefix(path: String) extends ConfigOption { final case class Prefix(path: String) extends ConfigOption {
override def update(config: Configuration): Configuration = override def update(config: Configuration): Configuration =
if (config.prefix.key.isEmpty) if (config.prefix.key.isEmpty)
prefix.set(RemoteKey(path))(config) prefix.set(RemoteKey(path))(config)
@ -33,17 +33,17 @@ object ConfigOption {
config config
} }
case class Include(pattern: String) extends ConfigOption { final case class Include(pattern: String) extends ConfigOption {
override def update(config: Configuration): Configuration = override def update(config: Configuration): Configuration =
filters.modify(domain.Filter.Include(pattern) :: _)(config) filters.modify(domain.Filter.Include(pattern) :: _)(config)
} }
case class Exclude(pattern: String) extends ConfigOption { final case class Exclude(pattern: String) extends ConfigOption {
override def update(config: Configuration): Configuration = override def update(config: Configuration): Configuration =
filters.modify(domain.Filter.Exclude(pattern) :: _)(config) filters.modify(domain.Filter.Exclude(pattern) :: _)(config)
} }
case class Debug() extends ConfigOption { final case class Debug() extends ConfigOption {
override def update(config: Configuration): Configuration = override def update(config: Configuration): Configuration =
debug.set(true)(config) debug.set(true)(config)
} }

View file

@ -2,29 +2,27 @@ package net.kemitix.thorp.config
import net.kemitix.thorp.domain.SimpleLens import net.kemitix.thorp.domain.SimpleLens
case class ConfigOptions( final case class ConfigOptions(options: List[ConfigOption]) {
options: List[ConfigOption] = List()
) {
def combine(
x: ConfigOptions,
y: ConfigOptions
): ConfigOptions =
x ++ y
def ++(other: ConfigOptions): ConfigOptions = def ++(other: ConfigOptions): ConfigOptions =
ConfigOptions(options ++ other.options) ConfigOptions.combine(this, other)
def ::(head: ConfigOption): ConfigOptions = def ::(head: ConfigOption): ConfigOptions =
ConfigOptions(head :: options) ConfigOptions(head :: options)
def contains[A1 >: ConfigOption](elem: A1): Boolean =
options contains elem
} }
object ConfigOptions { object ConfigOptions {
val empty: ConfigOptions = ConfigOptions(List.empty)
val options: SimpleLens[ConfigOptions, List[ConfigOption]] = val options: SimpleLens[ConfigOptions, List[ConfigOption]] =
SimpleLens[ConfigOptions, List[ConfigOption]](_.options, SimpleLens[ConfigOptions, List[ConfigOption]](_.options,
c => a => c.copy(options = a)) c => a => c.copy(options = a))
def combine(
x: ConfigOptions,
y: ConfigOptions
): ConfigOptions = ConfigOptions(x.options ++ y.options)
def contains[A1 >: ConfigOption](elem: A1)(
configOptions: ConfigOptions): Boolean =
configOptions.options.contains(elem)
} }

View file

@ -7,26 +7,27 @@ import net.kemitix.thorp.domain.Sources
trait ConfigQuery { trait ConfigQuery {
def showVersion(configOptions: ConfigOptions): Boolean = def showVersion(configOptions: ConfigOptions): Boolean =
configOptions contains ConfigOption.Version ConfigOptions.contains(ConfigOption.Version)(configOptions)
def batchMode(configOptions: ConfigOptions): Boolean = def batchMode(configOptions: ConfigOptions): Boolean =
configOptions contains ConfigOption.BatchMode ConfigOptions.contains(ConfigOption.BatchMode)(configOptions)
def ignoreUserOptions(configOptions: ConfigOptions): Boolean = def ignoreUserOptions(configOptions: ConfigOptions): Boolean =
configOptions contains ConfigOption.IgnoreUserOptions ConfigOptions.contains(ConfigOption.IgnoreUserOptions)(configOptions)
def ignoreGlobalOptions(configOptions: ConfigOptions): Boolean = def ignoreGlobalOptions(configOptions: ConfigOptions): Boolean =
configOptions contains ConfigOption.IgnoreGlobalOptions ConfigOptions.contains(ConfigOption.IgnoreGlobalOptions)(configOptions)
def sources(configOptions: ConfigOptions): Sources = { def sources(configOptions: ConfigOptions): Sources = {
val paths = configOptions.options.flatMap { val explicitPaths = configOptions.options.flatMap {
case ConfigOption.Source(sourcePath) => Some(sourcePath) case ConfigOption.Source(sourcePath) => List(sourcePath)
case _ => None case _ => List.empty
} }
Sources(paths match { val paths = explicitPaths match {
case List() => List(Paths.get(System.getenv("PWD"))) case List() => List(Paths.get(System.getenv("PWD")))
case _ => paths case _ => explicitPaths
}) }
Sources(paths)
} }
} }

View file

@ -21,7 +21,7 @@ object ConfigValidation {
override def errorMessage: String = "Bucket name is missing" override def errorMessage: String = "Bucket name is missing"
} }
case class ErrorReadingFile( final case class ErrorReadingFile(
file: File, file: File,
message: String message: String
) extends ConfigValidation { ) extends ConfigValidation {

View file

@ -3,15 +3,23 @@ package net.kemitix.thorp.config
import net.kemitix.thorp.domain.{Bucket, Filter, RemoteKey, SimpleLens, Sources} import net.kemitix.thorp.domain.{Bucket, Filter, RemoteKey, SimpleLens, Sources}
private[config] final case class Configuration( private[config] final case class Configuration(
bucket: Bucket = Bucket(""), bucket: Bucket,
prefix: RemoteKey = RemoteKey(""), prefix: RemoteKey,
filters: List[Filter] = List(), filters: List[Filter],
debug: Boolean = false, debug: Boolean,
batchMode: Boolean = false, batchMode: Boolean,
sources: Sources = Sources(List()) sources: Sources
) )
private[config] object Configuration { private[config] object Configuration {
val empty: Configuration = Configuration(
bucket = Bucket(""),
prefix = RemoteKey(""),
filters = List.empty,
debug = false,
batchMode = false,
sources = Sources(List.empty)
)
val sources: SimpleLens[Configuration, Sources] = val sources: SimpleLens[Configuration, Sources] =
SimpleLens[Configuration, Sources](_.sources, b => a => b.copy(sources = a)) SimpleLens[Configuration, Sources](_.sources, b => a => b.copy(sources = a))
val bucket: SimpleLens[Configuration, Bucket] = val bucket: SimpleLens[Configuration, Bucket] =

View file

@ -29,7 +29,7 @@ trait ConfigurationBuilder {
globalOpts <- globalOptions(priorityOpts ++ sourceOpts ++ userOpts) globalOpts <- globalOptions(priorityOpts ++ sourceOpts ++ userOpts)
} yield priorityOpts ++ sourceOpts ++ userOpts ++ globalOpts } yield priorityOpts ++ sourceOpts ++ userOpts ++ globalOpts
private val emptyConfig = ZIO.succeed(ConfigOptions()) private val emptyConfig = ZIO.succeed(ConfigOptions.empty)
private def userOptions(priorityOpts: ConfigOptions) = private def userOptions(priorityOpts: ConfigOptions) =
if (ConfigQuery.ignoreUserOptions(priorityOpts)) emptyConfig if (ConfigQuery.ignoreUserOptions(priorityOpts)) emptyConfig
@ -42,7 +42,7 @@ trait ConfigurationBuilder {
private def collateOptions(configOptions: ConfigOptions): Configuration = private def collateOptions(configOptions: ConfigOptions): Configuration =
ConfigOptions.options ConfigOptions.options
.get(configOptions) .get(configOptions)
.foldLeft(Configuration()) { (config, configOption) => .foldLeft(Configuration.empty) { (config, configOption) =>
configOption.update(config) configOption.update(config)
} }

View file

@ -17,21 +17,21 @@ trait ParseConfigLines {
private def parseLine(str: String) = private def parseLine(str: String) =
format.matcher(str) match { format.matcher(str) match {
case m if m.matches => parseKeyValue(m.group("key"), m.group("value")) case m if m.matches => parseKeyValue(m.group("key"), m.group("value"))
case _ => None case _ => List.empty
} }
private def parseKeyValue( private def parseKeyValue(
key: String, key: String,
value: String value: String
): Option[ConfigOption] = ): List[ConfigOption] =
key.toLowerCase match { key.toLowerCase match {
case "source" => Some(Source(Paths.get(value))) case "source" => List(Source(Paths.get(value)))
case "bucket" => Some(Bucket(value)) case "bucket" => List(Bucket(value))
case "prefix" => Some(Prefix(value)) case "prefix" => List(Prefix(value))
case "include" => Some(Include(value)) case "include" => List(Include(value))
case "exclude" => Some(Exclude(value)) case "exclude" => List(Exclude(value))
case "debug" => if (truthy(value)) Some(Debug()) else None case "debug" => if (truthy(value)) List(Debug()) else List.empty
case _ => None case _ => List.empty
} }
private def truthy(value: String): Boolean = private def truthy(value: String): Boolean =

View file

@ -1,16 +1,11 @@
package net.kemitix.thorp.config package net.kemitix.thorp.config
import java.io.{File, FileNotFoundException} import java.io.File
import scala.util.Try
object Resource { object Resource {
def apply( def apply(
base: AnyRef, base: AnyRef,
name: String name: String
): File = ): File = new File(base.getClass.getResource(name).getPath)
Try {
new File(base.getClass.getResource(name).getPath)
}.getOrElse(throw new FileNotFoundException(name))
} }

View file

@ -45,25 +45,27 @@ class CliArgsTest extends FunSpec {
val strings = List("--source", pathTo("."), "--bucket", "bucket", arg) val strings = List("--source", pathTo("."), "--bucket", "bucket", arg)
.filter(_ != "") .filter(_ != "")
val maybeOptions = invoke(strings) val maybeOptions = invoke(strings)
maybeOptions.getOrElse(ConfigOptions()) maybeOptions.getOrElse(ConfigOptions.empty)
} }
val containsDebug = ConfigOptions.contains(Debug())(_)
describe("when no debug flag") { describe("when no debug flag") {
val configOptions = invokeWithArgument("") val configOptions = invokeWithArgument("")
it("debug should be false") { it("debug should be false") {
assertResult(false)(configOptions.contains(Debug())) assertResult(false)(containsDebug(configOptions))
} }
} }
describe("when long debug flag") { describe("when long debug flag") {
val configOptions = invokeWithArgument("--debug") val configOptions = invokeWithArgument("--debug")
it("debug should be true") { it("debug should be true") {
assert(configOptions.contains(Debug())) assert(containsDebug(configOptions))
} }
} }
describe("when short debug flag") { describe("when short debug flag") {
val configOptions = invokeWithArgument("-d") val configOptions = invokeWithArgument("-d")
it("debug should be true") { it("debug should be true") {
assert(configOptions.contains(Debug())) assert(containsDebug(configOptions))
} }
} }
} }

View file

@ -12,7 +12,7 @@ class ConfigOptionTest extends FunSpec with TemporaryFolder {
withDirectory(path1 => { withDirectory(path1 => {
withDirectory(path2 => { withDirectory(path2 => {
val configOptions = ConfigOptions( val configOptions = ConfigOptions(
List( List[ConfigOption](
ConfigOption.Source(path1), ConfigOption.Source(path1),
ConfigOption.Source(path2), ConfigOption.Source(path2),
ConfigOption.Bucket("bucket"), ConfigOption.Bucket("bucket"),

View file

@ -2,6 +2,7 @@ package net.kemitix.thorp.config
import java.nio.file.Paths import java.nio.file.Paths
import net.kemitix.thorp.domain.NonUnit.~*
import net.kemitix.thorp.domain.Sources import net.kemitix.thorp.domain.Sources
import org.scalatest.FreeSpec import org.scalatest.FreeSpec
@ -75,7 +76,7 @@ class ConfigQueryTest extends FreeSpec {
val pwd = Paths.get(System.getenv("PWD")) val pwd = Paths.get(System.getenv("PWD"))
val expected = Sources(List(pwd)) val expected = Sources(List(pwd))
val result = ConfigQuery.sources(ConfigOptions(List())) val result = ConfigQuery.sources(ConfigOptions(List()))
assertResult(expected)(result) ~*(assertResult(expected)(result))
} }
} }
"when is set once" - { "when is set once" - {

View file

@ -3,6 +3,7 @@ package net.kemitix.thorp.config
import java.nio.file.{Path, Paths} import java.nio.file.{Path, Paths}
import net.kemitix.thorp.domain.Filter.{Exclude, Include} import net.kemitix.thorp.domain.Filter.{Exclude, Include}
import net.kemitix.thorp.domain.NonUnit.~*
import net.kemitix.thorp.domain._ import net.kemitix.thorp.domain._
import net.kemitix.thorp.filesystem.FileSystem import net.kemitix.thorp.filesystem.FileSystem
import org.scalatest.FunSpec import org.scalatest.FunSpec
@ -17,7 +18,7 @@ class ConfigurationBuilderTest extends FunSpec with TemporaryFolder {
private def configOptions(options: ConfigOption*): ConfigOptions = private def configOptions(options: ConfigOption*): ConfigOptions =
ConfigOptions( ConfigOptions(
List( List[ConfigOption](
ConfigOption.IgnoreUserOptions, ConfigOption.IgnoreUserOptions,
ConfigOption.IgnoreGlobalOptions ConfigOption.IgnoreGlobalOptions
) ++ options) ) ++ options)
@ -34,7 +35,7 @@ class ConfigurationBuilderTest extends FunSpec with TemporaryFolder {
describe("with .thorp.conf") { describe("with .thorp.conf") {
describe("with settings") { describe("with settings") {
withDirectory(source => { withDirectory(source => {
val configFileName = createFile(source, writeFile(source,
thorpConfigFileName, thorpConfigFileName,
"bucket = a-bucket", "bucket = a-bucket",
"prefix = a-prefix", "prefix = a-prefix",
@ -51,7 +52,8 @@ class ConfigurationBuilderTest extends FunSpec with TemporaryFolder {
} }
it("should have filters") { it("should have filters") {
val expected = val expected =
Right(List(Exclude("an-exclusion"), Include("an-inclusion"))) Right(
List[Filter](Exclude("an-exclusion"), Include("an-inclusion")))
assertResult(expected)(result.map(_.filters)) assertResult(expected)(result.map(_.filters))
} }
}) })
@ -125,14 +127,14 @@ class ConfigurationBuilderTest extends FunSpec with TemporaryFolder {
val expectedPrefixes = Right(RemoteKey("current-prefix")) val expectedPrefixes = Right(RemoteKey("current-prefix"))
// should have filters from both sources // should have filters from both sources
val expectedFilters = Right( val expectedFilters = Right(
List(Filter.Exclude("current-exclude"), List[Filter](Filter.Exclude("current-exclude"),
Filter.Include("current-include"))) Filter.Include("current-include")))
val options = configOptions(ConfigOption.Source(currentSource)) val options = configOptions(ConfigOption.Source(currentSource))
val result = invoke(options) val result = invoke(options)
assertResult(expectedSources)(result.map(_.sources)) ~*(assertResult(expectedSources)(result.map(_.sources)))
assertResult(expectedBuckets)(result.map(_.bucket)) ~*(assertResult(expectedBuckets)(result.map(_.bucket)))
assertResult(expectedPrefixes)(result.map(_.prefix)) ~*(assertResult(expectedPrefixes)(result.map(_.prefix)))
assertResult(expectedFilters)(result.map(_.filters)) ~*(assertResult(expectedFilters)(result.map(_.filters)))
}) })
}) })
} }

View file

@ -10,7 +10,7 @@ import zio.DefaultRuntime
class ParseConfigFileTest extends FunSpec with TemporaryFolder { class ParseConfigFileTest extends FunSpec with TemporaryFolder {
private val empty = Right(ConfigOptions()) private val empty = Right(ConfigOptions.empty)
describe("parse a missing file") { describe("parse a missing file") {
val file = new File("/path/to/missing/file") val file = new File("/path/to/missing/file")
@ -21,7 +21,7 @@ class ParseConfigFileTest extends FunSpec with TemporaryFolder {
describe("parse an empty file") { describe("parse an empty file") {
it("should return no options") { it("should return no options") {
withDirectory(dir => { withDirectory(dir => {
val file = writeFile(dir, "empty-file") val file = createFile(dir, "empty-file")
assertResult(empty)(invoke(file)) assertResult(empty)(invoke(file))
}) })
} }
@ -29,7 +29,7 @@ class ParseConfigFileTest extends FunSpec with TemporaryFolder {
describe("parse a file with no valid entries") { describe("parse a file with no valid entries") {
it("should return no options") { it("should return no options") {
withDirectory(dir => { withDirectory(dir => {
val file = writeFile(dir, "invalid-config", "no valid = config items") val file = createFile(dir, "invalid-config", "no valid = config items")
assertResult(empty)(invoke(file)) assertResult(empty)(invoke(file))
}) })
} }
@ -37,10 +37,11 @@ class ParseConfigFileTest extends FunSpec with TemporaryFolder {
describe("parse a file with properties") { describe("parse a file with properties") {
it("should return some options") { it("should return some options") {
val expected = Right( val expected = Right(
ConfigOptions(List(ConfigOption.Source(Paths.get("/path/to/source")), ConfigOptions(
List[ConfigOption](ConfigOption.Source(Paths.get("/path/to/source")),
ConfigOption.Bucket("bucket-name")))) ConfigOption.Bucket("bucket-name"))))
withDirectory(dir => { withDirectory(dir => {
val file = writeFile(dir, val file = createFile(dir,
"simple-config", "simple-config",
"source = /path/to/source", "source = /path/to/source",
"bucket = bucket-name") "bucket = bucket-name")

View file

@ -59,21 +59,21 @@ class ParseConfigLinesTest extends FunSpec {
} }
describe("debug - false") { describe("debug - false") {
it("should parse") { it("should parse") {
val expected = Right(ConfigOptions()) val expected = Right(ConfigOptions.empty)
val result = invoke(List("debug = false")) val result = invoke(List("debug = false"))
assertResult(expected)(result) assertResult(expected)(result)
} }
} }
describe("comment line") { describe("comment line") {
it("should be ignored") { it("should be ignored") {
val expected = Right(ConfigOptions()) val expected = Right(ConfigOptions.empty)
val result = invoke(List("# ignore me")) val result = invoke(List("# ignore me"))
assertResult(expected)(result) assertResult(expected)(result)
} }
} }
describe("unrecognised option") { describe("unrecognised option") {
it("should be ignored") { it("should be ignored") {
val expected = Right(ConfigOptions()) val expected = Right(ConfigOptions.empty)
val result = invoke(List("unsupported = option")) val result = invoke(List("unsupported = option"))
assertResult(expected)(result) assertResult(expected)(result)
} }

View file

@ -15,17 +15,17 @@ trait Console {
object Console { object Console {
trait Service { trait Service {
def putStrLn(line: ConsoleOut): ZIO[Console, Nothing, Unit] def putMessageLn(line: ConsoleOut): ZIO[Console, Nothing, Unit]
def putStrLn(line: String): ZIO[Console, Nothing, Unit] def putStrLn(line: String): ZIO[Console, Nothing, Unit]
} }
trait Live extends Console { trait Live extends Console {
val console: Service = new Service { val console: Service = new Service {
override def putStrLn(line: ConsoleOut): ZIO[Console, Nothing, Unit] = override def putMessageLn(line: ConsoleOut): ZIO[Console, Nothing, Unit] =
putStrLn(line.en) putStrLn(line.en)
override def putStrLn(line: String): ZIO[Console, Nothing, Unit] = override def putStrLn(line: String): ZIO[Console, Nothing, Unit] =
putStrLn(SConsole.out)(line) putStrLnPrintStream(SConsole.out)(line)
final def putStrLn(stream: PrintStream)( final def putStrLnPrintStream(stream: PrintStream)(
line: String): ZIO[Console, Nothing, Unit] = line: String): ZIO[Console, Nothing, Unit] =
UIO(SConsole.withOut(stream)(SConsole.println(line))) UIO(SConsole.withOut(stream)(SConsole.println(line)))
} }
@ -39,11 +39,11 @@ object Console {
def getOutput: List[String] = output.get def getOutput: List[String] = output.get
val console: Service = new Service { val console: Service = new Service {
override def putStrLn(line: ConsoleOut): ZIO[Console, Nothing, Unit] = override def putMessageLn(line: ConsoleOut): ZIO[Console, Nothing, Unit] =
putStrLn(line.en) putStrLn(line.en)
override def putStrLn(line: String): ZIO[Console, Nothing, Unit] = { override def putStrLn(line: String): ZIO[Console, Nothing, Unit] = {
output.accumulateAndGet(List(line), (a, b) => a ++ b) val _ = output.accumulateAndGet(List(line), (a, b) => a ++ b)
ZIO.succeed(()) ZIO.succeed(())
} }
@ -59,9 +59,9 @@ object Console {
ZIO.accessM(_.console putStrLn line) ZIO.accessM(_.console putStrLn line)
final def putMessageLn(line: ConsoleOut): ZIO[Console, Nothing, Unit] = final def putMessageLn(line: ConsoleOut): ZIO[Console, Nothing, Unit] =
ZIO.accessM(_.console putStrLn line) ZIO.accessM(_.console putMessageLn line)
final def putMessageLn( final def putMessageLnB(
line: ConsoleOut.WithBatchMode): ZIO[Console with Config, Nothing, Unit] = line: ConsoleOut.WithBatchMode): ZIO[Console with Config, Nothing, Unit] =
ZIO.accessM(line() >>= _.console.putStrLn) ZIO.accessM(line() >>= _.console.putStrLn)

View file

@ -23,7 +23,7 @@ object ConsoleOut {
if (batchMode) UIO(enBatch) else UIO(en) if (batchMode) UIO(enBatch) else UIO(en)
} }
case class ValidConfig( final case class ValidConfig(
bucket: Bucket, bucket: Bucket,
prefix: RemoteKey, prefix: RemoteKey,
sources: Sources sources: Sources
@ -36,7 +36,7 @@ object ConsoleOut {
.mkString(", ") .mkString(", ")
} }
case class UploadComplete(remoteKey: RemoteKey) final case class UploadComplete(remoteKey: RemoteKey)
extends ConsoleOut.WithBatchMode { extends ConsoleOut.WithBatchMode {
override def en: String = override def en: String =
s"${GREEN}Uploaded:$RESET ${remoteKey.key}$eraseToEndOfScreen" s"${GREEN}Uploaded:$RESET ${remoteKey.key}$eraseToEndOfScreen"
@ -44,7 +44,7 @@ object ConsoleOut {
s"Uploaded: ${remoteKey.key}" s"Uploaded: ${remoteKey.key}"
} }
case class CopyComplete(sourceKey: RemoteKey, targetKey: RemoteKey) final case class CopyComplete(sourceKey: RemoteKey, targetKey: RemoteKey)
extends ConsoleOut.WithBatchMode { extends ConsoleOut.WithBatchMode {
override def en: String = override def en: String =
s"${GREEN}Copied:$RESET ${sourceKey.key} => ${targetKey.key}$eraseToEndOfScreen" s"${GREEN}Copied:$RESET ${sourceKey.key} => ${targetKey.key}$eraseToEndOfScreen"
@ -52,7 +52,7 @@ object ConsoleOut {
s"Copied: ${sourceKey.key} => ${targetKey.key}" s"Copied: ${sourceKey.key} => ${targetKey.key}"
} }
case class DeleteComplete(remoteKey: RemoteKey) final case class DeleteComplete(remoteKey: RemoteKey)
extends ConsoleOut.WithBatchMode { extends ConsoleOut.WithBatchMode {
override def en: String = override def en: String =
s"${GREEN}Deleted:$RESET ${remoteKey.key}$eraseToEndOfScreen" s"${GREEN}Deleted:$RESET ${remoteKey.key}$eraseToEndOfScreen"
@ -60,7 +60,7 @@ object ConsoleOut {
s"Deleted: $remoteKey" s"Deleted: $remoteKey"
} }
case class ErrorQueueEventOccurred(action: Action, e: Throwable) final case class ErrorQueueEventOccurred(action: Action, e: Throwable)
extends ConsoleOut.WithBatchMode { extends ConsoleOut.WithBatchMode {
override def en: String = override def en: String =
s"${action.name} failed: ${action.keys}: ${e.getMessage}" s"${action.name} failed: ${action.keys}: ${e.getMessage}"

View file

@ -2,15 +2,16 @@ package net.kemitix.thorp.core
import net.kemitix.thorp.config.Config import net.kemitix.thorp.config.Config
import net.kemitix.thorp.core.Action.{DoNothing, ToCopy, ToUpload} import net.kemitix.thorp.core.Action.{DoNothing, ToCopy, ToUpload}
import net.kemitix.thorp.domain.Implicits._
import net.kemitix.thorp.domain._ import net.kemitix.thorp.domain._
import zio.RIO import zio.RIO
object ActionGenerator { object ActionGenerator {
def createAction( def createActions(
matchedMetadata: MatchedMetadata, matchedMetadata: MatchedMetadata,
previousActions: Stream[Action] previousActions: Stream[Action]
): RIO[Config, Action] = ): RIO[Config, Stream[Action]] =
for { for {
bucket <- Config.bucket bucket <- Config.bucket
} yield } yield
@ -30,7 +31,7 @@ object ActionGenerator {
anyMatches) anyMatches)
} }
case class TaggedMetadata( final case class TaggedMetadata(
matchedMetadata: MatchedMetadata, matchedMetadata: MatchedMetadata,
previousActions: Stream[Action], previousActions: Stream[Action],
remoteExists: Boolean, remoteExists: Boolean,
@ -39,14 +40,15 @@ object ActionGenerator {
) )
private def genAction(taggedMetadata: TaggedMetadata, private def genAction(taggedMetadata: TaggedMetadata,
bucket: Bucket): Action = { bucket: Bucket): Stream[Action] = {
taggedMetadata match { taggedMetadata match {
case TaggedMetadata(md, _, exists, matches, _) if exists && matches => case TaggedMetadata(md, _, remoteExists, remoteMatches, _)
if remoteExists && remoteMatches =>
doNothing(bucket, md.localFile.remoteKey) doNothing(bucket, md.localFile.remoteKey)
case TaggedMetadata(md, _, _, _, any) if any => case TaggedMetadata(md, _, _, _, anyMatches) if anyMatches =>
copyFile(bucket, md.localFile, md.matchByHash.head) copyFile(bucket, md.localFile, md.matchByHash)
case TaggedMetadata(md, previous, _, _, _) case TaggedMetadata(md, previous, _, _, _)
if isUploadAlreadyQueued(previous)(md.localFile) => if isNotUploadAlreadyQueued(previous)(md.localFile) =>
uploadFile(bucket, md.localFile) uploadFile(bucket, md.localFile)
case TaggedMetadata(md, _, _, _, _) => case TaggedMetadata(md, _, _, _, _) =>
doNothing(bucket, md.localFile.remoteKey) doNothing(bucket, md.localFile.remoteKey)
@ -55,34 +57,39 @@ object ActionGenerator {
private def key = LocalFile.remoteKey ^|-> RemoteKey.key private def key = LocalFile.remoteKey ^|-> RemoteKey.key
def isUploadAlreadyQueued( def isNotUploadAlreadyQueued(
previousActions: Stream[Action] previousActions: Stream[Action]
)( )(
localFile: LocalFile localFile: LocalFile
): Boolean = !previousActions.exists { ): Boolean = !previousActions.exists {
case ToUpload(_, lf, _) => key.get(lf) equals key.get(localFile) case ToUpload(_, lf, _) => key.get(lf) === key.get(localFile)
case _ => false case _ => false
} }
private def doNothing( private def doNothing(
bucket: Bucket, bucket: Bucket,
remoteKey: RemoteKey remoteKey: RemoteKey
) = DoNothing(bucket, remoteKey, 0L) ) = Stream(DoNothing(bucket, remoteKey, 0L))
private def uploadFile( private def uploadFile(
bucket: Bucket, bucket: Bucket,
localFile: LocalFile localFile: LocalFile
) = ToUpload(bucket, localFile, localFile.file.length) ) = Stream(ToUpload(bucket, localFile, localFile.file.length))
private def copyFile( private def copyFile(
bucket: Bucket, bucket: Bucket,
localFile: LocalFile, localFile: LocalFile,
remoteMetaData: RemoteMetaData remoteMetaData: Set[RemoteMetaData]
): Action = ) =
remoteMetaData
.take(1)
.toStream
.map(
other =>
ToCopy(bucket, ToCopy(bucket,
remoteMetaData.remoteKey, other.remoteKey,
remoteMetaData.hash, other.hash,
localFile.remoteKey, localFile.remoteKey,
localFile.file.length) localFile.file.length))
} }

View file

@ -3,13 +3,14 @@ package net.kemitix.thorp.core
import net.kemitix.thorp.domain.SimpleLens import net.kemitix.thorp.domain.SimpleLens
final case class Counters( final case class Counters(
uploaded: Int = 0, uploaded: Int,
deleted: Int = 0, deleted: Int,
copied: Int = 0, copied: Int,
errors: Int = 0 errors: Int
) )
object Counters { object Counters {
val empty: Counters = Counters(0, 0, 0, 0)
val uploaded: SimpleLens[Counters, Int] = val uploaded: SimpleLens[Counters, Int] =
SimpleLens[Counters, Int](_.uploaded, b => a => b.copy(uploaded = a)) SimpleLens[Counters, Int](_.uploaded, b => a => b.copy(uploaded = a))
val deleted: SimpleLens[Counters, Int] = val deleted: SimpleLens[Counters, Int] =

View file

@ -2,7 +2,7 @@ package net.kemitix.thorp.core
import net.kemitix.thorp.domain.StorageQueueEvent import net.kemitix.thorp.domain.StorageQueueEvent
case class EventQueue( final case class EventQueue(
events: Stream[StorageQueueEvent], events: Stream[StorageQueueEvent],
bytesInQueue: Long bytesInQueue: Long
) )

View file

@ -9,9 +9,9 @@ object Filters {
def isIncluded(p: Path)(filters: List[Filter]): Boolean = { def isIncluded(p: Path)(filters: List[Filter]): Boolean = {
sealed trait State sealed trait State
case class Unknown() extends State final case class Unknown() extends State
case class Accepted() extends State final case class Accepted() extends State
case class Discarded() extends State final case class Discarded() extends State
val excluded = isExcludedByFilter(p)(_) val excluded = isExcludedByFilter(p)(_)
val included = isIncludedByFilter(p)(_) val included = isIncludedByFilter(p)(_)
filters.foldRight(Unknown(): State)((filter, state) => filters.foldRight(Unknown(): State)((filter, state) =>
@ -33,9 +33,9 @@ object Filters {
} }
def isIncludedByFilter(path: Path)(filter: Filter): Boolean = def isIncludedByFilter(path: Path)(filter: Filter): Boolean =
filter.predicate.test(path.toString) filter.predicate.test(path.toFile.getPath)
def isExcludedByFilter(path: Path)(filter: Filter): Boolean = def isExcludedByFilter(path: Path)(filter: Filter): Boolean =
filter.predicate.test(path.toString) filter.predicate.test(path.toFile.getPath)
} }

View file

@ -13,7 +13,8 @@ object KeyGenerator {
)(path: Path): Task[RemoteKey] = )(path: Path): Task[RemoteKey] =
Sources Sources
.forPath(path)(sources) .forPath(path)(sources)
.map(p => p.relativize(path.toAbsolutePath).toString) .map(_.relativize(path.toAbsolutePath))
.map(_.toFile.getPath)
.map(RemoteKey.resolve(_)(prefix)) .map(RemoteKey.resolve(_)(prefix))
} }

View file

@ -65,9 +65,11 @@ object LocalFileStream {
_ <- filesMustExist(path, files) _ <- filesMustExist(path, files)
} yield Stream(files: _*).map(_.toPath) } yield Stream(files: _*).map(_.toPath)
private def filesMustExist(path: Path, files: Array[File]) = { private def filesMustExist(path: Path, files: Array[File]) =
Task.when(files == null)( Task {
Task.fail(new IllegalArgumentException(s"Directory not found $path"))) Option(files)
.map(_ => ())
.getOrElse(new IllegalArgumentException(s"Directory not found $path"))
} }
private def isIncluded(path: Path) = private def isIncluded(path: Path) =

View file

@ -22,11 +22,11 @@ object LocalFileValidator {
prefix: RemoteKey prefix: RemoteKey
): IO[Violation, LocalFile] = ): IO[Violation, LocalFile] =
for { for {
vFile <- validateFile(path.toFile) file <- validateFile(path.toFile)
remoteKey <- validateRemoteKey(sources, prefix, path) remoteKey <- validateRemoteKey(sources, prefix, path)
} yield LocalFile(vFile, source, hash, remoteKey) } yield LocalFile(file, source, hash, remoteKey)
private def validateFile(file: File) = private def validateFile(file: File): IO[Violation, File] =
if (file.isDirectory) if (file.isDirectory)
ZIO.fail(Violation.IsNotAFile(file)) ZIO.fail(Violation.IsNotAFile(file))
else else
@ -34,7 +34,7 @@ object LocalFileValidator {
private def validateRemoteKey(sources: Sources, private def validateRemoteKey(sources: Sources,
prefix: RemoteKey, prefix: RemoteKey,
path: Path) = path: Path): IO[Violation, RemoteKey] =
KeyGenerator KeyGenerator
.generateKey(sources, prefix)(path) .generateKey(sources, prefix)(path)
.mapError(e => Violation.InvalidRemoteKey(path, e)) .mapError(e => Violation.InvalidRemoteKey(path, e))
@ -43,10 +43,11 @@ object LocalFileValidator {
def getMessage: String def getMessage: String
} }
object Violation { object Violation {
case class IsNotAFile(file: File) extends Violation { final case class IsNotAFile(file: File) extends Violation {
override def getMessage: String = s"Local File must be a file: ${file}" override def getMessage: String = s"Local File must be a file: ${file}"
} }
case class InvalidRemoteKey(path: Path, e: Throwable) extends Violation { final case class InvalidRemoteKey(path: Path, e: Throwable)
extends Violation {
override def getMessage: String = override def getMessage: String =
s"Remote Key for '${path}' is invalid: ${e.getMessage}" s"Remote Key for '${path}' is invalid: ${e.getMessage}"
} }

View file

@ -2,10 +2,10 @@ package net.kemitix.thorp.core
import net.kemitix.thorp.domain.LocalFile import net.kemitix.thorp.domain.LocalFile
case class LocalFiles( final case class LocalFiles(
localFiles: Stream[LocalFile] = Stream(), localFiles: Stream[LocalFile],
count: Long = 0, count: Long,
totalSizeBytes: Long = 0 totalSizeBytes: Long
) { ) {
def ++(append: LocalFiles): LocalFiles = def ++(append: LocalFiles): LocalFiles =
copy( copy(
@ -16,8 +16,9 @@ case class LocalFiles(
} }
object LocalFiles { object LocalFiles {
val empty: LocalFiles = LocalFiles(Stream.empty, 0L, 0L)
def reduce: Stream[LocalFiles] => LocalFiles = def reduce: Stream[LocalFiles] => LocalFiles =
list => list.foldLeft(LocalFiles())((acc, lf) => acc ++ lf) list => list.foldLeft(LocalFiles.empty)((acc, lf) => acc ++ lf)
def one(localFile: LocalFile): LocalFiles = def one(localFile: LocalFile): LocalFiles =
LocalFiles(Stream(localFile), 1, localFile.file.length) LocalFiles(Stream(localFile), 1, localFile.file.length)
} }

View file

@ -35,7 +35,10 @@ object PlanBuilder {
createActions(remoteObjects, localData.localFiles) createActions(remoteObjects, localData.localFiles)
.map(_.filter(doesSomething).sortBy(SequencePlan.order)) .map(_.filter(doesSomething).sortBy(SequencePlan.order))
.map( .map(
SyncPlan(_, SyncTotals(localData.count, localData.totalSizeBytes))) SyncPlan
.create(_,
SyncTotals
.create(localData.count, localData.totalSizeBytes, 0L)))
} }
private def createActions( private def createActions(
@ -57,14 +60,14 @@ object PlanBuilder {
localFiles: Stream[LocalFile] localFiles: Stream[LocalFile]
) = ) =
ZIO.foldLeft(localFiles)(Stream.empty[Action])((acc, localFile) => ZIO.foldLeft(localFiles)(Stream.empty[Action])((acc, localFile) =>
createActionFromLocalFile(remoteObjects, acc, localFile).map(_ #:: acc)) createActionsFromLocalFile(remoteObjects, acc, localFile).map(_ #::: acc))
private def createActionFromLocalFile( private def createActionsFromLocalFile(
remoteObjects: RemoteObjects, remoteObjects: RemoteObjects,
previousActions: Stream[Action], previousActions: Stream[Action],
localFile: LocalFile localFile: LocalFile
) = ) =
ActionGenerator.createAction( ActionGenerator.createActions(
S3MetaDataEnricher.getMetadata(localFile, remoteObjects), S3MetaDataEnricher.getMetadata(localFile, remoteObjects),
previousActions) previousActions)
@ -72,12 +75,13 @@ object PlanBuilder {
ZIO.foldLeft(remoteKeys)(Stream.empty[Action])((acc, remoteKey) => ZIO.foldLeft(remoteKeys)(Stream.empty[Action])((acc, remoteKey) =>
createActionFromRemoteKey(remoteKey).map(_ #:: acc)) createActionFromRemoteKey(remoteKey).map(_ #:: acc))
private def createActionFromRemoteKey(remoteKey: RemoteKey) = private def createActionFromRemoteKey(
remoteKey: RemoteKey): ZIO[FileSystem with Config, Throwable, Action] =
for { for {
bucket <- Config.bucket bucket <- Config.bucket
prefix <- Config.prefix prefix <- Config.prefix
sources <- Config.sources sources <- Config.sources
needsDeleted <- Remote.isMissingLocally(sources, prefix)(remoteKey) needsDeleted <- Remote.isMissingLocally(sources, prefix, remoteKey)
} yield } yield
if (needsDeleted) ToDelete(bucket, remoteKey, 0L) if (needsDeleted) ToDelete(bucket, remoteKey, 0L)
else DoNothing(bucket, remoteKey, 0L) else DoNothing(bucket, remoteKey, 0L)

View file

@ -8,9 +8,9 @@ import zio.{RIO, ZIO}
object Remote { object Remote {
def isMissingLocally(sources: Sources, prefix: RemoteKey)( def isMissingLocally(sources: Sources,
remoteKey: RemoteKey prefix: RemoteKey,
): RIO[FileSystem, Boolean] = remoteKey: RemoteKey): RIO[FileSystem, Boolean] =
existsLocally(sources, prefix)(remoteKey) existsLocally(sources, prefix)(remoteKey)
.map(exists => !exists) .map(exists => !exists)

View file

@ -11,11 +11,11 @@ object S3MetaDataEnricher {
val (keyMatches, hashMatches) = getS3Status(localFile, remoteObjects) val (keyMatches, hashMatches) = getS3Status(localFile, remoteObjects)
MatchedMetadata( MatchedMetadata(
localFile, localFile,
matchByKey = keyMatches.map { hm => matchByKey = keyMatches.map { hash =>
RemoteMetaData(localFile.remoteKey, hm.hash, hm.modified) RemoteMetaData(localFile.remoteKey, hash)
}, },
matchByHash = hashMatches.map { matchByHash = hashMatches.map {
case (hash, km) => RemoteMetaData(km.key, hash, km.modified) case (key, hash) => RemoteMetaData(key, hash)
} }
) )
} }
@ -23,14 +23,14 @@ object S3MetaDataEnricher {
def getS3Status( def getS3Status(
localFile: LocalFile, localFile: LocalFile,
remoteObjects: RemoteObjects remoteObjects: RemoteObjects
): (Option[HashModified], Set[(MD5Hash, KeyModified)]) = { ): (Option[MD5Hash], Set[(RemoteKey, MD5Hash)]) = {
val matchingByKey = remoteObjects.byKey.get(localFile.remoteKey) val matchingByKey = remoteObjects.byKey.get(localFile.remoteKey)
val matchingByHash = localFile.hashes val matchingByHash = localFile.hashes
.map { .map {
case (_, md5Hash) => case (_, md5Hash) =>
remoteObjects.byHash remoteObjects.byHash
.getOrElse(md5Hash, Set()) .getOrElse(md5Hash, Set())
.map(km => (md5Hash, km)) .map(key => (key, md5Hash))
} }
.flatten .flatten
.toSet .toSet

View file

@ -1,6 +1,6 @@
package net.kemitix.thorp.core package net.kemitix.thorp.core
case class SequencedAction( final case class SequencedAction(
action: Action, action: Action,
index: Int index: Int
) )

View file

@ -32,7 +32,7 @@ trait SyncLogging {
def logRunFinished( def logRunFinished(
actions: Stream[StorageQueueEvent] actions: Stream[StorageQueueEvent]
): ZIO[Console, Nothing, Unit] = { ): ZIO[Console, Nothing, Unit] = {
val counters = actions.foldLeft(Counters())(countActivities) val counters = actions.foldLeft(Counters.empty)(countActivities)
Console.putStrLn(eraseToEndOfScreen) *> Console.putStrLn(eraseToEndOfScreen) *>
Console.putStrLn(s"Uploaded ${counters.uploaded} files") *> Console.putStrLn(s"Uploaded ${counters.uploaded} files") *>
Console.putStrLn(s"Copied ${counters.copied} files") *> Console.putStrLn(s"Copied ${counters.copied} files") *>

View file

@ -2,7 +2,13 @@ package net.kemitix.thorp.core
import net.kemitix.thorp.domain.SyncTotals import net.kemitix.thorp.domain.SyncTotals
case class SyncPlan( final case class SyncPlan private (
actions: Stream[Action] = Stream(), actions: Stream[Action],
syncTotals: SyncTotals = SyncTotals() syncTotals: SyncTotals
) )
object SyncPlan {
val empty: SyncPlan = SyncPlan(Stream.empty, SyncTotals.empty)
def create(actions: Stream[Action], syncTotals: SyncTotals): SyncPlan =
SyncPlan(actions, syncTotals)
}

View file

@ -24,13 +24,13 @@ trait ThorpArchive {
event: StorageQueueEvent): RIO[Console with Config, StorageQueueEvent] = event: StorageQueueEvent): RIO[Console with Config, StorageQueueEvent] =
event match { event match {
case UploadQueueEvent(remoteKey, _) => case UploadQueueEvent(remoteKey, _) =>
ZIO(event) <* Console.putMessageLn(UploadComplete(remoteKey)) ZIO(event) <* Console.putMessageLnB(UploadComplete(remoteKey))
case CopyQueueEvent(sourceKey, targetKey) => case CopyQueueEvent(sourceKey, targetKey) =>
ZIO(event) <* Console.putMessageLn(CopyComplete(sourceKey, targetKey)) ZIO(event) <* Console.putMessageLnB(CopyComplete(sourceKey, targetKey))
case DeleteQueueEvent(remoteKey) => case DeleteQueueEvent(remoteKey) =>
ZIO(event) <* Console.putMessageLn(DeleteComplete(remoteKey)) ZIO(event) <* Console.putMessageLnB(DeleteComplete(remoteKey))
case ErrorQueueEvent(action, _, e) => case ErrorQueueEvent(action, _, e) =>
ZIO(event) <* Console.putMessageLn(ErrorQueueEventOccurred(action, e)) ZIO(event) <* Console.putMessageLnB(ErrorQueueEventOccurred(action, e))
case DoNothingQueueEvent(_) => ZIO(event) case DoNothingQueueEvent(_) => ZIO(event)
case ShutdownQueueEvent() => ZIO(event) case ShutdownQueueEvent() => ZIO(event)
} }

View file

@ -8,7 +8,7 @@ import net.kemitix.thorp.domain._
import net.kemitix.thorp.storage.api.Storage import net.kemitix.thorp.storage.api.Storage
import zio.{Task, RIO} import zio.{Task, RIO}
case class UnversionedMirrorArchive(syncTotals: SyncTotals) final case class UnversionedMirrorArchive(syncTotals: SyncTotals)
extends ThorpArchive { extends ThorpArchive {
override def update( override def update(

View file

@ -48,7 +48,7 @@ private object MD5HashGenerator {
offset: Long, offset: Long,
endOffset: Long endOffset: Long
) = ) =
FileSystem.open(file, offset) >>= { managedFileInputStream => FileSystem.openAtOffset(file, offset) >>= { managedFileInputStream =>
managedFileInputStream.use { fileInputStream => managedFileInputStream.use { fileInputStream =>
digestFile(fileInputStream, offset, endOffset) digestFile(fileInputStream, offset, endOffset)
} }
@ -76,7 +76,7 @@ private object MD5HashGenerator {
if (nextBufferSize(currentOffset, endOffset) < maxBufferSize) if (nextBufferSize(currentOffset, endOffset) < maxBufferSize)
new Array[Byte](nextBufferSize(currentOffset, endOffset)) new Array[Byte](nextBufferSize(currentOffset, endOffset))
else defaultBuffer else defaultBuffer
fis read buffer val _ = fis read buffer
buffer buffer
} }

View file

@ -1,7 +1,5 @@
package net.kemitix.thorp.core package net.kemitix.thorp.core
import java.time.Instant
import net.kemitix.thorp.config._ import net.kemitix.thorp.config._
import net.kemitix.thorp.core.Action.{DoNothing, ToCopy, ToUpload} import net.kemitix.thorp.core.Action.{DoNothing, ToCopy, ToUpload}
import net.kemitix.thorp.domain.HashType.MD5 import net.kemitix.thorp.domain.HashType.MD5
@ -11,14 +9,13 @@ import org.scalatest.FunSpec
import zio.DefaultRuntime import zio.DefaultRuntime
class ActionGeneratorSuite extends FunSpec { class ActionGeneratorSuite extends FunSpec {
val lastModified = LastModified(Instant.now())
private val source = Resource(this, "upload") private val source = Resource(this, "upload")
private val sourcePath = source.toPath private val sourcePath = source.toPath
private val sources = Sources(List(sourcePath)) private val sources = Sources(List(sourcePath))
private val prefix = RemoteKey("prefix") private val prefix = RemoteKey("prefix")
private val bucket = Bucket("bucket") private val bucket = Bucket("bucket")
private val configOptions = ConfigOptions( private val configOptions = ConfigOptions(
List( List[ConfigOption](
ConfigOption.Bucket("bucket"), ConfigOption.Bucket("bucket"),
ConfigOption.Prefix("prefix"), ConfigOption.Prefix("prefix"),
ConfigOption.Source(sourcePath), ConfigOption.Source(sourcePath),
@ -38,9 +35,7 @@ class ActionGeneratorSuite extends FunSpec {
sourcePath, sourcePath,
sources, sources,
prefix) prefix)
theRemoteMetadata = RemoteMetaData(theFile.remoteKey, theRemoteMetadata = RemoteMetaData(theFile.remoteKey, theHash)
theHash,
lastModified)
input = MatchedMetadata( input = MatchedMetadata(
theFile, // local exists theFile, // local exists
matchByHash = Set(theRemoteMetadata), // remote matches matchByHash = Set(theRemoteMetadata), // remote matches
@ -69,9 +64,7 @@ class ActionGeneratorSuite extends FunSpec {
prefix) prefix)
theRemoteKey = theFile.remoteKey theRemoteKey = theFile.remoteKey
otherRemoteKey = RemoteKey.resolve("other-key")(prefix) otherRemoteKey = RemoteKey.resolve("other-key")(prefix)
otherRemoteMetadata = RemoteMetaData(otherRemoteKey, otherRemoteMetadata = RemoteMetaData(otherRemoteKey, theHash)
theHash,
lastModified)
input = MatchedMetadata( input = MatchedMetadata(
theFile, // local exists theFile, // local exists
matchByHash = Set(otherRemoteMetadata), // other matches matchByHash = Set(otherRemoteMetadata), // other matches
@ -128,12 +121,10 @@ class ActionGeneratorSuite extends FunSpec {
theRemoteKey = theFile.remoteKey theRemoteKey = theFile.remoteKey
oldHash = MD5Hash("old-hash") oldHash = MD5Hash("old-hash")
otherRemoteKey = RemoteKey.resolve("other-key")(prefix) otherRemoteKey = RemoteKey.resolve("other-key")(prefix)
otherRemoteMetadata = RemoteMetaData(otherRemoteKey, otherRemoteMetadata = RemoteMetaData(otherRemoteKey, theHash)
theHash,
lastModified)
oldRemoteMetadata = RemoteMetaData(theRemoteKey, oldRemoteMetadata = RemoteMetaData(theRemoteKey,
hash = oldHash, // remote no match hash = oldHash // remote no match
lastModified = lastModified) )
input = MatchedMetadata( input = MatchedMetadata(
theFile, // local exists theFile, // local exists
matchByHash = Set(otherRemoteMetadata), // other matches matchByHash = Set(otherRemoteMetadata), // other matches
@ -166,7 +157,7 @@ class ActionGeneratorSuite extends FunSpec {
prefix) prefix)
theRemoteKey = theFile.remoteKey theRemoteKey = theFile.remoteKey
oldHash = MD5Hash("old-hash") oldHash = MD5Hash("old-hash")
theRemoteMetadata = RemoteMetaData(theRemoteKey, oldHash, lastModified) theRemoteMetadata = RemoteMetaData(theRemoteKey, oldHash)
input = MatchedMetadata( input = MatchedMetadata(
theFile, // local exists theFile, // local exists
matchByHash = Set.empty, // remote no match, other no match matchByHash = Set.empty, // remote no match, other no match
@ -206,7 +197,7 @@ class ActionGeneratorSuite extends FunSpec {
for { for {
config <- ConfigurationBuilder.buildConfig(configOptions) config <- ConfigurationBuilder.buildConfig(configOptions)
_ <- Config.set(config) _ <- Config.set(config)
actions <- ActionGenerator.createAction(input, previousActions) actions <- ActionGenerator.createActions(input, previousActions)
} yield actions } yield actions
new DefaultRuntime {}.unsafeRunSync { new DefaultRuntime {}.unsafeRunSync {

View file

@ -7,7 +7,8 @@ import net.kemitix.thorp.domain._
import net.kemitix.thorp.storage.api.Storage import net.kemitix.thorp.storage.api.Storage
import zio.{RIO, UIO} import zio.{RIO, UIO}
case class DummyStorageService(remoteObjects: RemoteObjects, final case class DummyStorageService(
remoteObjects: RemoteObjects,
uploadFiles: Map[File, (RemoteKey, MD5Hash)]) uploadFiles: Map[File, (RemoteKey, MD5Hash)])
extends Storage.Service { extends Storage.Service {

View file

@ -20,7 +20,7 @@ class FiltersSuite extends FunSpec {
describe("Include") { describe("Include") {
describe("default filter") { describe("default filter") {
val include = Include() val include = Include.all
it("should include files") { it("should include files") {
paths.foreach(path => paths.foreach(path =>
assertResult(true)(Filters.isIncludedByFilter(path)(include))) assertResult(true)(Filters.isIncludedByFilter(path)(include)))
@ -43,10 +43,12 @@ class FiltersSuite extends FunSpec {
val matching = Paths.get("/upload/root-file") val matching = Paths.get("/upload/root-file")
assertResult(true)(Filters.isIncludedByFilter(matching)(include)) assertResult(true)(Filters.isIncludedByFilter(matching)(include))
} }
it("exclude non-matching files 'test-file-for-hash.txt' & '/upload/subdir/leaf-file'") { it("exclude non-matching files 'test-file-for-hash.txt'") {
val nonMatching1 = Paths.get("/test-file-for-hash.txt") val nonMatching1 = Paths.get("/test-file-for-hash.txt")
val nonMatching2 = Paths.get("/upload/subdir/leaf-file")
assertResult(false)(Filters.isIncludedByFilter(nonMatching1)(include)) assertResult(false)(Filters.isIncludedByFilter(nonMatching1)(include))
}
it("exclude non-matching files '/upload/subdir/leaf-file'") {
val nonMatching2 = Paths.get("/upload/subdir/leaf-file")
assertResult(false)(Filters.isIncludedByFilter(nonMatching2)(include)) assertResult(false)(Filters.isIncludedByFilter(nonMatching2)(include))
} }
} }
@ -78,10 +80,12 @@ class FiltersSuite extends FunSpec {
val matching = Paths.get("/upload/root-file") val matching = Paths.get("/upload/root-file")
assertResult(true)(Filters.isExcludedByFilter(matching)(exclude)) assertResult(true)(Filters.isExcludedByFilter(matching)(exclude))
} }
it("include non-matching files 'test-file-for-hash.txt' & '/upload/subdir/leaf-file'") { it("include non-matching files 'test-file-for-hash.txt'") {
val nonMatching1 = Paths.get("/test-file-for-hash.txt") val nonMatching1 = Paths.get("/test-file-for-hash.txt")
val nonMatching2 = Paths.get("/upload/subdir/leaf-file")
assertResult(false)(Filters.isExcludedByFilter(nonMatching1)(exclude)) assertResult(false)(Filters.isExcludedByFilter(nonMatching1)(exclude))
}
it("include non-matching files '/upload/subdir/leaf-file'") {
val nonMatching2 = Paths.get("/upload/subdir/leaf-file")
assertResult(false)(Filters.isExcludedByFilter(nonMatching2)(exclude)) assertResult(false)(Filters.isExcludedByFilter(nonMatching2)(exclude))
} }
} }
@ -116,7 +120,7 @@ class FiltersSuite extends FunSpec {
} }
} }
describe("when include .txt files, but then exclude everything trumps all") { describe("when include .txt files, but then exclude everything trumps all") {
val filters = List(Include(".txt"), Exclude(".*")) val filters = List[Filter](Include(".txt"), Exclude(".*"))
it("should include nothing") { it("should include nothing") {
val expected = List() val expected = List()
val result = invoke(filters) val result = invoke(filters)
@ -124,7 +128,7 @@ class FiltersSuite extends FunSpec {
} }
} }
describe("when exclude everything except .txt files") { describe("when exclude everything except .txt files") {
val filters = List(Exclude(".*"), Include(".txt")) val filters = List[Filter](Exclude(".*"), Include(".txt"))
it("should include only the .txt files") { it("should include only the .txt files") {
val expected = List(path2, path3).map(Paths.get(_)) val expected = List(path2, path3).map(Paths.get(_))
val result = invoke(filters) val result = invoke(filters)

View file

@ -32,7 +32,7 @@ class LocalFileStreamSuite extends FunSpec {
val result = val result =
invoke() invoke()
.map(_.localFiles) .map(_.localFiles)
.map(_.map(LocalFile.relativeToSource(_).toString)) .map(_.map(LocalFile.relativeToSource(_).toFile.getPath))
.map(_.toSet) .map(_.toSet)
assertResult(expected)(result) assertResult(expected)(result)
} }
@ -73,7 +73,7 @@ class LocalFileStreamSuite extends FunSpec {
file("subdir/leaf-file") -> Map(MD5 -> MD5HashData.Leaf.hash) file("subdir/leaf-file") -> Map(MD5 -> MD5HashData.Leaf.hash)
)) ))
val configOptions = ConfigOptions( val configOptions = ConfigOptions(
List( List[ConfigOption](
ConfigOption.IgnoreGlobalOptions, ConfigOption.IgnoreGlobalOptions,
ConfigOption.IgnoreUserOptions, ConfigOption.IgnoreUserOptions,
ConfigOption.Source(sourcePath), ConfigOption.Source(sourcePath),

View file

@ -1,7 +1,5 @@
package net.kemitix.thorp.core package net.kemitix.thorp.core
import java.time.Instant
import net.kemitix.thorp.config.Resource import net.kemitix.thorp.config.Resource
import net.kemitix.thorp.core.S3MetaDataEnricher.{getMetadata, getS3Status} import net.kemitix.thorp.core.S3MetaDataEnricher.{getMetadata, getS3Status}
import net.kemitix.thorp.domain.HashType.MD5 import net.kemitix.thorp.domain.HashType.MD5
@ -9,22 +7,19 @@ import net.kemitix.thorp.domain._
import org.scalatest.FunSpec import org.scalatest.FunSpec
class MatchedMetadataEnricherSuite extends FunSpec { class MatchedMetadataEnricherSuite extends FunSpec {
private val lastModified = LastModified(Instant.now())
private val source = Resource(this, "upload") private val source = Resource(this, "upload")
private val sourcePath = source.toPath private val sourcePath = source.toPath
private val sources = Sources(List(sourcePath)) private val sources = Sources(List(sourcePath))
private val prefix = RemoteKey("prefix") private val prefix = RemoteKey("prefix")
def getMatchesByKey( def getMatchesByKey(
status: (Option[HashModified], Set[(MD5Hash, KeyModified)])) status: (Option[MD5Hash], Set[(RemoteKey, MD5Hash)])): Option[MD5Hash] = {
: Option[HashModified] = {
val (byKey, _) = status val (byKey, _) = status
byKey byKey
} }
def getMatchesByHash( def getMatchesByHash(status: (Option[MD5Hash], Set[(RemoteKey, MD5Hash)]))
status: (Option[HashModified], Set[(MD5Hash, KeyModified)])) : Set[(RemoteKey, MD5Hash)] = {
: Set[(MD5Hash, KeyModified)] = {
val (_, byHash) = status val (_, byHash) = status
byHash byHash
} }
@ -42,10 +37,10 @@ class MatchedMetadataEnricherSuite extends FunSpec {
prefix) prefix)
theRemoteKey = theFile.remoteKey theRemoteKey = theFile.remoteKey
remoteObjects = RemoteObjects( remoteObjects = RemoteObjects(
byHash = Map(theHash -> Set(KeyModified(theRemoteKey, lastModified))), byHash = Map(theHash -> Set(theRemoteKey)),
byKey = Map(theRemoteKey -> HashModified(theHash, lastModified)) byKey = Map(theRemoteKey -> theHash)
) )
theRemoteMetadata = RemoteMetaData(theRemoteKey, theHash, lastModified) theRemoteMetadata = RemoteMetaData(theRemoteKey, theHash)
} yield (theFile, theRemoteMetadata, remoteObjects) } yield (theFile, theRemoteMetadata, remoteObjects)
it("generates valid metadata") { it("generates valid metadata") {
env.map({ env.map({
@ -70,10 +65,10 @@ class MatchedMetadataEnricherSuite extends FunSpec {
prefix) prefix)
theRemoteKey: RemoteKey = RemoteKey.resolve("the-file")(prefix) theRemoteKey: RemoteKey = RemoteKey.resolve("the-file")(prefix)
remoteObjects = RemoteObjects( remoteObjects = RemoteObjects(
byHash = Map(theHash -> Set(KeyModified(theRemoteKey, lastModified))), byHash = Map(theHash -> Set(theRemoteKey)),
byKey = Map(theRemoteKey -> HashModified(theHash, lastModified)) byKey = Map(theRemoteKey -> theHash)
) )
theRemoteMetadata = RemoteMetaData(theRemoteKey, theHash, lastModified) theRemoteMetadata = RemoteMetaData(theRemoteKey, theHash)
} yield (theFile, theRemoteMetadata, remoteObjects) } yield (theFile, theRemoteMetadata, remoteObjects)
it("generates valid metadata") { it("generates valid metadata") {
env.map({ env.map({
@ -98,13 +93,10 @@ class MatchedMetadataEnricherSuite extends FunSpec {
prefix) prefix)
otherRemoteKey = RemoteKey("other-key") otherRemoteKey = RemoteKey("other-key")
remoteObjects = RemoteObjects( remoteObjects = RemoteObjects(
byHash = byHash = Map(theHash -> Set(otherRemoteKey)),
Map(theHash -> Set(KeyModified(otherRemoteKey, lastModified))), byKey = Map(otherRemoteKey -> theHash)
byKey = Map(otherRemoteKey -> HashModified(theHash, lastModified))
) )
otherRemoteMetadata = RemoteMetaData(otherRemoteKey, otherRemoteMetadata = RemoteMetaData(otherRemoteKey, theHash)
theHash,
lastModified)
} yield (theFile, otherRemoteMetadata, remoteObjects) } yield (theFile, otherRemoteMetadata, remoteObjects)
it("generates valid metadata") { it("generates valid metadata") {
env.map({ env.map({
@ -128,7 +120,7 @@ class MatchedMetadataEnricherSuite extends FunSpec {
sourcePath, sourcePath,
sources, sources,
prefix) prefix)
remoteObjects = RemoteObjects() remoteObjects = RemoteObjects.empty
} yield (theFile, remoteObjects) } yield (theFile, remoteObjects)
it("generates valid metadata") { it("generates valid metadata") {
env.map({ env.map({
@ -157,17 +149,14 @@ class MatchedMetadataEnricherSuite extends FunSpec {
otherRemoteKey = RemoteKey.resolve("other-key")(prefix) otherRemoteKey = RemoteKey.resolve("other-key")(prefix)
remoteObjects = RemoteObjects( remoteObjects = RemoteObjects(
byHash = byHash =
Map(oldHash -> Set(KeyModified(theRemoteKey, lastModified)), Map(oldHash -> Set(theRemoteKey), theHash -> Set(otherRemoteKey)),
theHash -> Set(KeyModified(otherRemoteKey, lastModified))),
byKey = Map( byKey = Map(
theRemoteKey -> HashModified(oldHash, lastModified), theRemoteKey -> oldHash,
otherRemoteKey -> HashModified(theHash, lastModified) otherRemoteKey -> theHash
) )
) )
theRemoteMetadata = RemoteMetaData(theRemoteKey, oldHash, lastModified) theRemoteMetadata = RemoteMetaData(theRemoteKey, oldHash)
otherRemoteMetadata = RemoteMetaData(otherRemoteKey, otherRemoteMetadata = RemoteMetaData(otherRemoteKey, theHash)
theHash,
lastModified)
} yield (theFile, theRemoteMetadata, otherRemoteMetadata, remoteObjects) } yield (theFile, theRemoteMetadata, otherRemoteMetadata, remoteObjects)
it("generates valid metadata") { it("generates valid metadata") {
env.map({ env.map({
@ -197,13 +186,10 @@ class MatchedMetadataEnricherSuite extends FunSpec {
theRemoteKey = theFile.remoteKey theRemoteKey = theFile.remoteKey
oldHash = MD5Hash("old-hash") oldHash = MD5Hash("old-hash")
remoteObjects = RemoteObjects( remoteObjects = RemoteObjects(
byHash = Map(oldHash -> Set(KeyModified(theRemoteKey, lastModified)), byHash = Map(oldHash -> Set(theRemoteKey), theHash -> Set.empty),
theHash -> Set.empty), byKey = Map(theRemoteKey -> oldHash)
byKey = Map(
theRemoteKey -> HashModified(oldHash, lastModified)
) )
) theRemoteMetadata = RemoteMetaData(theRemoteKey, oldHash)
theRemoteMetadata = RemoteMetaData(theRemoteKey, oldHash, lastModified)
} yield (theFile, theRemoteMetadata, remoteObjects) } yield (theFile, theRemoteMetadata, remoteObjects)
it("generates valid metadata") { it("generates valid metadata") {
env.map({ env.map({
@ -243,17 +229,15 @@ class MatchedMetadataEnricherSuite extends FunSpec {
sourcePath, sourcePath,
sources, sources,
prefix) prefix)
lastModified = LastModified(Instant.now)
remoteObjects = RemoteObjects( remoteObjects = RemoteObjects(
byHash = Map( byHash = Map(
hash -> Set(KeyModified(key, lastModified), hash -> Set(key, keyOtherKey.remoteKey),
KeyModified(keyOtherKey.remoteKey, lastModified)), diffHash -> Set(keyDiffHash.remoteKey)
diffHash -> Set(KeyModified(keyDiffHash.remoteKey, lastModified))
), ),
byKey = Map( byKey = Map(
key -> HashModified(hash, lastModified), key -> hash,
keyOtherKey.remoteKey -> HashModified(hash, lastModified), keyOtherKey.remoteKey -> hash,
keyDiffHash.remoteKey -> HashModified(diffHash, lastModified) keyDiffHash.remoteKey -> diffHash
) )
) )
} yield (remoteObjects, localFile, keyDiffHash, diffHash) } yield (remoteObjects, localFile, keyDiffHash, diffHash)
@ -267,7 +251,7 @@ class MatchedMetadataEnricherSuite extends FunSpec {
env.map({ env.map({
case (remoteObjects, localFile: LocalFile, _, _) => case (remoteObjects, localFile: LocalFile, _, _) =>
val result = getMatchesByKey(invoke(localFile, remoteObjects)) val result = getMatchesByKey(invoke(localFile, remoteObjects))
assert(result.contains(HashModified(hash, lastModified))) assert(result.contains(hash))
}) })
} }
} }
@ -307,17 +291,15 @@ class MatchedMetadataEnricherSuite extends FunSpec {
} }
describe("when remote key exists and no others match hash") { describe("when remote key exists and no others match hash") {
env.map({ val _ = env.map({
case (remoteObjects, _, keyDiffHash, diffHash) => { case (remoteObjects, _, keyDiffHash, diffHash) => {
it("should return match by key") { it("should return match by key") {
val result = getMatchesByKey(invoke(keyDiffHash, remoteObjects)) val result = getMatchesByKey(invoke(keyDiffHash, remoteObjects))
assert(result.contains(HashModified(diffHash, lastModified))) assert(result.contains(diffHash))
} }
it("should return only itself in match by hash") { it("should return only itself in match by hash") {
val result = getMatchesByHash(invoke(keyDiffHash, remoteObjects)) val result = getMatchesByHash(invoke(keyDiffHash, remoteObjects))
assert( assert(result === Set((keyDiffHash.remoteKey, diffHash)))
result.equals(Set(
(diffHash, KeyModified(keyDiffHash.remoteKey, lastModified)))))
} }
} }
}) })

View file

@ -21,8 +21,7 @@ import zio.{DefaultRuntime, Task, UIO}
class PlanBuilderTest extends FreeSpec with TemporaryFolder { class PlanBuilderTest extends FreeSpec with TemporaryFolder {
private val lastModified: LastModified = LastModified() private val emptyRemoteObjects = RemoteObjects.empty
private val emptyRemoteObjects = RemoteObjects()
"create a plan" - { "create a plan" - {
@ -63,9 +62,8 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
val anOtherKey = RemoteKey("other") val anOtherKey = RemoteKey("other")
val expected = Right(List(toCopy(anOtherKey, aHash, remoteKey))) val expected = Right(List(toCopy(anOtherKey, aHash, remoteKey)))
val remoteObjects = RemoteObjects( val remoteObjects = RemoteObjects(
byHash = byHash = Map(aHash -> Set(anOtherKey)),
Map(aHash -> Set(KeyModified(anOtherKey, lastModified))), byKey = Map(anOtherKey -> aHash)
byKey = Map(anOtherKey -> HashModified(aHash, lastModified))
) )
val result = val result =
invoke(options(source), invoke(options(source),
@ -86,9 +84,8 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
// DoNothing actions should have been filtered out of the plan // DoNothing actions should have been filtered out of the plan
val expected = Right(List()) val expected = Right(List())
val remoteObjects = RemoteObjects( val remoteObjects = RemoteObjects(
byHash = byHash = Map(hash -> Set(remoteKey)),
Map(hash -> Set(KeyModified(remoteKey, lastModified))), byKey = Map(remoteKey -> hash)
byKey = Map(remoteKey -> HashModified(hash, lastModified))
) )
val result = val result =
invoke(options(source), invoke(options(source),
@ -108,10 +105,8 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
val expected = val expected =
Right(List(toUpload(remoteKey, currentHash, source, file))) Right(List(toUpload(remoteKey, currentHash, source, file)))
val remoteObjects = RemoteObjects( val remoteObjects = RemoteObjects(
byHash = Map(originalHash -> Set( byHash = Map(originalHash -> Set(remoteKey)),
KeyModified(remoteKey, lastModified))), byKey = Map(remoteKey -> originalHash)
byKey =
Map(remoteKey -> HashModified(originalHash, lastModified))
) )
val result = val result =
invoke(options(source), invoke(options(source),
@ -129,9 +124,8 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
val sourceKey = RemoteKey("other-key") val sourceKey = RemoteKey("other-key")
val expected = Right(List(toCopy(sourceKey, hash, remoteKey))) val expected = Right(List(toCopy(sourceKey, hash, remoteKey)))
val remoteObjects = RemoteObjects( val remoteObjects = RemoteObjects(
byHash = byHash = Map(hash -> Set(sourceKey)),
Map(hash -> Set(KeyModified(sourceKey, lastModified))), byKey = Map.empty
byKey = Map()
) )
val result = val result =
invoke(options(source), invoke(options(source),
@ -155,8 +149,8 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
// DoNothing actions should have been filtered out of the plan // DoNothing actions should have been filtered out of the plan
val expected = Right(List()) val expected = Right(List())
val remoteObjects = RemoteObjects( val remoteObjects = RemoteObjects(
byHash = Map(hash -> Set(KeyModified(remoteKey, lastModified))), byHash = Map(hash -> Set(remoteKey)),
byKey = Map(remoteKey -> HashModified(hash, lastModified)) byKey = Map(remoteKey -> hash)
) )
val result = val result =
invoke(options(source), invoke(options(source),
@ -172,8 +166,8 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
val hash = MD5Hash("file-content") val hash = MD5Hash("file-content")
val expected = Right(List(toDelete(remoteKey))) val expected = Right(List(toDelete(remoteKey)))
val remoteObjects = RemoteObjects( val remoteObjects = RemoteObjects(
byHash = Map(hash -> Set(KeyModified(remoteKey, lastModified))), byHash = Map(hash -> Set(remoteKey)),
byKey = Map(remoteKey -> HashModified(hash, lastModified)) byKey = Map(remoteKey -> hash)
) )
val result = val result =
invoke(options(source), invoke(options(source),
@ -208,7 +202,7 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
createFile(secondSource, filename2, "file-2-content") createFile(secondSource, filename2, "file-2-content")
val hash2 = md5Hash(fileInSecondSource) val hash2 = md5Hash(fileInSecondSource)
val expected = Right( val expected = Right(
List( Set(
toUpload(remoteKey2, hash2, secondSource, fileInSecondSource), toUpload(remoteKey2, hash2, secondSource, fileInSecondSource),
toUpload(remoteKey1, hash1, firstSource, fileInFirstSource) toUpload(remoteKey1, hash1, firstSource, fileInFirstSource)
)) ))
@ -219,7 +213,7 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
UIO.succeed( UIO.succeed(
Map(fileInFirstSource.toPath -> fileInFirstSource, Map(fileInFirstSource.toPath -> fileInFirstSource,
fileInSecondSource.toPath -> fileInSecondSource)) fileInSecondSource.toPath -> fileInSecondSource))
) ).map(_.toSet)
assertResult(expected)(result) assertResult(expected)(result)
}) })
}) })
@ -228,11 +222,11 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
"same filename in both" - { "same filename in both" - {
"only upload file in first source" in { "only upload file in first source" in {
withDirectory(firstSource => { withDirectory(firstSource => {
val fileInFirstSource: File = val fileInFirstSource =
createFile(firstSource, filename1, "file-1-content") createFile(firstSource, filename1, "file-1-content")
val hash1 = md5Hash(fileInFirstSource) val hash1 = md5Hash(fileInFirstSource)
withDirectory(secondSource => { withDirectory(secondSource => {
val fileInSecondSource: File = val fileInSecondSource =
createFile(secondSource, filename1, "file-2-content") createFile(secondSource, filename1, "file-2-content")
val hash2 = md5Hash(fileInSecondSource) val hash2 = md5Hash(fileInSecondSource)
val expected = Right(List( val expected = Right(List(
@ -258,10 +252,9 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
createFile(secondSource, filename2, "file-2-content") createFile(secondSource, filename2, "file-2-content")
val hash2 = md5Hash(fileInSecondSource) val hash2 = md5Hash(fileInSecondSource)
val expected = Right(List()) val expected = Right(List())
val remoteObjects = RemoteObjects( val remoteObjects =
byHash = RemoteObjects(byHash = Map(hash2 -> Set(remoteKey2)),
Map(hash2 -> Set(KeyModified(remoteKey2, lastModified))), byKey = Map(remoteKey2 -> hash2))
byKey = Map(remoteKey2 -> HashModified(hash2, lastModified)))
val result = val result =
invoke(options(firstSource)(secondSource), invoke(options(firstSource)(secondSource),
UIO.succeed(remoteObjects), UIO.succeed(remoteObjects),
@ -280,10 +273,9 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
val hash1 = md5Hash(fileInFirstSource) val hash1 = md5Hash(fileInFirstSource)
withDirectory(secondSource => { withDirectory(secondSource => {
val expected = Right(List()) val expected = Right(List())
val remoteObjects = RemoteObjects( val remoteObjects =
byHash = RemoteObjects(byHash = Map(hash1 -> Set(remoteKey1)),
Map(hash1 -> Set(KeyModified(remoteKey1, lastModified))), byKey = Map(remoteKey1 -> hash1))
byKey = Map(remoteKey1 -> HashModified(hash1, lastModified)))
val result = val result =
invoke(options(firstSource)(secondSource), invoke(options(firstSource)(secondSource),
UIO.succeed(remoteObjects), UIO.succeed(remoteObjects),
@ -299,8 +291,9 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
withDirectory(firstSource => { withDirectory(firstSource => {
withDirectory(secondSource => { withDirectory(secondSource => {
val expected = Right(List(toDelete(remoteKey1))) val expected = Right(List(toDelete(remoteKey1)))
val remoteObjects = RemoteObjects(byKey = val remoteObjects =
Map(remoteKey1 -> HashModified(MD5Hash(""), lastModified))) RemoteObjects(byHash = Map.empty,
byKey = Map(remoteKey1 -> MD5Hash("")))
val result = val result =
invoke(options(firstSource)(secondSource), invoke(options(firstSource)(secondSource),
UIO.succeed(remoteObjects), UIO.succeed(remoteObjects),
@ -336,7 +329,7 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
("upload", ("upload",
remoteKey.key, remoteKey.key,
MD5Hash.hash(md5Hash), MD5Hash.hash(md5Hash),
source.toString, source.toFile.getPath,
file.toString) file.toString)
private def toCopy( private def toCopy(
@ -398,6 +391,6 @@ class PlanBuilderTest extends FreeSpec with TemporaryFolder {
("copy", sourceKey.key, MD5Hash.hash(hash), targetKey.key, "") ("copy", sourceKey.key, MD5Hash.hash(hash), targetKey.key, "")
case DoNothing(_, remoteKey, _) => case DoNothing(_, remoteKey, _) =>
("do-nothing", remoteKey.key, "", "", "") ("do-nothing", remoteKey.key, "", "", "")
case x => ("other", x.toString, "", "", "") case x => ("other", "", "", "", "")
}) })
} }

View file

@ -27,7 +27,7 @@ class SequencePlanTest extends FreeSpec {
val source = new File("source") val source = new File("source")
val localFile1 = val localFile1 =
LocalFile(file1, source, hashes, remoteKey1) LocalFile(file1, source, hashes, remoteKey1)
val localFile2 = val _ =
LocalFile(file2, source, hashes, remoteKey2) LocalFile(file2, source, hashes, remoteKey2)
val copy1 = ToCopy(bucket, remoteKey1, hash, remoteKey2, size) val copy1 = ToCopy(bucket, remoteKey1, hash, remoteKey2, size)
val copy2 = ToCopy(bucket, remoteKey2, hash, remoteKey1, size) val copy2 = ToCopy(bucket, remoteKey2, hash, remoteKey1, size)
@ -36,8 +36,10 @@ class SequencePlanTest extends FreeSpec {
val delete1 = ToDelete(bucket, remoteKey1, size) val delete1 = ToDelete(bucket, remoteKey1, size)
val delete2 = ToDelete(bucket, remoteKey2, size) val delete2 = ToDelete(bucket, remoteKey2, size)
"should be in correct order" in { "should be in correct order" in {
val actions = List(copy1, delete1, upload1, delete2, upload2, copy2) val actions =
val expected = List(copy1, copy2, upload1, upload2, delete1, delete2) List[Action](copy1, delete1, upload1, delete2, upload2, copy2)
val expected =
List[Action](copy1, copy2, upload1, upload2, delete1, delete2)
val result = actions.sortBy(SequencePlan.order) val result = actions.sortBy(SequencePlan.order)
assertResult(expected)(result) assertResult(expected)(result)
} }

View file

@ -8,10 +8,13 @@ sealed trait Filter {
} }
object Filter { object Filter {
case class Include(include: String = ".*") extends Filter { final case class Include(include: String) extends Filter {
lazy val predicate: Predicate[String] = Pattern.compile(include).asPredicate lazy val predicate: Predicate[String] = Pattern.compile(include).asPredicate
} }
case class Exclude(exclude: String) extends Filter { object Include {
def all: Include = Include(".*")
}
final case class Exclude(exclude: String) extends Filter {
lazy val predicate: Predicate[String] = lazy val predicate: Predicate[String] =
Pattern.compile(exclude).asPredicate() Pattern.compile(exclude).asPredicate()
} }

View file

@ -1,6 +0,0 @@
package net.kemitix.thorp.domain
final case class HashModified(
hash: MD5Hash,
modified: LastModified
)

View file

@ -6,7 +6,7 @@ trait HexEncoder {
def encode(bytes: Array[Byte]): String = def encode(bytes: Array[Byte]): String =
String String
.format("%0" + (bytes.length << 1) + "x", new BigInteger(1, bytes)) .format(s"%0${bytes.length << 1}x", new BigInteger(1, bytes))
.toUpperCase .toUpperCase
def decode(hexString: String): Array[Byte] = def decode(hexString: String): Array[Byte] =

View file

@ -0,0 +1,11 @@
package net.kemitix.thorp.domain
object Implicits {
@SuppressWarnings(Array("org.wartremover.warts.Equals"))
implicit final class AnyOps[A](self: A) {
def ===(other: A): Boolean = self == other
def =/=(other: A): Boolean = self != other
}
}

View file

@ -1,6 +0,0 @@
package net.kemitix.thorp.domain
final case class KeyModified(
key: RemoteKey,
modified: LastModified
)

View file

@ -1,7 +0,0 @@
package net.kemitix.thorp.domain
import java.time.Instant
final case class LastModified(
when: Instant = Instant.now
)

View file

@ -4,6 +4,7 @@ import java.io.File
import java.nio.file.Path import java.nio.file.Path
import net.kemitix.thorp.domain.HashType.MD5 import net.kemitix.thorp.domain.HashType.MD5
import Implicits._
final case class LocalFile private ( final case class LocalFile private (
file: File, file: File,
@ -20,7 +21,7 @@ object LocalFile {
def relativeToSource(localFile: LocalFile): Path = def relativeToSource(localFile: LocalFile): Path =
localFile.source.toPath.relativize(localFile.file.toPath) localFile.source.toPath.relativize(localFile.file.toPath)
def matchesHash(localFile: LocalFile)(other: MD5Hash): Boolean = def matchesHash(localFile: LocalFile)(other: MD5Hash): Boolean =
localFile.hashes.values.exists(other equals _) localFile.hashes.values.exists(other === _)
def md5base64(localFile: LocalFile): Option[String] = def md5base64(localFile: LocalFile): Option[String] =
localFile.hashes.get(MD5).map(MD5Hash.hash64) localFile.hashes.get(MD5).map(MD5Hash.hash64)
} }

View file

@ -3,6 +3,6 @@ package net.kemitix.thorp.domain
// For the LocalFile, the set of matching S3 objects with the same MD5Hash, and any S3 object with the same remote key // For the LocalFile, the set of matching S3 objects with the same MD5Hash, and any S3 object with the same remote key
final case class MatchedMetadata( final case class MatchedMetadata(
localFile: LocalFile, localFile: LocalFile,
matchByHash: Set[RemoteMetaData], matchByHash: Set[RemoteMetaData], //TODO Can this be an Option?
matchByKey: Option[RemoteMetaData] matchByKey: Option[RemoteMetaData]
) )

View file

@ -0,0 +1,8 @@
package net.kemitix.thorp.domain
object NonUnit {
@specialized def ~*[A](evaluateForSideEffectOnly: A): Unit = {
val _: A = evaluateForSideEffectOnly
() //Return unit to prevent warning due to discarding value
}
}

View file

@ -1,7 +1,9 @@
package net.kemitix.thorp.domain package net.kemitix.thorp.domain
import Implicits._
object QuoteStripper { object QuoteStripper {
def stripQuotes: Char => Boolean = _ != '"' def stripQuotes: Char => Boolean = _ =/= '"'
} }

View file

@ -2,6 +2,7 @@ package net.kemitix.thorp.domain
import java.io.File import java.io.File
import java.nio.file.{Path, Paths} import java.nio.file.{Path, Paths}
import Implicits._
final case class RemoteKey(key: String) final case class RemoteKey(key: String)
@ -10,7 +11,7 @@ object RemoteKey {
SimpleLens[RemoteKey, String](_.key, b => a => b.copy(key = a)) SimpleLens[RemoteKey, String](_.key, b => a => b.copy(key = a))
def asFile(source: Path, prefix: RemoteKey)( def asFile(source: Path, prefix: RemoteKey)(
remoteKey: RemoteKey): Option[File] = remoteKey: RemoteKey): Option[File] =
if (remoteKey.key.length == 0) None if (remoteKey.key.length === 0) None
else Some(source.resolve(RemoteKey.relativeTo(prefix)(remoteKey)).toFile) else Some(source.resolve(RemoteKey.relativeTo(prefix)(remoteKey)).toFile)
def relativeTo(prefix: RemoteKey)(remoteKey: RemoteKey): Path = { def relativeTo(prefix: RemoteKey)(remoteKey: RemoteKey): Path = {
prefix match { prefix match {

View file

@ -2,6 +2,5 @@ package net.kemitix.thorp.domain
final case class RemoteMetaData( final case class RemoteMetaData(
remoteKey: RemoteKey, remoteKey: RemoteKey,
hash: MD5Hash, hash: MD5Hash
lastModified: LastModified
) )

View file

@ -3,7 +3,14 @@ package net.kemitix.thorp.domain
/** /**
* A list of objects and their MD5 hash values. * A list of objects and their MD5 hash values.
*/ */
final case class RemoteObjects( final case class RemoteObjects private (
byHash: Map[MD5Hash, Set[KeyModified]] = Map.empty, byHash: Map[MD5Hash, Set[RemoteKey]],
byKey: Map[RemoteKey, HashModified] = Map.empty byKey: Map[RemoteKey, MD5Hash]
) )
object RemoteObjects {
val empty: RemoteObjects = RemoteObjects(Map.empty, Map.empty)
def create(byHash: Map[MD5Hash, Set[RemoteKey]],
byKey: Map[RemoteKey, MD5Hash]): RemoteObjects =
RemoteObjects(byHash, byKey)
}

View file

@ -1,6 +1,6 @@
package net.kemitix.thorp.domain package net.kemitix.thorp.domain
case class SimpleLens[A, B](field: A => B, update: A => B => A) { final case class SimpleLens[A, B](field: A => B, update: A => B => A) {
def composeLens[C](other: SimpleLens[B, C]): SimpleLens[A, C] = def composeLens[C](other: SimpleLens[B, C]): SimpleLens[A, C] =
SimpleLens[A, C]( SimpleLens[A, C](

View file

@ -2,9 +2,9 @@ package net.kemitix.thorp.domain
object SizeTranslation { object SizeTranslation {
val kbLimit = 10240L val kbLimit: Long = 10240L
val mbLimit = kbLimit * 1024 val mbLimit: Long = kbLimit * 1024
val gbLimit = mbLimit * 1024 val gbLimit: Long = mbLimit * 1024
def sizeInEnglish(length: Long): String = def sizeInEnglish(length: Long): String =
length.toDouble match { length.toDouble match {

View file

@ -14,24 +14,17 @@ import zio.{Task, ZIO}
* *
* A path should only occur once in paths. * A path should only occur once in paths.
*/ */
case class Sources( final case class Sources(
paths: List[Path] paths: List[Path]
) { ) {
def +(path: Path)(implicit m: Monoid[Sources]): Sources = this ++ List(path) def +(path: Path): Sources = this ++ List(path)
def ++(otherPaths: List[Path])(implicit m: Monoid[Sources]): Sources = def ++(otherPaths: List[Path]): Sources =
m.op(this, Sources(otherPaths)) Sources(otherPaths.foldLeft(paths)((acc, path) =>
if (acc contains path) acc else acc ++ List(path)))
} }
object Sources { object Sources {
final val emptySources = Sources(List.empty) val emptySources: Sources = Sources(List.empty)
implicit def sourcesAppendMonoid: Monoid[Sources] = new Monoid[Sources] {
override def zero: Sources = emptySources
override def op(t1: Sources, t2: Sources): Sources =
Sources(t2.paths.foldLeft(t1.paths) { (acc, path) =>
if (acc.contains(path)) acc
else acc ++ List(path)
})
}
/** /**
* Returns the source path for the given path. * Returns the source path for the given path.

View file

@ -35,13 +35,13 @@ object StorageQueueEvent {
val keys: String val keys: String
} }
object Action { object Action {
case class Copy(keys: String) extends Action { final case class Copy(keys: String) extends Action {
override val name: String = "Copy" override val name: String = "Copy"
} }
case class Upload(keys: String) extends Action { final case class Upload(keys: String) extends Action {
override val name: String = "Upload" override val name: String = "Upload"
} }
case class Delete(keys: String) extends Action { final case class Delete(keys: String) extends Action {
override val name: String = "Delete" override val name: String = "Delete"
} }
} }

View file

@ -1,7 +1,15 @@
package net.kemitix.thorp.domain package net.kemitix.thorp.domain
case class SyncTotals( final case class SyncTotals private (
count: Long = 0L, count: Long,
totalSizeBytes: Long = 0L, totalSizeBytes: Long,
sizeUploadedBytes: Long = 0L sizeUploadedBytes: Long
) )
object SyncTotals {
val empty: SyncTotals = SyncTotals(0L, 0L, 0L)
def create(count: Long,
totalSizeBytes: Long,
sizeUploadedBytes: Long): SyncTotals =
SyncTotals(count, totalSizeBytes, sizeUploadedBytes)
}

View file

@ -1,5 +1,7 @@
package net.kemitix.thorp.domain package net.kemitix.thorp.domain
import Implicits._
object Terminal { object Terminal {
val esc: String = "\u001B" val esc: String = "\u001B"
@ -75,58 +77,58 @@ object Terminal {
* *
* Stops at the edge of the screen. * Stops at the edge of the screen.
*/ */
def cursorUp(lines: Int = 1): String = csi + lines + "A" def cursorUp(lines: Int): String = s"${csi}${lines}A"
/** /**
* Move the cursor down, default 1 line. * Move the cursor down, default 1 line.
* *
* Stops at the edge of the screen. * Stops at the edge of the screen.
*/ */
def cursorDown(lines: Int = 1): String = csi + lines + "B" def cursorDown(lines: Int): String = s"${csi}${lines}B"
/** /**
* Move the cursor forward, default 1 column. * Move the cursor forward, default 1 column.
* *
* Stops at the edge of the screen. * Stops at the edge of the screen.
*/ */
def cursorForward(cols: Int = 1): String = csi + cols + "C" def cursorForward(cols: Int): String = s"${csi}${cols}C"
/** /**
* Move the cursor back, default 1 column, * Move the cursor back, default 1 column,
* *
* Stops at the edge of the screen. * Stops at the edge of the screen.
*/ */
def cursorBack(cols: Int = 1): String = csi + cols + "D" def cursorBack(cols: Int): String = s"${csi}${cols}D"
/** /**
* Move the cursor to the beginning of the line, default 1, down. * Move the cursor to the beginning of the line, default 1, down.
*/ */
def cursorNextLine(lines: Int = 1): String = csi + lines + "E" def cursorNextLine(lines: Int): String = s"${csi}${lines}E"
/** /**
* Move the cursor to the beginning of the line, default 1, up. * Move the cursor to the beginning of the line, default 1, up.
*/ */
def cursorPrevLine(lines: Int = 1): String = csi + lines + "F" def cursorPrevLine(lines: Int): String = s"${csi}${lines}F"
/** /**
* Move the cursor to the column on the current line. * Move the cursor to the column on the current line.
*/ */
def cursorHorizAbs(col: Int): String = csi + col + "G" def cursorHorizAbs(col: Int): String = s"${csi}${col}G"
/** /**
* Move the cursor to the position on screen (1,1 is the top-left). * Move the cursor to the position on screen (1,1 is the top-left).
*/ */
def cursorPosition(row: Int, col: Int): String = csi + row + ";" + col + "H" def cursorPosition(row: Int, col: Int): String = s"${csi}${row};${col}H"
/** /**
* Scroll page up, default 1, lines. * Scroll page up, default 1, lines.
*/ */
def scrollUp(lines: Int = 1): String = csi + lines + "S" def scrollUp(lines: Int): String = s"${csi}${lines}S"
/** /**
* Scroll page down, default 1, lines. * Scroll page down, default 1, lines.
*/ */
def scrollDown(lines: Int = 1): String = csi + lines + "T" def scrollDown(lines: Int): String = s"${csi}${lines}T"
/** /**
* The Width of the terminal, as reported by the COLUMNS environment variable. * The Width of the terminal, as reported by the COLUMNS environment variable.
@ -154,7 +156,7 @@ object Terminal {
val pxDone = pxWidth * ratio val pxDone = pxWidth * ratio
val fullHeadSize: Int = (pxDone / phases).toInt val fullHeadSize: Int = (pxDone / phases).toInt
val part = (pxDone % phases).toInt val part = (pxDone % phases).toInt
val partial = if (part != 0) subBars.getOrElse(part, "") else "" val partial = if (part =/= 0) subBars.getOrElse(part, "") else ""
val head = ("█" * fullHeadSize) + partial val head = ("█" * fullHeadSize) + partial
val tailSize = barWidth - head.length val tailSize = barWidth - head.length
val tail = " " * tailSize val tail = " " * tailSize

View file

@ -1,31 +1,34 @@
package net.kemitix.thorp.domain package net.kemitix.thorp.domain
import java.util.concurrent.atomic.AtomicLong
import net.kemitix.thorp.domain.UploadEvent.RequestEvent import net.kemitix.thorp.domain.UploadEvent.RequestEvent
import net.kemitix.thorp.domain.UploadEventLogger.RequestCycle import net.kemitix.thorp.domain.UploadEventLogger.RequestCycle
object UploadEventListener { object UploadEventListener {
case class Settings( final case class Settings(
localFile: LocalFile, localFile: LocalFile,
index: Int, index: Int,
syncTotals: SyncTotals, syncTotals: SyncTotals,
totalBytesSoFar: Long totalBytesSoFar: Long
) )
def apply(settings: Settings): UploadEvent => Unit = def listener(settings: Settings): UploadEvent => Unit = {
uploadEvent => { val bytesTransferred = new AtomicLong(0L)
var bytesTransferred = 0L event =>
uploadEvent match { {
event match {
case e: RequestEvent => case e: RequestEvent =>
bytesTransferred += e.transferred
UploadEventLogger( UploadEventLogger(
RequestCycle(settings.localFile, RequestCycle(settings.localFile,
bytesTransferred, bytesTransferred.addAndGet(e.transferred),
settings.index, settings.index,
settings.syncTotals, settings.syncTotals,
settings.totalBytesSoFar)) settings.totalBytesSoFar))
case _ => () case _ => ()
} }
} }
}
} }

View file

@ -6,7 +6,7 @@ import scala.io.AnsiColor._
object UploadEventLogger { object UploadEventLogger {
case class RequestCycle( final case class RequestCycle(
localFile: LocalFile, localFile: LocalFile,
bytesTransferred: Long, bytesTransferred: Long,
index: Int, index: Int,

View file

@ -3,24 +3,24 @@ package net.kemitix.thorp.domain
object MD5HashData { object MD5HashData {
object Root { object Root {
val hash = MD5Hash("a3a6ac11a0eb577b81b3bb5c95cc8a6e") val hash: MD5Hash = MD5Hash("a3a6ac11a0eb577b81b3bb5c95cc8a6e")
val base64 = "o6asEaDrV3uBs7tclcyKbg==" val base64: String = "o6asEaDrV3uBs7tclcyKbg=="
} }
object Leaf { object Leaf {
val hash = MD5Hash("208386a650bdec61cfcd7bd8dcb6b542") val hash: MD5Hash = MD5Hash("208386a650bdec61cfcd7bd8dcb6b542")
val base64 = "IIOGplC97GHPzXvY3La1Qg==" val base64: String = "IIOGplC97GHPzXvY3La1Qg=="
} }
object BigFile { object BigFile {
val hash = MD5Hash("b1ab1f7680138e6db7309200584e35d8") val hash: MD5Hash = MD5Hash("b1ab1f7680138e6db7309200584e35d8")
object Part1 { object Part1 {
val offset = 0 val offset: Int = 0
val size = 1048576 val size: Int = 1048576
val hash = MD5Hash("39d4a9c78b9cfddf6d241a201a4ab726") val hash: MD5Hash = MD5Hash("39d4a9c78b9cfddf6d241a201a4ab726")
} }
object Part2 { object Part2 {
val offset = 1048576 val offset: Int = 1048576
val size = 1048576 val size: Int = 1048576
val hash = MD5Hash("af5876f3a3bc6e66f4ae96bb93d8dae0") val hash: MD5Hash = MD5Hash("af5876f3a3bc6e66f4ae96bb93d8dae0")
} }
} }

View file

@ -4,22 +4,27 @@ import java.io.{File, IOException, PrintWriter}
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.{FileVisitResult, Files, Path, SimpleFileVisitor} import java.nio.file.{FileVisitResult, Files, Path, SimpleFileVisitor}
import net.kemitix.thorp.domain.NonUnit.~*
import scala.util.Try import scala.util.Try
trait TemporaryFolder { trait TemporaryFolder {
def withDirectory(testCode: Path => Any): Any = { @SuppressWarnings(Array("org.wartremover.warts.TryPartial"))
def withDirectory(testCode: Path => Any): Unit = {
val dir: Path = Files.createTempDirectory("thorp-temp") val dir: Path = Files.createTempDirectory("thorp-temp")
val t = Try(testCode(dir)) val t = Try(testCode(dir))
remove(dir) remove(dir)
t.get ~*(t.get)
} }
def remove(root: Path): Unit = { def remove(root: Path): Unit = {
~*(
Files.walkFileTree( Files.walkFileTree(
root, root,
new SimpleFileVisitor[Path] { new SimpleFileVisitor[Path] {
override def visitFile(file: Path, override def visitFile(
file: Path,
attrs: BasicFileAttributes): FileVisitResult = { attrs: BasicFileAttributes): FileVisitResult = {
Files.delete(file) Files.delete(file)
FileVisitResult.CONTINUE FileVisitResult.CONTINUE
@ -30,16 +35,11 @@ trait TemporaryFolder {
FileVisitResult.CONTINUE FileVisitResult.CONTINUE
} }
} }
) ))
} }
def createFile(path: Path, name: String, content: String*): File = { def createFile(directory: Path, name: String, contents: String*): File = {
writeFile(path, name, content: _*) val _ = directory.toFile.mkdirs
path.resolve(name).toFile
}
def writeFile(directory: Path, name: String, contents: String*): File = {
directory.toFile.mkdirs
val file = directory.resolve(name).toFile val file = directory.resolve(name).toFile
val writer = new PrintWriter(file, "UTF-8") val writer = new PrintWriter(file, "UTF-8")
contents.foreach(writer.println) contents.foreach(writer.println)
@ -47,4 +47,7 @@ trait TemporaryFolder {
file file
} }
def writeFile(directory: Path, name: String, contents: String*): Unit =
~*(createFile(directory, name, contents: _*))
} }

View file

@ -16,7 +16,7 @@ object FileSystem {
trait Service { trait Service {
def fileExists(file: File): ZIO[FileSystem, Throwable, Boolean] def fileExists(file: File): ZIO[FileSystem, Throwable, Boolean]
def openManagedFileInputStream(file: File, offset: Long = 0L) def openManagedFileInputStream(file: File, offset: Long)
: RIO[FileSystem, ZManaged[Any, Throwable, FileInputStream]] : RIO[FileSystem, ZManaged[Any, Throwable, FileInputStream]]
def fileLines(file: File): RIO[FileSystem, Seq[String]] def fileLines(file: File): RIO[FileSystem, Seq[String]]
} }
@ -24,7 +24,7 @@ object FileSystem {
override val filesystem: Service = new Service { override val filesystem: Service = new Service {
override def fileExists( override def fileExists(
file: File file: File
): ZIO[FileSystem, Throwable, Boolean] = ZIO(file.exists) ): RIO[FileSystem, Boolean] = ZIO(file.exists)
override def openManagedFileInputStream(file: File, offset: Long) override def openManagedFileInputStream(file: File, offset: Long)
: RIO[FileSystem, ZManaged[Any, Throwable, FileInputStream]] = { : RIO[FileSystem, ZManaged[Any, Throwable, FileInputStream]] = {
@ -32,7 +32,7 @@ object FileSystem {
def acquire = def acquire =
Task { Task {
val stream = new FileInputStream(file) val stream = new FileInputStream(file)
stream skip offset val _ = stream.skip(offset)
stream stream
} }
@ -59,7 +59,7 @@ object FileSystem {
override val filesystem: Service = new Service { override val filesystem: Service = new Service {
override def fileExists(file: File): ZIO[FileSystem, Throwable, Boolean] = override def fileExists(file: File): RIO[FileSystem, Boolean] =
fileExistsResultMap.map(m => m.keys.exists(_ equals file.toPath)) fileExistsResultMap.map(m => m.keys.exists(_ equals file.toPath))
override def openManagedFileInputStream(file: File, offset: Long) override def openManagedFileInputStream(file: File, offset: Long)
@ -71,13 +71,17 @@ object FileSystem {
} }
} }
final def exists(file: File): ZIO[FileSystem, Throwable, Boolean] = final def exists(file: File): RIO[FileSystem, Boolean] =
ZIO.accessM(_.filesystem fileExists file) ZIO.accessM(_.filesystem fileExists file)
final def open(file: File, offset: Long = 0) final def openAtOffset(file: File, offset: Long)
: RIO[FileSystem, ZManaged[FileSystem, Throwable, FileInputStream]] = : RIO[FileSystem, ZManaged[FileSystem, Throwable, FileInputStream]] =
ZIO.accessM(_.filesystem openManagedFileInputStream (file, offset)) ZIO.accessM(_.filesystem openManagedFileInputStream (file, offset))
final def open(file: File)
: RIO[FileSystem, ZManaged[FileSystem, Throwable, FileInputStream]] =
ZIO.accessM(_.filesystem openManagedFileInputStream (file, 0L))
final def lines(file: File): RIO[FileSystem, Seq[String]] = final def lines(file: File): RIO[FileSystem, Seq[String]] =
ZIO.accessM(_.filesystem fileLines (file)) ZIO.accessM(_.filesystem fileLines (file))
} }

View file

@ -1,3 +1,4 @@
addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.3.2") addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.3.2")
addSbtPlugin("com.geirsson" % "sbt-ci-release" % "1.2.6") addSbtPlugin("com.geirsson" % "sbt-ci-release" % "1.2.6")
addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0")
addSbtPlugin("org.wartremover" % "sbt-wartremover" % "2.4.2")

View file

@ -18,7 +18,7 @@ object AmazonS3 {
} }
case class ClientImpl(amazonS3: AmazonS3Client) extends Client { final case class ClientImpl(amazonS3: AmazonS3Client) extends Client {
def shutdown(): UIO[Unit] = def shutdown(): UIO[Unit] =
UIO { UIO {

View file

@ -8,7 +8,15 @@ import net.kemitix.thorp.storage.aws.AmazonUpload.{
} }
import zio.{Task, UIO} import zio.{Task, UIO}
case class AmazonTransferManager(transferManager: TransferManager) { trait AmazonTransferManager {
def shutdownNow(now: Boolean): UIO[Unit]
def upload: PutObjectRequest => Task[InProgress]
}
object AmazonTransferManager {
final case class Wrapper(transferManager: TransferManager)
extends AmazonTransferManager {
def shutdownNow(now: Boolean): UIO[Unit] = def shutdownNow(now: Boolean): UIO[Unit] =
UIO(transferManager.shutdownNow(now)) UIO(transferManager.shutdownNow(now))
@ -18,3 +26,5 @@ case class AmazonTransferManager(transferManager: TransferManager) {
.map(CompletableUpload) .map(CompletableUpload)
} }
}

View file

@ -9,7 +9,7 @@ object AmazonUpload {
def waitForUploadResult: UploadResult def waitForUploadResult: UploadResult
} }
case class CompletableUpload(upload: Upload) extends InProgress { final case class CompletableUpload(upload: Upload) extends InProgress {
override def waitForUploadResult: UploadResult = override def waitForUploadResult: UploadResult =
upload.waitForUploadResult() upload.waitForUploadResult()
} }

View file

@ -7,7 +7,7 @@ import net.kemitix.thorp.domain.StorageQueueEvent.{
ErrorQueueEvent ErrorQueueEvent
} }
import net.kemitix.thorp.domain.{Bucket, RemoteKey, StorageQueueEvent} import net.kemitix.thorp.domain.{Bucket, RemoteKey, StorageQueueEvent}
import zio.{Task, UIO} import zio.{Task, UIO, ZIO}
trait Deleter { trait Deleter {
@ -16,16 +16,15 @@ trait Deleter {
remoteKey: RemoteKey remoteKey: RemoteKey
): UIO[StorageQueueEvent] = ): UIO[StorageQueueEvent] =
deleteObject(amazonS3)(bucket, remoteKey) deleteObject(amazonS3)(bucket, remoteKey)
.map(_ => DeleteQueueEvent(remoteKey))
.catchAll(e => .catchAll(e =>
UIO(ErrorQueueEvent(Action.Delete(remoteKey.key), remoteKey, e))) UIO(ErrorQueueEvent(Action.Delete(remoteKey.key), remoteKey, e)))
private def deleteObject(amazonS3: AmazonS3.Client)( private def deleteObject(amazonS3: AmazonS3.Client)(
bucket: Bucket, bucket: Bucket,
remoteKey: RemoteKey remoteKey: RemoteKey
): Task[Unit] = ): Task[StorageQueueEvent] =
amazonS3.deleteObject(new DeleteObjectRequest(bucket.name, remoteKey.key)) (amazonS3.deleteObject(new DeleteObjectRequest(bucket.name, remoteKey.key))
*> ZIO(DeleteQueueEvent(remoteKey)))
} }
object Deleter extends Deleter object Deleter extends Deleter

View file

@ -48,7 +48,7 @@ trait Lister {
fetch(request) fetch(request)
.map(summaries => { .map(summaries => {
RemoteObjects(byHash(summaries), byKey(summaries)) RemoteObjects.create(byHash(summaries), byKey(summaries))
}) })
} }

View file

@ -7,11 +7,11 @@ object S3ClientException {
override def getMessage: String = override def getMessage: String =
"The hash of the object to be overwritten did not match the the expected value" "The hash of the object to be overwritten did not match the the expected value"
} }
case class CopyError(error: Throwable) extends S3ClientException { final case class CopyError(error: Throwable) extends S3ClientException {
override def getMessage: String = override def getMessage: String =
"The hash of the object to be overwritten did not match the the expected value" "The hash of the object to be overwritten did not match the the expected value"
} }
case class S3Exception(message: String) extends S3ClientException { final case class S3Exception(message: String) extends S3ClientException {
override def getMessage: String = message override def getMessage: String = message
} }
} }

View file

@ -1,22 +1,17 @@
package net.kemitix.thorp.storage.aws package net.kemitix.thorp.storage.aws
import com.amazonaws.services.s3.model.S3ObjectSummary import com.amazonaws.services.s3.model.S3ObjectSummary
import net.kemitix.thorp.domain.{KeyModified, LastModified, MD5Hash, RemoteKey} import net.kemitix.thorp.domain.{MD5Hash, RemoteKey}
object S3ObjectsByHash { object S3ObjectsByHash {
def byHash( def byHash(
os: Stream[S3ObjectSummary] os: Stream[S3ObjectSummary]
): Map[MD5Hash, Set[KeyModified]] = { ): Map[MD5Hash, Set[RemoteKey]] = {
val mD5HashToS3Objects: Map[MD5Hash, Stream[S3ObjectSummary]] = val mD5HashToS3Objects: Map[MD5Hash, Stream[S3ObjectSummary]] =
os.groupBy(o => MD5Hash(o.getETag.filter(_ != '"'))) os.groupBy(o => MD5Hash(o.getETag.filter(_ != '"')))
mD5HashToS3Objects.mapValues { os => mD5HashToS3Objects.mapValues { os =>
os.map { o => os.map(_.getKey).map(RemoteKey(_)).toSet
KeyModified(
RemoteKey(o.getKey),
LastModified(o.getLastModified.toInstant)
)
}.toSet
} }
} }

View file

@ -1,17 +1,16 @@
package net.kemitix.thorp.storage.aws package net.kemitix.thorp.storage.aws
import com.amazonaws.services.s3.model.S3ObjectSummary import com.amazonaws.services.s3.model.S3ObjectSummary
import net.kemitix.thorp.domain.{HashModified, LastModified, MD5Hash, RemoteKey} import net.kemitix.thorp.domain.{MD5Hash, RemoteKey}
object S3ObjectsByKey { object S3ObjectsByKey {
def byKey(os: Stream[S3ObjectSummary]): Map[RemoteKey, HashModified] = def byKey(os: Stream[S3ObjectSummary]): Map[RemoteKey, MD5Hash] =
os.map { o => os.map { o =>
{ {
val remoteKey = RemoteKey(o.getKey) val remoteKey = RemoteKey(o.getKey)
val hash = MD5Hash(o.getETag) val hash = MD5Hash(o.getETag)
val lastModified = LastModified(o.getLastModified.toInstant) (remoteKey, hash)
(remoteKey, HashModified(hash, lastModified))
} }
}.toMap }.toMap

View file

@ -17,7 +17,8 @@ object S3Storage {
private val client: AmazonS3.Client = private val client: AmazonS3.Client =
AmazonS3.ClientImpl(AmazonS3ClientBuilder.defaultClient) AmazonS3.ClientImpl(AmazonS3ClientBuilder.defaultClient)
private val transferManager: AmazonTransferManager = private val transferManager: AmazonTransferManager =
AmazonTransferManager(TransferManagerBuilder.defaultTransferManager) AmazonTransferManager.Wrapper(
TransferManagerBuilder.defaultTransferManager)
override def listObjects(bucket: Bucket, override def listObjects(bucket: Bucket,
prefix: RemoteKey): RIO[Console, RemoteObjects] = prefix: RemoteKey): RIO[Console, RemoteObjects] =
@ -42,7 +43,7 @@ object S3Storage {
Deleter.delete(client)(bucket, remoteKey) Deleter.delete(client)(bucket, remoteKey)
override def shutdown: UIO[StorageQueueEvent] = { override def shutdown: UIO[StorageQueueEvent] = {
transferManager.shutdownNow(true) transferManager.shutdownNow(true) *>
client.shutdown().map(_ => ShutdownQueueEvent()) client.shutdown().map(_ => ShutdownQueueEvent())
} }
} }

View file

@ -1,8 +1,10 @@
package net.kemitix.thorp.storage.aws package net.kemitix.thorp.storage.aws
import com.amazonaws.event.{ProgressEvent, ProgressEventType, ProgressListener} import com.amazonaws.event.ProgressEventType.RESPONSE_BYTE_TRANSFER_EVENT
import com.amazonaws.event.{ProgressEvent, ProgressListener}
import com.amazonaws.services.s3.model.{ObjectMetadata, PutObjectRequest} import com.amazonaws.services.s3.model.{ObjectMetadata, PutObjectRequest}
import net.kemitix.thorp.config.Config import net.kemitix.thorp.config.Config
import net.kemitix.thorp.domain.Implicits._
import net.kemitix.thorp.domain.StorageQueueEvent.{ import net.kemitix.thorp.domain.StorageQueueEvent.{
Action, Action,
ErrorQueueEvent, ErrorQueueEvent,
@ -14,22 +16,18 @@ import net.kemitix.thorp.domain.UploadEvent.{
TransferEvent TransferEvent
} }
import net.kemitix.thorp.domain.{StorageQueueEvent, _} import net.kemitix.thorp.domain.{StorageQueueEvent, _}
import net.kemitix.thorp.storage.aws.Uploader.Request
import zio.{UIO, ZIO} import zio.{UIO, ZIO}
trait Uploader { trait Uploader {
case class Request(
localFile: LocalFile,
bucket: Bucket,
uploadEventListener: UploadEventListener.Settings
)
def upload(transferManager: => AmazonTransferManager)( def upload(transferManager: => AmazonTransferManager)(
request: Request): ZIO[Config, Nothing, StorageQueueEvent] = request: Request): ZIO[Config, Nothing, StorageQueueEvent] =
transfer(transferManager)(request) transfer(transferManager)(request)
.catchAll(handleError(request.localFile.remoteKey)) .catchAll(handleError(request.localFile.remoteKey))
private def handleError(remoteKey: RemoteKey)(e: Throwable) = private def handleError(remoteKey: RemoteKey)(
e: Throwable): UIO[StorageQueueEvent] =
UIO(ErrorQueueEvent(Action.Upload(remoteKey.key), remoteKey, e)) UIO(ErrorQueueEvent(Action.Upload(remoteKey.key), remoteKey, e))
private def transfer(transferManager: => AmazonTransferManager)( private def transfer(transferManager: => AmazonTransferManager)(
@ -77,15 +75,15 @@ trait Uploader {
listenerSettings => listenerSettings =>
new ProgressListener { new ProgressListener {
override def progressChanged(progressEvent: ProgressEvent): Unit = override def progressChanged(progressEvent: ProgressEvent): Unit =
UploadEventListener(listenerSettings)(eventHandler(progressEvent)) UploadEventListener.listener(listenerSettings)(
eventHandler(progressEvent))
private def eventHandler: ProgressEvent => UploadEvent = private def eventHandler: ProgressEvent => UploadEvent =
progressEvent => { progressEvent => {
def isTransfer: ProgressEvent => Boolean = def isTransfer: ProgressEvent => Boolean =
_.getEventType.isTransferEvent _.getEventType.isTransferEvent
def isByteTransfer: ProgressEvent => Boolean = def isByteTransfer: ProgressEvent => Boolean =
_.getEventType.equals( (_.getEventType === RESPONSE_BYTE_TRANSFER_EVENT)
ProgressEventType.RESPONSE_BYTE_TRANSFER_EVENT)
progressEvent match { progressEvent match {
case e: ProgressEvent if isTransfer(e) => case e: ProgressEvent if isTransfer(e) =>
TransferEvent(e.getEventType.name) TransferEvent(e.getEventType.name)
@ -101,4 +99,10 @@ trait Uploader {
} }
object Uploader extends Uploader object Uploader extends Uploader {
final case class Request(
localFile: LocalFile,
bucket: Bucket,
uploadEventListener: UploadEventListener.Settings
)
}

View file

@ -10,8 +10,11 @@ import zio.{RIO, UIO, ZIO}
trait AmazonS3ClientTestFixture extends MockFactory { trait AmazonS3ClientTestFixture extends MockFactory {
val fixture: Fixture = @SuppressWarnings(Array("org.wartremover.warts.PublicInference"))
Fixture(stub[AmazonS3.Client], stub[AmazonTransferManager]) private val manager = stub[AmazonTransferManager]
@SuppressWarnings(Array("org.wartremover.warts.PublicInference"))
private val client = stub[AmazonS3.Client]
val fixture: Fixture = Fixture(client, manager)
case class Fixture( case class Fixture(
amazonS3Client: AmazonS3.Client, amazonS3Client: AmazonS3.Client,
@ -53,7 +56,7 @@ trait AmazonS3ClientTestFixture extends MockFactory {
Deleter.delete(client)(bucket, remoteKey) Deleter.delete(client)(bucket, remoteKey)
override def shutdown: UIO[StorageQueueEvent] = { override def shutdown: UIO[StorageQueueEvent] = {
transferManager.shutdownNow(true) transferManager.shutdownNow(true) *>
client.shutdown().map(_ => ShutdownQueueEvent()) client.shutdown().map(_ => ShutdownQueueEvent())
} }
} }

View file

@ -2,6 +2,7 @@ package net.kemitix.thorp.storage.aws
import com.amazonaws.services.s3.model.{AmazonS3Exception, CopyObjectResult} import com.amazonaws.services.s3.model.{AmazonS3Exception, CopyObjectResult}
import net.kemitix.thorp.console.Console import net.kemitix.thorp.console.Console
import net.kemitix.thorp.domain.NonUnit.~*
import net.kemitix.thorp.domain.StorageQueueEvent.{Action, ErrorQueueEvent} import net.kemitix.thorp.domain.StorageQueueEvent.{Action, ErrorQueueEvent}
import net.kemitix.thorp.domain._ import net.kemitix.thorp.domain._
import net.kemitix.thorp.storage.aws.S3ClientException.{CopyError, HashError} import net.kemitix.thorp.storage.aws.S3ClientException.{CopyError, HashError}
@ -24,34 +25,36 @@ class CopierTest extends FreeSpec {
val event = StorageQueueEvent.CopyQueueEvent(sourceKey, targetKey) val event = StorageQueueEvent.CopyQueueEvent(sourceKey, targetKey)
val expected = Right(event) val expected = Right(event)
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3Client.copyObject _) (fixture.amazonS3Client.copyObject _)
.when() .when()
.returns(_ => Task.succeed(Some(new CopyObjectResult))) .returns(_ => Task.succeed(Some(new CopyObjectResult))))
private val result = private val result =
invoke(bucket, sourceKey, hash, targetKey, fixture.amazonS3Client) invoke(bucket, sourceKey, hash, targetKey, fixture.amazonS3Client)
assertResult(expected)(result) ~*(assertResult(expected)(result))
} }
} }
} }
"when source hash does not match" - { "when source hash does not match" - {
"skip the file with an error" in { "skip the file with an error" in {
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3Client.copyObject _) (fixture.amazonS3Client.copyObject _)
.when() .when()
.returns(_ => Task.succeed(None)) .returns(_ => Task.succeed(None)))
private val result = private val result =
invoke(bucket, sourceKey, hash, targetKey, fixture.amazonS3Client) invoke(bucket, sourceKey, hash, targetKey, fixture.amazonS3Client)
result match { ~*(result match {
case Right( case Right(
ErrorQueueEvent(Action.Copy("sourceKey => targetKey"), ErrorQueueEvent(Action.Copy("sourceKey => targetKey"),
RemoteKey("targetKey"), RemoteKey("targetKey"),
e)) => e)) =>
e match { e match {
case HashError => assert(true) case HashError => assert(true)
case _ => fail("Not a HashError: " + e) case _ => fail(s"Not a HashError: ${e.getMessage}")
}
case e => fail("Not an ErrorQueueEvent: " + e)
} }
case e => fail(s"Not an ErrorQueueEvent: $e")
})
} }
} }
} }
@ -59,12 +62,12 @@ class CopierTest extends FreeSpec {
"skip the file with an error" in { "skip the file with an error" in {
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
private val expectedMessage = "The specified key does not exist" private val expectedMessage = "The specified key does not exist"
(fixture.amazonS3Client.copyObject _) ~*((fixture.amazonS3Client.copyObject _)
.when() .when()
.returns(_ => Task.fail(new AmazonS3Exception(expectedMessage))) .returns(_ => Task.fail(new AmazonS3Exception(expectedMessage))))
private val result = private val result =
invoke(bucket, sourceKey, hash, targetKey, fixture.amazonS3Client) invoke(bucket, sourceKey, hash, targetKey, fixture.amazonS3Client)
result match { ~*(result match {
case Right( case Right(
ErrorQueueEvent(Action.Copy("sourceKey => targetKey"), ErrorQueueEvent(Action.Copy("sourceKey => targetKey"),
RemoteKey("targetKey"), RemoteKey("targetKey"),
@ -72,10 +75,10 @@ class CopierTest extends FreeSpec {
e match { e match {
case CopyError(cause) => case CopyError(cause) =>
assert(cause.getMessage.startsWith(expectedMessage)) assert(cause.getMessage.startsWith(expectedMessage))
case _ => fail("Not a CopyError: " + e) case _ => fail(s"Not a CopyError: ${e.getMessage}")
}
case e => fail("Not an ErrorQueueEvent: " + e)
} }
case e => fail(s"Not an ErrorQueueEvent: ${e}")
})
} }
} }
} }

View file

@ -12,6 +12,7 @@ import net.kemitix.thorp.domain.{Bucket, RemoteKey}
import org.scalatest.FreeSpec import org.scalatest.FreeSpec
import zio.internal.PlatformLive import zio.internal.PlatformLive
import zio.{Runtime, Task, UIO} import zio.{Runtime, Task, UIO}
import net.kemitix.thorp.domain.NonUnit.~*
class DeleterTest extends FreeSpec { class DeleterTest extends FreeSpec {
@ -23,11 +24,12 @@ class DeleterTest extends FreeSpec {
"when no errors" in { "when no errors" in {
val expected = Right(DeleteQueueEvent(remoteKey)) val expected = Right(DeleteQueueEvent(remoteKey))
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3Client.deleteObject _) (fixture.amazonS3Client.deleteObject _)
.when() .when()
.returns(_ => UIO.succeed(())) .returns(_ => UIO.succeed(())))
private val result = invoke(fixture.amazonS3Client)(bucket, remoteKey) private val result = invoke(fixture.amazonS3Client)(bucket, remoteKey)
assertResult(expected)(result) ~*(assertResult(expected)(result))
} }
} }
"when Amazon Service Exception" in { "when Amazon Service Exception" in {
@ -36,11 +38,12 @@ class DeleterTest extends FreeSpec {
Right( Right(
ErrorQueueEvent(Action.Delete(remoteKey.key), remoteKey, exception)) ErrorQueueEvent(Action.Delete(remoteKey.key), remoteKey, exception))
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3Client.deleteObject _) (fixture.amazonS3Client.deleteObject _)
.when() .when()
.returns(_ => Task.fail(exception)) .returns(_ => Task.fail(exception)))
private val result = invoke(fixture.amazonS3Client)(bucket, remoteKey) private val result = invoke(fixture.amazonS3Client)(bucket, remoteKey)
assertResult(expected)(result) ~*(assertResult(expected)(result))
} }
} }
"when Amazon SDK Client Exception" in { "when Amazon SDK Client Exception" in {
@ -49,11 +52,12 @@ class DeleterTest extends FreeSpec {
Right( Right(
ErrorQueueEvent(Action.Delete(remoteKey.key), remoteKey, exception)) ErrorQueueEvent(Action.Delete(remoteKey.key), remoteKey, exception))
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3Client.deleteObject _) (fixture.amazonS3Client.deleteObject _)
.when() .when()
.returns(_ => Task.fail(exception)) .returns(_ => Task.fail(exception)))
private val result = invoke(fixture.amazonS3Client)(bucket, remoteKey) private val result = invoke(fixture.amazonS3Client)(bucket, remoteKey)
assertResult(expected)(result) ~*(assertResult(expected)(result))
} }
} }
def invoke(amazonS3Client: AmazonS3.Client)(bucket: Bucket, def invoke(amazonS3Client: AmazonS3.Client)(bucket: Bucket,

View file

@ -9,6 +9,7 @@ import com.amazonaws.services.s3.model.{
S3ObjectSummary S3ObjectSummary
} }
import net.kemitix.thorp.console._ import net.kemitix.thorp.console._
import net.kemitix.thorp.domain.NonUnit.~*
import net.kemitix.thorp.domain._ import net.kemitix.thorp.domain._
import org.scalatest.FreeSpec import org.scalatest.FreeSpec
import zio.internal.PlatformLive import zio.internal.PlatformLive
@ -24,58 +25,50 @@ class ListerTest extends FreeSpec {
"when no errors" - { "when no errors" - {
"when single fetch required" in { "when single fetch required" in {
val nowDate = new Date val nowDate = new Date
val nowInstant = nowDate.toInstant
val key = "key" val key = "key"
val etag = "etag" val etag = "etag"
val expectedHashMap = Map( val expectedHashMap = Map(MD5Hash(etag) -> Set(RemoteKey(key)))
MD5Hash(etag) -> Set( val expectedKeyMap = Map(RemoteKey(key) -> MD5Hash(etag))
KeyModified(RemoteKey(key), LastModified(nowInstant))))
val expectedKeyMap = Map(
RemoteKey(key) -> HashModified(MD5Hash(etag),
LastModified(nowInstant))
)
val expected = Right(RemoteObjects(expectedHashMap, expectedKeyMap)) val expected = Right(RemoteObjects(expectedHashMap, expectedKeyMap))
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3Client.listObjectsV2 _) (fixture.amazonS3Client.listObjectsV2 _)
.when() .when()
.returns(_ => { .returns(_ => {
UIO.succeed(objectResults(nowDate, key, etag, false)) UIO.succeed(objectResults(nowDate, key, etag, false))
}) }))
private val result = invoke(fixture.amazonS3Client)(bucket, prefix) private val result = invoke(fixture.amazonS3Client)(bucket, prefix)
assertResult(expected)(result) ~*(assertResult(expected)(result))
} }
} }
"when second fetch required" in { "when second fetch required" in {
val nowDate = new Date val nowDate = new Date
val nowInstant = nowDate.toInstant
val key1 = "key1" val key1 = "key1"
val etag1 = "etag1" val etag1 = "etag1"
val key2 = "key2" val key2 = "key2"
val etag2 = "etag2" val etag2 = "etag2"
val expectedHashMap = Map( val expectedHashMap = Map(
MD5Hash(etag1) -> Set( MD5Hash(etag1) -> Set(RemoteKey(key1)),
KeyModified(RemoteKey(key1), LastModified(nowInstant))), MD5Hash(etag2) -> Set(RemoteKey(key2))
MD5Hash(etag2) -> Set(
KeyModified(RemoteKey(key2), LastModified(nowInstant)))
) )
val expectedKeyMap = Map( val expectedKeyMap = Map(
RemoteKey(key1) -> HashModified(MD5Hash(etag1), RemoteKey(key1) -> MD5Hash(etag1),
LastModified(nowInstant)), RemoteKey(key2) -> MD5Hash(etag2)
RemoteKey(key2) -> HashModified(MD5Hash(etag2),
LastModified(nowInstant))
) )
val expected = Right(RemoteObjects(expectedHashMap, expectedKeyMap)) val expected = Right(RemoteObjects(expectedHashMap, expectedKeyMap))
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3Client.listObjectsV2 _) (fixture.amazonS3Client.listObjectsV2 _)
.when() .when()
.returns(_ => UIO(objectResults(nowDate, key1, etag1, true))) .returns(_ => UIO(objectResults(nowDate, key1, etag1, true)))
.noMoreThanOnce() .noMoreThanOnce())
~*(
(fixture.amazonS3Client.listObjectsV2 _) (fixture.amazonS3Client.listObjectsV2 _)
.when() .when()
.returns(_ => UIO(objectResults(nowDate, key2, etag2, false))) .returns(_ => UIO(objectResults(nowDate, key2, etag2, false))))
private val result = invoke(fixture.amazonS3Client)(bucket, prefix) private val result = invoke(fixture.amazonS3Client)(bucket, prefix)
assertResult(expected)(result) ~*(assertResult(expected)(result))
} }
} }
@ -92,7 +85,7 @@ class ListerTest extends FreeSpec {
etag: String, etag: String,
truncated: Boolean) = { truncated: Boolean) = {
val result = new ListObjectsV2Result val result = new ListObjectsV2Result
result.getObjectSummaries.add(objectSummary(key, etag, nowDate)) ~*(result.getObjectSummaries.add(objectSummary(key, etag, nowDate)))
result.setTruncated(truncated) result.setTruncated(truncated)
result result
} }
@ -101,9 +94,10 @@ class ListerTest extends FreeSpec {
"when Amazon Service Exception" in { "when Amazon Service Exception" in {
val exception = new AmazonS3Exception("message") val exception = new AmazonS3Exception("message")
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3Client.listObjectsV2 _) (fixture.amazonS3Client.listObjectsV2 _)
.when() .when()
.returns(_ => Task.fail(exception)) .returns(_ => Task.fail(exception)))
private val result = invoke(fixture.amazonS3Client)(bucket, prefix) private val result = invoke(fixture.amazonS3Client)(bucket, prefix)
assert(result.isLeft) assert(result.isLeft)
} }
@ -111,9 +105,10 @@ class ListerTest extends FreeSpec {
"when Amazon SDK Client Exception" in { "when Amazon SDK Client Exception" in {
val exception = new SdkClientException("message") val exception = new SdkClientException("message")
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3Client.listObjectsV2 _) (fixture.amazonS3Client.listObjectsV2 _)
.when() .when()
.returns(_ => Task.fail(exception)) .returns(_ => Task.fail(exception)))
private val result = invoke(fixture.amazonS3Client)(bucket, prefix) private val result = invoke(fixture.amazonS3Client)(bucket, prefix)
assert(result.isLeft) assert(result.isLeft)
} }

View file

@ -1,11 +1,7 @@
package net.kemitix.thorp.storage.aws package net.kemitix.thorp.storage.aws
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.Date
import com.amazonaws.services.s3.model.S3ObjectSummary import com.amazonaws.services.s3.model.S3ObjectSummary
import net.kemitix.thorp.domain.{KeyModified, LastModified, MD5Hash, RemoteKey} import net.kemitix.thorp.domain.{MD5Hash, RemoteKey}
import org.scalatest.FunSpec import org.scalatest.FunSpec
class S3ObjectsByHashSuite extends FunSpec { class S3ObjectsByHashSuite extends FunSpec {
@ -14,14 +10,12 @@ class S3ObjectsByHashSuite extends FunSpec {
val hash = MD5Hash("hash") val hash = MD5Hash("hash")
val key1 = RemoteKey("key-1") val key1 = RemoteKey("key-1")
val key2 = RemoteKey("key-2") val key2 = RemoteKey("key-2")
val lastModified = LastModified(Instant.now.truncatedTo(ChronoUnit.MILLIS)) val o1 = s3object(hash, key1)
val o1 = s3object(hash, key1, lastModified) val o2 = s3object(hash, key2)
val o2 = s3object(hash, key2, lastModified)
val os = Stream(o1, o2) val os = Stream(o1, o2)
it("should group by the hash value") { it("should group by the hash value") {
val expected: Map[MD5Hash, Set[KeyModified]] = Map( val expected: Map[MD5Hash, Set[RemoteKey]] = Map(
hash -> Set(KeyModified(key1, lastModified), hash -> Set(key1, key2)
KeyModified(key2, lastModified))
) )
val result = S3ObjectsByHash.byHash(os) val result = S3ObjectsByHash.byHash(os)
assertResult(expected)(result) assertResult(expected)(result)
@ -29,12 +23,10 @@ class S3ObjectsByHashSuite extends FunSpec {
} }
private def s3object(md5Hash: MD5Hash, private def s3object(md5Hash: MD5Hash,
remoteKey: RemoteKey, remoteKey: RemoteKey): S3ObjectSummary = {
lastModified: LastModified): S3ObjectSummary = {
val summary = new S3ObjectSummary() val summary = new S3ObjectSummary()
summary.setETag(MD5Hash.hash(md5Hash)) summary.setETag(MD5Hash.hash(md5Hash))
summary.setKey(remoteKey.key) summary.setKey(remoteKey.key)
summary.setLastModified(Date.from(lastModified.when))
summary summary
} }

View file

@ -1,7 +1,5 @@
package net.kemitix.thorp.storage.aws package net.kemitix.thorp.storage.aws
import java.time.Instant
import net.kemitix.thorp.config.Resource import net.kemitix.thorp.config.Resource
import net.kemitix.thorp.core.{LocalFileValidator, S3MetaDataEnricher} import net.kemitix.thorp.core.{LocalFileValidator, S3MetaDataEnricher}
import net.kemitix.thorp.domain.HashType.MD5 import net.kemitix.thorp.domain.HashType.MD5
@ -37,23 +35,20 @@ class StorageServiceSuite extends FunSpec with MockFactory {
sourcePath, sourcePath,
sources, sources,
prefix) prefix)
lastModified = LastModified(Instant.now)
s3ObjectsData = RemoteObjects( s3ObjectsData = RemoteObjects(
byHash = Map( byHash = Map(
hash -> Set(KeyModified(key, lastModified), hash -> Set(key, keyOtherKey.remoteKey),
KeyModified(keyOtherKey.remoteKey, lastModified)), diffHash -> Set(keyDiffHash.remoteKey)
diffHash -> Set(KeyModified(keyDiffHash.remoteKey, lastModified))
), ),
byKey = Map( byKey = Map(
key -> HashModified(hash, lastModified), key -> hash,
keyOtherKey.remoteKey -> HashModified(hash, lastModified), keyOtherKey.remoteKey -> hash,
keyDiffHash.remoteKey -> HashModified(diffHash, lastModified) keyDiffHash.remoteKey -> diffHash
) )
) )
} yield } yield
(s3ObjectsData, (s3ObjectsData,
localFile: LocalFile, localFile: LocalFile,
lastModified,
keyOtherKey, keyOtherKey,
keyDiffHash, keyDiffHash,
diffHash, diffHash,
@ -62,16 +57,14 @@ class StorageServiceSuite extends FunSpec with MockFactory {
def invoke(localFile: LocalFile, s3ObjectsData: RemoteObjects) = def invoke(localFile: LocalFile, s3ObjectsData: RemoteObjects) =
S3MetaDataEnricher.getS3Status(localFile, s3ObjectsData) S3MetaDataEnricher.getS3Status(localFile, s3ObjectsData)
def getMatchesByKey( def getMatchesByKey(status: (Option[MD5Hash], Set[(RemoteKey, MD5Hash)]))
status: (Option[HashModified], Set[(MD5Hash, KeyModified)])) : Option[MD5Hash] = {
: Option[HashModified] = {
val (byKey, _) = status val (byKey, _) = status
byKey byKey
} }
def getMatchesByHash( def getMatchesByHash(status: (Option[MD5Hash], Set[(RemoteKey, MD5Hash)]))
status: (Option[HashModified], Set[(MD5Hash, KeyModified)])) : Set[(RemoteKey, MD5Hash)] = {
: Set[(MD5Hash, KeyModified)] = {
val (_, byHash) = status val (_, byHash) = status
byHash byHash
} }
@ -80,25 +73,18 @@ class StorageServiceSuite extends FunSpec with MockFactory {
"when remote key exists, unmodified and other key matches the hash") { "when remote key exists, unmodified and other key matches the hash") {
it("should return the match by key") { it("should return the match by key") {
env.map({ env.map({
case (s3ObjectsData, localFile, lastModified, _, _, _, _) => { case (s3ObjectsData, localFile, _, _, _, _) => {
val result = getMatchesByKey(invoke(localFile, s3ObjectsData)) val result = getMatchesByKey(invoke(localFile, s3ObjectsData))
assert(result.contains(HashModified(hash, lastModified))) assert(result.contains(hash))
} }
}) })
} }
it("should return both matches for the hash") { it("should return both matches for the hash") {
env.map({ env.map({
case (s3ObjectsData, case (s3ObjectsData, localFile, keyOtherKey, _, _, key) => {
localFile,
lastModified,
keyOtherKey,
_,
_,
key) => {
val result = getMatchesByHash(invoke(localFile, s3ObjectsData)) val result = getMatchesByHash(invoke(localFile, s3ObjectsData))
assertResult( assertResult(
Set((hash, KeyModified(key, lastModified)), Set((hash, key), (hash, keyOtherKey.remoteKey))
(hash, KeyModified(keyOtherKey.remoteKey, lastModified)))
)(result) )(result)
} }
}) })
@ -115,7 +101,7 @@ class StorageServiceSuite extends FunSpec with MockFactory {
it("should return no matches by key") { it("should return no matches by key") {
env2.map(localFile => { env2.map(localFile => {
env.map({ env.map({
case (s3ObjectsData, _, _, _, _, _, _) => { case (s3ObjectsData, _, _, _, _, _) => {
val result = getMatchesByKey(invoke(localFile, s3ObjectsData)) val result = getMatchesByKey(invoke(localFile, s3ObjectsData))
assert(result.isEmpty) assert(result.isEmpty)
} }
@ -125,7 +111,7 @@ class StorageServiceSuite extends FunSpec with MockFactory {
it("should return no matches by hash") { it("should return no matches by hash") {
env2.map(localFile => { env2.map(localFile => {
env.map({ env.map({
case (s3ObjectsData, _, _, _, _, _, _) => { case (s3ObjectsData, _, _, _, _, _) => {
val result = getMatchesByHash(invoke(localFile, s3ObjectsData)) val result = getMatchesByHash(invoke(localFile, s3ObjectsData))
assert(result.isEmpty) assert(result.isEmpty)
} }
@ -137,31 +123,18 @@ class StorageServiceSuite extends FunSpec with MockFactory {
describe("when remote key exists and no others match hash") { describe("when remote key exists and no others match hash") {
it("should return the match by key") { it("should return the match by key") {
env.map({ env.map({
case (s3ObjectsData, case (s3ObjectsData, _, _, keyDiffHash, diffHash, _) => {
_,
lastModified,
_,
keyDiffHash,
diffHash,
_) => {
val result = getMatchesByKey(invoke(keyDiffHash, s3ObjectsData)) val result = getMatchesByKey(invoke(keyDiffHash, s3ObjectsData))
assert(result.contains(HashModified(diffHash, lastModified))) assert(result.contains(diffHash))
} }
}) })
} }
it("should return one match by hash") { it("should return one match by hash") {
env.map({ env.map({
case (s3ObjectsData, case (s3ObjectsData, _, _, keyDiffHash, diffHash, _) => {
_,
lastModified,
_,
keyDiffHash,
diffHash,
_) => {
val result = getMatchesByHash(invoke(keyDiffHash, s3ObjectsData)) val result = getMatchesByHash(invoke(keyDiffHash, s3ObjectsData))
assertResult( assertResult(
Set( Set((diffHash, keyDiffHash.remoteKey))
(diffHash, KeyModified(keyDiffHash.remoteKey, lastModified)))
)(result) )(result)
} }
}) })

View file

@ -16,6 +16,7 @@ import net.kemitix.thorp.domain._
import org.scalamock.scalatest.MockFactory import org.scalamock.scalatest.MockFactory
import org.scalatest.FreeSpec import org.scalatest.FreeSpec
import zio.{DefaultRuntime, Task} import zio.{DefaultRuntime, Task}
import net.kemitix.thorp.domain.NonUnit.~*
class UploaderTest extends FreeSpec with MockFactory { class UploaderTest extends FreeSpec with MockFactory {
@ -39,16 +40,17 @@ class UploaderTest extends FreeSpec with MockFactory {
val expected = val expected =
Right(UploadQueueEvent(remoteKey, aHash)) Right(UploadQueueEvent(remoteKey, aHash))
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3TransferManager.upload _) (fixture.amazonS3TransferManager.upload _)
.when() .when()
.returns(_ => Task.succeed(inProgress)) .returns(_ => Task.succeed(inProgress)))
private val result = private val result =
invoke(fixture.amazonS3TransferManager)( invoke(fixture.amazonS3TransferManager)(
localFile, localFile,
bucket, bucket,
listenerSettings listenerSettings
) )
assertResult(expected)(result) ~*(assertResult(expected)(result))
} }
} }
"when Amazon Service Exception" in { "when Amazon Service Exception" in {
@ -57,16 +59,17 @@ class UploaderTest extends FreeSpec with MockFactory {
Right( Right(
ErrorQueueEvent(Action.Upload(remoteKey.key), remoteKey, exception)) ErrorQueueEvent(Action.Upload(remoteKey.key), remoteKey, exception))
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3TransferManager.upload _) (fixture.amazonS3TransferManager.upload _)
.when() .when()
.returns(_ => Task.fail(exception)) .returns(_ => Task.fail(exception)))
private val result = private val result =
invoke(fixture.amazonS3TransferManager)( invoke(fixture.amazonS3TransferManager)(
localFile, localFile,
bucket, bucket,
listenerSettings listenerSettings
) )
assertResult(expected)(result) ~*(assertResult(expected)(result))
} }
} }
"when Amazon SDK Client Exception" in { "when Amazon SDK Client Exception" in {
@ -75,16 +78,17 @@ class UploaderTest extends FreeSpec with MockFactory {
Right( Right(
ErrorQueueEvent(Action.Upload(remoteKey.key), remoteKey, exception)) ErrorQueueEvent(Action.Upload(remoteKey.key), remoteKey, exception))
new AmazonS3ClientTestFixture { new AmazonS3ClientTestFixture {
~*(
(fixture.amazonS3TransferManager.upload _) (fixture.amazonS3TransferManager.upload _)
.when() .when()
.returns(_ => Task.fail(exception)) .returns(_ => Task.fail(exception)))
private val result = private val result =
invoke(fixture.amazonS3TransferManager)( invoke(fixture.amazonS3TransferManager)(
localFile, localFile,
bucket, bucket,
listenerSettings listenerSettings
) )
assertResult(expected)(result) ~*(assertResult(expected)(result))
} }
} }
def invoke(transferManager: AmazonTransferManager)( def invoke(transferManager: AmazonTransferManager)(