From bb978ed33fea68e775ec278a130181ca43813d9b Mon Sep 17 00:00:00 2001 From: Joel Klinghed Date: Thu, 31 Oct 2024 21:07:53 +0100 Subject: Add single merge Single as in merge in one direction, a source and a target. Split merge in to calculating (done by SingleMerge) which returns a number of steps to do. Modifier then applies the steps. Still thinking of pair or two-way merge, need a database for that or you can never remove any files. --- libs/io/build.gradle.kts | 2 + .../main/java/org/the_jk/cleversync/io/Action.kt | 26 + .../main/java/org/the_jk/cleversync/io/Modifier.kt | 152 +++++ .../java/org/the_jk/cleversync/io/SingleMerge.kt | 198 ++++++ .../the_jk/cleversync/io/BaseSingleMergeTest.kt | 709 +++++++++++++++++++++ .../the_jk/cleversync/io/SingleMergeLocalTest.kt | 26 + 6 files changed, 1113 insertions(+) create mode 100644 libs/io/src/main/java/org/the_jk/cleversync/io/Action.kt create mode 100644 libs/io/src/main/java/org/the_jk/cleversync/io/Modifier.kt create mode 100644 libs/io/src/main/java/org/the_jk/cleversync/io/SingleMerge.kt create mode 100644 libs/io/src/test/java/org/the_jk/cleversync/io/BaseSingleMergeTest.kt create mode 100644 libs/io/src/test/java/org/the_jk/cleversync/io/SingleMergeLocalTest.kt diff --git a/libs/io/build.gradle.kts b/libs/io/build.gradle.kts index c091a90..d45387e 100644 --- a/libs/io/build.gradle.kts +++ b/libs/io/build.gradle.kts @@ -9,4 +9,6 @@ android { dependencies { api(libs.androidx.livedata) api(libs.androidx.livedata.ktx) + implementation(project(":libs:utils")) + testImplementation(project(":libs:local")) } diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/Action.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/Action.kt new file mode 100644 index 0000000..fdce54d --- /dev/null +++ b/libs/io/src/main/java/org/the_jk/cleversync/io/Action.kt @@ -0,0 +1,26 @@ +package org.the_jk.cleversync.io + +sealed interface Action { + // Remove file, noop if name no longer exists + data class RemoveFile(val name: String): Action + + // Remove link, noop if name no longer exists + data class RemoveLink(val name: String): Action + + // Remove directory and its content recursively. Fails if + // not a directory. Noop if name no longer exists. + data class RemoveDir(val name: String): Action + + // Create a symlink called name pointing to target. noop if link pointing to target already exists. + // If overwrite is true, existing link will be overwritten. Fails if a file or directory + // exists with the name. + data class Link(val name: String, val target: String, val overwrite: Boolean = false): Action + + // Create file called name in target and copy content from source. If overwrite is true + // any file already existing will be overwritten with new content. + data class Copy(val name: String, val overwrite: Boolean = false): Action + + // Change directory to name and execute actions there. If create is true and target directory + // does not exist it is created. + data class ChangeDir(val name: String, val actions: List, val create: Boolean = false): Action +} diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/Modifier.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/Modifier.kt new file mode 100644 index 0000000..d0138b1 --- /dev/null +++ b/libs/io/src/main/java/org/the_jk/cleversync/io/Modifier.kt @@ -0,0 +1,152 @@ +package org.the_jk.cleversync.io + +import org.the_jk.cleversync.PathUtils +import java.io.IOException + +object Modifier { + fun apply(target: ModifiableDirectory, source: Directory, actions: List): List { + val errors = mutableListOf() + apply(target, source, actions, "", errors) + return errors + } + + @Suppress("CyclomaticComplexMethod", "LongMethod", "NestedBlockDepth") + private fun apply( + target: ModifiableDirectory, + source: Directory, + actions: List, + path: String, + errors: MutableList, + ) { + actions.forEach { action -> + when (action) { + is Action.ChangeDir -> { + val newPath = PathUtils.join(path, action.name) + val sourceDir = source.openDir(action.name) + if (sourceDir == null) { + errors.add("$path/${action.name}: Source directory does not exist") + } else { + val targetDir = if (action.create) { + try { + target.createDirectory(action.name) + } catch (_: IOException) { + target.modifiableOpenDir(action.name) + } + } else { + target.modifiableOpenDir(action.name) + } + if (targetDir == null) { + errors.add( + if (action.create) { + "$newPath: Unable to create directory" + } else { + "$newPath: Target directory does not exist" + } + ) + } else { + apply( + targetDir, + sourceDir, + action.actions, + newPath, + errors + ) + } + } + } + is Action.Copy -> { + val sourceFile = source.openFile(action.name) + if (sourceFile == null) { + errors.add("$path/${action.name}: Unable to open file") + } else { + try { + target.createFile(action.name) + } catch (e: IOException) { + if (action.overwrite) { + target.modifiableOpenFile(action.name) + } else { + errors.add("$path/${action.name}: Unable to create file: ${e.message}") + null + } + }?.let { targetFile -> + try { + val buffer = ByteArray(BUFFER_SIZE) + targetFile.write().use { output -> + sourceFile.read().use { input -> + val got = input.read(buffer) + output.write(buffer, 0, got) + } + output.flush() + } + } catch (e: IOException) { + errors.add("$path/${action.name}: Error writing file: ${e.message}") + } + } + } + } + is Action.Link -> { + try { + target.createLink(action.name, action.target) + } catch (e: IOException) { + val targetLink = target.modifiableOpenLink(action.name) + if (targetLink == null) { + errors.add("$path/${action.name}: Unable to create link: ${e.message}") + } else { + if (targetLink.resolve().path == action.target) { + // No need to do anything + } else { + try { + targetLink.target(action.target) + } catch (e2: IOException) { + errors.add("$path/${action.name}: Unable modify link: ${e2.message}") + } + } + } + } + } + is Action.RemoveDir -> { + try { + if (!target.removeDirectory(action.name)) { + if (exists(target, action.name)) { + errors.add("$path/${action.name}: Unable to remove dir") + } + } + } catch (e: IOException) { + errors.add("$path/${action.name}: Unable to remove dir: ${e.message}") + } + } + is Action.RemoveFile -> { + try { + if (!target.removeFile(action.name)) { + if (exists(target, action.name)) { + errors.add("$path/${action.name}: Unable to remove file") + } + } + } catch (e: IOException) { + errors.add("$path/${action.name}: Unable to remove file: ${e.message}") + } + } + is Action.RemoveLink -> { + try { + if (!target.removeLink(action.name)) { + if (exists(target, action.name)) { + errors.add("$path/${action.name}: Unable to remove link") + } + } + } catch (e: IOException) { + errors.add("$path/${action.name}: Unable to remove link: ${e.message}") + } + } + } + } + } + + private fun exists(target: Directory, name: String): Boolean { + val content = target.list() + return content.directories.any { it.name == name } || + content.files.any { it.name == name } || + content.links.any { it.name == name } + } + + private const val BUFFER_SIZE = 65535 +} diff --git a/libs/io/src/main/java/org/the_jk/cleversync/io/SingleMerge.kt b/libs/io/src/main/java/org/the_jk/cleversync/io/SingleMerge.kt new file mode 100644 index 0000000..33c87fc --- /dev/null +++ b/libs/io/src/main/java/org/the_jk/cleversync/io/SingleMerge.kt @@ -0,0 +1,198 @@ +package org.the_jk.cleversync.io + +import org.the_jk.cleversync.PathUtils +import java.time.Instant + +object SingleMerge { + fun calculate(target: Directory, source: Directory, options: Options = Options()): List { + return visit(target, source.list(), source, "/", options) + } + + private fun visit( + target: Directory?, + source: Directory.Content, + root: Directory, + path: String, + options: Options, + ) : List { + val targetContent = target?.list() + val targetNames = if (targetContent != null && options.deleteFilesOnlyInTarget) { + val ret = mutableMapOf() + targetContent.directories.forEach { ret[it.name] = Type.DIRECTORY } + targetContent.files.forEach { ret[it.name] = Type.FILE } + targetContent.links.forEach { ret[it.name] = Type.LINK } + ret + } else null + return buildList { + source.directories.forEach { sourceDir -> + targetNames?.remove(sourceDir.name) + visitDir(targetContent, sourceDir.name, sourceDir.list(), this@buildList, root, path, options) + } + source.files.forEach { sourceFile -> + targetNames?.remove(sourceFile.name) + visitFile(targetContent, sourceFile.name, sourceFile.size, sourceFile.lastModified, this@buildList) + } + source.links.forEach { sourceLink -> + targetNames?.remove(sourceLink.name) + visitLink(targetContent, sourceLink, this@buildList, root, path, options) + } + targetNames?.forEach { (name, type) -> + when (type) { + Type.DIRECTORY -> add(Action.RemoveDir(name)) + Type.FILE -> add(Action.RemoveFile(name)) + Type.LINK -> add(Action.RemoveLink(name)) + } + } + } + } + + @Suppress("LongParameterList") + private fun visitDir( + targetContent: Directory.Content?, + sourceName: String, + sourceContent: Directory.Content, + actions: MutableList, + root: Directory, + path: String, + options: Options, + ) { + val sourcePath = PathUtils.join(path, sourceName) + if (targetContent == null) { + actions.add( + Action.ChangeDir( + sourceName, + visit(null, sourceContent, root, sourcePath, options), + create = true, + ), + ) + } else { + val targetDir = targetContent.directories.find { it.name == sourceName } + if (targetDir != null) { + actions.add( + Action.ChangeDir(sourceName, visit(targetDir, sourceContent, root, sourcePath, options)), + ) + } else { + if (targetContent.files.any { it.name == sourceName }) { + actions.add(Action.RemoveFile(sourceName)) + } else if (targetContent.links.any { it.name == sourceName }) { + actions.add(Action.RemoveLink(sourceName)) + } + actions.add( + Action.ChangeDir( + sourceName, + visit(null, sourceContent, root, sourcePath, options), + create = true, + ), + ) + } + } + } + + private fun visitFile( + targetContent: Directory.Content?, + sourceName: String, + sourceSize: ULong, + sourceLastModified: Instant, + actions: MutableList, + ) { + if (targetContent == null) { + actions.add(Action.Copy(sourceName)) + } else { + val targetFile = targetContent.files.find { it.name == sourceName } + if (targetFile != null) { + if (targetFile.size != sourceSize || sourceLastModified > targetFile.lastModified) { + actions.add(Action.Copy(sourceName, overwrite = true)) + } + } else { + if (targetContent.directories.any { it.name == sourceName }) { + actions.add(Action.RemoveDir(sourceName)) + } else if (targetContent.links.any { it.name == sourceName }) { + actions.add(Action.RemoveLink(sourceName)) + } + actions.add(Action.Copy(sourceName)) + } + } + } + + @Suppress("LongParameterList", "NestedBlockDepth") + private fun visitLink( + targetContent: Directory.Content?, + sourceLink: Link, + actions: MutableList, + root: Directory, + path: String, + options: Options, + ) { + val linkTarget = sourceLink.resolve() + if (!options.resolveLinks && insideRoot(root, path, linkTarget.path)) { + if (targetContent == null) { + actions.add(Action.Link(sourceLink.name, linkTarget.path)) + } else { + val targetLink = + targetContent.links.find { it.name == sourceLink.name } + if (targetLink != null) { + if (targetLink.resolve().path != linkTarget.path) { + actions.add( + Action.Link( + sourceLink.name, + linkTarget.path, + overwrite = true + ) + ) + } + } else { + if (targetContent.directories.any { it.name == sourceLink.name }) { + actions.add(Action.RemoveDir(sourceLink.name)) + } else if (targetContent.files.any { it.name == sourceLink.name }) { + actions.add(Action.RemoveFile(sourceLink.name)) + } + actions.add(Action.Link(sourceLink.name, linkTarget.path)) + } + } + } else { + when (linkTarget) { + is Link.DirectoryTarget -> { + visitDir( + targetContent, + sourceLink.name, + linkTarget.directory.list(), + actions, + root, + path, + options, + ) + } + + is Link.FileTarget -> { + visitFile( + targetContent, + sourceLink.name, + linkTarget.file.size, + linkTarget.file.lastModified, + actions, + ) + } + + is Link.NoTarget -> Unit + } + } + } + + @Suppress("UNUSED_PARAMETER") + private fun insideRoot(root: Directory, path: String, linkTarget: String): Boolean { + val fakeRoot = "/--root--" + val resolved = PathUtils.resolve(PathUtils.join(PathUtils.join(fakeRoot, path.substring(1)), linkTarget)) + return resolved.startsWith("$fakeRoot/") + } + + data class Options( + val deleteFilesOnlyInTarget: Boolean = false, + val resolveLinks: Boolean = false, + ) + + private enum class Type { + DIRECTORY, + FILE, + LINK, + } +} diff --git a/libs/io/src/test/java/org/the_jk/cleversync/io/BaseSingleMergeTest.kt b/libs/io/src/test/java/org/the_jk/cleversync/io/BaseSingleMergeTest.kt new file mode 100644 index 0000000..65d870b --- /dev/null +++ b/libs/io/src/test/java/org/the_jk/cleversync/io/BaseSingleMergeTest.kt @@ -0,0 +1,709 @@ +package org.the_jk.cleversync.io + +import com.google.common.truth.Correspondence +import com.google.common.truth.Truth.assertThat +import org.junit.Assert +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import kotlin.time.Duration + +abstract class BaseSingleMergeTest { + private lateinit var src: ModifiableTree + private lateinit var tgt: ModifiableTree + + @Before + fun setUp() { + src = source() + tgt = target() + } + + @Test + fun empty() { + assertThat(SingleMerge.calculate(tgt, src)).isEmpty() + } + + @Test + fun oneFile() { + val srcFile = src.createFile("foo") + srcFile.write().writer().use { it.write("Hello World") } + + val actions = SingleMerge.calculate(tgt, src) + assertThat(actions).containsExactly(Action.Copy("foo")) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val tgtFile = tgt.openFile("foo") + assertThat(tgtFile?.read()?.reader()?.use { it.readText() }).isEqualTo("Hello World") + } + + @Test + fun oneFileAlreadyExistsSameContent() { + val srcFile = src.createFile("foo") + srcFile.write().writer().use { it.write("Hello World") } + val tgtFile = tgt.createFile("foo") + tgtFile.write().writer().use { it.write("Hello World") } + + assertThat(SingleMerge.calculate(tgt, src)).isEmpty() + } + + @Test + fun oneFileAlreadyExistsDifferentContent() { + val srcFile = src.createFile("foo") + srcFile.write().writer().use { it.write("Hello World") } + val tgtFile = tgt.createFile("foo") + tgtFile.write().writer().use { it.write("Bye bye!") } + + val actions = SingleMerge.calculate(tgt, src) + assertThat(actions).containsExactly(Action.Copy("foo", overwrite = true)) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val newTgtFile = tgt.openFile("foo") + assertThat(newTgtFile?.read()?.reader()?.use { it.readText() }).isEqualTo("Hello World") + } + + @Test + fun oneFileAlreadyExistsSameSizeContentOlder() { + val tgtFile = tgt.createFile("foo") + tgtFile.write().writer().use { it.write("Cruel World") } + Thread.sleep(minModificationTime().inWholeMilliseconds) + val srcFile = src.createFile("foo") + srcFile.write().writer().use { it.write("Hello World") } + + val actions = SingleMerge.calculate(tgt, src) + assertThat(actions).containsExactly(Action.Copy("foo", overwrite = true)) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val newTgtFile = tgt.openFile("foo") + assertThat(newTgtFile?.read()?.reader()?.use { it.readText() }).isEqualTo("Hello World") + } + + @Test + fun oneFileAlreadyExistsSameSizeContentNewer() { + val srcFile = src.createFile("foo") + srcFile.write().writer().use { it.write("Hello World") } + Thread.sleep(minModificationTime().inWholeMilliseconds) + val tgtFile = tgt.createFile("foo") + tgtFile.write().writer().use { it.write("hello world") } + + assertThat(SingleMerge.calculate(tgt, src)).isEmpty() + } + + @Test + fun filesInDirectories() { + val srcFoo = src.createFile("foo") + srcFoo.write().writer().use { it.write("Hello World") } + val srcDir = src.createDirectory("bar") + val srcBarFoo = srcDir.createFile("foo") + srcBarFoo.write().writer().use { it.write("World Hello") } + + val actions = SingleMerge.calculate(tgt, src) + assertThat(actions).containsExactly( + Action.Copy("foo"), + Action.ChangeDir("bar", listOf(Action.Copy("foo")), create = true), + ) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val tgtFoo = tgt.openFile("foo") + assertThat(tgtFoo?.read()?.reader()?.use { it.readText() }).isEqualTo("Hello World") + val tgtBarFoo = tgt.openFile("bar/foo") + assertThat(tgtBarFoo?.read()?.reader()?.use { it.readText() }).isEqualTo("World Hello") + } + + @Test + fun dirInDirInDir() { + val srcFoo = src.createDirectory("foo") + val srcFooBar = srcFoo.createDirectory("bar") + srcFooBar.createDirectory("fum") + + val actions = SingleMerge.calculate(tgt, src) + assertThat(actions).containsExactly( + Action.ChangeDir( + "foo", + listOf( + Action.ChangeDir( + "bar", + listOf( + Action.ChangeDir( + "fum", + emptyList(), + create = true, + ), + ), + create = true, + ), + ), + create = true, + ), + ) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val tgtFooBarFum = tgt.openDir("foo/bar/fum") + assertThat(tgtFooBarFum).isNotNull() + assertThat(tgtFooBarFum?.list()?.directories).isEmpty() + } + + @Test + fun symlink() { + Assume.assumeTrue(sourceSupportsSymlinks() && targetSupportsSymlinks()) + + val srcFoo = src.createFile("foo") + srcFoo.write().writer().use { it.write("Hello World") } + + src.createLink("link1", srcFoo) + src.createLink("link2", "does-not-exist") + src.createLink("link3", "../../escaped") + + val actions = SingleMerge.calculate(tgt, src) + assertThat(actions).containsExactly( + Action.Copy("foo"), + Action.Link("link1", "foo"), + Action.Link("link2", "does-not-exist"), + ) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val tgtFoo = tgt.openFile("foo") + assertThat(tgtFoo?.read()?.reader()?.use { it.readText() }).isEqualTo("Hello World") + val tgtLink1 = tgt.openLink("link1") + when (val linkTarget = tgtLink1?.resolve()) { + is Link.FileTarget -> assertThat(linkTarget.file).isEqualTo(tgtFoo) + else -> Assert.fail() + } + val tgtLink2 = tgt.openLink("link2") + when (val linkTarget = tgtLink2?.resolve()) { + is Link.NoTarget -> assertThat(linkTarget.path).isEqualTo("does-not-exist") + else -> Assert.fail() + } + assertThat(tgt.openLink("link3")).isNull() + } + + @Test + fun resolveSymlink() { + Assume.assumeTrue(sourceSupportsSymlinks()) + + val srcFoo = src.createFile("foo") + srcFoo.write().writer().use { it.write("Hello World") } + val srcBar = src.createDirectory("bar") + + src.createLink("link1", srcFoo) + src.createLink("link2", srcBar) + src.createLink("link3", "does-not-exist") + + val actions = SingleMerge.calculate(tgt, src, options = SingleMerge.Options( + resolveLinks = true, + )) + assertThat(actions).containsExactly( + Action.Copy("foo"), + Action.Copy("link1"), + Action.ChangeDir("bar", emptyList(), create = true), + Action.ChangeDir("link2", emptyList(), create = true), + ) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val tgtFoo = tgt.openFile("foo") + assertThat(tgtFoo?.read()?.reader()?.use { it.readText() }).isEqualTo("Hello World") + val tgtLink1 = tgt.openFile("link1") + assertThat(tgtLink1?.read()?.reader()?.use { it.readText() }).isEqualTo("Hello World") + assertThat(tgt.openDir("bar")).isNotNull() + assertThat(tgt.openDir("link2")).isNotNull() + } + + @Test + fun symlinkInDir() { + Assume.assumeTrue(sourceSupportsSymlinks() && targetSupportsSymlinks()) + + val srcFoo = src.createDirectory("foo") + srcFoo.createLink("link", srcFoo) + + val actions = SingleMerge.calculate(tgt, src) + assertThat(actions).containsExactly( + Action.ChangeDir( + "foo", + listOf(Action.Link("link", "../foo")), + create = true, + ), + ) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val tgtFoo = tgt.openDir("foo") + val tgtLink = tgtFoo?.openLink("link") + when (val linkTarget = tgtLink?.resolve()) { + is Link.DirectoryTarget -> assertThat(linkTarget.directory).isEqualTo(tgtFoo) + else -> Assert.fail() + } + } + + @Test + fun symlinkUpdate() { + Assume.assumeTrue(sourceSupportsSymlinks() && targetSupportsSymlinks()) + + src.createLink("link", "does-not-exist") + tgt.createLink("link", "also-does-not-exist") + + val actions = SingleMerge.calculate(tgt, src) + assertThat(actions).containsExactly( + Action.Link( + "link", + target = "does-not-exist", + overwrite = true, + ), + ) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val tgtLink = tgt.openLink("link") + when (val linkTarget = tgtLink?.resolve()) { + is Link.NoTarget -> assertThat(linkTarget.path).isEqualTo("does-not-exist") + else -> Assert.fail() + } + } + + @Test + fun symlinkNoUpdate() { + Assume.assumeTrue(sourceSupportsSymlinks() && targetSupportsSymlinks()) + + src.createLink("link", "does-not-exist") + tgt.createLink("link", "does-not-exist") + + assertThat(SingleMerge.calculate(tgt, src)).isEmpty() + } + + @Test + fun deleteInTarget() { + val tgtFoo = tgt.createFile("foo") + tgtFoo.write().writer().use { it.write("aaa") } + val tgtBar = tgt.createDirectory("bar") + val tgtBarFoo = tgtBar.createFile("foo") + tgtBarFoo.write().writer().use { it.write("aaa") } + + val actions = SingleMerge.calculate(tgt, src, options = SingleMerge.Options( + deleteFilesOnlyInTarget = true, + )) + assertThat(actions).containsExactly( + Action.RemoveFile("foo"), + Action.RemoveDir("bar"), + ) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val content = tgt.list() + assertThat(content.directories).isEmpty() + assertThat(content.files).isEmpty() + assertThat(content.links).isEmpty() + } + + @Test + fun deleteInTargetMixed() { + val srcFoo = src.createFile("foo") + srcFoo.write().writer().use { it.write("Hello World") } + val srcDir = src.createDirectory("bar") + val srcBarFoo = srcDir.createFile("foo") + srcBarFoo.write().writer().use { it.write("World Hello") } + + val tgtFile = tgt.createFile("file") + tgtFile.write().writer().use { it.write("aaa") } + val tgtBar = tgt.createDirectory("bar") + val tgtBarFile = tgtBar.createFile("file") + tgtBarFile.write().writer().use { it.write("aaa") } + + val actions = SingleMerge.calculate(tgt, src, options = SingleMerge.Options( + deleteFilesOnlyInTarget = true, + )) + assertThat(actions).containsExactly( + Action.Copy("foo"), + Action.RemoveFile("file"), + Action.ChangeDir( + "bar", + listOf( + Action.Copy("foo"), + Action.RemoveFile("file"), + ), + ), + ) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val tgtFoo = tgt.openFile("foo") + assertThat(tgtFoo?.read()?.reader()?.use { it.readText() }).isEqualTo("Hello World") + assertThat(tgt.openFile("file")).isNull() + val tgtBarFoo = tgt.openFile("bar/foo") + assertThat(tgtBarFoo?.read()?.reader()?.use { it.readText() }).isEqualTo("World Hello") + assertThat(tgt.openFile("bar/file")).isNull() + } + + @Test + fun deleteInTargetLink() { + Assume.assumeTrue(targetSupportsSymlinks()) + + tgt.createLink("foo", "does-not-exist") + + val actions = SingleMerge.calculate(tgt, src, options = SingleMerge.Options( + deleteFilesOnlyInTarget = true, + )) + assertThat(actions).containsExactly(Action.RemoveLink("foo")) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun mixedTypes() { + Assume.assumeTrue(targetSupportsSymlinks()) + + val srcFoo = src.createFile("foo") + srcFoo.write().writer().use { it.write("Hello World") } + src.createDirectory("bar") + + val tgtBar = tgt.createFile("bar") + tgtBar.write().writer().use { it.write("Remove me") } + tgt.createLink("foo", tgtBar) + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val tgtBar2 = tgt.openDir("bar") + assertThat(tgtBar2).isNotNull() + val tgtFoo = tgt.openFile("foo") + assertThat(tgtFoo?.read()?.reader()?.use { it.readText() }).isEqualTo("Hello World") + } + + @Test + fun mixedTypes2() { + Assume.assumeTrue(targetSupportsSymlinks()) + + src.createDirectory("foo") + + tgt.createLink("foo", "does-not-exist") + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + assertThat(tgt.openDir("foo")).isNotNull() + } + + @Test + fun mixedTypes3() { + val srcFoo = src.createFile("foo") + srcFoo.write().writer().use { it.write("Hello World") } + src.createDirectory("bar") + + val tgtBar = tgt.createFile("bar") + tgtBar.write().writer().use { it.write("Remove me") } + tgt.createDirectory("foo") + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + assertThat(tgt.openFile("foo")?.read()?.reader()?.use { it.readText() }).isEqualTo("Hello World") + assertThat(tgt.openDir("bar")).isNotNull() + } + + @Test + fun mixedTypes4() { + Assume.assumeTrue(sourceSupportsSymlinks() && targetSupportsSymlinks()) + + src.createLink("foo", "does-not-exist") + src.createLink("bar", "also-does-not-exist") + + tgt.createDirectory("foo") + tgt.createFile("bar").write().writer().use { it.write("Foo") } + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + + val tgtFoo = tgt.openLink("foo") + when (val linkTarget = tgtFoo?.resolve()) { + is Link.NoTarget -> assertThat(linkTarget.path).isEqualTo("does-not-exist") + else -> Assert.fail() + } + val tgtBar = tgt.openLink("bar") + when (val linkTarget = tgtBar?.resolve()) { + is Link.NoTarget -> assertThat(linkTarget.path).isEqualTo("also-does-not-exist") + else -> Assert.fail() + } + } + + @Test + fun errorSourceDirGone() { + val foo = src.createDirectory("foo") + foo.createFile("bar").write().use { it.write(1) } + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(actions).containsExactly( + Action.ChangeDir("foo", actions = listOf(Action.Copy("bar", overwrite = false)), create = true), + ) + + assertThat(src.removeDirectory("foo")).isTrue() + + assertThat(Modifier.apply(tgt, src, actions)).containsExactly("/foo: Source directory does not exist") + assertThat(tgt.list().directories).isEmpty() + assertThat(tgt.list().files).isEmpty() + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun errorTargetDirGone() { + val foo = src.createDirectory("foo") + foo.createFile("bar").write().use { it.write(1) } + tgt.createDirectory("foo") + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(actions).containsExactly( + Action.ChangeDir("foo", actions = listOf(Action.Copy("bar", overwrite = false)), create = false), + ) + + assertThat(tgt.removeDirectory("foo")).isTrue() + + assertThat(Modifier.apply(tgt, src, actions)).containsExactly("foo: Target directory does not exist") + assertThat(tgt.list().directories).isEmpty() + assertThat(tgt.list().files).isEmpty() + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun errorUnableToCreateTargetDir() { + val foo = src.createDirectory("foo") + foo.createFile("bar").write().use { it.write(1) } + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(actions).containsExactly( + Action.ChangeDir("foo", actions = listOf(Action.Copy("bar", overwrite = false)), create = true), + ) + + val tgtFoo = tgt.createFile("foo") + tgtFoo.write().use { it.write(2) } + + assertThat(Modifier.apply(tgt, src, actions)).containsExactly("foo: Unable to create directory") + assertThat(tgt.list().directories).isEmpty() + assertThat(tgt.list().files).containsExactly(tgtFoo) + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun errorSourceFileGone() { + src.createFile("foo").write().use { it.write(1) } + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(actions).containsExactly(Action.Copy("foo", overwrite = false)) + + assertThat(src.removeFile("foo")).isTrue() + + assertThat(Modifier.apply(tgt, src, actions)).containsExactly("/foo: Unable to open file") + assertThat(tgt.list().directories).isEmpty() + assertThat(tgt.list().files).isEmpty() + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun errorSourceFileIsNowADirectory() { + src.createFile("foo").write().use { it.write(1) } + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(actions).containsExactly(Action.Copy("foo", overwrite = false)) + + assertThat(src.removeFile("foo")).isTrue() + src.createDirectory("foo") + + assertThat(Modifier.apply(tgt, src, actions)).containsExactly("/foo: Unable to open file") + assertThat(tgt.list().directories).isEmpty() + assertThat(tgt.list().files).isEmpty() + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun errorTargetFileExists() { + src.createFile("foo").write().use { it.write(1) } + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(actions).containsExactly(Action.Copy("foo", overwrite = false)) + + val tgtFoo = tgt.createFile("foo") + tgtFoo.write().use { it.write(2) } + + assertThat(Modifier.apply(tgt, src, actions)).comparingElementsUsing( + Correspondence.from({ a: String, b: String -> a.startsWith(b) }, "startsWith"), + ).containsExactly("/foo: Unable to create file: ") + assertThat(tgt.list().directories).isEmpty() + assertThat(tgt.list().files).containsExactly(tgtFoo) + assertThat(tgtFoo.read().use { it.readAllBytes() }).asList().containsExactly(2.toByte()) + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun errorTargetFileExists2() { + src.createFile("foo").write().use { it.write(1) } + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(actions).containsExactly(Action.Copy("foo", overwrite = false)) + + val tgtFoo = tgt.createDirectory("foo") + + assertThat(Modifier.apply(tgt, src, actions)).comparingElementsUsing( + Correspondence.from({ a: String, b: String -> a.startsWith(b) }, "startsWith"), + ).containsExactly("/foo: Unable to create file: ") + assertThat(tgt.list().directories).containsExactly(tgtFoo) + assertThat(tgt.list().files).isEmpty() + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun errorTargetLinkExists() { + Assume.assumeTrue(sourceSupportsSymlinks() && targetSupportsSymlinks()) + + src.createLink("foo", "bar") + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(actions).containsExactly(Action.Link("foo", "bar", overwrite = false)) + + val tgtFoo = tgt.createFile("foo") + tgtFoo.write().use { it.write(2) } + + assertThat(Modifier.apply(tgt, src, actions)).comparingElementsUsing( + Correspondence.from({ a: String, b: String -> a.startsWith(b) }, "startsWith"), + ).containsExactly("/foo: Unable to create link: ") + assertThat(tgt.list().directories).isEmpty() + assertThat(tgt.list().files).containsExactly(tgtFoo) + assertThat(tgtFoo.read().use { it.readAllBytes() }).asList().containsExactly(2.toByte()) + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun errorTargetLinkExists2() { + Assume.assumeTrue(sourceSupportsSymlinks() && targetSupportsSymlinks()) + + src.createLink("foo", "bar") + + val actions = SingleMerge.calculate(tgt, src) + + assertThat(actions).containsExactly(Action.Link("foo", "bar", overwrite = false)) + + val tgtFoo = tgt.createDirectory("foo") + + assertThat(Modifier.apply(tgt, src, actions)).comparingElementsUsing( + Correspondence.from({ a: String, b: String -> a.startsWith(b) }, "startsWith"), + ).containsExactly("/foo: Unable to create link: ") + assertThat(tgt.list().directories).containsExactly(tgtFoo) + assertThat(tgt.list().files).isEmpty() + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun targetDirAlreadyGone() { + tgt.createDirectory("foo") + + val actions = SingleMerge.calculate(tgt, src, options = SingleMerge.Options(deleteFilesOnlyInTarget = true)) + + assertThat(actions).containsExactly(Action.RemoveDir("foo")) + + assertThat(tgt.removeDirectory("foo")).isTrue() + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + } + + @Test + fun errorTargetDirIsNowAFile() { + tgt.createDirectory("foo") + + val actions = SingleMerge.calculate(tgt, src, options = SingleMerge.Options(deleteFilesOnlyInTarget = true)) + + assertThat(actions).containsExactly(Action.RemoveDir("foo")) + + assertThat(tgt.removeDirectory("foo")).isTrue() + val foo = tgt.createFile("foo") + foo.write().use { it.write(1) } + + assertThat(Modifier.apply(tgt, src, actions)).containsExactly("/foo: Unable to remove dir") + assertThat(tgt.list().directories).isEmpty() + assertThat(tgt.list().files).containsExactly(foo) + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun targetFileAlreadyGone() { + tgt.createFile("foo").write().use { it.write(1) } + + val actions = SingleMerge.calculate(tgt, src, options = SingleMerge.Options(deleteFilesOnlyInTarget = true)) + + assertThat(actions).containsExactly(Action.RemoveFile("foo")) + + assertThat(tgt.removeFile("foo")).isTrue() + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + } + + @Test + fun errorTargetFileIsNowADir() { + tgt.createFile("foo").write().use { it.write(1) } + + val actions = SingleMerge.calculate(tgt, src, options = SingleMerge.Options(deleteFilesOnlyInTarget = true)) + + assertThat(actions).containsExactly(Action.RemoveFile("foo")) + + assertThat(tgt.removeFile("foo")).isTrue() + val foo = tgt.createDirectory("foo") + + assertThat(Modifier.apply(tgt, src, actions)).containsExactly("/foo: Unable to remove file") + assertThat(tgt.list().directories).containsExactly(foo) + assertThat(tgt.list().files).isEmpty() + assertThat(tgt.list().links).isEmpty() + } + + @Test + fun targetLinkAlreadyGone() { + Assume.assumeTrue(targetSupportsSymlinks()) + tgt.createLink("foo", "bar") + + val actions = SingleMerge.calculate(tgt, src, options = SingleMerge.Options(deleteFilesOnlyInTarget = true)) + + assertThat(actions).containsExactly(Action.RemoveLink("foo")) + + assertThat(tgt.removeLink("foo")).isTrue() + + assertThat(Modifier.apply(tgt, src, actions)).isEmpty() + } + + @Test + fun errorTargetLinkIsNowADir() { + Assume.assumeTrue(targetSupportsSymlinks()) + tgt.createLink("foo", "bar") + + val actions = SingleMerge.calculate(tgt, src, options = SingleMerge.Options(deleteFilesOnlyInTarget = true)) + + assertThat(actions).containsExactly(Action.RemoveLink("foo")) + + assertThat(tgt.removeLink("foo")).isTrue() + val foo = tgt.createDirectory("foo") + + assertThat(Modifier.apply(tgt, src, actions)).containsExactly("/foo: Unable to remove link") + assertThat(tgt.list().directories).containsExactly(foo) + assertThat(tgt.list().files).isEmpty() + assertThat(tgt.list().links).isEmpty() + } + + abstract fun source(): ModifiableTree + abstract fun sourceSupportsSymlinks(): Boolean + abstract fun target(): ModifiableTree + abstract fun targetSupportsSymlinks(): Boolean + + abstract fun minModificationTime(): Duration +} diff --git a/libs/io/src/test/java/org/the_jk/cleversync/io/SingleMergeLocalTest.kt b/libs/io/src/test/java/org/the_jk/cleversync/io/SingleMergeLocalTest.kt new file mode 100644 index 0000000..07cc848 --- /dev/null +++ b/libs/io/src/test/java/org/the_jk/cleversync/io/SingleMergeLocalTest.kt @@ -0,0 +1,26 @@ +package org.the_jk.cleversync.io + +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.the_jk.cleversync.local.LocalTreeFactory +import kotlin.time.Duration.Companion.milliseconds + +class SingleMergeLocalTest : BaseSingleMergeTest() { + @Rule + @JvmField + val temp = TemporaryFolder() + + override fun source(): ModifiableTree { + return LocalTreeFactory.modifiableTree(temp.newFolder("source").toPath()) + } + + override fun sourceSupportsSymlinks() = true + + override fun target(): ModifiableTree { + return LocalTreeFactory.modifiableTree(temp.newFolder("target").toPath()) + } + + override fun targetSupportsSymlinks() = true + + override fun minModificationTime() = 10.milliseconds +} -- cgit v1.2.3-70-g09d2